mirror of
https://github.com/usestrix/strix.git
synced 2026-10-12 03:47:56 +00:00
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:
parent
2f1bb901cc
commit
5aa61c5202
4 changed files with 205 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
107
strix/core/large_output.py
Normal 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
|
||||
86
tests/core/test_large_output.py
Normal file
86
tests/core/test_large_output.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue