diff --git a/strix/agents/factory.py b/strix/agents/factory.py index 6a5af17b..010bfb40 100644 --- a/strix/agents/factory.py +++ b/strix/agents/factory.py @@ -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 diff --git a/strix/config/settings.py b/strix/config/settings.py index 91fbdef1..d94c6b0d 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -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): diff --git a/strix/core/large_output.py b/strix/core/large_output.py new file mode 100644 index 00000000..86a6f49d --- /dev/null +++ b/strix/core/large_output.py @@ -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 diff --git a/tests/core/test_large_output.py b/tests/core/test_large_output.py new file mode 100644 index 00000000..1df3a8cf --- /dev/null +++ b/tests/core/test_large_output.py @@ -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