File size: 25,698 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
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
"""Runtime helpers for the native stateful ReAct agent."""

from __future__ import annotations

import asyncio
import logging
import os
import re
from typing import ClassVar

from frontier_agent.components.finalization import (
    COMMON_RECOVERY_NUDGE_PREFIXES,
    build_recovery_context,
    chat_with_fallback_budget,
    minimal_best_effort_answer,
)
from frontier_agent.core.llm import LLMClient
from frontier_agent.core.loop_types import AgentLoopResult, ToolResult
from frontier_agent.core.messages import (
    Message,
    assistant_msg_with_reasoning,
    text_of,
    user_msg,
)
from frontier_agent.core.runtime.loop.tool_exec import ToolResultPostProcessor
from frontier_agent.infra.config import get_config
from plugins.tools.bash import BASH_STDERR_SEPARATOR
from plugins.tools.document_node_toolchain import (
    render_document_node_toolchain_note,
)
from workflows.stateful_react_agent.prompts import get_summarize_prompt

logger = logging.getLogger(__name__)

SANDBOX_FS_NOTE = (
    "\n\nFILESYSTEM CONVENTION: Your current working directory /workspace is "
    "private to you. Read task input files from /inputs when provided. Write "
    "ALL intermediate, scratch, and temporary files to /workspace. Write ONLY "
    "your final deliverable file(s) to /outputs. The /outputs directory is "
    "collected and graded verbatim, so keep it clean: no scratch files, no "
    "intermediate or duplicate versions, only the final deliverable(s) exactly "
    "as the task requests."
)

# matplotlib clips silently: an annotation straddling the edge of its axes is
# drawn with the outside half missing and no warning, and clipped white text on
# a white background leaves no trace for the eyeball check. This workflow wires
# no SkillInjectionMiddleware, so it never sees
# plugins/skills/chart-visualization β€” the rule has to ride in the prompt.
# agent_team carries its own copy for the same reason.
CHART_NOTE = (
    "\n\nCHART RULE: matplotlib clips silently β€” a label, badge or legend that "
    "straddles the edge of the axes is drawn with its outside half missing and "
    "no warning at all. So whenever you produce a chart: keep annotations "
    "wholly inside the axes (`y=0.98, va='top'`) or wholly outside "
    "(`y=1.02, va='bottom'`), never straddling `y=1.0`; pass `clip_on=False` to "
    "anything drawn outside the axes (`ax.text` defaults to False, but "
    "`ax.add_patch` defaults to True, so a hand-rolled badge gets clipped); "
    "prefer `ax.legend()`, which measures itself and is never clipped; and save "
    "with `bbox_inches='tight'`. Prove it in code before `savefig`: call "
    "`fig.canvas.draw()`, then for every `ax.texts` entry, `ax.get_legend()` and "
    "every standalone `ax.patches` decoration (bars are owned by "
    "`ax.containers` and are supposed to clip β€” skip those) compare "
    "`get_window_extent()` against `get_clip_box()` and fail loudly if anything "
    "falls outside its clip box. Do not rely on looking at the image β€” "
    "clipped white text on a white background is invisible."
)

# Everything appended to the system prompt once the filesystem tools are live:
# the /workspace-vs-/outputs convention, plus the chart rule (charts are
# produced through those same tools).
SYSTEM_PROMPT_NOTES = SANDBOX_FS_NOTE + CHART_NOTE


