feat: truncate oversized exec_command output to prevent context overflow

exec_command output is now capped at STRIX_MAX_TOOL_OUTPUT_CHARS (default
65536). JSON arrays are parsed and trimmed to the first 50 records (valid
JSON preserved); plain text keeps the first 300 lines. Both include a
header showing the total size and record/line count so the agent retains
full awareness of what was produced.

A secondary character cap prevents single very long lines from bypassing
the line limit. Set STRIX_MAX_TOOL_OUTPUT_CHARS=0 to disable.
This commit is contained in:
Mads Hvelplund 2026-06-19 12:22:52 +02:00
parent 2f1bb901cc
commit 5aa61c5202
No known key found for this signature in database
GPG key ID: 08208EA18E154F00
4 changed files with 205 additions and 1 deletions

View file

@ -16,6 +16,8 @@ from agents.tool import CustomTool, FunctionTool, Tool
from pydantic import ValidationError
from strix.agents.prompt import render_system_prompt
from strix.config import load_settings
from strix.core.large_output import truncate_exec_result
from strix.tools.agents_graph.tools import (
agent_finish,
create_agent,
@ -210,7 +212,7 @@ def _wrap_exec_command(tool: FunctionTool) -> FunctionTool:
async def invoke(ctx: Any, raw_input: str) -> Any:
try:
return await invoke_tool(ctx, raw_input)
result = await invoke_tool(ctx, raw_input)
except ValidationError as exc:
return _format_validation_error(tool.name, exc)
except InvalidManifestPathError as exc:
@ -220,6 +222,10 @@ def _wrap_exec_command(tool: FunctionTool) -> FunctionTool:
"(or omitted to use the turn's cwd). "
f"Got: {rel!r}."
)
if isinstance(result, str):
threshold = load_settings().runtime.max_tool_output_chars
result = truncate_exec_result(result, threshold=threshold)
return result
tool.on_invoke_tool = invoke
return tool

View file

@ -52,6 +52,11 @@ class RuntimeSettings(BaseSettings):
# on large repos). Above this, the user must bind-mount via ``--mount``.
# Set to 0 (or less) to disable the pre-flight check entirely.
max_local_copy_mb: int = Field(default=1024, alias="STRIX_MAX_LOCAL_COPY_MB")
# Hard cap on a single exec_command output stored in the agent session.
# JSON arrays are trimmed to their first records; plain text to first lines.
# Both cases preserve the total count so the agent knows what was omitted.
# Set to 0 to disable.
max_tool_output_chars: int = Field(default=65536, alias="STRIX_MAX_TOOL_OUTPUT_CHARS")
class TelemetrySettings(BaseSettings):

107
strix/core/large_output.py Normal file
View file

