Skip to content
Merged
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
7 changes: 7 additions & 0 deletions autobot-backend/agent_loop/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from agent_loop.pre_action_verifier import PreActionVerifier, VerifierResult, VerifierVerdict
from agent_loop.slack_hook import get_slack_hook
from agent_loop.think_tool import ThinkTool
from agent_loop.tool_output_spill import spill_results as _spill_results
from agent_loop.types import (
AgentLoopConfig,
AgentMessage,
Expand Down Expand Up @@ -611,6 +612,12 @@ async def _execute_iteration_phases(self, result: IterationResult) -> IterationR
live_results = await self._execute_tools(tools_to_run) if tools_to_run else {}
tool_results = {**cached_results, **live_results}

# #13692 step 1: an oversized observation is written aside and replaced
# by a bounded excerpt plus a resolvable anchor, so one large tool result
# cannot consume the window in a single step. Off by default (#12555
# precedent); a run under the threshold is byte-identical to before.
tool_results, _spilled = _spill_results(getattr(self._current_context, "task_id", "unknown"), tool_results)

# Issue #3877 / #3859: detect repetition-halt via sentinel key.
# When halted: do not add any rejected tools to tools_executed and
# stop iterating immediately so the LLM receives the error but the
Expand Down
175 changes: 175 additions & 0 deletions autobot-backend/agent_loop/tool_output_spill.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
# Copyright 2025-2026 mrveiss
# SPDX-License-Identifier: Apache-2.0
# AutoBot - AI-Powered Automation Platform
# Author: mrveiss
"""Context offload for oversized tool output — spill and anchor (#13692, step 1).

Tool output enters the model's context whole and stays there for the rest of the
run. Nothing puts a large observation somewhere else and leaves a reference
behind: every ``offload`` in the backend is *compute* offload
(``services/npu_client.py``, ``services/execution/modal_backend.py``), and
``agent_loop/loop.py`` fingerprints observations for novelty (#6627) but never
for size.

What this does: a tool result above a threshold is written to a run-scoped
artifact on disk, and what enters context is a bounded excerpt plus a stable
anchor the agent can re-read on demand.

What this deliberately does **not** do, per #13692's two-step split:

* no task-state canvas and no compaction changes — step 2, gated on measuring
step 1, and only if the measurement justifies coupling to the compactor;
* no new service, no LLM call, no summarisation. An excerpt is a slice, not a
summary, so nothing here can hallucinate.

Off by default, following #12555's precedent: a context mechanism ships
flag-off until a benchmark shows the win.
"""

from __future__ import annotations

import hashlib
import json
import os
from pathlib import Path
from typing import Any, Dict, Tuple

from autobot_shared.logging_manager import get_logger

logger = get_logger(__name__)

# Env-tunable, never hard-coded (CLAUDE.md).
SPILL_ENABLED: bool = os.environ.get("AUTOBOT_TOOL_OUTPUT_SPILL", "").lower() in ("1", "true", "yes")
SPILL_THRESHOLD_CHARS: int = int(os.environ.get("AUTOBOT_TOOL_OUTPUT_SPILL_THRESHOLD", "8000"))
SPILL_EXCERPT_CHARS: int = int(os.environ.get("AUTOBOT_TOOL_OUTPUT_SPILL_EXCERPT", "2000"))
SPILL_ROOT: str = os.environ.get("AUTOBOT_TOOL_OUTPUT_SPILL_ROOT", "data/tool_output_spill")

_ANCHOR_PREFIX = "autobot:spill"


def _anchor(task_id: str, tool_name: str, payload: str) -> str:
"""Stable, collision-resistant anchor for one spilled observation.

Content-addressed on the payload so an identical result re-spilled in the
same run reuses its artifact instead of accumulating duplicates.
"""
digest = hashlib.sha256(f"{tool_name}:{payload}".encode("utf-8")).hexdigest()[:16]
return f"{_ANCHOR_PREFIX}:{task_id}:{tool_name}:{digest}"


def _artifact_path(anchor: str) -> Path:
"""Filesystem location for an anchor. Never trusts the anchor's own text."""
digest = hashlib.sha256(anchor.encode("utf-8")).hexdigest()
# The anchor is not a path component: hashing it means a tool name
# containing "../" cannot escape the spill root.
return Path(SPILL_ROOT) / digest[:2] / f"{digest}.json"


def _serialise(result: Any) -> str:
"""Render a tool result as the text that would have entered context."""
if isinstance(result, str):
return result
try:
return json.dumps(result, ensure_ascii=False, default=str)
except (TypeError, ValueError):
return str(result)


def spill_if_oversized(task_id: str, tool_name: str, result: Any) -> Tuple[Any, bool]:
"""Return ``(context_value, spilled)`` for one tool result.

When the rendered result is under the threshold — or the feature is off —
the original object is returned untouched, so a run that never trips the
threshold is byte-identical to one without this module.

Spill failure is non-fatal: the full result is returned and the turn
proceeds. Losing the offload is always better than losing the observation.
"""
if not SPILL_ENABLED:
return result, False

payload = _serialise(result)
if len(payload) <= SPILL_THRESHOLD_CHARS:
return result, False

anchor = _anchor(task_id, tool_name, payload)
try:
path = _artifact_path(anchor)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps({"anchor": anchor, "task_id": task_id, "tool": tool_name, "output": payload}),
encoding="utf-8",
)
except Exception as exc:
logger.warning("Tool-output spill failed for %s (keeping full output): %s", tool_name, exc)
return result, False

