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
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,25 @@ guidance. It leaves error-only and non-dict results untouched and skips
Storage is best-effort: any spill failure keeps the original successful
tool result inline.

An individual agent can override spill for itself via `tools_config` (JSON
agents, or the same shape from `get_tools_config()` in Python agents):

```json
{
"tools_config": {
"spill": {
"enabled": false,
"skip_tools": ["custom_report"]
}
}
}
```

Only the literal boolean `false` disables spill for that agent; malformed
settings keep the global behavior. `skip_tools` names are additive to the
global `spill_skip_tools` set (they can add exemptions, never remove them).
Settings are resolved per executing agent, so concurrent agents may differ.

---

## Messaging & UI
Expand Down
74 changes: 73 additions & 1 deletion code_puppy_core_plugins/spill/register_callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@
``read_file`` is skipped by default to avoid a read -> spill -> read loop.
Every failure is best-effort: the successful tool result stays available inline.

Config (puppy.cfg, also settable with ``/set``):
Global config (puppy.cfg, also settable with ``/set``):
spill_max_inline_bytes = 32768 # 0 or negative disables
spill_preview_bytes = 4096 # source bytes retained per field
spill_root = # unset uses a private OS temp directory
spill_skip_tools = read_file # comma-separated tool names

An individual agent can opt out or add exact-name tool skips:
"tools_config": {"spill": {"enabled": false}}
"tools_config": {"spill": {"skip_tools": ["custom_report"]}}
"""

from __future__ import annotations
Expand All @@ -39,6 +43,9 @@
PREVIEW_KEY = "spill_preview_bytes"
ROOT_KEY = "spill_root"
SKIP_TOOLS_KEY = "spill_skip_tools"
AGENT_CONFIG_KEY = "spill"
AGENT_ENABLED_KEY = "enabled"
AGENT_SKIP_TOOLS_KEY = "skip_tools"
DEFAULT_MAX_INLINE_BYTES = 32768
DEFAULT_PREVIEW_BYTES = 4096
DEFAULT_SKIP_TOOLS = frozenset({"read_file"})
Expand Down Expand Up @@ -79,6 +86,59 @@ def _get_skip_tools() -> frozenset[str]:
return frozenset(name.strip() for name in str(raw).split(",") if name.strip())


def _get_executing_agent_spill_config() -> dict[str, Any]:
"""Return valid spill config for this run's agent, or an empty config.

