mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
core/agent+cli: accumulate usage and show on turn end /status
ChatAgent tracks session_usage and last_turn_usage; CLI prints a quiet token line after successful turns and includes totals in /status.
This commit is contained in:
@@ -57,7 +57,8 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
|
|||||||
- **`run_chat_loop`**: multi-round tool loop; default **streaming** text deltas + stream tool-call merge; parallel tools; tool-result char budget; soft context compact on request; cooperative cancel points; optional `on_limit`.
|
- **`run_chat_loop`**: multi-round tool loop; default **streaming** text deltas + stream tool-call merge; parallel tools; tool-result char budget; soft context compact on request; cooperative cancel points; optional `on_limit`.
|
||||||
- **`ChatAgent`**: optional `MemoryStore` (persist on success only); `stream`; system prompt; `pending_retry_text` + `retry()`.
|
- **`ChatAgent`**: optional `MemoryStore` (persist on success only); `stream`; system prompt; `pending_retry_text` + `retry()`.
|
||||||
- **`/compact`**: soft-compact tool dumps → model summary (no tools) → **new** session seeded with summary message.
|
- **`/compact`**: soft-compact tool dumps → model summary (no tools) → **new** session seeded with summary message.
|
||||||
- Events: text_delta, assistant_message, tool_call/result, max_rounds, **error** (`retryable`/`source`), **cancelled** (`reason`).
|
- Events: text_delta, assistant_message, tool_call/result, max_rounds, **error** (`retryable`/`source`), **cancelled** (`reason`), **usage** (`TokenUsage`).
|
||||||
|
- Usage: API `usage` from completions (stream with `include_usage`); `ChatAgent.session_usage` / `last_turn_usage`; CLI end-of-turn + `/status`.
|
||||||
- Config ``[agent]``: `system_prompt`, `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_chars`.
|
- Config ``[agent]``: `system_prompt`, `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_chars`.
|
||||||
|
|
||||||
### Tools (`tools/`)
|
### Tools (`tools/`)
|
||||||
@@ -97,10 +98,10 @@ Basedpyright `recommended`. Ruff includes `ANN` (private return types `ANN202` i
|
|||||||
|
|
||||||
## Roadmap notes (single-user → platform)
|
## Roadmap notes (single-user → platform)
|
||||||
|
|
||||||
- **Phase D (context quality)**: soft char budget, request compact, `/compact`, richer errors/cancel, workspace sessions — **current**. Context size is **char estimate only** until usage lands.
|
- **Phase D (context quality)**: soft char budget, request compact, `/compact`, richer errors/cancel, workspace sessions. Context size is **char estimate** plus optional API usage when reported.
|
||||||
- **No local tokenizer stage** for now. Prefer **API usage v2** later.
|
- **No local tokenizer stage** for now.
|
||||||
- **Phase E**: tooling depth (grep/glob, VCS backends; prefer `edit_replace` / `edit_lineno` over model-generated patches).
|
- **Phase E**: tooling depth (grep/glob, VCS backends; prefer `edit_replace` / `edit_lineno` over model-generated patches).
|
||||||
- **Phase F (providers + usage v2)**: capture response/stream `usage` (prompt/completion/total); session/turn totals; `/status` or end-of-turn line; optional cost. Optional later: tokenizer-backed estimates if needed.
|
- **Phase F (providers + usage v2)**: capture response/stream `usage` — **in progress / landing**; session/turn totals; `/status` + end-of-turn line. Optional later: cost, tokenizer-backed estimates.
|
||||||
- **Phase G–H**: CLI polish, hardening; then multi-tenant platform (`router/`, auth, sandboxed tools).
|
- **Phase G–H**: CLI polish, hardening; then multi-tenant platform (`router/`, auth, sandboxed tools).
|
||||||
|
|
||||||
## Commit messages
|
## Commit messages
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from .events import MaxRoundsEvent as MaxRoundsEvent
|
|||||||
from .events import TextDeltaEvent as TextDeltaEvent
|
from .events import TextDeltaEvent as TextDeltaEvent
|
||||||
from .events import ToolCallEvent as ToolCallEvent
|
from .events import ToolCallEvent as ToolCallEvent
|
||||||
from .events import ToolResultEvent as ToolResultEvent
|
from .events import ToolResultEvent as ToolResultEvent
|
||||||
|
from .events import UsageEvent as UsageEvent
|
||||||
from .loop import DEFAULT_MAX_ROUNDS as DEFAULT_MAX_ROUNDS
|
from .loop import DEFAULT_MAX_ROUNDS as DEFAULT_MAX_ROUNDS
|
||||||
from .loop import run_chat_loop as run_chat_loop
|
from .loop import run_chat_loop as run_chat_loop
|
||||||
from .tools import DangerClassifier as DangerClassifier
|
from .tools import DangerClassifier as DangerClassifier
|
||||||
@@ -18,3 +19,5 @@ from .tools import ToolDefinition as ToolDefinition
|
|||||||
from .tools import ToolRegistry as ToolRegistry
|
from .tools import ToolRegistry as ToolRegistry
|
||||||
from .tools import schema_from_callable as schema_from_callable
|
from .tools import schema_from_callable as schema_from_callable
|
||||||
from .tools import tool as tool
|
from .tools import tool as tool
|
||||||
|
from .usage import TokenUsage as TokenUsage
|
||||||
|
from .usage import token_usage_from_api as token_usage_from_api
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ from typing import TYPE_CHECKING
|
|||||||
from plyngent.lmproto.openai_compatible.model import SystemChatMessage, UserChatMessage
|
from plyngent.lmproto.openai_compatible.model import SystemChatMessage, UserChatMessage
|
||||||
|
|
||||||
from .budget import DEFAULT_CONTEXT_MAX_CHARS, DEFAULT_TOOL_RESULT_MAX_CHARS
|
from .budget import DEFAULT_CONTEXT_MAX_CHARS, DEFAULT_TOOL_RESULT_MAX_CHARS
|
||||||
|
from .events import UsageEvent
|
||||||
from .loop import DEFAULT_MAX_ROUNDS, run_chat_loop
|
from .loop import DEFAULT_MAX_ROUNDS, run_chat_loop
|
||||||
|
from .usage import TokenUsage
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
|
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
|
||||||
@@ -38,6 +40,8 @@ class ChatAgent:
|
|||||||
max_context_chars: int
|
max_context_chars: int
|
||||||
messages: list[AnyChatMessage]
|
messages: list[AnyChatMessage]
|
||||||
pending_retry_text: str | None
|
pending_retry_text: str | None
|
||||||
|
session_usage: TokenUsage
|
||||||
|
last_turn_usage: TokenUsage
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -72,6 +76,8 @@ class ChatAgent:
|
|||||||
self.max_context_chars = max_context_chars
|
self.max_context_chars = max_context_chars
|
||||||
self.messages = list(messages) if messages is not None else []
|
self.messages = list(messages) if messages is not None else []
|
||||||
self.pending_retry_text = None
|
self.pending_retry_text = None
|
||||||
|
self.session_usage = TokenUsage()
|
||||||
|
self.last_turn_usage = TokenUsage()
|
||||||
self._ensure_system_prompt()
|
self._ensure_system_prompt()
|
||||||
|
|
||||||
def _ensure_system_prompt(self) -> None:
|
def _ensure_system_prompt(self) -> None:
|
||||||
@@ -116,6 +122,7 @@ class ChatAgent:
|
|||||||
self.messages.append(user_msg)
|
self.messages.append(user_msg)
|
||||||
|
|
||||||
completed = False
|
completed = False
|
||||||
|
turn_usage = TokenUsage()
|
||||||
try:
|
try:
|
||||||
async for event in run_chat_loop(
|
async for event in run_chat_loop(
|
||||||
self.client,
|
self.client,
|
||||||
@@ -130,6 +137,9 @@ class ChatAgent:
|
|||||||
parallel_tools=self.parallel_tools,
|
parallel_tools=self.parallel_tools,
|
||||||
max_context_chars=self.max_context_chars,
|
max_context_chars=self.max_context_chars,
|
||||||
):
|
):
|
||||||
|
if isinstance(event, UsageEvent):
|
||||||
|
turn_usage = turn_usage.add(event.usage)
|
||||||
|
self.session_usage = self.session_usage.add(event.usage)
|
||||||
yield event
|
yield event
|
||||||
completed = True
|
completed = True
|
||||||
except BaseException:
|
except BaseException:
|
||||||
@@ -137,6 +147,7 @@ class ChatAgent:
|
|||||||
self._rollback_turn(pre_len, user_msg.content)
|
self._rollback_turn(pre_len, user_msg.content)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
self.last_turn_usage = turn_usage
|
||||||
for message in self.messages[pre_len:]:
|
for message in self.messages[pre_len:]:
|
||||||
await self._persist(message)
|
await self._persist(message)
|
||||||
self.pending_retry_text = None
|
self.pending_retry_text = None
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from plyngent.agent import (
|
|||||||
TextDeltaEvent,
|
TextDeltaEvent,
|
||||||
ToolCallEvent,
|
ToolCallEvent,
|
||||||
ToolResultEvent,
|
ToolResultEvent,
|
||||||
|
UsageEvent,
|
||||||
)
|
)
|
||||||
from plyngent.lmproto.openai_compatible.model import AssistantFunctionToolCall
|
from plyngent.lmproto.openai_compatible.model import AssistantFunctionToolCall
|
||||||
|
|
||||||
@@ -82,6 +83,9 @@ async def render_events(events: AsyncIterator[AgentEvent]) -> None: # noqa: C90
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
click.secho(f"\n[max rounds reached: {event.rounds}]", fg="red")
|
click.secho(f"\n[max rounds reached: {event.rounds}]", fg="red")
|
||||||
|
elif isinstance(event, UsageEvent):
|
||||||
|
# Printed at end of turn as a summary; individual round usage is quiet.
|
||||||
|
_ = event
|
||||||
else:
|
else:
|
||||||
# AssistantMessageEvent — text already shown via TextDeltaEvent.
|
# AssistantMessageEvent — text already shown via TextDeltaEvent.
|
||||||
_ = event
|
_ = event
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ def _cmd_status(state: ReplState) -> None:
|
|||||||
pending_disp = "yes" if pending else "no"
|
pending_disp = "yes" if pending else "no"
|
||||||
ctx_chars = estimate_messages_chars(state.agent.messages)
|
ctx_chars = estimate_messages_chars(state.agent.messages)
|
||||||
ctx_budget = state.agent.max_context_chars
|
ctx_budget = state.agent.max_context_chars
|
||||||
|
session_u = state.agent.session_usage
|
||||||
|
last_u = state.agent.last_turn_usage
|
||||||
click.echo(
|
click.echo(
|
||||||
f"provider={state.provider_name} model={state.model}\n"
|
f"provider={state.provider_name} model={state.model}\n"
|
||||||
f"session={state.session_id} messages={len(state.agent.messages)} "
|
f"session={state.session_id} messages={len(state.agent.messages)} "
|
||||||
@@ -68,6 +70,8 @@ def _cmd_status(state: ReplState) -> None:
|
|||||||
f"rounds={state.max_rounds} stream={'on' if state.agent.stream else 'off'}\n"
|
f"rounds={state.max_rounds} stream={'on' if state.agent.stream else 'off'}\n"
|
||||||
f"context_chars={ctx_chars}/{ctx_budget} "
|
f"context_chars={ctx_chars}/{ctx_budget} "
|
||||||
f"tool_result_max={state.agent.max_tool_result_chars}\n"
|
f"tool_result_max={state.agent.max_tool_result_chars}\n"
|
||||||
|
f"usage_session={session_u.format_line()}\n"
|
||||||
|
f"usage_last_turn={last_u.format_line()}\n"
|
||||||
f"workspace={state.workspace}"
|
f"workspace={state.workspace}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+25
-14
@@ -88,6 +88,29 @@ async def run_cancellable[T](coro: Coroutine[object, object, T]) -> T:
|
|||||||
await task
|
await task
|
||||||
|
|
||||||
|
|
||||||
|
def _echo_turn_usage(agent: ChatAgent) -> None:
|
||||||
|
if not agent.last_turn_usage.is_zero():
|
||||||
|
click.secho(f"[{agent.last_turn_usage.format_line()}]", fg="bright_black")
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_for_retry(attempt: int, max_retries: int, wait: float) -> bool:
|
||||||
|
click.secho(
|
||||||
|
f"auto-retry {attempt}/{max_retries} in {wait:g}s "
|
||||||
|
f"(Ctrl+C to cancel; then /retry later)",
|
||||||
|
fg="yellow",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
ok = await sleep_cancellable(wait)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
ok = False
|
||||||
|
if not ok:
|
||||||
|
click.secho("auto-retry cancelled; use /retry to try again", fg="yellow")
|
||||||
|
click.echo()
|
||||||
|
return False
|
||||||
|
click.secho(f"retrying ({attempt}/{max_retries})…", fg="yellow")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def run_turn_with_retries(
|
async def run_turn_with_retries(
|
||||||
agent: ChatAgent,
|
agent: ChatAgent,
|
||||||
*,
|
*,
|
||||||
@@ -131,24 +154,12 @@ async def run_turn_with_retries(
|
|||||||
)
|
)
|
||||||
click.echo()
|
click.echo()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
wait = delays[attempt]
|
wait = delays[attempt]
|
||||||
attempt += 1
|
attempt += 1
|
||||||
click.secho(
|
if not await _wait_for_retry(attempt, max_retries, wait):
|
||||||
f"auto-retry {attempt}/{max_retries} in {wait:g}s "
|
|
||||||
f"(Ctrl+C to cancel; then /retry later)",
|
|
||||||
fg="yellow",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
ok = await sleep_cancellable(wait)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
ok = False
|
|
||||||
if not ok:
|
|
||||||
click.secho("auto-retry cancelled; use /retry to try again", fg="yellow")
|
|
||||||
click.echo()
|
|
||||||
return False
|
return False
|
||||||
click.secho(f"retrying ({attempt}/{max_retries})…", fg="yellow")
|
|
||||||
else:
|
else:
|
||||||
|
_echo_turn_usage(agent)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user