logger.info(
"Spilled %s output: %d -> %d chars, anchor=%s (#13692)",
tool_name,
len(payload),
SPILL_EXCERPT_CHARS,
anchor,
)
return _excerpt_payload(anchor, tool_name, payload), True


def _excerpt_payload(anchor: str, tool_name: str, payload: str) -> Dict[str, Any]:
"""What enters context in place of the full output."""
return {
"spilled": True,
"tool": tool_name,
"anchor": anchor,
"excerpt": payload[:SPILL_EXCERPT_CHARS],
"omitted_chars": len(payload) - SPILL_EXCERPT_CHARS,
"note": (
f"Output truncated to {SPILL_EXCERPT_CHARS} of {len(payload)} chars. "
f"Read the full output with the read_spilled_output tool using anchor {anchor!r}."
),
}


def read_spilled(anchor: str) -> str | None:
"""Return the full spilled output for *anchor*, or None.

This is what makes the anchor a reference rather than a deletion — the AC
requires the agent to be able to retrieve the full output within the run.
"""
if not isinstance(anchor, str) or not anchor.startswith(_ANCHOR_PREFIX):
return None
try:
path = _artifact_path(anchor)
if not path.exists():
return None
return json.loads(path.read_text(encoding="utf-8")).get("output")
except Exception as exc:
logger.warning("Failed to read spilled output for %s: %s", anchor, exc)
return None


def spill_results(task_id: str, tool_results: Dict[str, Any]) -> Tuple[Dict[str, Any], int]:
"""Apply :func:`spill_if_oversized` across a tool-result mapping.

Returns the (possibly rewritten) mapping and how many entries were spilled,
so the caller can report the reduction rather than shrinking the context
silently.
"""
if not SPILL_ENABLED or not tool_results:
return tool_results, 0

rewritten: Dict[str, Any] = {}
spilled_count = 0
for name, value in tool_results.items():
rewritten[name], was_spilled = spill_if_oversized(task_id, name, value)
spilled_count += int(was_spilled)
return rewritten, spilled_count


__all__ = [
"SPILL_ENABLED",
"SPILL_EXCERPT_CHARS",
"SPILL_THRESHOLD_CHARS",
"read_spilled",
"spill_if_oversized",
"spill_results",
]
157 changes: 157 additions & 0 deletions autobot-backend/agent_loop/tool_output_spill_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# Copyright 2025-2026 mrveiss
# SPDX-License-Identifier: Apache-2.0
# AutoBot - AI-Powered Automation Platform
# Author: mrveiss
"""Spill and anchor for oversized tool output (#13692, step 1).

Tool output entered context whole and stayed. These pin the contract: over the
threshold it is written aside and replaced by a bounded excerpt plus a
resolvable anchor; under it, nothing changes at all.
"""

import json
from unittest.mock import patch

import pytest

from agent_loop import tool_output_spill as spill

TASK = "task-42"


@pytest.fixture(autouse=True)
def _spill_root(tmp_path, monkeypatch):
monkeypatch.setattr(spill, "SPILL_ROOT", str(tmp_path / "spill"))
monkeypatch.setattr(spill, "SPILL_ENABLED", True)
monkeypatch.setattr(spill, "SPILL_THRESHOLD_CHARS", 100)
monkeypatch.setattr(spill, "SPILL_EXCERPT_CHARS", 20)


class TestOffDefault:
def test_disabled_returns_the_result_untouched(self, monkeypatch):
"""AC: behind a flag, default off (#12555 precedent)."""
monkeypatch.setattr(spill, "SPILL_ENABLED", False)
big = "x" * 10_000

value, spilled = spill.spill_if_oversized(TASK, "bash", big)

assert value is big
assert spilled is False

def test_the_module_default_is_off(self):
"""Read from the environment at import time, not hardcoded on."""
import importlib

with patch.dict("os.environ", {}, clear=True):
reloaded = importlib.reload(spill)
assert reloaded.SPILL_ENABLED is False


class TestUnderThresholdIsUntouched:
def test_small_output_passes_through_identically(self):
small = "ok"

