mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 15:37:55 +08:00
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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user