From 7abc7c2cb8030947b2d9302775c675d4d5b508a7 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Thu, 29 Oct 2026 15:17:12 +0800 Subject: [PATCH] core/agent: lead a ceiling-cut tool result with its dropped-tail notice The storage ceiling's notice trailed the head it was cut from, and the wire cap sends the head: the only way to learn that a result had been cut was to fetch a 2 MB body back through its document, which reads to the very end. The notice now leads, says the tail is not readable, and `cap_tool_message` names the same fact in the document marker, so neither the head nor the marker presents a cut body as the whole output. --- src/plyngent/agent/budget.py | 34 ++++++++++++++++++++++++--------- src/plyngent/agent/loop.py | 5 +++-- src/plyngent/config/models.py | 5 +++-- src/plyngent/tools/documents.py | 16 +++++++++++++--- 4 files changed, 44 insertions(+), 16 deletions(-) diff --git a/src/plyngent/agent/budget.py b/src/plyngent/agent/budget.py index 32fa5e0..b7eee5c 100644 --- a/src/plyngent/agent/budget.py +++ b/src/plyngent/agent/budget.py @@ -23,9 +23,14 @@ if TYPE_CHECKING: DEFAULT_TOOL_RESULT_MAX_CHARS = 32_000 # Storage ceiling for one tool result kept whole in its message. A result at or # below this is stored in full (the request-prep cap trims it on the wire); one -# above it is cut here and carries no document, since its remainder is past what -# a message keeps. +# above it keeps its head and says at that head how much was dropped, since the +# dropped tail is past what a message keeps and no document can serve it. DEFAULT_HARD_MAX_TOOL_RESULT_CHARS = 2_000_000 +# Leading notice of a body the storage ceiling cut. It leads rather than trails +# because the wire cap sends the head only: a notice at the end of a 2 MB body +# would be readable only by fetching the whole thing back through its document. +_CEILING_NOTICE_HEAD = "[Stored at the {max_chars}-char ceiling: {omitted} " +_CEILING_NOTICE_TAIL = "chars beyond it were dropped and cannot be read back]" # Soft context budget in tokens (API-calibrated when possible; else ~4 chars/token). DEFAULT_CONTEXT_MAX_TOKENS = 200_000 DEFAULT_OLD_TOOL_RESULT_CHARS = 800 @@ -41,18 +46,26 @@ IMAGE_CHARS = 4_096 DEFAULT_CONTEXT_MAX_CHARS = DEFAULT_CONTEXT_MAX_TOKENS * 4 +def cut_at_storage_ceiling(text: str) -> bool: + """Whether a stored tool body was cut by :func:`cap_hard_tool_text`.""" + head, _, _ = text.partition("\n") + return head.startswith("[Stored at the ") and head.endswith(_CEILING_NOTICE_TAIL) + + def cap_hard_tool_text(text: str, max_chars: int = DEFAULT_HARD_MAX_TOOL_RESULT_CHARS) -> str: """Bound what one tool message stores (the storage ceiling). The request-prep pass trims a result on the wire and names a document for the - remainder; this ceiling bounds the stored body itself. A result above it is - cut here and carries no document, since its remainder is past what a message - keeps. ``max_chars < 1`` stores the result whole. + stored remainder; this ceiling bounds the stored body itself. A result above + it keeps its head behind a leading notice that says how much was dropped: the + dropped tail is past what a message keeps, so no document holds it and a + reader has to be told that up front. ``max_chars < 1`` stores the result whole. """ if max_chars < 1 or len(text) <= max_chars: return text omitted = len(text) - max_chars - return f"{text[:max_chars]}\n[Stored at the {max_chars}-char ceiling: {omitted} chars beyond it]" + notice = _CEILING_NOTICE_HEAD.format(max_chars=max_chars, omitted=omitted) + _CEILING_NOTICE_TAIL + return f"{notice}\n{text[:max_chars]}" def cap_tool_message(message: ToolChatMessage, max_chars: int) -> ToolChatMessage: @@ -61,15 +74,18 @@ def cap_tool_message(message: ToolChatMessage, max_chars: int) -> ToolChatMessag The fixed rule of the request-prep pass: a result over ``max_chars`` keeps its head and gains a marker whose ``document_id`` (the message's ``tool_call_id``) lets the model read the rest with ``read_document`` / ``search_document``. The - stored message stays whole; only the returned copy is trimmed. + stored message stays whole; only the returned copy is trimmed. A body the + storage ceiling already cut says so in the marker too, so the document is not + mistaken for the complete output. """ if max_chars < 1 or len(message.content) <= max_chars: return message from plyngent.tools.documents import document_marker total = len(message.content) - head_len = max(1, max_chars - len(document_marker(message.tool_call_id, max_chars, total))) - marker = document_marker(message.tool_call_id, head_len, total) + cut = cut_at_storage_ceiling(message.content) + head_len = max(1, max_chars - len(document_marker(message.tool_call_id, max_chars, total, stored_cut=cut))) + marker = document_marker(message.tool_call_id, head_len, total, stored_cut=cut) return msgspec.structs.replace(message, content=message.content[:head_len] + marker) diff --git a/src/plyngent/agent/loop.py b/src/plyngent/agent/loop.py index 1c593ef..54a1695 100644 --- a/src/plyngent/agent/loop.py +++ b/src/plyngent/agent/loop.py @@ -555,8 +555,9 @@ async def run_chat_loop( # noqa: C901, PLR0915 — multi-phase tool loop over ``max_context_tokens``, then every tool result is trimmed to ``max_tool_result_chars`` with a marker naming its document (``read_document`` / ``search_document`` read the rest). A tool result is stored whole in its - message, up to ``hard_max_tool_result_chars``; a result above that ceiling is - cut on store and has no document. + message, up to ``hard_max_tool_result_chars``; a result above that ceiling + keeps its head and says at that head how much was dropped, and its document + cannot serve the dropped tail. *reasoning_effort* is the resolved thinking strength (``""`` = send nothing, the provider's own default); each API surface maps it onto its own wire field diff --git a/src/plyngent/config/models.py b/src/plyngent/config/models.py index 2ce77c2..09a4d54 100644 --- a/src/plyngent/config/models.py +++ b/src/plyngent/config/models.py @@ -153,8 +153,9 @@ class AgentConfig(Struct, omit_defaults=True): mcp_instructions: bool = True max_tool_result_chars: int = 32_000 # Storage ceiling for one tool result kept whole in its message. The wire cap - # above trims a result on the request copy and names a document for the rest; - # a result above this ceiling is cut on store and has no document. + # above trims a result on the request copy and names a document for the stored + # remainder; a result above this ceiling keeps its head behind a notice saying + # how much was dropped (the document cannot serve that tail). hard_max_tool_result_chars: int = 2_000_000 parallel_tools: bool = True confirm_destructive: bool = True diff --git a/src/plyngent/tools/documents.py b/src/plyngent/tools/documents.py index 99361f1..3aaf0fb 100644 --- a/src/plyngent/tools/documents.py +++ b/src/plyngent/tools/documents.py @@ -40,10 +40,20 @@ class Document: return len(self.text.splitlines()) -def document_marker(document_id: str, shown: int, total: int) -> str: - """The request-prep cap hint naming the document that holds the remainder.""" +def document_marker(document_id: str, shown: int, total: int, *, stored_cut: bool = False) -> str: + """The request-prep cap hint naming the document that holds the remainder. + + *total* counts what the stored message holds, so it is the end of what the + document can serve. ``stored_cut`` says the storage ceiling already dropped a + tail, which the document cannot serve either. + """ + cut = ( + " The stored body was itself cut at the storage ceiling, so part of the output is not in the document." + if stored_cut + else "" + ) return ( - f"\n[Truncated: {shown} of {total} chars shown. document_id={document_id}. " + f"\n[Truncated: {shown} of {total} chars shown.{cut} document_id={document_id}. " f"Continue with read_document(document_id, offset=...) or " f"search_document(document_id, pattern=...).]" )