Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion strix/agents/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
from pydantic import ValidationError

from strix.agents.prompt import render_system_prompt
from strix.config import load_settings
from strix.core.tool_output import truncate_tool_output
from strix.tools.agents_graph.tools import (
agent_finish,
create_agent,
Expand Down Expand Up @@ -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:
Expand All @@ -220,6 +222,20 @@ async def invoke(ctx: Any, raw_input: str) -> Any:
"(or omitted to use the turn's cwd). "
f"Got: {rel!r}."
)
# Cap scanner dumps before they land in SQLite session history and
# get re-serialized into the next model request (context overflow).
if isinstance(result, str):
max_chars = load_settings().runtime.max_tool_output_chars
truncated = truncate_tool_output(result, max_chars)
if truncated != result:
logger.info(
"Truncated exec_command output from %d to %d chars (cap=%d)",
len(result),
len(truncated),
max_chars,
)
return truncated
return result

tool.on_invoke_tool = invoke
return tool
Expand Down
7 changes: 7 additions & 0 deletions strix/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ 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")
# Cap shell/tool stdout stored in the agent session. Unbounded scanner
# dumps (e.g. semgrep JSON on a monorepo) otherwise re-enter the next LLM
# request and raise ContextWindowExceededError. Set to 0 to disable.
max_tool_output_chars: int = Field(
default=65_536,
alias="STRIX_MAX_TOOL_OUTPUT_CHARS",
)


class TelemetrySettings(BaseSettings):
Expand Down
53 changes: 51 additions & 2 deletions strix/core/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,15 @@
from docker import errors as docker_errors # type: ignore[import-untyped, unused-ignore]
from openai import APIError

from strix.config import load_settings
from strix.core.hooks import BudgetExceededError
from strix.core.inputs import child_initial_input
from strix.core.sessions import open_agent_session, strip_all_images_from_session
from strix.core.sessions import (
open_agent_session,
strip_all_images_from_session,
truncate_large_outputs_in_session,
)
from strix.core.tool_output import is_context_window_error


if TYPE_CHECKING:
Expand Down Expand Up @@ -346,6 +352,7 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
hooks: RunHooks[dict[str, Any]] | None,
) -> RunResultBase | None:
image_strips = 0
context_truncations = 0
while True:
try:
await coordinator.mark_running(agent_id)
Expand Down Expand Up @@ -398,10 +405,50 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
await coordinator.trigger_budget_stop()
raise
except Exception as exc:
# Context-window overflows often arrive as HTTP 400. Image-strip
# recovery cannot help (there are no images) and only burns
# retries — truncate oversized tool outputs instead.
if (
context_truncations < 2
and session is not None
and is_context_window_error(exc)
):
max_chars = load_settings().runtime.max_tool_output_chars
try:
truncated = await truncate_large_outputs_in_session(
session,
max_chars=max_chars,
)
except Exception:
logger.exception("context-window truncation recovery failed for %s", agent_id)
truncated = False
if truncated:
context_truncations += 1
logger.warning(
"Truncated oversized tool outputs for %s after context-window "
"error; retrying (%d)",
agent_id,
context_truncations,
)
input_data = [
{
"role": "user",
"content": (
"Your previous tool output exceeded the model context window "
"and was truncated in session history. Continue from the "
"truncated results: write full scanner dumps to files under "
"/workspace, then inspect samples with head/rg/jq instead of "
"printing entire monorepo findings."
),
}
]
continue

if (
image_strips < 3
and session is not None
and getattr(exc, "status_code", None) in _INPUT_REJECTION_CODES
and not is_context_window_error(exc)
):
try:
stripped = await strip_all_images_from_session(session)
Expand All @@ -421,7 +468,9 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
raise
if isinstance(exc, MaxTurnsExceeded):
status: Status = "stopped"
elif isinstance(exc, UserError | AgentsException | APIError):
elif isinstance(exc, UserError | AgentsException | APIError) or is_context_window_error(
exc
):
status = "failed"
else:
status = "crashed"
Expand Down
108 changes: 100 additions & 8 deletions strix/core/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
from __future__ import annotations