def render_system_prompt_notes(
    *,
    sandbox_mode: str,
    tool_names: list[str] | tuple[str, ...],
) -> str:
    """Return static filesystem/chart guidance plus discovered toolchains."""
    filesystem_note = SANDBOX_FS_NOTE
    project = os.environ.get("FRONTIER_AGENT_PROJECT_DIR", "").strip()
    if sandbox_mode == "native":
        workspace = os.environ.get("FRONTIER_AGENT_WORKSPACE_DIR", os.getcwd())
        inputs = os.environ.get("FRONTIER_AGENT_INPUTS_DIR", "inputs")
        outputs = os.environ.get("FRONTIER_AGENT_OUTPUTS_DIR", "outputs")
        filesystem_note = (
            "\n\nFILESYSTEM CONVENTION (native mode): Your current working "
            f"directory {workspace} is the workspace. Read task inputs from "
            f"{inputs}. Write final deliverables to {outputs}. Keep scratch "
            "and intermediate files in the workspace, not the outputs directory. "
            "Scientific and plotting packages are optional in native mode. "
            "Office writer packages used by create_file are already installed. "
            "Do not pre-install optional packages; if the task needs a missing package, "
            "install only that package with `python -m pip install <package>`. "
            "It will be placed in the workspace-local native dependency overlay."
        )
    if project:
        workspace = os.environ.get("FRONTIER_AGENT_WORKSPACE_DIR", "/workspace")
        if os.path.realpath(project) != os.path.realpath(workspace):
            filesystem_note += (
                f" The user's project/coding directory is {project}; read or "
                "edit repository files there, and use the workspace above only "
                "for clones, downloads, drafts, generated probes, and other "
                "run-private intermediate files."
            )
    return filesystem_note + CHART_NOTE + render_document_node_toolchain_note(
        sandbox_mode=sandbox_mode,
        tool_names=tool_names,
    )

# Graceful terminations β€” the loop ran out of its turn / context budget with
# real history to summarize. A forced final-answer is always worth attempting.
# ``response_truncated`` belongs here rather than with the infra errors: the run
# was mid-work with real history, and the LAST assistant text is a fragment cut
# mid-token. Without the rescue that fragment IS the answer β€” which is how a run
# whose visible output was "The shell ate my `" scored zero.
_GRACEFUL_FINAL_STOP_REASONS = frozenset({
    "max_turns", "context_limit_reached", "response_truncated",
})

# Abnormal / infra terminations: the LLM endpoint exhausted retries
# (``llm_error``), the wall-clock or token budget was hit (``wall_deadline`` /
# ``budget_exhausted``), or the runaway-rollback guard tripped
# (``max_attempts``). ``wall_deadline`` and ``max_attempts`` are emitted by the
# current engine (agent_loop.py:411,790) and did not exist on the source loop.
#
# Salvaging these is a best-effort fallback: try to coax a plain-text
# answer out of whatever history exists.  ``salvage_infra_errors=False`` skips
# the extra LLM rescue call for fail-fast benchmark scenarios, but the node
# still returns an explicit deterministic partial-status answer rather than an
# error sentinel.
_INFRA_FINAL_STOP_REASONS = frozenset(
    {"llm_error", "budget_exhausted", "wall_deadline", "max_attempts"},
)


def _forced_final_stop_reasons(salvage_infra_errors: bool) -> frozenset[str]:
    if salvage_infra_errors:
        return _GRACEFUL_FINAL_STOP_REASONS | _INFRA_FINAL_STOP_REASONS
    return _GRACEFUL_FINAL_STOP_REASONS


_FINAL_ANSWER_NUDGE = (
    "Please provide your final answer now based on all information gathered. "
    "Do not call any more tools; reply with plain text."
)
# Retrying a deterministic 400 against the same poisoned tool-call history used
# to waste 4m30s before returning ``<ANSWER_NOT_FOUND>``.  We now ask from a
# clean, single-user-message recovery context and retry only once for
# transient/empty responses.  The caller's finalization timeout is honoured as
# the *per fallback-leg* read timeout; a separate finite outer guard gives every
# configured fallback leg a chance to run without allowing a custom client that
# ignores ``timeout=`` to hang forever.
_FORCE_FINAL_MAX_RETRIES = 2
_FORCE_FINAL_WAIT_S = 1
_THINK_BLOCK_RE = re.compile(r"<think>[\s\S]*?</think>\s*")
_RECOVERY_NUDGE_PREFIXES = (
    # This workflow's own summarize/report prompts, on top of the framework set.
    "Summarize the above conversation",
    "Please provide the final research summary",
    *COMMON_RECOVERY_NUDGE_PREFIXES,
)
_RECOVERY_EMPTY_FALLBACK = (
    "No reliable intermediate text was recoverable. Produce the most "
    "useful honest answer possible from the original task alone."
)

_LEAKED_TOOL_CALL_BLOCK_RE = re.compile(
    r"<\s*tool_call[^>]*>[\s\S]*?<\s*/\s*tool_call\s*>", re.IGNORECASE,
)
_LEAKED_TOOL_RESPONSE_BLOCK_RE = re.compile(
    r"<\s*tool_response[^>]*>[\s\S]*?<\s*/\s*tool_response\s*>", re.IGNORECASE,
)
_LEAKED_FUNCTION_BLOCK_RE = re.compile(
    r"<\s*function\s*=[\s\S]*?<\s*/\s*function\s*>", re.IGNORECASE,
)
_LEAKED_TAG_FRAGMENT_RE = re.compile(
    r"<\s*(tool_call|tool_response|function)\b[^>]*>?", re.IGNORECASE,
)

# CommonMark fence marker: backtick OR tilde, any run length >= 3. A fixed
# ```` ``` #```` (3-backtick) regex mismatches a legitimate 4-backtick
# ```` ````markdown ```` block β€” it "closes" on the first 3 of the 4 closing
# backticks, leaving a stray backtick that turns the whole thing into a
# single-backtick inline code span instead of unwrapping it.
_FENCE_LINE_RE = re.compile(r"^[ \t]{0,3}(?P<char>`|~)(?P<run>(?P=char){2,})")

# A single line that is ENTIRELY one image β€” no leading/trailing prose.
_IMAGE_ONLY_LINE_RE = re.compile(r"^[ \t]*!\[[^\]]*\]\(([^)\n]+)\)[ \t]*$")

# ``[N] Title. PATH_OR_URL`` β€” the exact References-line shape
# ``get_report_prompt`` (prompts.py) mandates. Line-anchored so an in-text
# ``[3]`` citation mid-sentence never matches; only a full standalone line in
# this shape does.
_REFERENCE_LINE_RE = re.compile(r"^\[\d+\]\s+.+?\.\s+(\S+)\s*$", re.MULTILINE)

_URL_SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.\-]*://")


def _fence_marker(line: str) -> tuple[str, int] | None:
    """Return ``(char, length)`` when ``line`` opens/closes a fence, else ``None``."""
    match = _FENCE_LINE_RE.match(line)
    if not match:
        return None
    return match.group("char"), len(match.group("run")) + 1


def _sandbox_ref_key(ref: str) -> tuple[str, str] | None:
    """``(dir_hint, basename)`` for a local sandbox ref, ``None`` for a URL.

    ``dir_hint`` is ``"outputs"``, ``"inputs"``, or ``""`` when the ref has no
    recognised sandbox prefix at all (a bare filename).
    """
    s = ref.strip()
    if _URL_SCHEME_RE.match(s):
        return None
    s = s.lstrip("./")
    match = re.match(r"^/?(outputs|inputs)/(.+)$", s)
    if match:
        return match.group(1), match.group(2).rsplit("/", 1)[-1]
    return "", s.rsplit("/", 1)[-1]


def _refs_match(cited: str, embedded: str) -> bool:
    """True when ``embedded`` (an image src) is the same source as ``cited``.

    A URL matches only by exact string β€” there is no local-execution
    analogue to the path drift handled below, so tolerating a mismatch there
    would be an unverified guess rather than a real signal. A sandbox path
    matches by basename, tolerant of either side using a bare filename
    instead of the full ``/outputs/...`` / ``/inputs/...`` form (e.g. a bash
    script that prints a cwd-relative save path rather than the absolute
    one) β€” but ``outputs`` and ``inputs`` never satisfy each other even when
    the basename collides, since they mean different things (generated vs.
    user-provided).
    """
    cited, embedded = cited.strip(), embedded.strip()
    if _URL_SCHEME_RE.match(cited) or _URL_SCHEME_RE.match(embedded):
        return cited == embedded
    key_c, key_e = _sandbox_ref_key(cited), _sandbox_ref_key(embedded)
    if key_c is None or key_e is None:
        return False
    dir_c, base_c = key_c
    dir_e, base_e = key_e
    if base_c != base_e:
        return False
    return dir_c == "" or dir_e == "" or dir_c == dir_e


def unwrap_fenced_images(text: str) -> str:
    """Strip a fence wrapping ONLY image markdown so it renders as an image.

    The reporter sometimes wraps a bare ``![alt](path)`` in a
    ```` ```markdown ```` / ```` ```md ```` / bare ```` ``` ```` fence, which
    renders as literal text instead of an image. Fence detection follows
    CommonMark's closing rule (same character, closer run length >= the
    opener's) rather than a fixed-length regex, so a legitimate 4-backtick
    (or tilde) fence is scanned correctly instead of mis-parsed.

    Unwrapping is gated on the report's OWN citation discipline, not just the
    fence's content shape: every image inside a candidate block must also
    match a ``[N] Title. PATH`` line elsewhere in the SAME report (via
    :func:`_refs_match`). ``get_report_prompt`` already requires any embedded
    image to be a cited source, so this is a self-consistency check, not a
    guess β€” and it is what stops a block from being unwrapped when it is
    actually a deliberate demonstration (e.g. the user asked "how do I write
    markdown image syntax" and the model answered with an example β€” that
    example has no reason to also appear in the References list, so it stays
    fenced). A block with no matching citation, or a report with no
    References section at all, is left untouched β€” the safe direction; this
    can only fail to unwrap a real image, never wrongly render a fake one. An
    unclosed trailing fence is left untouched too, for the same reason.
    """
    if not text:
        return text
    cited_refs = set(_REFERENCE_LINE_RE.findall(text))
    lines = text.split("\n")
    by_start: dict[int, tuple[int, str]] = {}
    # One Optional holding both halves: as two variables the checker could not
    # see that the index and its marker are always set and cleared together, so
    # every open_marker read looked possibly-None.
    open_block: tuple[int, tuple[str, int]] | None = None
    for i, line in enumerate(lines):
        marker = _fence_marker(line)
        if marker is None:
            continue
        if open_block is None:
            open_block = (i, marker)
            continue
        open_at, open_marker = open_block
        if marker[0] != open_marker[0] or marker[1] < open_marker[1]:
            # Inner marker (different char, or shorter run): part of the
            # block's content, not this block's delimiter.
            continue
        opener_match = _FENCE_LINE_RE.match(lines[open_at])
        assert opener_match is not None
        info = lines[open_at][opener_match.end():].strip().lower()
        body = lines[open_at + 1:i]
        # Collect the matches, then require every line to have matched: ``all``
        # on a list of Optionals does not narrow the elements for the reads below.
        image_matches = [_IMAGE_ONLY_LINE_RE.match(ln) for ln in body]
        matched = [m for m in image_matches if m is not None]
        if (
            info in ("", "markdown", "md")
            and body
            and len(matched) == len(image_matches)
            and all(
                any(_refs_match(ref, m.group(1)) for ref in cited_refs)
                for m in matched
            )
        ):
            by_start[open_at] = (i, "\n".join(ln.strip() for ln in body))
        open_block = None
    if not by_start:
        return text
    out: list[str] = []
    idx = 0
    n = len(lines)
    while idx < n:
        hit = by_start.get(idx)
        if hit is not None:
            end, replacement = hit
            out.append(replacement)
            idx = end + 1
        else:
            out.append(lines[idx])
            idx += 1
    return "\n".join(out)


def _strip_thinking(text: str) -> str:
    return _THINK_BLOCK_RE.sub("", text or "").strip()


def scrub_leaked_tool_calls(text: str) -> str:
    """Remove qwen text-mode tool-call markup, WITHOUT the outer strip.

    Split out of :func:`_strip_leaked_tool_calls` so a streaming filter can apply
    the same removals to each settled segment (interior whitespace must survive
    a mid-report segment boundary) and only strip once at the end.
    """
    if not text:
        return text
    out = _LEAKED_TOOL_CALL_BLOCK_RE.sub("", text)
    out = _LEAKED_TOOL_RESPONSE_BLOCK_RE.sub("", out)
    out = _LEAKED_FUNCTION_BLOCK_RE.sub("", out)
    return _LEAKED_TAG_FRAGMENT_RE.sub("", out)


def _strip_leaked_tool_calls(text: str) -> str:
    """Remove qwen text-mode tool-call markup that leaked into content."""
    if not text:
        return text
    return scrub_leaked_tool_calls(text).strip()


def _build_recovery_messages(
    messages: list[Message],
    *,
    task_description: str,
    final_prompt: str,
    context_max_tokens: int | None = None,
) -> list[Message]:
    """Build a valid tool-free context for the finalization call.

    The original history may contain an empty/truncated
    ``function.arguments`` value or orphan tool messages β€” exactly the kind of
    protocol defect that caused the run supplied with this bug report to 400 on
    every retry.  Flatten useful visible/user/tool text into one user message
    instead of replaying any tool protocol fields.
    """
    recovery_context = build_recovery_context(
        messages,
        strip_thinking=_strip_thinking,
        strip_leaked_tool_calls=_strip_leaked_tool_calls,
        nudge_prefixes=_RECOVERY_NUDGE_PREFIXES,
        empty_fallback=_RECOVERY_EMPTY_FALLBACK,
        fixed_prompt_text=(
            f"{final_prompt}\n\nOriginal task:\n{task_description}\n\n"
            "## Clean recovery context\n"
        ),
        context_max_tokens=context_max_tokens,
    )
    prompt = (
        f"{final_prompt}\n\n"
        "## Clean recovery context\n"
        "The original tool protocol was intentionally removed because it may "
        "contain malformed or orphaned tool calls. Use the observations below "
        "only as partial progress; do not call tools.\n\n"
        f"Original task:\n{task_description}\n\n"
        f"{recovery_context}"
    )
    return [user_msg(prompt)]


def _minimal_best_effort_answer(
    task_description: str,
    stopped_by: str,
    *,
    language: str = "",
) -> str:
    """Always provide a user-facing answer even when the rescue LLM is down."""
    return minimal_best_effort_answer(
        task_description, stopped_by, language=language,
    )


def _is_non_retryable_finalize_error(exc: Exception) -> bool:
    text = str(exc).lower()
    return (
        "error code: 400" in text
        or "badrequest" in text
        or "context length" in text
        or "longer than the model" in text
    )


async def force_final_answer(
    result: AgentLoopResult,
    llm: LLMClient,
    timeout: float,
    task_description: str = "",
    *,
    thinking_format: str = "tag",
    salvage_infra_errors: bool = True,
    final_prompt: str | None = None,
    language: str = "",
) -> AgentLoopResult:
    """Run a tool-free LLM call to extract a plain-text final answer.

    ``salvage_infra_errors`` (default ``True``) also makes an LLM rescue call on
    abnormal/infra exits (:data:`_INFRA_FINAL_STOP_REASONS`). Set ``False`` to
    skip that call; the node-level deterministic best-effort response still
    guarantees non-empty user output.
    """
    forced = _forced_final_stop_reasons(salvage_infra_errors)
    if result.metadata.get("final_answer") or result.stopped_by not in forced:
        return result

    prompt = (
        final_prompt
        or (
            get_summarize_prompt(task_description)
            if task_description
            else _FINAL_ANSWER_NUDGE
        )
    )
    recovery_messages = _build_recovery_messages(
        list(result.messages),
        task_description=task_description,
        final_prompt=prompt,
    )
    ask_timeout = max(float(timeout), 1.0)

    text = ""
    rescue_mode = "deterministic_fallback"
    last_exc: Exception | None = None
    for attempt in range(_FORCE_FINAL_MAX_RETRIES):
        try:
            resp = await chat_with_fallback_budget(
                llm,
                recovery_messages,
                per_leg_timeout_s=ask_timeout,
            )
            content = _strip_leaked_tool_calls(_strip_thinking(text_of(resp.content)))
            if content:
                result.messages.extend(recovery_messages)
                result.messages.append(
                    assistant_msg_with_reasoning(
                        content,
                        getattr(resp, "reasoning_content", "") or "",
                        thinking_format=thinking_format,
                    ),
                )
                text = content
                rescue_mode = "clean_context_llm"
                break
            last_exc = RuntimeError("LLM returned empty content")
            logger.warning(
                "force_final_answer attempt %d/%d returned empty content",
                attempt + 1,
                _FORCE_FINAL_MAX_RETRIES,
            )
        except Exception as exc:
            last_exc = exc
            logger.warning(
                "force_final_answer attempt %d/%d failed: %s",
                attempt + 1,
                _FORCE_FINAL_MAX_RETRIES,
                exc,
            )
            if _is_non_retryable_finalize_error(exc):
                break

        if attempt < _FORCE_FINAL_MAX_RETRIES - 1:
            await asyncio.sleep(_FORCE_FINAL_WAIT_S)

    if not text and last_exc is not None:
        logger.warning("force_final_answer exhausted retries (%s)", last_exc)
    if not text:
        text = _strip_leaked_tool_calls(_strip_thinking(result.final_content))
        if text:
            rescue_mode = "existing_partial"
    if not text:
        text = _minimal_best_effort_answer(
            task_description, result.stopped_by, language=language,
        )
    result.metadata["final_answer"] = text
    result.metadata["final_answer_rescued"] = True
    result.metadata["final_answer_rescue_mode"] = rescue_mode
    result.metadata["final_answer_source"] = rescue_mode
    result.final_content = text
    logger.info(
        "force_final_answer injected after %s (%d chars)",
        result.stopped_by,
        len(text),
    )
    return result


