core/agent: cap tool results in the request-prep pass

A tool result now stays whole in its message (up to a storage ceiling) and
is trimmed on the request copy by the request-prep pass, where soft compact
already runs. The trimmed copy names the document that holds the rest, so
every surface (chat-completions, Responses, Messages) shares one rule.

New [agent] hard_max_tool_result_chars bounds the stored body; the wire cap
stays [agent] max_tool_result_chars.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
2026-10-29 10:04:32 +08:00
parent dfaaad563c
commit 63109ef9f2
12 changed files with 158 additions and 95 deletions
+37 -15
View File
@@ -1,27 +1,49 @@
from __future__ import annotations
from plyngent.agent.budget import estimate_message_chars, truncate_tool_result
from plyngent.agent.budget import (
cap_hard_tool_text,
cap_tool_message,
estimate_message_chars,
truncate_for_request,
)
from plyngent.lmproto.openai_compatible.model import ToolChatMessage, UserChatMessage
def test_truncate_tool_result_short() -> None:
assert truncate_tool_result("hello", 100) == "hello"
def test_cap_hard_tool_text_short_untouched() -> None:
assert cap_hard_tool_text("hello", 100) == "hello"
def test_truncate_tool_result_long() -> None:
text = "a" * 50
out = truncate_tool_result(text, 20)
assert "Truncated" in out
assert "20" in out
assert "30" in out
# Generic tool results now embed a memory token too (resumable via get_truncated).
assert "truncate_token=" in out
assert out.startswith(("a" * 10, "a" * 20)) # marker is budgeted
def test_cap_hard_tool_text_cuts_with_ceiling_marker() -> None:
out = cap_hard_tool_text("a" * 50, 20)
assert out.startswith("a" * 20)
assert "[Stored at the 20-char ceiling: 30 chars beyond it]" in out
def test_truncate_tool_result_fits_no_marker() -> None:
assert truncate_tool_result("tiny", 100) == "tiny"
assert "Truncated" not in truncate_tool_result("tiny", 100)
def test_cap_tool_message_names_the_document() -> None:
message = ToolChatMessage(content="a" * 50, tool_call_id="call-7")
capped = cap_tool_message(message, 20)
assert capped is not message
assert capped.tool_call_id == "call-7"
assert "document_id=call-7" in capped.content
assert capped.content.startswith("a")
# The stored message keeps its body whole.
assert message.content == "a" * 50
def test_cap_tool_message_short_untouched() -> None:
message = ToolChatMessage(content="tiny", tool_call_id="call-1")
assert cap_tool_message(message, 100) is message
def test_truncate_for_request_caps_only_tool_messages() -> None:
user = UserChatMessage(content="u" * 1000)
tool = ToolChatMessage(content="t" * 1000, tool_call_id="call-1")
out = truncate_for_request([user, tool], max_tool_result_chars=200)
assert out[0] is user
capped = out[1]
assert isinstance(capped, ToolChatMessage)
assert "document_id=call-1" in capped.content
assert len(capped.content) <= 200
def test_estimate_message_chars() -> None:
+2 -24
View File
@@ -8,7 +8,6 @@ from plyngent.agent.budget import (
compact_messages_for_request,
estimate_messages_tokens,
measure_messages_tokens,
truncate_tool_result,
)
from plyngent.agent.loop import run_chat_loop
from plyngent.lmproto.openai_compatible.model import (
@@ -33,27 +32,6 @@ if TYPE_CHECKING:
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
def test_truncate_tool_result_short() -> None:
assert truncate_tool_result("hello", 100) == "hello"
def test_truncate_tool_result_long() -> None:
text = "a" * 50
out = truncate_tool_result(text, 20)
assert "Truncated" in out
assert "20" in out
assert "30" in out
# Generic tool results now embed a memory token too (resumable via get_truncated).
assert "truncate_token=" in out
def test_truncate_tool_result_no_hint() -> None:
text = "a" * 50
out = truncate_tool_result(text, 20)
assert "Truncated" in out
assert "truncate_token=" in out
def test_compact_shrinks_old_tool_results() -> None:
messages: list[AnyChatMessage] = [
UserChatMessage(content="start"),
@@ -92,8 +70,8 @@ def test_compact_shrinks_old_tool_results() -> None:
assert isinstance(compacted[2], ToolChatMessage)
assert len(compacted[2].content) < original_len
assert "Truncated" in compacted[2].content
# Compact shrinks carry a token too (resumable via get_truncated).
assert "truncate_token=" in compacted[2].content
# A shrunk result names its document (read_document / search_document).
assert "document_id=1" in compacted[2].content
# Full history unchanged
assert isinstance(messages[2], ToolChatMessage)
assert len(messages[2].content) == original_len
+11 -6
View File
@@ -1065,7 +1065,7 @@ async def test_chat_agent_system_prompt_prepended() -> None:
async def test_tool_result_char_budget() -> None:
@tool(register=False)
def big() -> str:
return "x" * 100
return "x" * 2000
registry = ToolRegistry([big])
client = ScriptedClient(
@@ -1093,16 +1093,21 @@ async def test_tool_result_char_budget() -> None:
model="m",
tools=registry,
stream=False,
max_tool_result_chars=20,
max_tool_result_chars=200,
parallel_tools=False,
)
]
from plyngent.lmproto.openai_compatible.model import ToolChatMessage
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
# The stored result stays whole; only the wire copy is capped, and it names
# the document that reads the rest.
stored = [m for m in messages if isinstance(m, ToolChatMessage)]
assert len(stored) == 1
assert stored[0].content == "x" * 2000
wire = next(m for m in client.calls[-1].messages if isinstance(m, ToolChatMessage))
assert wire.content.startswith("x")
assert "document_id=1" in wire.content
assert len(wire.content) < 2000
async def test_empty_completion_raises() -> None:
+5 -5
View File
@@ -71,7 +71,7 @@ async def test_an_unknown_tool_is_error_text_without_images() -> None:
async def test_the_loop_keeps_tool_images_on_the_tool_message() -> None:
message, err = await _run_one_tool(_registry(), _call("shot"), max_result_chars=32_000)
message, err = await _run_one_tool(_registry(), _call("shot"), hard_max_chars=32_000)
assert err is None
assert isinstance(message, ToolChatMessage)
@@ -80,16 +80,16 @@ async def test_the_loop_keeps_tool_images_on_the_tool_message() -> None:
async def test_the_loop_leaves_the_images_field_unset_for_text_tools() -> None:
message, err = await _run_one_tool(_registry(), _call("plain"), max_result_chars=32_000)
message, err = await _run_one_tool(_registry(), _call("plain"), hard_max_chars=32_000)
assert err is None
assert message.images is UNSET
async def test_a_truncated_tool_result_keeps_its_images() -> None:
message, _ = await _run_one_tool(_registry(), _call("shot"), max_result_chars=1)
async def test_a_tool_result_above_the_storage_ceiling_keeps_its_images() -> None:
message, _ = await _run_one_tool(_registry(), _call("shot"), hard_max_chars=1)
assert "[Truncated" in message.content
assert "[Stored at the 1-char ceiling" in message.content
assert message.images == [_image()]