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.
This commit is contained in:
2026-10-29 15:17:12 +08:00
parent 7d29498dfa
commit 7abc7c2cb8
4 changed files with 44 additions and 16 deletions
+25 -9
View File
@@ -23,9 +23,14 @@ if TYPE_CHECKING:
DEFAULT_TOOL_RESULT_MAX_CHARS = 32_000 DEFAULT_TOOL_RESULT_MAX_CHARS = 32_000
# Storage ceiling for one tool result kept whole in its message. A result at or # 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 # 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 # above it keeps its head and says at that head how much was dropped, since the
# a message keeps. # dropped tail is past what a message keeps and no document can serve it.
DEFAULT_HARD_MAX_TOOL_RESULT_CHARS = 2_000_000 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). # Soft context budget in tokens (API-calibrated when possible; else ~4 chars/token).
DEFAULT_CONTEXT_MAX_TOKENS = 200_000 DEFAULT_CONTEXT_MAX_TOKENS = 200_000
DEFAULT_OLD_TOOL_RESULT_CHARS = 800 DEFAULT_OLD_TOOL_RESULT_CHARS = 800
@@ -41,18 +46,26 @@ IMAGE_CHARS = 4_096
DEFAULT_CONTEXT_MAX_CHARS = DEFAULT_CONTEXT_MAX_TOKENS * 4 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: 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). """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 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 stored remainder; this ceiling bounds the stored body itself. A result above
cut here and carries no document, since its remainder is past what a message it keeps its head behind a leading notice that says how much was dropped: the
keeps. ``max_chars < 1`` stores the result whole. 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: if max_chars < 1 or len(text) <= max_chars:
return text return text
omitted = len(text) - max_chars 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: 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 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``) 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 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: if max_chars < 1 or len(message.content) <= max_chars:
return message return message
from plyngent.tools.documents import document_marker from plyngent.tools.documents import document_marker
total = len(message.content) total = len(message.content)
head_len = max(1, max_chars - len(document_marker(message.tool_call_id, max_chars, total))) cut = cut_at_storage_ceiling(message.content)
marker = document_marker(message.tool_call_id, head_len, total) 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) return msgspec.structs.replace(message, content=message.content[:head_len] + marker)
+3 -2
View File
@@ -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 over ``max_context_tokens``, then every tool result is trimmed to
``max_tool_result_chars`` with a marker naming its document (``read_document`` ``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 / ``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 message, up to ``hard_max_tool_result_chars``; a result above that ceiling
cut on store and has no document. 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, *reasoning_effort* is the resolved thinking strength (``""`` = send nothing,
the provider's own default); each API surface maps it onto its own wire field the provider's own default); each API surface maps it onto its own wire field
+3 -2
View File
@@ -153,8 +153,9 @@ class AgentConfig(Struct, omit_defaults=True):
mcp_instructions: bool = True mcp_instructions: bool = True
max_tool_result_chars: int = 32_000 max_tool_result_chars: int = 32_000
# Storage ceiling for one tool result kept whole in its message. The wire cap # 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; # above trims a result on the request copy and names a document for the stored
# a result above this ceiling is cut on store and has no document. # 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 hard_max_tool_result_chars: int = 2_000_000
parallel_tools: bool = True parallel_tools: bool = True
confirm_destructive: bool = True confirm_destructive: bool = True
+13 -3
View File
@@ -40,10 +40,20 @@ class Document:
return len(self.text.splitlines()) return len(self.text.splitlines())
def document_marker(document_id: str, shown: int, total: int) -> str: 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.""" """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 ( 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"Continue with read_document(document_id, offset=...) or "
f"search_document(document_id, pattern=...).]" f"search_document(document_id, pattern=...).]"
) )