@ -0,0 +1,107 @@
"""Structure-aware truncation for oversized tool outputs.
Prevents context-window overflow by replacing large outputs with a compact
summary before they are stored in the agent session.
"""
from __future__ import annotations
import json
import logging
logger = logging.getLogger(__name__)
# Maximum records to keep when truncating a JSON array.
_JSON_KEEP = 50
# Maximum lines to keep when truncating plain text.
_TEXT_KEEP = 300
# Separator the agents SDK inserts between its header and the actual stdout.
_SDK_OUTPUT_SEPARATOR = "\nOutput:\n"
def truncate_exec_result(result: str, *, threshold: int) -> str:
"""Truncate an exec_command result, preserving the SDK's header section.
The agents SDK wraps stdout in metadata lines (Chunk ID, Wall time, exit
code) followed by a ``\\nOutput:\\n`` separator. Truncation must apply only
to the content after that separator so JSON detection works on the actual
command output, not on the header text.
"""
idx = result.find(_SDK_OUTPUT_SEPARATOR)
if idx < 0:
return truncate_tool_output(result, threshold=threshold)
sep_end = idx + len(_SDK_OUTPUT_SEPARATOR)
return result[:sep_end] + truncate_tool_output(result[sep_end:], threshold=threshold)
def truncate_tool_output(output: str, *, threshold: int) -> str:
"""Return *output* unchanged if it fits within *threshold* chars.
When *output* exceeds the threshold the most useful portion is preserved:
- JSON arrays: first :data:`_JSON_KEEP` records (syntactically valid,
halved progressively if those records still exceed the threshold).
- Plain text: first :data:`_TEXT_KEEP` lines, capped at *threshold* chars.
A header line is prepended in both cases so the agent knows the full size
and how many records or lines were omitted. Pass ``threshold=0`` to
disable truncation entirely.
"""
if threshold <= 0 or len(output) <= threshold:
return output
total_chars = len(output)
logger.warning(
"Tool output is %d chars (threshold %d) — truncating",
total_chars,
threshold,
)
stripped = output.lstrip()
if stripped.startswith("["):
result = _truncate_json_array(stripped, total_chars, threshold=threshold)
if result is not None:
return result
return _truncate_text(output, total_chars, threshold=threshold)
def _truncate_json_array(output: str, total_chars: int, *, threshold: int) -> str | None:
"""Attempt JSON-array truncation; return None if *output* is not valid JSON."""
try:
records = json.loads(output)
except json.JSONDecodeError:
return None
if not isinstance(records, list):
return None
# Start with _JSON_KEEP records; halve until the re-serialised form fits.
kept = records[:_JSON_KEEP]
serialized = json.dumps(kept, indent=2)
while len(serialized) > threshold and len(kept) > 1:
kept = kept[: len(kept) // 2]
serialized = json.dumps(kept, indent=2)
header = (
f"[truncated: {total_chars:,} chars, {len(records):,} records"
f" — showing first {len(kept)}]\n"
)
return header + serialized
def _truncate_text(output: str, total_chars: int, *, threshold: int) -> str:
"""Truncate plain-text output to the first :data:`_TEXT_KEEP` lines.
A secondary character cap is applied after line selection so that a single
extremely long line cannot bypass the threshold.
"""
lines = output.splitlines()
kept = lines[:_TEXT_KEEP]
header = (
f"[truncated: {total_chars:,} chars, {len(lines):,} lines — showing first {len(kept)}]\n"
)
body = "\n".join(kept)
if len(body) > threshold:
body = body[:threshold] + " …"
return header + body

View file

@ -0,0 +1,86 @@
"""Tests for strix/core/large_output.py — structure-aware output truncation."""
from __future__ import annotations
import json
from strix.core.large_output import truncate_exec_result, truncate_tool_output
def test_small_output_is_unchanged() -> None:
"""Output below threshold must be returned verbatim."""
output = "hello world\n" * 10
assert truncate_tool_output(output, threshold=65536) == output
def test_plain_text_is_truncated_with_header() -> None:
"""Plain text over threshold keeps first lines and prepends a summary header."""
line = "x" * 200 + "\n"
output = line * 500 # 100_500 chars
result = truncate_tool_output(output, threshold=65536)
assert len(result) <= 65536 + 200 # header may push slightly over
assert "[truncated" in result
assert "500 lines" in result
def test_json_array_is_truncated_to_valid_json() -> None:
"""A large JSON array must be trimmed to first N records, still valid JSON."""
records = [{"id": i, "data": "x" * 200} for i in range(500)]
output = json.dumps(records)
result = truncate_tool_output(output, threshold=65536)
assert "[truncated" in result
# The JSON portion after the header must be parseable.
json_part = result[result.index("[") :].split("\n", 1)[-1].strip()
parsed = json.loads(json_part)
assert isinstance(parsed, list)
assert len(parsed) < 500
def test_invalid_json_falls_back_to_text_truncation() -> None:
"""Output that looks like JSON but is invalid falls back to plain-text truncation."""
line = "[not valid json " + "x" * 100
output = (line + "\n") * 700 # ~81,900 chars across 700 lines
result = truncate_tool_output(output, threshold=65536)
assert "[truncated" in result
assert "700 lines" in result
assert len(result) < len(output)
def test_threshold_zero_disables_truncation() -> None:
"""threshold=0 means no truncation (disabled)."""
output = "x" * 200_000
assert truncate_tool_output(output, threshold=0) == output
def test_plain_text_single_long_line_is_capped() -> None:
"""A single line longer than the threshold must be capped at threshold chars."""
output = "x" * 200_000
result = truncate_tool_output(output, threshold=65536)
assert "[truncated" in result
assert len(result) <= 65536 + 200 # generous header allowance
def test_json_truncation_respects_threshold_for_large_records() -> None:
"""When 50 large records exceed the threshold, fewer records must be kept."""
records = [{"id": i, "data": "x" * 2000} for i in range(500)]
output = json.dumps(records)
result = truncate_tool_output(output, threshold=65536)
assert "[truncated" in result
assert "records" in result
assert len(result) <= 65536 + 300 # header is modest; total must fit
def test_truncate_exec_result_detects_json_in_sdk_wrapped_output() -> None:
"""JSON content after the SDK's 'Output:' marker must use JSON-aware truncation."""
records = [{"id": i, "data": "x" * 200} for i in range(500)]
sdk_output = (
"Chunk ID: abc123\nWall time: 1.234s\nProcess exited with code 0\nOutput:\n"
+ json.dumps(records)
)
result = truncate_exec_result(sdk_output, threshold=65536)
assert "Chunk ID" in result # SDK header preserved
assert "records" in result # JSON path used (not plain-text path)
assert "[truncated" in result