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
+14 -22
View File
@@ -23,7 +23,6 @@ from plyngent.tools.net.policy import (
parse_fetch_url,
soft_confirm_reason,
)
from plyngent.tools.truncate_token import decode_truncate_token
from tests.test_tools.helpers import call_async
if TYPE_CHECKING:
@@ -299,37 +298,30 @@ async def test_fetch_truncation_and_binary(workspace: Path, http_server: str) ->
assert "binary body omitted" in out_bin
async def test_fetch_char_truncation_embeds_token(workspace: Path, http_server: str) -> None:
del workspace
port = int(http_server.rsplit(":", 1)[1])
grant_private_host("127.0.0.1", port)
out = await call_async(fetch_local, f"{http_server}/big", max_chars=1000)
assert "truncated=true" in out
body = out.split("--- body ---", 1)[-1]
assert "truncate_token=" in body
token_line = next(ln for ln in body.splitlines() if "truncate_token=" in ln)
token_str = token_line.split("truncate_token=", 1)[1].rstrip("]")
token = decode_truncate_token(token_str)
assert token is not None
# The body is kept in memory, so resuming never repeats the request.
assert token.kind == "memory"
assert token.limit == 1000
assert 0 < token.offset < 1000
async def test_fetch_offset_skips_body(workspace: Path, http_server: str) -> None:
del workspace
port = int(http_server.rsplit(":", 1)[1])
grant_private_host("127.0.0.1", port)
# /big body is 5000 chars; offset 4900 leaves 100 chars, no token needed.
out = await call_async(fetch_local, f"{http_server}/big", max_chars=200, offset=4900)
# /big body is 5000 chars; offset 4900 leaves the last 100 chars.
out = await call_async(fetch_local, f"{http_server}/big", offset=4900)
body = out.split("--- body ---", 1)[-1]
assert body.strip() == "x" * 100
assert "truncate_token=" not in out
async def test_fetch_keeps_the_body_whole(workspace: Path, http_server: str) -> None:
"""The body is stored whole (the request-prep pass caps it on the wire)."""
del workspace
port = int(http_server.rsplit(":", 1)[1])
grant_private_host("127.0.0.1", port)
out = await call_async(fetch_local, f"{http_server}/big")
body = out.split("--- body ---", 1)[-1].strip()
assert body == "x" * 5000
assert "truncate_token=" not in out
async def test_fetch_private_denied_without_grant(workspace: Path, http_server: str) -> None:
del workspace
# No grant, no policy hook → hard deny (even though server is up).
+7 -9
View File
@@ -24,8 +24,8 @@ class _Client:
return self.page
def _tool(client: _Client, *, max_chars: int = 32_000):
return build_fetch_tool(client, default_max_chars=max_chars)
def _tool(client: _Client):
return build_fetch_tool(client)
def test_definition_shape() -> None:
@@ -33,7 +33,7 @@ def test_definition_shape() -> None:
assert definition.name == FETCH_TOOL_NAME == "fetch"
assert definition.tags == ToolTag.LOCAL | ToolTag.READ_ONLY
properties = definition.parameters["properties"]
assert set(properties) == {"url", "offset", "max_chars"}
assert set(properties) == {"url", "offset"}
assert definition.parameters["required"] == ["url"]
assert "fetch_local" in definition.description
@@ -48,7 +48,6 @@ async def test_handler_renders_title_url_and_content() -> None:
"title=Example article",
"url=https://example.com/article",
"chars=16",
"truncated=false",
"--- content ---",
"# Heading",
"",
@@ -57,14 +56,13 @@ async def test_handler_renders_title_url_and_content() -> None:
@pytest.mark.asyncio
async def test_offset_and_truncation_keep_the_remainder_resumable() -> None:
async def test_offset_skips_leading_content() -> None:
client = _Client(page=FetchResponse(title="", content="0123456789"))
result = await _tool(client, max_chars=3).handler(url="https://example.com/", offset=5)
result = await _tool(client).handler(url="https://example.com/", offset=5)
assert "title=(untitled)" in result
assert "--- content ---\n567" in result
assert "truncated=true" in result
assert "truncate_token=" in result
assert "--- content ---\n56789" in result
assert "truncate_token=" not in result
@pytest.mark.asyncio
-36
View File
@@ -84,42 +84,6 @@ async def test_get_truncated_file_missing(workspace: Path) -> None:
assert out.startswith("error:")
async def test_get_truncated_serves_a_fetch_body_without_refetching(monkeypatch) -> None:
"""A fetch body's token reads the stored remainder; the request is not repeated."""
from plyngent.tools.net.fetch_local import format_fetch_result
fetch_module = importlib.import_module("plyngent.tools.net.fetch_local")
async def _must_not_refetch(*_args: object, **_kwargs: object) -> str:
msg = "resuming a fetch body must not re-run the request"
raise AssertionError(msg)
monkeypatch.setattr(fetch_module, "http_fetch", _must_not_refetch)
first = format_fetch_result(
status=200,
final_url="https://example.com/x",
content_type="text/plain",
body_text="x" * 600,
body_bytes=600,
truncated=False,
redirects=0,
method="GET",
security="public",
warnings=[],
body_kind="text",
max_chars=200,
)
token = _extract_token(first)
assert token is not None
parsed = decode_truncate_token(token)
assert parsed is not None
assert parsed.kind == "memory"
out = await call_async(get_truncated, token, max_chars=200)
content, _, _ = out.partition("\n[Truncated")
assert content == "x" * len(content)
assert 0 < len(content) <= 200
async def test_get_truncated_memory_chains() -> None:
from plyngent.tools.truncate_token import truncate_generic
+6 -6
View File
@@ -192,14 +192,14 @@ async def test_run_argv_timeout_keeps_partial_output(workspace: object) -> None:
assert "partial-out" in out
async def test_run_argv_stdout_cap_embeds_token(workspace: object) -> None:
async def test_run_argv_returns_the_full_stdout(workspace: object) -> None:
del workspace
# Per-stream cap default is 32000 chars: a bigger stdout embeds a token.
# The result is stored whole; the request-prep pass caps it on the wire and
# names its document, so nothing is cut at the tool.
out = await call_async(run_argv, _py("print('x' * 40000)"))
assert "truncate_token=" in out
token_line = next(ln for ln in out.splitlines() if "truncate_token=" in ln)
assert "[Truncated (32000 chars max.;" in token_line
assert "[Truncated" in out
body = out.split("--- stdout ---", 1)[1].split("--- stderr ---", 1)[0].strip()
assert body == "x" * 40000
assert "truncate_token=" not in out
async def test_run_argv_small_stdout_no_marker(workspace: object) -> None: