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))