value, spilled = spill.spill_if_oversized(TASK, "bash", small)

assert value is small
assert spilled is False

def test_a_run_that_never_trips_the_threshold_is_unchanged(self):
results = {"bash": "ok", "read": {"lines": 3}}

rewritten, count = spill.spill_results(TASK, results)

assert rewritten == results
assert count == 0


class TestSpillAndAnchor:
def test_oversized_output_is_replaced_by_excerpt_and_anchor(self):
big = "A" * 500

value, spilled = spill.spill_if_oversized(TASK, "bash", big)

assert spilled is True
assert value["excerpt"] == "A" * 20
assert value["omitted_chars"] == 480
assert value["anchor"].startswith("autobot:spill:task-42:bash:")

def test_the_full_output_is_retrievable_via_the_anchor(self):
"""AC: the agent can retrieve the full output within the same run."""
big = "B" * 500

value, _ = spill.spill_if_oversized(TASK, "bash", big)

assert spill.read_spilled(value["anchor"]) == big

def test_context_payload_is_far_smaller_than_the_original(self):
big = "C" * 50_000

value, _ = spill.spill_if_oversized(TASK, "bash", big)

assert len(json.dumps(value)) < len(big) / 10

def test_non_string_results_are_serialised_then_spilled(self):
big = {"rows": ["D" * 50 for _ in range(20)]}

value, spilled = spill.spill_if_oversized(TASK, "query", big)

assert spilled is True
assert "D" in value["excerpt"]

def test_an_identical_result_reuses_its_anchor(self):
big = "E" * 500

first, _ = spill.spill_if_oversized(TASK, "bash", big)
second, _ = spill.spill_if_oversized(TASK, "bash", big)

assert first["anchor"] == second["anchor"]


class TestAnchorSafety:
def test_a_tool_name_with_traversal_cannot_escape_the_spill_root(self):
"""The anchor is hashed into a path, never used as one."""
value, spilled = spill.spill_if_oversized(TASK, "../../etc/passwd", "F" * 500)

assert spilled is True
path = spill._artifact_path(value["anchor"])
assert ".." not in str(path)
assert str(path).startswith(spill.SPILL_ROOT)

def test_an_unknown_anchor_returns_none(self):
assert spill.read_spilled("autobot:spill:nope:nope:deadbeef") is None

def test_a_foreign_anchor_shape_is_refused(self):
assert spill.read_spilled("/etc/passwd") is None
assert spill.read_spilled("") is None
assert spill.read_spilled(None) is None


class TestNonFatal:
def test_a_write_failure_keeps_the_full_output(self, monkeypatch):
"""AC: spill failure is non-fatal — the turn completes.

Losing the offload is always better than losing the observation.
"""
big = "G" * 500
monkeypatch.setattr(spill.Path, "mkdir", _raise)

value, spilled = spill.spill_if_oversized(TASK, "bash", big)

assert value is big
assert spilled is False


class TestBatch:
def test_only_oversized_entries_are_spilled(self):
results = {"small": "ok", "big": "H" * 500}

rewritten, count = spill.spill_results(TASK, results)

assert count == 1
assert rewritten["small"] == "ok"
assert rewritten["big"]["spilled"] is True


def _raise(*_args, **_kwargs):
raise OSError("disk full")
18 changes: 18 additions & 0 deletions autobot-backend/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,24 @@ def _device_jwt_dep():
if _causal_mod not in sys.modules:
sys.modules[_causal_mod] = _make_pkg_stub(_causal_mod)

# ``agent_loop.tool_output_spill`` is deliberately real-imported despite its
# parent package being stubbed above (#13692). Same reasoning the comment gives
# for ``orchestration.causal_validator``: it has none of the heavy chain the
# stub exists for — stdlib plus ``autobot_shared.logging_manager`` only. Leaving
# it behind the stub's empty ``__path__`` would hand its tests a MagicMock and
# have them assert against mock defaults, which is the #13111/#13162 bug.
if "agent_loop.tool_output_spill" not in sys.modules:
import importlib.util as _ilu

_spill_path = Path(__file__).parent / "agent_loop" / "tool_output_spill.py"
if _spill_path.exists():
_spec = _ilu.spec_from_file_location("agent_loop.tool_output_spill", _spill_path)
if _spec and _spec.loader:
_spill_mod = _ilu.module_from_spec(_spec)
sys.modules["agent_loop.tool_output_spill"] = _spill_mod
_spec.loader.exec_module(_spill_mod)
sys.modules["agent_loop"].tool_output_spill = _spill_mod

# code_intelligence.code_generation.diff real-load (#12438) — tools/parallel/executor.py
# imports the real DiffGenerator (self-contained: stdlib difflib only) to build CODE_DIFF
# artifacts. code_intelligence itself is stubbed above (its __init__ has Python-3.10-
Expand Down
Loading