Spaces:
Running
Running
File size: 24,062 Bytes
9d0fd45 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 | """Tool result overflow β persist large results to disk, return summary inline."""
from __future__ import annotations
import contextlib
import hashlib
import json
import logging
import os
import time
import uuid
from pathlib import Path
from plugins.tools.meta import get_tool_meta
logger = logging.getLogger(__name__)
# The store's physical root comes from ``_sandbox.spill_root()``; see there for
# why it lives outside every root the agent can write. What is left here is the
# per-conversation partitioning under it.
_RUN_SUBDIR = "spill"
_SPILL_SEPARATOR = "\n---\n\n"
# Backends whose commands always run on this process's own filesystem, so the
# physical path IS the path a model command can name. Container mode normally
# does too, but may opt into an inner bwrap jail; that case is resolved at run
# time in :func:`_overflow_dir`.
_SAME_FILESYSTEM_BACKENDS = frozenset({"native"})
# Backends whose commands run on another machine entirely. Nothing on this
# filesystem is nameable there, so spill advertises no path and the footer says
# the remainder is unreadable rather than pointing somewhere that cannot resolve.
_REMOTE_BACKENDS = frozenset({"e2b"})
#: Stores this process created, so a discarded session can drop exactly its own
#: recovery files. Replaces walking a directory tree looking for them: we delete
#: only paths we made, which is why this needs no symlink or filesystem-root
#: defence β the previous implementation walked a tree inside the agent's own
#: workspace and had to assume it was hostile.
_created_stores: set[Path] = set()
def _scope_component(task_id: str) -> str:
"""Map an arbitrary task id to one safe, stable directory component."""
return hashlib.sha256(task_id.encode("utf-8")).hexdigest()[:16] if task_id else ""
def _current_task_id() -> str:
from frontier_agent.core.execution_context import get_current_execution_scope
scope = get_current_execution_scope()
if scope is None:
return ""
task_id = str(scope.task_id or "")
session_id = str(scope.metadata.get("llm_session_id") or "")
# This is a per-conversation overflow cache, not cross-session memory.
return f"{task_id}:{session_id}" if session_id else task_id
def _resolved_backend() -> str:
"""The active backend as ``_sandbox`` resolves it, or "" when unresolvable.
Reading ``SANDBOX_BACKEND`` straight from the environment misses a backend
supplied only through ``config.yaml`` β the resolver consults ``get_config()``
for exactly that case β and would then advertise ``/workspace/.spill/...``
for a run whose commands execute directly on the host filesystem, where that
literal path may name an unrelated directory. A misconfigured backend must
not take spill down with it: spill is a diagnostic aid, so fall back to the
conservative canonical mount rather than raising.
"""
from plugins.tools._sandbox import _get_sandbox_backend
try:
return _get_sandbox_backend()
except Exception:
return ""
def _overflow_dir(task_id: str = "", *, create: bool = True) -> tuple[Path, str]:
"""Return the physical write directory and the path visible to the agent.
``create=False`` resolves the same location without touching the filesystem,
for callers that only want to inspect or remove an existing store.
This used to branch four ways over the workspace, the configured mount dir,
a run directory and a legacy host-only path, each with its own rule for what
the agent could name. The store now has one root outside every write root, so
the only remaining question is how a model command reaches it: directly under
``native`` and ordinary ``container`` mode, through the read-only ``/spill``
mount under bwrap, and not at all from a remote backend.
"""
from plugins.tools._sandbox import _DEFAULT_SPILL_DIR, spill_root
scope = _scope_component(task_id)
target = spill_root() / scope if scope else spill_root()
if create:
target.mkdir(parents=True, exist_ok=True)
# Readable and traversable by others, writable only by the harness. Set
# explicitly because this is the WHOLE enforcement under ``container``,
# where model commands are dropped to an unprivileged uid: they may read
# a 0644 spill file but cannot create or unlink inside a directory they
# do not own. Leaving it to the ambient umask would make that guarantee
# depend on whoever launched the process. bwrap gets a read-only mount
# instead (uid 0 in a user namespace ignores DAC); ``native`` has no
# isolation to enforce anything with.
with contextlib.suppress(OSError):
target.chmod(0o755)
_created_stores.add(target)
backend = _resolved_backend()
if backend in _REMOTE_BACKENDS:
return target, ""
if backend in _SAME_FILESYSTEM_BACKENDS:
return target, str(target)
if backend == "container":
# Production CurrentSandbox explicitly runs without a mount namespace;
# advertising /spill there points at nothing because the physical store
# is normally under /tmp or the run directory. Only the optional inner
# bwrap path creates the canonical read-only /spill mount.
from plugins.tools._sandbox import container_uses_inner_bwrap
if not container_uses_inner_bwrap():
return target, str(target)
return target, f"{_DEFAULT_SPILL_DIR}/{scope}" if scope else _DEFAULT_SPILL_DIR
def body_names_a_spill_file(body: str) -> bool:
"""Whether *body* already carries a spill pointer the agent can act on.
A presence test against the two roots the store can be named by β the
canonical mount and the physical path β not a parse of the pointer's prose.
The wording differs per backend and per caller, and ``7cf9188`` moved
deliberately away from recognising spill refs by shape.
Deliberately does NOT go through :func:`agent_visible_spill_dir`, which
resolves via ``_overflow_dir`` and would CREATE the store as a side effect of
asking a read-only question.
Exists so the site-3 recovery footer can stay quiet when it would be
redundant. Measured on a live agent-team run: every result site 3 shortened
was a ``bash`` result that already carried a spill pointer, and the spill file
behind it held the FULL pre-gate-β output β 42,770 chars against the 8,000 the
model saw. The agent read those files with ``cat`` and never called the tool
the footer named. Two routes to the same bytes, and the footer lost.
"""
if not body:
return False
from plugins.tools._sandbox import _DEFAULT_SPILL_DIR, spill_root
roots = [_DEFAULT_SPILL_DIR]
with contextlib.suppress(Exception):
roots.append(str(spill_root()))
# The separator is not cosmetic: a bare ``"/spill" in body`` also fires on
# ``/spillover``, and a pointer always names a FILE under the store, so the
# trailing slash is both stricter and exactly what a real pointer contains.
return any(
root and f"{root.rstrip('/')}/" in body for root in roots
)
def agent_visible_spill_dir() -> str:
"""Return the spill directory as tools should name it, or empty if unreadable."""
return _overflow_dir(_current_task_id())[1]
# Smallest inline preview worth keeping. A cap tighter than
# ``footer + _MIN_PREVIEW_CHARS`` is honoured only approximately: the pointer is
# worth more than the last few hundred characters of body.
_MIN_PREVIEW_CHARS = 500
# Below this, splitting a budget in two leaves two useless slivers.
_MIN_SIDE_CHARS = 200
# Bodies smaller than this are not worth a recovery file of their own.
_SPILL_MIN_CHARS = 1_500
def _truncation_mode(tool_name: str = "") -> str:
"""The preview shape for one tool: ``middle`` (head AND tail) or ``head``.
Read per call rather than captured at import so an A/B run can flip arms
through ``TOOL_RESULT_TRUNCATION`` without a rebuild. A malformed value
falls back to ``middle`` β this is output shaping, not a place to fail.
``auto`` decides per tool from ``ToolMeta.result_is_ranked``, because the two
shapes are not competing for the same kind of output. An exec log states its
verdict last, so cutting the middle keeps it. A relevance-ranked search
result is the opposite: its tail is its worst entries, and splitting the
budget spends half of it on them instead of on more good hits. The first live
A/B was run on a search benchmark and found no accuracy difference in either
direction, which is consistent with the two effects cancelling β see
docs/tool-result-truncation-ab.md.
"""
from frontier_agent.infra.config import get_config
try:
mode = str(get_config().tool_result_truncation).strip().lower()
except Exception:
return "middle"
if mode == "auto":
if not tool_name:
return "middle"
return "head" if get_tool_meta(tool_name).result_is_ranked else "middle"
return mode if mode in {"middle", "head"} else "middle"
def _elision(removed: int) -> str:
return f"\nβ¦ {removed:,} chars elided β¦\n"
def _head_end(text: str, budget: int) -> int:
"""Where a head slice of at most ``budget`` chars ends, snapped to a line.
Snapping back past the halfway point would throw away more than it buys, so
a single line longer than half the budget is cut mid-line instead.
"""
if budget >= len(text):
return len(text)
newline = text.rfind("\n", budget // 2, budget)
return newline if newline > 0 else budget
def _tail_start(text: str, budget: int) -> int:
"""Where a tail slice of at most ``budget`` chars starts, snapped to a line.
Snapping FORWARD (dropping the partial first line) rather than back, so the
slice never exceeds ``budget`` and never opens mid-token.
"""
start = max(len(text) - budget, 0)
if start == 0:
return 0
newline = text.find("\n", start, start + max(budget // 2, 1))
return newline + 1 if newline != -1 else start
def truncate_preview(text: str, budget: int, *, tool_name: str = "") -> str:
"""Cut ``text`` to at most ``budget`` chars, keeping the head AND the tail.
A head-only cut is the wrong default for tool output: a pytest run states
its verdict in the last ten lines, a compiler in the last error, a script in
its exit status. Keeping only the head hides precisely the part the model
called the tool for, and costs an extra recovery round-trip to get it back.
Both codex (``truncate_middle_with_token_budget``) and the shape used here
split the budget evenly and name the gap.
The marker is sized against an upper bound on the elided count before the
split, so the assembled preview is never longer than ``budget``.
"""
if budget <= 0:
return ""
if len(text) <= budget:
return text
if _truncation_mode(tool_name) == "head":
return text[:_head_end(text, budget)]
room = budget - len(_elision(len(text)))
if room < 2 * _MIN_SIDE_CHARS:
return text[:_head_end(text, budget)]
head_end = _head_end(text, room // 2)
tail_start = _tail_start(text, room - room // 2)
if tail_start <= head_end:
return text[:_head_end(text, budget)]
return text[:head_end] + _elision(tail_start - head_end) + text[tail_start:]
def budgeted_preview(
body: str,
*,
cap: int,
ref: str,
full_len: int | None = None,
note: str = "",
tool_name: str = "",
) -> str:
"""Preview plus recovery pointer, together within ``cap``.
The footer is measured FIRST and charged against the preview budget. Adding
it afterwards β as every call site used to β makes a tool that advertises an
8K cap return 8K plus a few hundred characters, on every overflowing call,
for exactly the results that are already the largest in the turn.
"""
total = len(body) if full_len is None else full_len
footer = _spill_footer(ref, full_len=total, note=note)
return truncate_preview(
body, max(cap - len(footer), _MIN_PREVIEW_CHARS), tool_name=tool_name,
) + footer
def _write_spill(
tool_name: str, body: str, *, require_visible: bool, task_id: str = "",
) -> tuple[Path, str] | None:
"""Persist ``body`` once and return its path plus the agent-visible ref.
Named by ``sha256(tool_name, body)`` rather than a fresh uuid so re-spilling
the SAME body is idempotent. Tier 2 re-spills every protected fan-in result
on each pass that wins, which under a uuid name left one identical copy per
compaction on disk and burned a manifest slot each time.
Returns ``None`` when the store is unreachable, or when the caller requires
an agent-visible path and this backend cannot name one.
"""
write_dir, visible_dir = _overflow_dir(task_id or _current_task_id())
if require_visible and not visible_dir:
return None
digest = hashlib.sha256(
tool_name.encode("utf-8") + b"\x00" + body.encode("utf-8", "replace"),
).hexdigest()[:16]
path = write_dir / f"{digest}.md"
if not path.exists():
# Write-then-rename: a crash mid-write would otherwise leave a truncated
# file under the name the digest resolves to, which ``path.exists()``
# then treats as a complete spill forever.
tmp = path.with_name(f".{digest}.{uuid.uuid4().hex[:8]}.tmp")
try:
tmp.write_text(_spill_document(tool_name, digest, body), encoding="utf-8")
os.replace(tmp, path)
except OSError as exc:
logger.warning("Failed to spill %s result: %s", tool_name, exc)
with contextlib.suppress(OSError):
tmp.unlink()
return None
return path, (f"{visible_dir}/{path.name}" if visible_dir else "")
def _spill_document(tool_name: str, spill_id: str, result: str) -> str:
"""Use grep-friendly markdown and keep the captured body verbatim.
``spill_id`` is the content digest, not a call id: the same body spilled
twice is one file, so no single call owns it.
"""
return (
f"# {tool_name} β spilled tool result\n\n"
f"- id: `{spill_id}`\n"
f"- captured: {time.strftime('%Y-%m-%dT%H:%M:%S%z')}\n"
f"- length: {len(result):,} chars"
f"{_SPILL_SEPARATOR}{result}"
)
def _spill_footer(ref: str, *, full_len: int, note: str = "") -> str:
"""The model-visible pointer back to the full result.
Kept at a fixed shape so :func:`budgeted_preview` can charge its length to
the preview budget before deciding where to cut.
"""
suffix = f" {note}" if note else ""
if not ref:
return (
f"\n\n[... only part of this {full_len:,}-char result is shown; the "
f"remainder is not readable from this backend.{suffix}]"
)
directory = ref.rsplit("/", 1)[0]
# Name more than one route: ``read_file`` is not bound in every profile (the
# stateful_react benchmark profile binds bash/grep_search/glob_search and no
# reader), and an agent that follows the advice literally there gets "unknown
# tool" instead of its own spilled content.
return (
f"\n\n[... only part of this {full_len:,}-char result is shown (head and "
f"tail; the gap is marked above). Full content is saved read-only at "
f"{ref}. Only if the elided middle is required, read that path with "
f"whichever tool you have β read_file, `cat` via bash, or "
f"grep_search(pattern=\"...\", path=\"{directory}\"). It is read-only; "
f"do not write there.{suffix}]"
)
def spill_compacted_body(tool_name: str, body: str) -> str | None:
"""Persist a result immediately before compaction discards its inline body."""
if len(body) < _SPILL_MIN_CHARS:
return None
spilled = _write_spill(tool_name, body, require_visible=True)
return spilled[1] if spilled else None
def maybe_overflow(
tool_name: str,
result: str,
*,
task_id: str = "",
call_id: str = "",
) -> str:
"""Check if result exceeds max_result_chars and overflow to disk if needed.
Args:
tool_name: Name of the tool that produced the result.
result: The full tool result string.
task_id: Optional task ID for organizing overflow files. Defaults to
the current execution scope. Pass the SAME composite form the scope
uses (``f"{task_id}:{llm_session_id}"``) or the store will not be
the one ``cleanup_overflow`` removes.
call_id: Accepted for backwards compatibility and no longer used to name
the file β see :func:`_write_spill` on content-hash naming.
Returns:
The original result if within limits, or a head-and-tail preview with a
reference to the overflow file, together no longer than the cap.
"""
meta = get_tool_meta(tool_name)
# 0 means no limit
if meta.max_result_chars <= 0:
return result
if len(result) <= meta.max_result_chars:
return result
spilled = _write_spill(
tool_name, result, require_visible=False, task_id=task_id,
)
if spilled is not None:
logger.info(
"Tool result overflow: %s result (%d chars) saved to %s",
tool_name, len(result), spilled[0],
)
# A failed write only costs the pointer: the footer then says the remainder
# is unreadable instead of naming a path that does not exist.
ref = spilled[1] if spilled else ""
return budgeted_preview(
result, cap=meta.max_result_chars, ref=ref, tool_name=tool_name,
)
# ββ Aggregate budget (per-turn total) βββββββββββββββββββββββββββββββββββ
# Max total chars across all tool results in a single ReAct turn.
# Prevents N parallel tools from flooding the context.
# Ref: Claude Code MAX_TOOL_RESULTS_PER_MESSAGE_CHARS = 200,000
MAX_AGGREGATE_RESULT_CHARS = 200_000
# Floor for one result inside the aggregate pass: a turn of many big results
# must not cut any single one down to nothing.
_MIN_AGGREGATE_KEEP = 2_000
def check_aggregate_budget(
results: list[str],
tool_names: list[str] | None = None,
task_id: str = "",
) -> list[str]:
"""Enforce aggregate budget across multiple tool results in one turn.
If the total exceeds MAX_AGGREGATE_RESULT_CHARS, the largest results are
re-cut β spilling first, so what this pass removes stays recoverable β until
the total fits. A result that already carries a spill pointer keeps it: the
pointer sits at the very end of the string and the tail half of the preview
survives, so recovery chains from this file to the original one.
Args:
results: List of tool result strings (already individually overflowed).
tool_names: Optional list of tool names (parallel to results).
task_id: Unused; the store follows the current execution scope.
Returns:
Adjusted list of results, same length as input.
"""
total = sum(len(r) for r in results)
if total <= MAX_AGGREGATE_RESULT_CHARS:
return results
logger.info(
"Aggregate tool results (%d chars) exceed budget (%d), re-truncating",
total, MAX_AGGREGATE_RESULT_CHARS,
)
names = list(tool_names or [])
adjusted = list(results)
# Largest first: cutting the biggest result is what buys the most room, and
# leaves the small results in the turn untouched.
for idx, result in sorted(enumerate(results), key=lambda x: len(x[1]), reverse=True):
if total <= MAX_AGGREGATE_RESULT_CHARS:
break
excess = total - MAX_AGGREGATE_RESULT_CHARS
cap = max(_MIN_AGGREGATE_KEEP, len(result) - excess)
if cap >= len(result):
continue
name = names[idx] if idx < len(names) else "tool"
spilled = (
_write_spill(name, result, require_visible=True)
if len(result) >= _SPILL_MIN_CHARS
else None
)
replacement = budgeted_preview(
result,
cap=cap,
ref=spilled[1] if spilled else "",
note="Cut further to fit the per-turn tool-result budget.",
tool_name=name,
)
total -= len(result) - len(replacement)
adjusted[idx] = replacement
return adjusted
def get_overflow_content(overflow_path: str) -> str | None:
"""Read the full content from an overflow file.
Args:
overflow_path: Path to the overflow JSON file.
Returns:
The full tool result content, or None if not found.
"""
path = Path(overflow_path)
if not path.is_file():
return None
try:
text = path.read_text(encoding="utf-8")
if _SPILL_SEPARATOR in text:
return text.split(_SPILL_SEPARATOR, 1)[1]
# Backward compatibility for sessions holding pointers to old JSON spills.
data = json.loads(text)
return data.get("content")
except Exception as e:
logger.warning("Failed to read overflow file %s: %s", overflow_path, e)
return None
def cleanup_overflow(
scope: str | None = None,
*,
workspace: str | Path | None = None,
) -> int:
"""Remove the spilled tool results of one finished conversation.
Args:
scope: The store to remove, in the SAME composite form the writers use β
``f"{task_id}:{llm_session_id}"``, which is what
:func:`_current_task_id` returns. A bare ``task_id`` hashes to a
different directory and would silently match nothing, since
``llm_session_id`` defaults to ``task_id`` rather than staying empty.
Omit it to clean up the caller's own current scope. An explicitly
empty string is always a safe no-op.
workspace: Ignored, kept so existing teardown calls still type-check.
The store no longer lives under a workspace.
Returns:
Number of files removed.
"""
del workspace
if scope == "":
return 0
resolved_scope = _current_task_id() if scope is None else scope
if not resolved_scope:
return 0
# ``create=False``: resolving a store in order to delete it must not first
# bring it into existence, which would also leave a stray empty directory
# behind for any scope that never spilled.
return _remove_store(_overflow_dir(resolved_scope, create=False)[0])
def _remove_store(store: Path) -> int:
"""Delete one store directory's files, then the directory. Count the files."""
if not store.is_dir():
return 0
count = 0
for entry in store.iterdir():
try:
entry.unlink()
count += 1
except OSError:
pass
with contextlib.suppress(OSError):
store.rmdir()
_created_stores.discard(store)
return count
def cleanup_overflow_process() -> int:
"""Remove every store THIS process created.
For a discarded conversation: the TUI's ``/clear`` and ``/mode`` drop all
history that could reference a spill path, so the files it named are dead.
PRECONDITION: one conversation per process, which holds for the only caller β
the terminal app β and for the benchmark runner's subprocess-per-question. A
server multiplexing concurrent sessions in one process must NOT use this; it
would delete a live session's recovery files. Such a caller wants
:func:`cleanup_overflow` per scope instead.
Scoped to what this process created rather than to a directory tree, which
is both safer β another session's store is not ours to delete, whatever it
is next to β and simpler, since deleting only paths we made needs none of
the symlink and filesystem-root defences that walking the agent's workspace
required.
"""
return sum(_remove_store(store) for store in list(_created_stores))
|