From 3fe1c4cf91098d76cee919eaf9a66108f9beb203 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Sat, 1 Aug 2026 01:42:17 +0800 Subject: [PATCH] core/agent: add model-friendly hint to truncated tool results --- CLAUDE.md | 2 +- src/plyngent/agent/budget.py | 23 ++++++++++++++++++++--- tests/test_agent/test_budget.py | 12 +++++++++++- tests/test_agent/test_compact.py | 12 +++++++++++- tests/test_agent/test_loop.py | 2 +- 5 files changed, 44 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ee78161..d432fbb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,7 +76,7 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite ` - **`/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`). - 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), `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens). +- 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), `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. ### Tools (`tools/`) diff --git a/src/plyngent/agent/budget.py b/src/plyngent/agent/budget.py index 34a7c98..5135c71 100644 --- a/src/plyngent/agent/budget.py +++ b/src/plyngent/agent/budget.py @@ -28,13 +28,30 @@ DEFAULT_RECENT_TOOL_RESULTS = 12 DEFAULT_CONTEXT_MAX_CHARS = DEFAULT_CONTEXT_MAX_TOKENS * 4 -def truncate_tool_result(text: str, max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS) -> str: - """Cap tool output so huge dumps do not flood model context.""" +def truncate_tool_result( + text: str, + max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS, + *, + hint: bool = True, +) -> str: + """Cap tool output so huge dumps do not flood model context. + + With ``hint=True`` the suffix tells the model the result was cut and that + it may ask the user to raise the config limit (``max_tool_result_chars``). + ``hint=False`` keeps the terse marker for internal shrinks whose cap is not + a user-facing config knob (e.g. request-time compacting). + """ if max_chars < 1: return text if len(text) <= max_chars: return text omitted = len(text) - max_chars + if hint: + return ( + f"{text[:max_chars]}\n" + f"...[Truncated ({max_chars} chars max.; {omitted} chars omitted). " + f"You may ask for user to increase the limit via configuration file.]" + ) return f"{text[:max_chars]}\n...[truncated {omitted} characters]" @@ -103,7 +120,7 @@ def _shrink_tool(message: ToolChatMessage, max_chars: int) -> ToolChatMessage: return message return msgspec.structs.replace( message, - content=truncate_tool_result(message.content, max_chars), + content=truncate_tool_result(message.content, max_chars, hint=False), ) diff --git a/tests/test_agent/test_budget.py b/tests/test_agent/test_budget.py index b4eb18b..6fe5a99 100644 --- a/tests/test_agent/test_budget.py +++ b/tests/test_agent/test_budget.py @@ -12,8 +12,18 @@ def test_truncate_tool_result_long() -> None: text = "a" * 50 out = truncate_tool_result(text, 20) assert out.startswith("a" * 20) - assert "truncated" in out + assert "Truncated" in out + assert "20" in out assert "30" in out + assert "configuration file" in out + + +def test_truncate_tool_result_no_hint() -> None: + text = "a" * 50 + out = truncate_tool_result(text, 20, hint=False) + assert out.startswith("a" * 20) + assert "truncated" in out + assert "configuration file" not in out def test_estimate_message_chars() -> None: diff --git a/tests/test_agent/test_compact.py b/tests/test_agent/test_compact.py index 6fafeb0..1c12e2b 100644 --- a/tests/test_agent/test_compact.py +++ b/tests/test_agent/test_compact.py @@ -41,8 +41,18 @@ def test_truncate_tool_result_long() -> None: text = "a" * 50 out = truncate_tool_result(text, 20) assert out.startswith("a" * 20) - assert "truncated" in out + assert "Truncated" in out + assert "20" in out assert "30" in out + assert "configuration file" in out + + +def test_truncate_tool_result_no_hint() -> None: + text = "a" * 50 + out = truncate_tool_result(text, 20, hint=False) + assert out.startswith("a" * 20) + assert "truncated" in out + assert "configuration file" not in out def test_compact_shrinks_old_tool_results() -> None: diff --git a/tests/test_agent/test_loop.py b/tests/test_agent/test_loop.py index cce5119..189e5f8 100644 --- a/tests/test_agent/test_loop.py +++ b/tests/test_agent/test_loop.py @@ -848,7 +848,7 @@ async def test_tool_result_char_budget() -> None: tool_msgs = [m for m in messages if isinstance(m, ToolChatMessage)] assert len(tool_msgs) == 1 assert tool_msgs[0].content.startswith("x" * 20) - assert "truncated" in tool_msgs[0].content + assert "Truncated" in tool_msgs[0].content async def test_empty_completion_raises() -> None: