core/agent: token-ify generic tool-result cap and compact shrinks

This commit is contained in:
2026-08-11 18:50:10 +08:00
parent ec1ae59a28
commit 6fdfb8398e
3 changed files with 26 additions and 35 deletions
+10 -14
View File
@@ -31,27 +31,23 @@ DEFAULT_CONTEXT_MAX_CHARS = DEFAULT_CONTEXT_MAX_TOKENS * 4
def truncate_tool_result( def truncate_tool_result(
text: str, text: str,
max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS, max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
*,
hint: bool = True,
) -> str: ) -> str:
"""Cap tool output so huge dumps do not flood model context. """Cap tool output so huge dumps do not flood model context.
This is the generic agent-loop cap for arbitrary tool results — there is no This is the generic agent-loop cap for arbitrary tool results. It embeds a
resumable source, so no ``TRUNCATE_TOKEN`` is emitted here. Token-capable memory truncate token via :func:`truncate_generic` so the model can always
sources (``read_file`` / ``fetch`` / ``get_truncated``) embed their own resume the remainder with ``get_truncated`` — resumable or not (remainders
``[TRUNCATE_TOKEN: ...]`` marker via :func:`truncate_with_token`, and only live in the in-memory store for the process lifetime). The compact marker
there does the result point the model at ``get_truncated`` (hint and token ``[Truncated (N chars max.; M omitted). truncate_token=...]`` is emitted
always appear together). ``hint=False`` keeps the terse marker for internal only when output is actually cut.
shrinks whose cap is not a user-facing knob (e.g. request-time compacting).
""" """
if max_chars < 1: if max_chars < 1:
return text return text
if len(text) <= max_chars: if len(text) <= max_chars:
return text return text
omitted = len(text) - max_chars from plyngent.tools.truncate_token import truncate_generic
if hint:
return f"{text[:max_chars]}\n...[Truncated ({max_chars} chars max.; {omitted} chars omitted)]" return truncate_generic(text, max_chars)
return f"{text[:max_chars]}\n...[truncated {omitted} characters]"
def estimate_message_chars(message: AnyChatMessage) -> int: def estimate_message_chars(message: AnyChatMessage) -> int:
@@ -119,7 +115,7 @@ def _shrink_tool(message: ToolChatMessage, max_chars: int) -> ToolChatMessage:
return message return message
return msgspec.structs.replace( return msgspec.structs.replace(
message, message,
content=truncate_tool_result(message.content, max_chars, hint=False), content=truncate_tool_result(message.content, max_chars),
) )
+6 -10
View File
@@ -11,21 +11,17 @@ def test_truncate_tool_result_short() -> None:
def test_truncate_tool_result_long() -> None: def test_truncate_tool_result_long() -> None:
text = "a" * 50 text = "a" * 50
out = truncate_tool_result(text, 20) out = truncate_tool_result(text, 20)
assert out.startswith("a" * 20)
assert "Truncated" in out assert "Truncated" in out
assert "20" in out assert "20" in out
assert "30" in out assert "30" in out
# Generic tool results carry no resumable source → no token, no get_truncated. # Generic tool results now embed a memory token too (resumable via get_truncated).
assert "get_truncated" not in out assert "truncate_token=" in out
assert "TRUNCATE_TOKEN" not in out assert out.startswith(("a" * 10, "a" * 20)) # marker is budgeted
def test_truncate_tool_result_no_hint() -> None: def test_truncate_tool_result_fits_no_marker() -> None:
text = "a" * 50 assert truncate_tool_result("tiny", 100) == "tiny"
out = truncate_tool_result(text, 20, hint=False) assert "Truncated" not in truncate_tool_result("tiny", 100)
assert out.startswith("a" * 20)
assert "truncated" in out
assert "get_truncated" not in out
def test_estimate_message_chars() -> None: def test_estimate_message_chars() -> None:
+10 -11
View File
@@ -40,21 +40,18 @@ def test_truncate_tool_result_short() -> None:
def test_truncate_tool_result_long() -> None: def test_truncate_tool_result_long() -> None:
text = "a" * 50 text = "a" * 50
out = truncate_tool_result(text, 20) out = truncate_tool_result(text, 20)
assert out.startswith("a" * 20)
assert "Truncated" in out assert "Truncated" in out
assert "20" in out assert "20" in out
assert "30" in out assert "30" in out
# Generic tool results carry no resumable source → no token, no get_truncated. # Generic tool results now embed a memory token too (resumable via get_truncated).
assert "get_truncated" not in out assert "truncate_token=" in out
assert "TRUNCATE_TOKEN" not in out
def test_truncate_tool_result_no_hint() -> None: def test_truncate_tool_result_no_hint() -> None:
text = "a" * 50 text = "a" * 50
out = truncate_tool_result(text, 20, hint=False) out = truncate_tool_result(text, 20)
assert out.startswith("a" * 20) assert "Truncated" in out
assert "truncated" in out assert "truncate_token=" in out
assert "get_truncated" not in out
def test_compact_shrinks_old_tool_results() -> None: def test_compact_shrinks_old_tool_results() -> None:
@@ -94,7 +91,9 @@ def test_compact_shrinks_old_tool_results() -> None:
) )
assert isinstance(compacted[2], ToolChatMessage) assert isinstance(compacted[2], ToolChatMessage)
assert len(compacted[2].content) < original_len assert len(compacted[2].content) < original_len
assert "truncated" in compacted[2].content assert "Truncated" in compacted[2].content
# Compact shrinks carry a token too (resumable via get_truncated).
assert "truncate_token=" in compacted[2].content
# Full history unchanged # Full history unchanged
assert isinstance(messages[2], ToolChatMessage) assert isinstance(messages[2], ToolChatMessage)
assert len(messages[2].content) == original_len assert len(messages[2].content) == original_len
@@ -134,7 +133,7 @@ def test_compact_uses_api_calibration() -> None:
# Raw est under budget → no compact # Raw est under budget → no compact
no_api = compact_messages_for_request(messages, max_tokens=est + 100) no_api = compact_messages_for_request(messages, max_tokens=est + 100)
assert isinstance(no_api[1], ToolChatMessage) assert isinstance(no_api[1], ToolChatMessage)
assert "truncated" not in no_api[1].content assert "Truncated" not in no_api[1].content
# Calibrate so measured size is 5x → over a mid budget → shrink old tool # Calibrate so measured size is 5x → over a mid budget → shrink old tool
compacted = compact_messages_for_request( compacted = compact_messages_for_request(
messages, messages,
@@ -147,7 +146,7 @@ def test_compact_uses_api_calibration() -> None:
assert isinstance(compacted[1], ToolChatMessage) assert isinstance(compacted[1], ToolChatMessage)
old = messages[1] old = messages[1]
assert isinstance(old, ToolChatMessage) assert isinstance(old, ToolChatMessage)
assert "truncated" in compacted[1].content or len(compacted[1].content) < len(old.content) assert "Truncated" in compacted[1].content or len(compacted[1].content) < len(old.content)
def _response(message: AssistantChatMessage) -> ChatCompletionResponse: def _response(message: AssistantChatMessage) -> ChatCompletionResponse: