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