class ReactToolResultPostProcessor(ToolResultPostProcessor):
    """Per-tool char budgets for fresh tool-result content."""

    # read_file already paginates itself and its trailing offset is part of the
    # recovery contract. A second head-only cut would either drop that footer
    # or create a silent gap before the next offset.
    _PASS_THROUGH = frozenset({
        "read_file", "web_fetch", "web_search",
        # Paginates itself and its trailing offset IS the recovery contract β€”
        # the same reason read_file is whitelisted. Head-capping it would cut the
        # continuation pointer off the content the call exists to fetch back, so
        # recovery would silently return less than it says it did.
        "recover_result",
    })

    # Uniform scheme for the high-volume tools: most cut at 8K upstream (gate β‘ 
    # β€” regular read_file pages at max_chars=8K + "PARTIAL READ … offset=N" hint;
    # bash/run_python_code/grep/glob overflow to disk at meta.max_result_chars=8K
    # + "saved to <path>, use read_file" pointer), and this post-processor (gate
    # β‘‘) gives 10K of headroom so the 8K body PLUS its trailing footer both
    # survive into history. A second cut at/below 8K would chop the footer and
    # lose the recovery path β€” exactly the bug that left results silently
    # truncated with no way to fetch the rest.
    _BUDGETS: ClassVar[dict[str, int]] = {
        "grep_search": 10_000,
        "glob_search": 10_000,
    }
    _CONFIGURED_EXEC_TOOLS = frozenset({"bash", "run_python_code"})
    _EXEC_FOOTER_HEADROOM = 2_000
    _BUDGET_DEFAULT = 6_000
    _BASH_STDERR_KEEP = 3_000
    _ELLIPSIS = (
        "\n\n[... body truncated for context budget; re-fetch or rerun with "
        "a focused query if you need the rest ...]"
    )

    def process(self, tool_result: ToolResult) -> str:
        content = (
            tool_result.result
            if isinstance(tool_result.result, str)
            else str(tool_result.result)
        )
        if tool_result.is_error:
            return content

        name = tool_result.name or ""
        if name in self._PASS_THROUGH:
            return content

        if name in self._CONFIGURED_EXEC_TOOLS:
            # Gate β‘  is configurable. Gate β‘‘ must track it or a deployment
            # raising TOOL_EXEC_RESULT_MAX_CHARS can lose the overflow pointer.
            budget = (
                get_config().tool_exec_result_max_chars
                + self._EXEC_FOOTER_HEADROOM
            )
        else:
            budget = self._BUDGETS.get(name, self._BUDGET_DEFAULT)
        if name == "bash":
            return self._compact_bash(content, budget)
        return self._head_cap(content, budget)

    def _head_cap(self, content: str, budget: int) -> str:
        if len(content) <= budget + len(self._ELLIPSIS):
            return content
        return content[:budget] + self._ELLIPSIS

    def _compact_bash(self, content: str, budget: int) -> str:
        if len(content) <= budget:
            return content

        if BASH_STDERR_SEPARATOR in content:
            stdout, stderr = content.split(BASH_STDERR_SEPARATOR, 1)
        else:
            stdout, stderr = content, ""

        stderr_keep = stderr[-self._BASH_STDERR_KEEP:] if len(stderr) > self._BASH_STDERR_KEEP else stderr
        overhead = len(BASH_STDERR_SEPARATOR) + 80 if stderr_keep else 0
        stdout_budget = max(500, budget - len(stderr_keep) - overhead)
        if len(stdout) > stdout_budget:
            half = stdout_budget // 2
            stdout_keep = (
                stdout[:half]
                + f"\n[... {len(stdout) - stdout_budget} stdout chars elided ...]\n"
                + stdout[-half:]
            )
        else:
            stdout_keep = stdout

        if stderr_keep:
            return stdout_keep + BASH_STDERR_SEPARATOR + stderr_keep
        return stdout_keep