Older Code Puppy versions do not expose the execution-context seam. They
safely retain the historical globally configured behavior.
"""
try:
from code_puppy.agent_execution_context import get_executing_agent
except ImportError:
return {}

agent = get_executing_agent()
if agent is None:
return {}

try:
tools_config = agent.get_tools_config()
except Exception:
logger.debug("Could not read executing agent tools_config", exc_info=True)
return {}

if not isinstance(tools_config, dict):
return {}
spill_config = tools_config.get(AGENT_CONFIG_KEY)
return spill_config if isinstance(spill_config, dict) else {}


def _is_agent_spill_enabled(spill_config: dict[str, Any]) -> bool:
"""Return false only for an explicit per-agent boolean opt-out."""
enabled = spill_config.get(AGENT_ENABLED_KEY, True)
return enabled if isinstance(enabled, bool) else True


def _get_agent_skip_tools(spill_config: dict[str, Any]) -> frozenset[str]:
"""Return valid exact-name skips contributed by one agent."""
raw = spill_config.get(AGENT_SKIP_TOOLS_KEY)
if not isinstance(raw, list):
return frozenset()
return frozenset(
name.strip() for name in raw if isinstance(name, str) and name.strip()
)


def _is_enabled_for_executing_agent(
spill_config: dict[str, Any] | None = None,
) -> bool:
"""Return whether spill is enabled for the executing agent."""
effective_config = (
_get_executing_agent_spill_config() if spill_config is None else spill_config
)
return _is_agent_spill_enabled(effective_config)


def _byte_size(text: str) -> int:
return len(text.encode("utf-8"))

Expand Down Expand Up @@ -217,6 +277,11 @@ async def _on_post_tool_call(
try:
if not isinstance(result, dict) or set(result) == {"error"}:
return
spill_config = _get_executing_agent_spill_config()
if not _is_enabled_for_executing_agent(spill_config):
return
if tool_name in _get_agent_skip_tools(spill_config):
return
# Capture session attribution before entering the worker. The result
# reference remains valid and the callback dispatcher awaits us before
# the model serializes it.
Expand Down Expand Up @@ -248,6 +313,9 @@ def _reset_state() -> None:


__all__ = [
"AGENT_CONFIG_KEY",
"AGENT_ENABLED_KEY",
"AGENT_SKIP_TOOLS_KEY",
"DEFAULT_MAX_INLINE_BYTES",
"DEFAULT_PREVIEW_BYTES",
"DEFAULT_SKIP_TOOLS",
Expand All @@ -256,7 +324,11 @@ def _reset_state() -> None:
"ROOT_KEY",
"SKIP_TOOLS_KEY",
"_build_replacement",
"_get_agent_skip_tools",
"_get_executing_agent_spill_config",
"_get_int",
"_is_agent_spill_enabled",
"_is_enabled_for_executing_agent",
"_on_post_tool_call",
"_on_startup",
"_reset_state",
Expand Down
102 changes: 100 additions & 2 deletions tests/test_spill.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,27 @@ def _string_bytes(result: dict) -> int:
)


try:
from code_puppy.agent_execution_context import executing_agent_context

_HAS_AGENT_CONTEXT = True
except ImportError: # older Code Puppy runtimes
_HAS_AGENT_CONTEXT = False

_requires_agent_context = pytest.mark.skipif(
not _HAS_AGENT_CONTEXT,
reason="requires per-agent execution context",
)


class _ConfiguredAgent:
def __init__(self, tools_config):
self._tools_config = tools_config

def get_tools_config(self):
return self._tools_config


@pytest.fixture(autouse=True)
def _spill_root(tmp_path):
root = tmp_path / "spills"
Expand Down Expand Up @@ -78,16 +99,16 @@ def test_multiple_fields_spill_largest_first_until_under_cap(monkeypatch):
config.set_value(spill.PREVIEW_KEY, "80")
result = {"second": "b" * 1200, "largest": "a" * 2000, "small": "ok"}
saved_contents: list[str] = []
real_save = store.save_text

def recording_save(
content: str,
tool_name: str,
configured_root: str | None,
session_id: str | None = None,
):
_ = tool_name, configured_root, session_id
saved_contents.append(content)
return real_save(content, tool_name, configured_root, session_id)
return Path("/tmp/spill-result")

monkeypatch.setattr(store, "save_text", recording_save)
_call("agent_run_shell_command", result)
Expand Down Expand Up @@ -160,6 +181,83 @@ def test_zero_cap_disables_plugin():
assert result == original


@pytest.mark.asyncio
@_requires_agent_context
async def test_agent_can_disable_spill_without_affecting_concurrent_agent(
_spill_root,
):
config.set_value(spill.MAX_INLINE_KEY, "500")
config.set_value(spill.PREVIEW_KEY, "100")
disabled_result = {"stdout": "d" * 5000}
disabled_original = disabled_result.copy()
enabled_result = {"stdout": "e" * 5000}

async def call_for_agent(agent, result):
with executing_agent_context(agent):
await spill._on_post_tool_call("some_tool", {}, result, 1.0)

disabled_agent = _ConfiguredAgent({"spill": {"enabled": False}})
enabled_agent = _ConfiguredAgent({"spill": {"enabled": True}})
await asyncio.gather(
call_for_agent(disabled_agent, disabled_result),
call_for_agent(enabled_agent, enabled_result),
)

assert disabled_result == disabled_original
assert "Full output stored at:" in enabled_result["stdout"]
assert len(list(_spill_root.glob("session-*/*"))) == 1


@_requires_agent_context
def test_agent_can_skip_selected_tool_while_spilling_other_tools(_spill_root):
config.set_value(spill.MAX_INLINE_KEY, "500")
config.set_value(spill.PREVIEW_KEY, "100")
skipped_result = {"content": "s" * 5000}
skipped_original = skipped_result.copy()
default_skipped_result = {"content": "r" * 5000}
default_skipped_original = default_skipped_result.copy()
spilled_result = {"content": "p" * 5000}
agent = _ConfiguredAgent(
{"spill": {"skip_tools": [" custom_report ", "", 123, None]}}
)

with executing_agent_context(agent):
_call("custom_report", skipped_result)
_call("read_file", default_skipped_result)
_call("other_tool", spilled_result)

assert skipped_result == skipped_original
assert default_skipped_result == default_skipped_original
assert "Full output stored at:" in spilled_result["content"]
assert len(list(_spill_root.glob("session-*/*"))) == 1


@_requires_agent_context
def test_malformed_agent_skip_tools_fails_open(_spill_root):
config.set_value(spill.MAX_INLINE_KEY, "500")
result = {"content": "x" * 5000}
agent = _ConfiguredAgent({"spill": {"skip_tools": "custom_report"}})

with executing_agent_context(agent):
_call("custom_report", result)

assert "Full output stored at:" in result["content"]
assert list(_spill_root.glob("session-*/*"))


@_requires_agent_context
def test_invalid_agent_spill_setting_fails_open(_spill_root):
config.set_value(spill.MAX_INLINE_KEY, "500")
result = {"stdout": "x" * 5000}
agent = _ConfiguredAgent({"spill": {"enabled": "false"}})

with executing_agent_context(agent):
_call("some_tool", result)

assert "Full output stored at:" in result["stdout"]
assert list(_spill_root.glob("session-*/*"))


def test_invalid_cap_falls_back_to_default(_spill_root, caplog):
config.set_value(spill.MAX_INLINE_KEY, "definitely-not-a-number")
result = {"stdout": "x" * (spill.DEFAULT_MAX_INLINE_BYTES + 1000)}
Expand Down