import contextlib
import logging
from typing import TYPE_CHECKING, Any, cast

from agents.memory import SQLiteSession

from strix.core.tool_output import DEFAULT_MAX_TOOL_OUTPUT_CHARS, truncate_tool_output


if TYPE_CHECKING:
from pathlib import Path
Expand All @@ -15,6 +18,9 @@
from agents.memory import Session


logger = logging.getLogger(__name__)


def open_agent_session(agent_id: str, path: Path) -> SQLiteSession:
path.parent.mkdir(parents=True, exist_ok=True)
return SQLiteSession(session_id=agent_id, db_path=path)
Expand All @@ -23,6 +29,32 @@ def open_agent_session(agent_id: str, path: Path) -> SQLiteSession:
_IMAGE_REJECTED_TEXT = "[image rejected by the model]"


async def _rewrite_session_items(
session: Session,
rebuilt: list[Any],
*,
previous: list[Any],
) -> None:
"""Replace session items with ``rebuilt``, restoring ``previous`` on failure.

The SDK only exposes clear+add, so a failed write after clear would otherwise
wipe history. Always keep a snapshot of the prior items and re-insert it if
the new payload cannot be persisted.
"""
rebuilt_items = cast("list[TResponseInputItem]", rebuilt)
previous_items = cast("list[TResponseInputItem]", list(previous))
await session.clear_session()
try:
await session.add_items(rebuilt_items)
except Exception:
logger.exception("Failed to write rebuilt session items; restoring prior history")
with contextlib.suppress(Exception):
await session.clear_session()
if previous_items:
await session.add_items(previous_items)
raise


async def strip_all_images_from_session(session: Session) -> bool:
items = await session.get_items()
if not items:
Expand Down Expand Up @@ -54,12 +86,72 @@ async def strip_all_images_from_session(session: Session) -> bool:
if not changed:
return False

rebuilt_items = cast("list[TResponseInputItem]", rebuilt)
await session.clear_session()
try:
await session.add_items(rebuilt_items)
except Exception:
with contextlib.suppress(Exception):
await session.add_items(rebuilt_items)
raise
await _rewrite_session_items(session, rebuilt, previous=items)
return True


def _truncate_function_output_value(output: Any, max_chars: int) -> tuple[Any, bool]:
"""Return ``(new_output, changed)`` for one function_call_output payload."""
if isinstance(output, str):
truncated = truncate_tool_output(output, max_chars)
return truncated, truncated != output

if isinstance(output, list):
new_blocks: list[Any] = []
changed = False
for block in output:
if isinstance(block, dict) and block.get("type") == "input_text":
text = block.get("text")
if isinstance(text, str):
truncated = truncate_tool_output(text, max_chars)
if truncated != text:
changed = True
new_blocks.append({**block, "text": truncated})
continue
new_blocks.append(block)
return new_blocks, changed

return output, False


async def truncate_large_outputs_in_session(
session: Session,
max_chars: int = DEFAULT_MAX_TOOL_OUTPUT_CHARS,
) -> bool:
"""Truncate oversized ``function_call_output`` items already in the session.

Used as recovery after a context-window rejection: huge tool results
(semgrep dumps, etc.) sit in SQLite and get resent on the next turn.
Returns True when at least one item was shortened.
"""
if max_chars <= 0:
return False

items = await session.get_items()
if not items:
return False

rebuilt: list[Any] = []
changed = False
for item in items:
item_dict = cast("dict[str, Any]", item) if isinstance(item, dict) else None
if item_dict is not None and item_dict.get("type") == "function_call_output":
new_output, item_changed = _truncate_function_output_value(
item_dict.get("output"),
max_chars,
)
if item_changed:
changed = True
rebuilt.append({**item_dict, "output": new_output})
continue
rebuilt.append(item)

if not changed:
return False

await _rewrite_session_items(session, rebuilt, previous=items)
logger.info(
"Truncated oversized tool outputs in session (cap=%d chars)",
max_chars,
)
return True
Loading