tools: keep tool output whole and let the request-prep pass cap it

run_argv, run_argv_batch, fetch, fetch_local, and the vcs tools now return
their output whole, so the request-prep cap trims it on the wire and names
its document. A fetched page or a long log reads on from the tool message,
with no re-fetch and no re-run.

The run_argv_batch run budget stays as an execution guard: it stops a
runaway batch from spawning further huge-output steps.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
2026-10-29 14:13:36 +08:00
parent 63109ef9f2
commit 7d6e367143
10 changed files with 60 additions and 143 deletions
+9 -15
View File
@@ -9,7 +9,6 @@ from plyngent.tools.net.client import FetchResult, http_fetch
from plyngent.tools.net.policy import (
DEFAULT_MAX_BODY_CHARS_IN,
DEFAULT_MAX_BYTES,
DEFAULT_MAX_CHARS,
DEFAULT_MAX_REDIRECTS,
DEFAULT_TIMEOUT_SECONDS,
FetchPolicyError,
@@ -18,7 +17,6 @@ from plyngent.tools.net.policy import (
parse_fetch_url,
soft_confirm_reason,
)
from plyngent.tools.truncate_token import truncate_to_memory
from plyngent.tools.workspace import image_input_refusal
@@ -35,15 +33,13 @@ def format_fetch_result(
security: str,
warnings: list[str],
body_kind: str,
max_chars: int,
offset: int = 0,
) -> str:
start = max(0, offset)
# The body is already in hand, so the remainder is kept in memory rather than
# re-requested: resuming a fetch must not cost another round trip (latency)
# nor re-hit the remote (rate limits / risk control).
body, token = truncate_to_memory(body_text[start:], max_chars)
char_truncated = token is not None
# The body is kept whole in the tool message (the request-prep pass trims it
# on the wire and names its document), so resuming reads the message rather
# than re-hitting the remote.
body = body_text[start:]
warn_line = "; ".join(warnings) if warnings else ""
parts = [
f"status={status}",
@@ -52,7 +48,7 @@ def format_fetch_result(
f"content_type={content_type}",
f"body_kind={body_kind}",
f"bytes={body_bytes}",
f"truncated={'true' if truncated or char_truncated else 'false'}",
f"truncated={'true' if truncated else 'false'}",
f"redirects={redirects}",
f"security={security}",
]
@@ -73,19 +69,18 @@ async def fetch_local(
user_agent: str | None = None,
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
max_bytes: int = DEFAULT_MAX_BYTES,
max_chars: int = DEFAULT_MAX_CHARS,
max_redirects: int = DEFAULT_MAX_REDIRECTS,
follow_redirects: bool = True,
allow_http_downgrade: bool = False,
offset: int = 0,
) -> str | ToolOutput:
"""HTTP request (GET/POST/PUT/DELETE); return status, metadata, and truncated body.
"""HTTP request (GET/POST/PUT/DELETE); return status, metadata, and the body.
Use for public docs/APIs or (after human policy allow) local/LAN servers.
``offset`` skips that many leading characters of the body (0-based), for
paging through a body larger than ``max_chars``; a body truncated by
``max_chars`` keeps its remainder in memory instead, resumable via its
``truncate_token`` without another request.
paging through a body the wire cap trimmed; a trimmed body keeps its
remainder in the tool message, so its ``document_id`` reads the rest with no
new request.
``user_agent`` sets User-Agent when provided and takes precedence over a
User-Agent entry in ``headers``. A headers-only User-Agent is kept as-is.
When both omit UA, a small default is used.
@@ -138,7 +133,6 @@ async def fetch_local(
security=result.security,
warnings=result.warnings,
body_kind=result.body_kind,
max_chars=max_chars,
offset=offset,
)
return _attach_image(text, result)
+9 -15
View File
@@ -13,8 +13,6 @@ import inspect
from typing import TYPE_CHECKING
from plyngent.agent.tools import ToolDefinition, ToolTag, schema_from_callable
from plyngent.tools.net.policy import DEFAULT_MAX_CHARS
from plyngent.tools.truncate_token import truncate_to_memory
if TYPE_CHECKING:
from plyngent.lmproto.deepseek.webtools import DeepseekWebToolsClient
@@ -27,31 +25,28 @@ def format_page_result(
title: str,
url: str,
content: str,
max_chars: int,
offset: int = 0,
) -> str:
"""Render a fetched page the way the other structured tool results read."""
start = max(0, offset)
body, token = truncate_to_memory(content[start:], max_chars)
# The page is kept whole in the tool message (the request-prep pass trims it
# on the wire and names its document), so neither a re-fetch nor a charge
# follows a read of the rest.
body = content[start:]
parts = [
f"title={' '.join(title.split()) or '(untitled)'}",
f"url={url}",
f"chars={len(content)}",
f"truncated={'true' if token is not None else 'false'}",
"--- content ---",
body,
]
return "\n".join(parts)
def build_fetch_tool(
client: DeepseekWebToolsClient,
*,
default_max_chars: int = DEFAULT_MAX_CHARS,
) -> ToolDefinition:
def build_fetch_tool(client: DeepseekWebToolsClient) -> ToolDefinition:
"""A ``fetch`` definition backed by DeepSeek's Fetch API."""
async def fetch(url: str, offset: int = 0, max_chars: int = default_max_chars) -> str:
async def fetch(url: str, offset: int = 0) -> str:
"""Read a public web page as Markdown through DeepSeek's Fetch API.
Use for public articles and docs when clean Markdown is enough. The page
@@ -60,9 +55,9 @@ def build_fetch_tool(
when you need the raw HTTP status/headers. A successful fetch is billed
per request (failures are not).
``offset`` (0-based) skips leading characters of the content. A body cut
by ``max_chars`` keeps its remainder in memory, resumable via its
``truncate_token`` without another request (and without another charge).
``offset`` (0-based) skips leading characters of the content. The page is
kept whole in the tool message, so a trimmed ``document_id`` reads the
rest with no new request (and no new charge).
"""
try:
page = await client.fetch(url)
@@ -72,7 +67,6 @@ def build_fetch_tool(
title=page.title,
url=url,
content=page.content,
max_chars=max_chars,
offset=offset,
)
-1
View File
@@ -36,7 +36,6 @@ DEFAULT_USER_AGENT = "plyngent-fetch/0.2"
DEFAULT_MAX_REDIRECTS = 5
DEFAULT_TIMEOUT_SECONDS = 30.0
DEFAULT_MAX_BYTES = 1_000_000
DEFAULT_MAX_CHARS = 32_000
DEFAULT_MAX_BODY_CHARS_IN = 256_000 # request body size limit (tool arg)
# Cloud / link-local style targets never granted via policy UI.
+2 -19
View File
@@ -18,26 +18,9 @@ from plyngent.tools.workspace import (
)
DEFAULT_TIMEOUT_SECONDS = 30
DEFAULT_MAX_OUTPUT_CHARS = 32_000
DEFAULT_MAX_BATCH_STEPS = 20
def truncate_output(text: str, label: str, max_chars: int = DEFAULT_MAX_OUTPUT_CHARS) -> str:
"""Cap a per-stream output block; embed a truncate token when cut.
``label`` (stdout/stderr/batch) is kept for call-site clarity but the
marker itself is the uniform ``[Truncated (N chars max.; M omitted).
truncate_token=...]`` (no label), matching every other truncation site.
Returns ``text`` unchanged when it fits.
"""
del label
if max_chars < 1 or len(text) <= max_chars:
return text
from plyngent.tools.truncate_token import truncate_generic
return truncate_generic(text, max_chars)
def format_command_result(
*,
returncode: int | None,
@@ -53,9 +36,9 @@ def format_command_result(
f"cwd={workdir_display}",
f"cmd={shlex.join(command)}",
"--- stdout ---",
truncate_output(stdout, "stdout"),
stdout,
"--- stderr ---",
truncate_output(stderr, "stderr"),
stderr,
]
return "\n".join(parts)
+9 -8
View File
@@ -9,13 +9,14 @@ from plyngent.tools.workspace import WorkspaceError
from .command_exec import (
DEFAULT_MAX_BATCH_STEPS,
DEFAULT_MAX_OUTPUT_CHARS,
DEFAULT_TIMEOUT_SECONDS,
CommandStepResult,
execute_argv,
truncate_output,
)
# Execution budget: stop the batch once accumulated output crosses this factor
# of the base budget, so a runaway batch cannot keep spawning huge-output steps.
BATCH_OUTPUT_BUDGET_CHARS = 32_000
BATCH_OUTPUT_SOFT_CAP_FACTOR = 4
@@ -102,9 +103,9 @@ def _format_step(index: int, result: CommandStepResult, *, pipe_out: bool) -> st
f"pipe_out={'true' if pipe_out else 'false'}",
f"mix_stderr={'true' if result.mix_stderr else 'false'}",
"--- stdout ---",
truncate_output(result.stdout, "stdout"),
result.stdout,
"--- stderr ---",
truncate_output(result.stderr, "stderr"),
result.stderr,
]
)
@@ -147,7 +148,7 @@ async def _run_batch_steps(
effective_stop = stop_on_error if step["stop_on_error"] is None else bool(step["stop_on_error"])
if failed and effective_stop:
return results, True
if total_chars > DEFAULT_MAX_OUTPUT_CHARS * BATCH_OUTPUT_SOFT_CAP_FACTOR:
if total_chars > BATCH_OUTPUT_BUDGET_CHARS * BATCH_OUTPUT_SOFT_CAP_FACTOR:
return results, True
del index # used for clarity in loop only
@@ -178,7 +179,9 @@ async def run_argv_batch(
}
``pipe_out`` is set on the **provider** step. Last-step ``pipe_out`` is an error.
Default ``stop_on_error`` is true. Max 20 steps; total output budget 32k chars.
Default ``stop_on_error`` is true. Max 20 steps; the batch stops early once
accumulated output passes the run budget (128k chars). Each step's output is
kept whole for the model; a long result carries a ``document_id`` to read on.
"""
try:
raw_steps = _normalize_steps(steps)
@@ -195,8 +198,6 @@ async def run_argv_batch(
body = "\n".join(
_format_step(i, result, pipe_out=bool(step["pipe_out"])) for i, (step, result) in enumerate(results)
)
if len(body) > DEFAULT_MAX_OUTPUT_CHARS:
body = truncate_output(body, "batch")
last_exit = results[-1][1].exit_code if results else ""
return "\n".join(
[
+4 -12
View File
@@ -8,7 +8,6 @@ if TYPE_CHECKING:
from pathlib import Path
DEFAULT_TIMEOUT_SECONDS = 30.0
DEFAULT_MAX_OUTPUT_CHARS = 64_000
def is_git_repo(root: Path) -> bool:
@@ -32,13 +31,6 @@ class _GitResult:
self.returncode = returncode
def _truncate(text: str) -> str:
if len(text) <= DEFAULT_MAX_OUTPUT_CHARS:
return text
omitted = len(text) - DEFAULT_MAX_OUTPUT_CHARS
return f"{text[:DEFAULT_MAX_OUTPUT_CHARS]}\n...[truncated {omitted} characters]"
def _run_git(root: Path, args: list[str], *, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> _GitResult:
argv = ["git", "-c", "color.ui=false", *args]
try:
@@ -57,12 +49,12 @@ def _run_git(root: Path, args: list[str], *, timeout: float = DEFAULT_TIMEOUT_SE
except OSError as exc:
return _GitResult(ok=False, stdout="", stderr=str(exc), returncode=1)
stdout = _truncate(completed.stdout or "")
stderr = _truncate(completed.stderr or "")
# Output is kept whole; the request-prep pass trims it on the wire and names
# its document, so a long log/diff reads on rather than re-running git.
return _GitResult(
ok=completed.returncode == 0,
stdout=stdout,
stderr=stderr,
stdout=completed.stdout or "",
stderr=completed.stderr or "",
returncode=completed.returncode,
)