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
# 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)
+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
``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
+3 -2
View File
@@ -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
+13 -3
View File
@@ -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=...).]"
)