mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
Compare commits
3 Commits
3c555278d5
..
v0.9.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 2054423ebb | |||
| 3c8ca5fcde | |||
| 3cd95504c6 |
@@ -81,10 +81,10 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
|
||||
- **`messages_bridge` / `messages_dispatch`**: chat ↔ Anthropic Messages; system fold; tool_use/tool_result; stream text + tool argument fragments; `thinking` from the request's `reasoning_effort` (level → token budget, floor 1024, `max_tokens` grown when the budget would leave no room) plus the client's configured `thinking_budget_tokens`; a thinking block the model refuses (from Claude 4.7 on, `thinking.type.enabled` is a 400 asking for adaptive thinking + `output_config.effort`; a model whose thinking is always on refuses `thinking.type.disabled`) gets one retry with the shape it takes — adaptive thinking with the level as the effort, or no block at all — and the refusal is remembered per model on the client, so only its first request pays the round trip (only a refusal that arrives before the stream yields anything is retried; a later failure has already been seen); a server tool (`server_tool_use` + its `web_search_tool_result`) is the surface's own business — it streams `input_json_delta` fragments like a call but nothing local asked for it, so only a `tool_use` block becomes a tool call and nothing is executed; `thinking` and the two search blocks are kept as `AssistantChatMessage.provider_blocks` (the thinking text also lands in `reasoning_content`, so it streams as reasoning) and go back with the round, which is also why a round that kept only those blocks is not an empty completion; a chat-completions request drops the field (`compat.coerce_chat_messages`).
|
||||
- **Provider-side tools**: `provider_tools` on the OpenAI **and** DeepSeek presets (list of dicts; `[{type="web_search"}]` when omitted; `[]` disables) threaded via `ChatAgent`/`run_chat_loop` into Responses and Messages; never executed by `ToolRegistry`. The Messages surface declares its own server tool instead — a bare `web_search` becomes `{type="web_search_20250305", name="web_search"}` (the search then runs inside the response, and its `encrypted_content` results go back verbatim with the round — `AssistantChatMessage.provider_blocks`) — and a DeepSeek provider on a surface that ignores hosted tools (`config.routing.local_web_search`) gets the local `web_search` tool from `tools/net/search.py` instead.
|
||||
- **`@tool` / `ToolRegistry`**: decorator infers JSON Schema from type hints; execute tools by name. Before running, `execute` answers a call that omits a required argument with the accepted names (`_missing_required_error`) — the handler's bare `TypeError` never reaches the model, and no confirm prompt fires for a call that cannot run. Shape mistakes inside a *present* argument are the tool's own job (see the `chat` bullet). `@tool(writes=("path",))` names a handler's file arguments: one batch applies calls that name the same file one at a time, in call order — the lock is taken before the confirm prompt, so a pending prompt cannot be overtaken — while different files keep running in parallel.
|
||||
- **`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 (**API-calibrated** after first usage when available); cooperative cancel points; optional `on_limit`; resolved `reasoning_effort` on every request (see Config).
|
||||
- **`ChatAgent`**: optional `MemoryStore` (user message persisted immediately; **completed tool batches checkpointed** mid-turn; unfinished assistant suffix rolled back on failure); `stream`; system prompt; `provider_tools`; `retry()` continues incomplete turns (user-only **or** after committed tools — does not re-run those tools).
|
||||
- **`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 (**API-calibrated** after first usage when available); cooperative cancel points; optional `on_limit`; resolved `reasoning_effort` on every request (see Config). A round the provider cut off at the output limit is kept, not failed: the answer and its thinking blocks stay in `messages`, the round's tool calls are dropped (a half-written call must not run — the model re-issues it), and up to `max_truncation_continues` (2) further rounds carry on from a truncation notice; 0 spends the turn as unfinished instead, for `/retry`.
|
||||
- **`ChatAgent`**: optional `MemoryStore` (user message persisted immediately; **completed tool batches checkpointed** mid-turn; unfinished assistant suffix rolled back on failure); `stream`; system prompt; `provider_tools`; `retry()` continues incomplete turns (user-only, after committed tools — does not re-run those tools — or after a kept truncated answer) and `pending_retry_text` reads a truncation notice as unfinished work.
|
||||
- **`/compact`**: soft-compact tool dumps → model summary (no tools) via the same kind dispatch → **new** session seeded with summary message. Soft-compact keeps the last **12** tool results full-size by default (`DEFAULT_RECENT_TOOL_RESULTS`); older tool payloads shrink first.
|
||||
- Events: text_delta, **reasoning_delta**, assistant_message, tool_call/result, max_rounds, **error** (`retryable`/`source`), **cancelled** (`reason`), **usage** (`TokenUsage`).
|
||||
- Events: text_delta, **reasoning_delta**, assistant_message, tool_call/result, max_rounds, **truncated** (`kept_chars`/`continued`), **error** (`retryable`/`source`), **cancelled** (`reason`), **usage** (`TokenUsage`).
|
||||
- Usage: API `usage` from completions (stream with `include_usage`); **char≈token fallback** (~4 chars/token) when omitted; **context size** = last request ``prompt_tokens`` (API preferred); `last_turn_usage` / `session_usage` are **billed sums** (tool rounds re-send history); CLI end-of-turn + `/status`.
|
||||
- Config ``[agent]``: `system_prompt` (persona; default `DEFAULT_SYSTEM_PROMPT`; `""` omits persona), `tool_directives` (tool playbook; default `DEFAULT_TOOL_DIRECTIVES`; `""` omits playbook; both empty → no system), `mcp_instructions` (fold connected MCP servers' initialize ``instructions`` into the system prompt when tools on; default true), `allow_paths` (static out-of-workspace pre-allow `path → read|write|exec`; never prompts), `auto_continue_limits` (auto-raise tool/PTY limits without prompting; default false), `reasoning_effort` / `thinking_budget_tokens` (thinking strength; see Config), `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens). Truncated tool results carry a model hint (`[Truncated (N chars max; M omitted). You may ask for user to increase the limit via configuration file.]`) so the model can ask the user to raise `max_tool_result_chars`; request-time compact shrinks (`hint=False`) keep the terse `...[truncated N characters]` marker.
|
||||
|
||||
@@ -121,7 +121,7 @@ Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
|
||||
- `/btw` side questions: forked session; main transcript/DB untouched. `--tools=read` (default) exposes only `READ_ONLY` tools under `read_only_context()` (fetch=GET) with the main session view; `--tools=no` off; `--tools=full` whole-registry clone with fresh session. Tools off: default falls back to no tools; explicit `read`/`full` errors. The model gets an `aside` notice as its last message before the question (unsaved exchange + tool scope), shown to the user as `[notice] aside: …`.
|
||||
- Explicit `/resume` or `--session` from another workspace prompts: **keep** / **update** / **abort**. A session resumed by a **freshly started process** (`--session` / default latest) also gets a `resume` notice about what a restart drops (PTY sessions, running commands, temp workspaces) — resuming inside a live process pushes nothing.
|
||||
- Host notices (`agent/notices.py`): system-level messages appended as trailing `developer` rows, never system-prompt edits — `Notice.to_message()` for the model, `.summary` for the user via `cli.display.echo_notice` (`--quiet` hides the user line only). Durable hosts persist them (`ChatAgent.push_notice`); a side agent without memory keeps its notice local. `has_notice()` dedupes a session-scoped notice: the `skills` one is pushed once per session (feature enabled + tools on), lists nothing, and makes the model ask with `skill_list` / `skill_search`. Trailing developer messages never end a turn, so `/retry` still sees an interrupted turn behind a notice or checkpoint.
|
||||
- Failed/cancelled turns: user kept; **committed tool rounds kept**, and a batch cut short keeps the calls that already produced a result (`rollback_tail`; written to the store, so a resume continues from them instead of re-running them) — side effects are not re-run on `/retry`, and only unfinished assistant text is dropped; Ctrl+C cancels the in-flight turn (a session-scoped asyncio SIGINT handler routes it, `cli/interrupt.py` — a stray Ctrl+C while the loop waits never tears the REPL down); TTY confirms/ask prompts run off-loop and register their own SIGINT target, so Ctrl+C there cancels just the poll-based read (`wait` reports `cancelled by user`) or is ignored with a hint for readline prompts (`ask_user_*`, confirms) — never the turn, never the loop; auto-retry waits 5s/10s/15s/20s then +10s (10 attempts), its countdown is Ctrl+C-cancellable, and the budget **resets when an attempt completes a model round** (connection recovered); then `/retry`.
|
||||
- Failed/cancelled turns: user kept; **committed tool rounds kept**, and a batch cut short keeps the calls that already produced a result (`rollback_tail`; written to the store, so a resume continues from them instead of re-running them) — side effects are not re-run on `/retry`, and only unfinished assistant text is dropped; a round cut off at the output limit is not a failure at all: it is kept under a `truncated` notice (see the `run_chat_loop` bullet), which also keeps the turn unfinished for `/retry`; Ctrl+C cancels the in-flight turn (a session-scoped asyncio SIGINT handler routes it, `cli/interrupt.py` — a stray Ctrl+C while the loop waits never tears the REPL down); TTY confirms/ask prompts run off-loop and register their own SIGINT target, so Ctrl+C there cancels just the poll-based read (`wait` reports `cancelled by user`) or is ignored with a hint for readline prompts (`ask_user_*`, confirms) — never the turn, never the loop; auto-retry waits 5s/10s/15s/20s then +10s (10 attempts), its countdown is Ctrl+C-cancellable, and the budget **resets when an attempt completes a model round** (connection recovered); then `/retry`.
|
||||
- Pretty tool lines: every builtin tool prints its `* Verb 'target' ` prefix as the call starts and appends the outcome (`L1-4 (done)`, `(exit code 0)`, `(session 3)`) when the result lands, so a blocking tool stays visible while it runs; namespaced MCP tools share one generic line (`* MCP <server>:<tool> (done)`). Plugin tools and custom tool calls keep the `[tool] …` / `[tool ok] …` whole-line style. Interactive TTY only (`interactive_terminal()`): piped/plain output and `--verbose` keep whole lines. A second call of the same round holds its prefix back until the open row takes its own detail (results arrive in call order), so each call still prints one finished row; a prompt box (or other output) printed while the tool runs *ends* the open prefix as its own line and falls back to whole lines for the rest of the batch — the cursor is never moved, so a prefix wider than the terminal cannot be half-erased. A batch (one round's calls) that only touches todo tools prints one `* Todo x<N>: Push, Update` line plus its last result, whose text already carries the final stack, instead of one header and a full list render per call. `run_argv_batch` lists its commands above its row: one `$ command` line per step, a `pipe_out` chain joined into one line with ` | `, each line elided past 80 characters.
|
||||
- **`plyngent providers`**, **`config path|edit`**. Config open: `$VISUAL`/`$EDITOR` (wait), else system default (`xdg-open` / `open` / `startfile`, non-blocking). `/edit` stays blocking-only. No providers → optional edit then reload when waited.
|
||||
- Tools default on; workspace defaults to cwd; `--max-rounds` default 32. Readline history under platformdirs (`repl_history`). PTY: `close_all` on chat exit.
|
||||
|
||||
@@ -294,7 +294,7 @@ Type `/help` in the REPL for the live list. Common ones:
|
||||
| `/stream` `/verbose` `/markdown` `/tools` `/rounds` | Toggles and limits |
|
||||
| `/yolo [on\|off\|once]` | Soft destructive confirms: sticky skip, off, or next turn only |
|
||||
|
||||
| `/retry` | Re-run incomplete last user turn (after error/cancel) |
|
||||
| `/retry` | Retry/continue the incomplete last user turn (after error, cancel, or output limit) |
|
||||
| `/btw [--tools read\|no\|full] [--fresh]` | Side question without changing the main session (read-only tools by default; the model is told the exchange is unsaved and how its tools are scoped) |
|
||||
| `/provider` `/model` | Switch without restarting |
|
||||
| `/model --persist` | Save current model id into `plyngent.toml` catalog |
|
||||
@@ -306,7 +306,7 @@ Type `/help` in the REPL for the live list. Common ones:
|
||||
| `/config` | Edit `plyngent.toml` ($VISUAL/$EDITOR or system open); reload after blocking editor |
|
||||
| `/quit` | Leave the REPL |
|
||||
|
||||
User messages are saved immediately. On API error or Ctrl+C, unfinished assistant text is discarded — tool calls that already produced a result stay, so `/retry` does not run them again — but the user message stays so `/retry` works after resume. Interactive auto-retry waits 5s, 10s, 15s, 20s, then +10s each step (10 attempts); an attempt that gets a full model round back **resets that budget**, so a connection that recovers and drops again starts over from 5s. Tool calls of one round run in parallel, but writes to the same file apply one at a time, in call order — a later edit sees the earlier one's result instead of stale text.
|
||||
User messages are saved immediately. On API error or Ctrl+C, unfinished assistant text is discarded — tool calls that already produced a result stay, so `/retry` does not run them again — but the user message stays so `/retry` works after resume. Interactive auto-retry waits 5s, 10s, 15s, 20s, then +10s each step (10 attempts); an attempt that gets a full model round back **resets that budget**, so a connection that recovers and drops again starts over from 5s. A round cut off at the output limit (`max_tokens`) is not an error: what the model had written — thinking blocks included — stays in the transcript, its tool calls are dropped (they are re-issued, not replayed), and a notice tells it where it stopped, so up to 2 further rounds carry on from there; when that budget is spent the turn ends with the partial answer kept and `/retry` continues it. Tool calls of one round run in parallel, but writes to the same file apply one at a time, in call order — a later edit sees the earlier one's result instead of stale text.
|
||||
|
||||
Ctrl+C cancels the in-flight turn, and during the auto-retry countdown it cancels the retry. Model-initiated prompts run off the event loop: a `wait` prompt is cancelled by Ctrl+C (the tool reports `cancelled by user` and the turn continues), while `ask_user_*` and confirm prompts ignore it with a one-line hint — answer them to continue. Ctrl+C never exits the REPL (use Ctrl+D or `/quit`), and MCP servers run in their own process group, so it never kills them either.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "plyngent"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
description = "LLM chat and agent toolkit"
|
||||
authors = [
|
||||
{ name = "HivertMoZara", email = "worldmozara@163.com" },
|
||||
|
||||
@@ -17,6 +17,7 @@ from .events import ReasoningDeltaEvent as ReasoningDeltaEvent
|
||||
from .events import TextDeltaEvent as TextDeltaEvent
|
||||
from .events import ToolCallEvent as ToolCallEvent
|
||||
from .events import ToolResultEvent as ToolResultEvent
|
||||
from .events import TruncatedEvent as TruncatedEvent
|
||||
from .events import UsageEvent as UsageEvent
|
||||
from .loop import DEFAULT_MAX_ROUNDS as DEFAULT_MAX_ROUNDS
|
||||
from .loop import run_chat_loop as run_chat_loop
|
||||
@@ -26,6 +27,7 @@ from .notices import format_moment as format_moment
|
||||
from .notices import has_notice as has_notice
|
||||
from .notices import resume_notice as resume_notice
|
||||
from .notices import skill_tools_notice as skill_tools_notice
|
||||
from .notices import truncated_turn_notice as truncated_turn_notice
|
||||
from .tools import DangerClassifier as DangerClassifier
|
||||
from .tools import ToolConfirmHook as ToolConfirmHook
|
||||
from .tools import ToolDefinition as ToolDefinition
|
||||
|
||||
@@ -27,7 +27,8 @@ from .directive_checkpoint import (
|
||||
parse_checkpoint_bands,
|
||||
)
|
||||
from .events import UsageEvent
|
||||
from .loop import DEFAULT_MAX_ROUNDS, run_chat_loop
|
||||
from .loop import DEFAULT_MAX_ROUNDS, DEFAULT_MAX_TRUNCATION_CONTINUES, run_chat_loop
|
||||
from .notices import TRUNCATED_NOTICE_KIND, notice_of_kind
|
||||
from .todo_nag import (
|
||||
DEFAULT_TODO_NAG_STRATEGY,
|
||||
parse_todo_nag_strategy,
|
||||
@@ -140,8 +141,10 @@ def incomplete_turn_user_text(messages: Sequence[AnyChatMessage]) -> str | None:
|
||||
"""User text for an incomplete turn, if history can be retried.
|
||||
|
||||
Incomplete when the last non-system message is a user message (failed before
|
||||
any commit), or tool results after a complete tool batch (failed on a later
|
||||
model round — keep tools so retry does not re-execute side effects).
|
||||
any commit), tool results after a complete tool batch (failed on a later
|
||||
model round — keep tools so retry does not re-execute side effects), or a
|
||||
partial answer under a truncation notice (the round hit the output limit; its
|
||||
answer was kept, so retry continues it instead of regenerating).
|
||||
Trailing developer messages (host notices, directive checkpoints) belong to
|
||||
the turn but never end it, so they are skipped when reading the tail.
|
||||
"""
|
||||
@@ -150,14 +153,17 @@ def incomplete_turn_user_text(messages: Sequence[AnyChatMessage]) -> str | None:
|
||||
if messages and isinstance(messages[0], SystemChatMessage):
|
||||
start = 1
|
||||
body = list(messages[start:])
|
||||
truncated = False
|
||||
while body and isinstance(body[-1], DeveloperChatMessage):
|
||||
if notice_of_kind(body[-1], TRUNCATED_NOTICE_KIND):
|
||||
truncated = True
|
||||
_ = body.pop()
|
||||
if not body:
|
||||
return None
|
||||
last = body[-1]
|
||||
if isinstance(last, UserChatMessage):
|
||||
return last.content
|
||||
if isinstance(last, ToolChatMessage):
|
||||
if isinstance(last, ToolChatMessage) or (truncated and isinstance(last, AssistantChatMessage)):
|
||||
# Walk back to the user that opened this turn.
|
||||
for msg in reversed(body):
|
||||
if isinstance(msg, UserChatMessage):
|
||||
@@ -289,6 +295,8 @@ class ChatAgent:
|
||||
memory: MemoryStore | None
|
||||
session_id: int | None
|
||||
max_rounds: int
|
||||
# Rounds a turn may carry on after hitting the output limit (0 = none).
|
||||
max_truncation_continues: int
|
||||
temperature: float | None
|
||||
# Resolved thinking strength ("" = provider default); see config.reasoning.
|
||||
reasoning_effort: str
|
||||
@@ -322,6 +330,7 @@ class ChatAgent:
|
||||
memory: MemoryStore | None = None,
|
||||
session_id: int | None = None,
|
||||
max_rounds: int = DEFAULT_MAX_ROUNDS,
|
||||
max_truncation_continues: int = DEFAULT_MAX_TRUNCATION_CONTINUES,
|
||||
temperature: float | None = None,
|
||||
reasoning_effort: str = "",
|
||||
messages: Sequence[AnyChatMessage] | None = None,
|
||||
@@ -345,6 +354,7 @@ class ChatAgent:
|
||||
self.memory = memory
|
||||
self.session_id = session_id
|
||||
self.max_rounds = max_rounds
|
||||
self.max_truncation_continues = max(0, int(max_truncation_continues))
|
||||
self.temperature = temperature
|
||||
self.reasoning_effort = reasoning_effort
|
||||
self.on_limit = on_limit
|
||||
@@ -603,6 +613,7 @@ class ChatAgent:
|
||||
model=self.model,
|
||||
tools=self.tools,
|
||||
max_rounds=self.max_rounds,
|
||||
max_truncation_continues=self.max_truncation_continues,
|
||||
temperature=self.temperature,
|
||||
reasoning_effort=self.reasoning_effort,
|
||||
on_limit=self.on_limit,
|
||||
@@ -747,6 +758,7 @@ class ChatAgent:
|
||||
memory=None,
|
||||
session_id=None,
|
||||
max_rounds=rounds,
|
||||
max_truncation_continues=self.max_truncation_continues,
|
||||
temperature=self.temperature,
|
||||
reasoning_effort=self.reasoning_effort,
|
||||
on_limit=self.on_limit,
|
||||
|
||||
@@ -34,6 +34,19 @@ class MaxRoundsEvent(Struct, tag_field="type", tag="max_rounds"):
|
||||
continued: bool = False
|
||||
|
||||
|
||||
class TruncatedEvent(Struct, tag_field="type", tag="truncated"):
|
||||
"""A round the provider cut off at the output limit (``finish_reason=length``).
|
||||
|
||||
The partial answer stays in history rather than being discarded, and the
|
||||
model is told where it stopped (see ``notices.truncated_turn_notice``);
|
||||
``continued`` says whether the turn went on with another round or is waiting
|
||||
for ``/retry``.
|
||||
"""
|
||||
|
||||
kept_chars: int = 0
|
||||
continued: bool = False
|
||||
|
||||
|
||||
class ErrorEvent(Struct, tag_field="type", tag="error"):
|
||||
message: str
|
||||
retryable: bool = True
|
||||
@@ -60,6 +73,7 @@ type AgentEvent = (
|
||||
| ToolCallEvent
|
||||
| ToolResultEvent
|
||||
| MaxRoundsEvent
|
||||
| TruncatedEvent
|
||||
| ErrorEvent
|
||||
| CancelledEvent
|
||||
| UsageEvent
|
||||
|
||||
+81
-14
@@ -43,8 +43,11 @@ from .events import (
|
||||
TextDeltaEvent,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
TruncatedEvent,
|
||||
UsageEvent,
|
||||
)
|
||||
from .finish_reason import chat_finish_reason
|
||||
from .notices import truncated_turn_notice
|
||||
from .todo_nag import (
|
||||
DEFAULT_TODO_NAG_STRATEGY,
|
||||
inject_todo_nag_for_stack_with_events,
|
||||
@@ -68,6 +71,10 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
DEFAULT_MAX_ROUNDS = 32
|
||||
# Rounds cut off at the output limit are kept and the model carries on in another
|
||||
# round, this many times per turn; 0 stops after the truncated round (`/retry`
|
||||
# continues it from the kept partial answer).
|
||||
DEFAULT_MAX_TRUNCATION_CONTINUES = 2
|
||||
|
||||
|
||||
async def _call_on_limit(on_limit: LimitContinueHook, reason: str) -> bool:
|
||||
@@ -150,6 +157,11 @@ def _assistant_has_payload(assistant: AssistantChatMessage) -> bool:
|
||||
return bool(isinstance(reasoning, str) and reasoning.strip())
|
||||
|
||||
|
||||
def _kept_chars(assistant: AssistantChatMessage) -> int:
|
||||
"""How much answer a cut-off round had written (0 if only reasoning/blocks)."""
|
||||
return len(assistant.content) if isinstance(assistant.content, str) else 0
|
||||
|
||||
|
||||
def _finish_reason_value(finish: object) -> str | None:
|
||||
if finish is UNSET or finish is None:
|
||||
return None
|
||||
@@ -163,20 +175,26 @@ def _validate_assistant_terminal(
|
||||
*,
|
||||
finish_reason: str | None,
|
||||
stream_terminal: bool,
|
||||
) -> None:
|
||||
"""Raise when the round is not a usable agent stop.
|
||||
) -> bool:
|
||||
"""Raise when the round is not a usable agent stop; True if it hit the output limit.
|
||||
|
||||
Distinguishes empty generation, truncated/filtered stops, and missing
|
||||
stream terminals (network/client glitch) from a normal stop/tool_calls end.
|
||||
Distinguishes empty generation, filtered stops, and missing stream terminals
|
||||
(network/client glitch) from a normal stop/tool_calls end. A round cut off at
|
||||
the output limit is not an error of its own: what it wrote is usable, so the
|
||||
caller keeps it and tells the model where it stopped. A truncated round that
|
||||
wrote nothing stays a failure — there is nothing to continue from.
|
||||
"""
|
||||
reason = (finish_reason or "").lower() or None
|
||||
canonical = chat_finish_reason(reason, has_tool_calls=False)
|
||||
|
||||
if reason in {"length", "content_filter"}:
|
||||
label = "truncated (max tokens)" if reason == "length" else "content filter"
|
||||
detail = ""
|
||||
if isinstance(assistant.content, str) and assistant.content.strip():
|
||||
detail = f"; partial text kept ({len(assistant.content)} chars)"
|
||||
msg = f"model stopped early: {label}{detail}"
|
||||
if canonical == "length":
|
||||
if _assistant_has_payload(assistant):
|
||||
return True
|
||||
msg = "model stopped early: output limit reached with nothing written"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
if canonical == "content_filter":
|
||||
msg = "model stopped early: content filter"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
if reason in {"failed", "incomplete", "cancelled"}:
|
||||
@@ -184,7 +202,7 @@ def _validate_assistant_terminal(
|
||||
raise RuntimeError(msg)
|
||||
|
||||
if _assistant_has_payload(assistant):
|
||||
return
|
||||
return False
|
||||
|
||||
if not stream_terminal and reason is None:
|
||||
msg = (
|
||||
@@ -249,7 +267,7 @@ async def _non_stream_round(
|
||||
choice = response.choices[0]
|
||||
assistant = choice.message
|
||||
finish = _finish_reason_value(choice.finish_reason)
|
||||
_validate_assistant_terminal(
|
||||
truncated = _validate_assistant_terminal(
|
||||
assistant,
|
||||
finish_reason=finish,
|
||||
stream_terminal=True,
|
||||
@@ -260,6 +278,8 @@ async def _non_stream_round(
|
||||
if isinstance(assistant.content, str) and assistant.content:
|
||||
yield TextDeltaEvent(content=assistant.content)
|
||||
yield AssistantMessageEvent(message=assistant)
|
||||
if truncated:
|
||||
yield TruncatedEvent(kept_chars=_kept_chars(assistant))
|
||||
yield UsageEvent(
|
||||
usage=resolve_round_usage(response.usage, param.messages, assistant),
|
||||
)
|
||||
@@ -376,12 +396,14 @@ async def _stream_round(
|
||||
# If we got payload but no finish_reason, treat as terminal (some providers
|
||||
# omit it); if empty and no finish_reason, flag as missing terminal.
|
||||
stream_terminal = saw_terminal or _assistant_has_payload(assistant)
|
||||
_validate_assistant_terminal(
|
||||
truncated = _validate_assistant_terminal(
|
||||
assistant,
|
||||
finish_reason=finish_reason,
|
||||
stream_terminal=stream_terminal,
|
||||
)
|
||||
yield AssistantMessageEvent(message=assistant)
|
||||
if truncated:
|
||||
yield TruncatedEvent(kept_chars=_kept_chars(assistant))
|
||||
yield UsageEvent(
|
||||
usage=resolve_round_usage(last_api_usage, param.messages, assistant),
|
||||
)
|
||||
@@ -408,6 +430,27 @@ async def _assistant_round(
|
||||
yield event
|
||||
|
||||
|
||||
def _keep_truncated_round(
|
||||
messages: list[AnyChatMessage],
|
||||
truncated: TruncatedEvent,
|
||||
*,
|
||||
continued: bool,
|
||||
) -> TruncatedEvent:
|
||||
"""Keep a round cut off at the output limit and tell the model where it stopped.
|
||||
|
||||
The answer and its thinking blocks stay in ``messages`` (the provider verifies
|
||||
those blocks, so dropping them would make the round uncontinuable), but the
|
||||
round's tool calls go: a half-written call must not run, its arguments may not
|
||||
even parse, and the model re-issues it after reading the notice. Returns the
|
||||
event to yield, with the continuation outcome filled in.
|
||||
"""
|
||||
last = messages[-1]
|
||||
if isinstance(last, AssistantChatMessage) and last.tool_calls is not UNSET and last.tool_calls:
|
||||
messages[-1] = msgspec.structs.replace(last, tool_calls=UNSET)
|
||||
messages.append(truncated_turn_notice().to_message())
|
||||
return msgspec.structs.replace(truncated, continued=continued)
|
||||
|
||||
|
||||
def _last_assistant(messages: list[AnyChatMessage], pre_len: int) -> AssistantChatMessage:
|
||||
if len(messages) <= pre_len:
|
||||
msg = "model round produced no assistant message"
|
||||
@@ -448,13 +491,14 @@ async def _maybe_inject_directive_checkpoints(
|
||||
return new_band
|
||||
|
||||
|
||||
async def run_chat_loop( # noqa: C901 — multi-phase tool loop
|
||||
async def run_chat_loop( # noqa: C901, PLR0915 — multi-phase tool loop
|
||||
client: AnyLLMClient,
|
||||
messages: list[AnyChatMessage],
|
||||
*,
|
||||
model: str,
|
||||
tools: ToolRegistry | None = None,
|
||||
max_rounds: int = DEFAULT_MAX_ROUNDS,
|
||||
max_truncation_continues: int = DEFAULT_MAX_TRUNCATION_CONTINUES,
|
||||
temperature: float | None = None,
|
||||
reasoning_effort: str = "",
|
||||
on_limit: LimitContinueHook | None = None,
|
||||
@@ -481,6 +525,12 @@ async def run_chat_loop( # noqa: C901 — multi-phase tool loop
|
||||
the provider's own default); each API surface maps it onto its own wire field
|
||||
(see ``config.reasoning``).
|
||||
|
||||
A round the provider cuts off at the output limit (``finish_reason=length``)
|
||||
is kept instead of failing the turn: its answer and thinking blocks stay in
|
||||
``messages``, its tool calls are dropped, and a truncation notice tells the
|
||||
model where it stopped. Up to *max_truncation_continues* rounds per turn then
|
||||
carry on from there (0 = stop, leaving the turn for ``/retry``).
|
||||
|
||||
*provider_tools* are hosted/provider-side tools (OpenAI Responses only, e.g.
|
||||
``web_search``) merged into the request as opaque dicts; never executed by the
|
||||
:class:`ToolRegistry`. Ignored by non-Responses clients.
|
||||
@@ -500,6 +550,7 @@ async def run_chat_loop( # noqa: C901 — multi-phase tool loop
|
||||
|
||||
rounds_used = 0
|
||||
allowance = max_rounds
|
||||
truncation_continues = 0
|
||||
# Calibrate soft-compact from last model call's prompt_tokens (API preferred).
|
||||
prompt_tokens_hint: int | None = None
|
||||
sent_estimate_tokens: int | None = None
|
||||
@@ -532,6 +583,7 @@ async def run_chat_loop( # noqa: C901 — multi-phase tool loop
|
||||
|
||||
pre_len = len(messages)
|
||||
last_usage_event: UsageEvent | None = None
|
||||
truncated: TruncatedEvent | None = None
|
||||
async for event in _assistant_round(
|
||||
client,
|
||||
param,
|
||||
@@ -544,9 +596,24 @@ async def run_chat_loop( # noqa: C901 — multi-phase tool loop
|
||||
prompt_tokens_hint = event.usage.prompt_tokens
|
||||
sent_estimate_tokens = sent_est
|
||||
last_usage_event = event
|
||||
if isinstance(event, TruncatedEvent):
|
||||
# Yielded below, once the continuation decision is known.
|
||||
truncated = event
|
||||
continue
|
||||
yield event
|
||||
|
||||
assistant = _last_assistant(messages, pre_len)
|
||||
if truncated is not None:
|
||||
# The provider cut the round off at the output limit: keep what it
|
||||
# wrote and tell the model where it stopped (see the helper).
|
||||
continued = truncation_continues < max_truncation_continues
|
||||
if continued:
|
||||
truncation_continues += 1
|
||||
yield _keep_truncated_round(messages, truncated, continued=continued)
|
||||
if continued:
|
||||
continue
|
||||
return
|
||||
|
||||
tool_calls = assistant.tool_calls
|
||||
has_tools = tool_calls is not UNSET and bool(tool_calls) and tools is not None
|
||||
if has_tools:
|
||||
|
||||
@@ -27,6 +27,11 @@ if TYPE_CHECKING:
|
||||
# developer messages (durable directive checkpoints share the role).
|
||||
NOTICE_MARKER = "[plyngent notice"
|
||||
|
||||
# Notice kind for a turn the provider cut off at the output limit. The turn's
|
||||
# partial answer is kept, so the agent reads such a turn as unfinished and
|
||||
# ``/retry`` continues it (see ``ChatAgent.pending_retry_text``).
|
||||
TRUNCATED_NOTICE_KIND = "truncated"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Notice:
|
||||
@@ -41,14 +46,18 @@ class Notice:
|
||||
return DeveloperChatMessage(content=f"{NOTICE_MARKER}: {self.kind}]\n{self.body.strip()}")
|
||||
|
||||
|
||||
def notice_of_kind(message: AnyChatMessage, kind: str) -> bool:
|
||||
"""Whether *message* is a notice of *kind*."""
|
||||
return isinstance(message, DeveloperChatMessage) and message.content.startswith(f"{NOTICE_MARKER}: {kind}]")
|
||||
|
||||
|
||||
def has_notice(messages: Sequence[AnyChatMessage], kind: str) -> bool:
|
||||
"""Whether *messages* already carries a notice of *kind*.
|
||||
|
||||
Notices are persisted with the transcript, so a host asks this before
|
||||
pushing one that belongs to the session rather than to an event.
|
||||
"""
|
||||
marker = f"{NOTICE_MARKER}: {kind}]"
|
||||
return any(isinstance(message, DeveloperChatMessage) and message.content.startswith(marker) for message in messages)
|
||||
return any(notice_of_kind(message, kind) for message in messages)
|
||||
|
||||
|
||||
def format_moment(moment: datetime | None) -> str:
|
||||
@@ -82,6 +91,27 @@ def resume_notice(*, last_active: datetime | None) -> Notice:
|
||||
return Notice(kind="resume", body=body, summary=summary)
|
||||
|
||||
|
||||
def truncated_turn_notice() -> Notice:
|
||||
"""Notice for a round the provider cut off at the output limit.
|
||||
|
||||
The partial answer is kept in the transcript (thinking blocks included, so
|
||||
the provider still verifies them), which is why the model has to be told the
|
||||
round was not finished: it continues from what it already wrote, re-issues
|
||||
the tool call that was dropped, and keeps the remainder short enough to fit.
|
||||
"""
|
||||
body = (
|
||||
"Your previous round was cut off at the output limit (max_tokens). The partial answer it wrote "
|
||||
"was kept in the transcript, and any tool call in that round was discarded — re-issue it if you "
|
||||
"still need it. Continue from where the answer stopped instead of writing it again from the "
|
||||
"start, and keep the rest short enough to finish: split the work, or hand off what is left."
|
||||
)
|
||||
return Notice(
|
||||
kind=TRUNCATED_NOTICE_KIND,
|
||||
body=body,
|
||||
summary="output truncated (max_tokens); partial answer kept",
|
||||
)
|
||||
|
||||
|
||||
_ASIDE_BODIES: dict[str, str] = {
|
||||
"read": (
|
||||
"Side question: this exchange is not saved to the session transcript and does not "
|
||||
|
||||
@@ -21,6 +21,7 @@ from plyngent.agent import (
|
||||
TextDeltaEvent,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
TruncatedEvent,
|
||||
UsageEvent,
|
||||
)
|
||||
from plyngent.lmproto.openai_compatible.model import AssistantFunctionToolCall
|
||||
@@ -1289,6 +1290,14 @@ async def render_events( # noqa: C901, PLR0915 — one branch per event type
|
||||
)
|
||||
else:
|
||||
click.secho(f"\n[max rounds reached: {event.rounds}]", fg="red")
|
||||
elif isinstance(event, TruncatedEvent):
|
||||
flush_assistant()
|
||||
tool_lines.close_line()
|
||||
tail = "continuing" if event.continued else "/retry continues from here"
|
||||
click.secho(
|
||||
f"\n[limit] output truncated ({event.kept_chars} chars kept); {tail}",
|
||||
fg="yellow",
|
||||
)
|
||||
elif isinstance(event, UsageEvent):
|
||||
_ = event
|
||||
else:
|
||||
|
||||
@@ -14,10 +14,12 @@ from plyngent.agent import (
|
||||
ToolCallEvent,
|
||||
ToolRegistry,
|
||||
ToolResultEvent,
|
||||
TruncatedEvent,
|
||||
UsageEvent,
|
||||
run_chat_loop,
|
||||
tool,
|
||||
)
|
||||
from plyngent.agent.notices import TRUNCATED_NOTICE_KIND, notice_of_kind
|
||||
from plyngent.config.models import DatabaseConfig
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
AnyChatMessage,
|
||||
@@ -987,20 +989,115 @@ async def test_empty_completion_raises() -> None:
|
||||
_ = [e async for e in run_chat_loop(client, messages, model="m", stream=False)]
|
||||
|
||||
|
||||
async def test_length_finish_raises() -> None:
|
||||
async def test_length_finish_keeps_the_partial_answer_and_continues() -> None:
|
||||
client = ScriptedClient(
|
||||
[
|
||||
_response(
|
||||
AssistantChatMessage(content="partial"),
|
||||
finish_reason="length",
|
||||
)
|
||||
_response(AssistantChatMessage(content="partial"), finish_reason="length"),
|
||||
_response(AssistantChatMessage(content="rest")),
|
||||
]
|
||||
)
|
||||
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||
with pytest.raises(RuntimeError, match="truncated"):
|
||||
events = [e async for e in run_chat_loop(client, messages, model="m", stream=False)]
|
||||
|
||||
truncated = [e for e in events if isinstance(e, TruncatedEvent)]
|
||||
assert len(truncated) == 1
|
||||
assert truncated[0].kept_chars == len("partial")
|
||||
assert truncated[0].continued
|
||||
# The partial answer stays in history under a truncation notice, and the
|
||||
# continuation round is sent that history — the model continues its answer
|
||||
# rather than writing it again from the start.
|
||||
assert isinstance(messages[1], AssistantChatMessage)
|
||||
assert messages[1].content == "partial"
|
||||
assert notice_of_kind(messages[2], TRUNCATED_NOTICE_KIND)
|
||||
assert isinstance(messages[3], AssistantChatMessage)
|
||||
assert messages[3].content == "rest"
|
||||
assert any(notice_of_kind(m, TRUNCATED_NOTICE_KIND) for m in client.calls[1].messages)
|
||||
|
||||
|
||||
async def test_length_finish_stops_once_the_continuation_budget_is_spent() -> None:
|
||||
client = ScriptedClient([_response(AssistantChatMessage(content="partial"), finish_reason="length")] * 2)
|
||||
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||
events = [
|
||||
e
|
||||
async for e in run_chat_loop(
|
||||
client,
|
||||
messages,
|
||||
model="m",
|
||||
stream=False,
|
||||
max_truncation_continues=1,
|
||||
)
|
||||
]
|
||||
|
||||
assert [e.continued for e in events if isinstance(e, TruncatedEvent)] == [True, False]
|
||||
assert len(client.calls) == 2
|
||||
# Nothing raised: the turn ends with the partial answer kept, so `/retry`
|
||||
# continues it instead of regenerating.
|
||||
assert isinstance(messages[-2], AssistantChatMessage)
|
||||
assert messages[-2].content == "partial"
|
||||
assert notice_of_kind(messages[-1], TRUNCATED_NOTICE_KIND)
|
||||
|
||||
|
||||
async def test_length_finish_drops_the_rounds_tool_calls() -> None:
|
||||
ran: list[str] = []
|
||||
|
||||
@tool(name="boom", register=False)
|
||||
async def _boom(argument: str) -> str:
|
||||
ran.append(argument)
|
||||
return "ran"
|
||||
|
||||
call = AssistantFunctionToolCall(
|
||||
id="c1",
|
||||
function=AssistantFunctionTool(name="boom", arguments='{"argument": "x"'),
|
||||
)
|
||||
client = ScriptedClient(
|
||||
[
|
||||
_response(
|
||||
AssistantChatMessage(content="partial", tool_calls=[call]),
|
||||
finish_reason="length",
|
||||
),
|
||||
_response(AssistantChatMessage(content="done")),
|
||||
]
|
||||
)
|
||||
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||
events = [e async for e in run_chat_loop(client, messages, model="m", tools=ToolRegistry([_boom]), stream=False)]
|
||||
|
||||
# A call cut off mid-write is not replayed: the model re-issues it after
|
||||
# reading the notice.
|
||||
assert ran == []
|
||||
assert not [e for e in events if isinstance(e, (ToolCallEvent, ToolResultEvent))]
|
||||
kept = messages[1]
|
||||
assert isinstance(kept, AssistantChatMessage)
|
||||
assert kept.content == "partial"
|
||||
assert kept.tool_calls is UNSET
|
||||
|
||||
|
||||
async def test_length_finish_without_content_still_raises() -> None:
|
||||
client = ScriptedClient([_response(AssistantChatMessage(content=None), finish_reason="length")])
|
||||
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||
with pytest.raises(RuntimeError, match="output limit reached with nothing written"):
|
||||
_ = [e async for e in run_chat_loop(client, messages, model="m", stream=False)]
|
||||
|
||||
|
||||
async def test_truncated_turn_is_retryable() -> None:
|
||||
store = await MemoryStore.open(DatabaseConfig())
|
||||
session = await store.create_session(name="t")
|
||||
client = ScriptedClient(
|
||||
[
|
||||
_response(AssistantChatMessage(content="partial"), finish_reason="length"),
|
||||
_response(AssistantChatMessage(content="rest")),
|
||||
]
|
||||
)
|
||||
agent = ChatAgent(client, model="m", memory=store, session_id=session.sid, max_truncation_continues=0, stream=False)
|
||||
_ = [e async for e in agent.run("hi")]
|
||||
|
||||
# Kept, not rolled back: the turn is unfinished, so retry continues it.
|
||||
assert agent.pending_retry_text == "hi"
|
||||
_ = [e async for e in agent.retry()]
|
||||
assert any(notice_of_kind(m, TRUNCATED_NOTICE_KIND) for m in client.calls[1].messages)
|
||||
assert agent.pending_retry_text is None
|
||||
await store.close()
|
||||
|
||||
|
||||
async def test_content_filter_finish_raises() -> None:
|
||||
client = ScriptedClient(
|
||||
[
|
||||
|
||||
@@ -2,7 +2,16 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta, timezone
|
||||
|
||||
from plyngent.agent import ChatAgent, Notice, aside_notice, format_moment, has_notice, resume_notice, skill_tools_notice
|
||||
from plyngent.agent import (
|
||||
ChatAgent,
|
||||
Notice,
|
||||
aside_notice,
|
||||
format_moment,
|
||||
has_notice,
|
||||
resume_notice,
|
||||
skill_tools_notice,
|
||||
truncated_turn_notice,
|
||||
)
|
||||
from plyngent.agent.chat import incomplete_turn_user_text
|
||||
from plyngent.config.models import DatabaseConfig
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
@@ -124,3 +133,26 @@ def test_trailing_developer_message_keeps_incomplete_turn_detectable() -> None:
|
||||
# A finished turn (trailing assistant) stays non-retryable past a notice.
|
||||
done = [UserChatMessage(content="do it"), AssistantChatMessage(content="ok"), notice]
|
||||
assert incomplete_turn_user_text(done) is None
|
||||
|
||||
|
||||
def test_truncated_turn_notice_keeps_the_partial_answer_retryable() -> None:
|
||||
"""A kept partial answer is unfinished work, so retry continues it."""
|
||||
tail = [
|
||||
UserChatMessage(content="do it"),
|
||||
AssistantChatMessage(content="partial answer"),
|
||||
truncated_turn_notice().to_message(),
|
||||
]
|
||||
assert incomplete_turn_user_text(tail) == "do it"
|
||||
# The same tail without the truncation notice is a finished turn.
|
||||
assert incomplete_turn_user_text(tail[:2]) is None
|
||||
|
||||
|
||||
def test_truncated_turn_notice_tells_the_model_where_it_stopped() -> None:
|
||||
notice = truncated_turn_notice()
|
||||
assert notice.kind == "truncated"
|
||||
assert notice.to_message().content.startswith("[plyngent notice: truncated]")
|
||||
assert "max_tokens" in notice.body
|
||||
# The dropped call and the kept answer are both spelled out.
|
||||
assert "discarded" in notice.body
|
||||
assert "kept in the transcript" in notice.body
|
||||
assert "max_tokens" in notice.summary
|
||||
|
||||
@@ -2,7 +2,14 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.agent import ErrorEvent, ReasoningDeltaEvent, TextDeltaEvent, ToolCallEvent, ToolResultEvent
|
||||
from plyngent.agent import (
|
||||
ErrorEvent,
|
||||
ReasoningDeltaEvent,
|
||||
TextDeltaEvent,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
TruncatedEvent,
|
||||
)
|
||||
from plyngent.cli.display import (
|
||||
_RUN_BATCH_CMD_LIMIT,
|
||||
_clear_streamed_lines,
|
||||
@@ -1257,3 +1264,12 @@ async def test_pretty_prefix_closed_before_error_line(
|
||||
assert "* Read 'a.txt' \n" in out
|
||||
assert "[error] source=tool boom\n" in out
|
||||
assert "* Read 'a.txt' (error)\n" in out
|
||||
|
||||
|
||||
async def test_render_truncated_round(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""A round cut off at the output limit says so, and how the turn goes on."""
|
||||
await render_events(_aiter([TruncatedEvent(kept_chars=42, continued=True)]))
|
||||
assert "[limit] output truncated (42 chars kept); continuing\n" in capsys.readouterr().out
|
||||
|
||||
await render_events(_aiter([TruncatedEvent(kept_chars=42, continued=False)]))
|
||||
assert "[limit] output truncated (42 chars kept); /retry continues from here\n" in capsys.readouterr().out
|
||||
|
||||
Reference in New Issue
Block a user