Skip to content

v3.10 — Hardening + new tools (test, web_fetch, web_search, research) - #10

Merged
Kurtisone merged 23 commits into
mainfrom
v3.10-agents-tools
Aug 3, 2026
Merged

v3.10 — Hardening + new tools (test, web_fetch, web_search, research)#10
Kurtisone merged 23 commits into
mainfrom
v3.10-agents-tools

Conversation

@Kurtisone

@Kurtisone Kurtisone commented Aug 3, 2026

Copy link
Copy Markdown
Owner

23 commits. Every fix here was found through real usage, not anticipated in advance —
the branch grew by testing each new piece live and following the bugs it actually produced.

New tools

  • test — dedicated pytest/ruff runner, its own allowlist (TEST_ALLOWED_COMMANDS)
    independent of the general shell tool's.
  • web_fetch — fetches a known URL. Non-configurable SSRF guard (blocks
    private/loopback/link-local resolved IPs, no auto-redirects), optional domain
    allowlist, stdlib-only HTML→text extraction.
  • web_search — SearXNG-backed (self-hosted, not a cloud API), returns ranked
    links/snippets only.
  • research — search → fetch top N results → single synthesized answer, run as one
    deterministic call. Exists specifically because asking the router to chain
    web_search into a second step (answer or fetch) proved unreliable with this
    model — two different steering-hint designs both failed the same way (repeated
    the identical search instead of following either option), and disabling prompt
    caching reproduced the exact same failure, ruling out a KV-cache bug. The fix is
    architectural: remove the decision from the router entirely, same pattern as review.

review

What was briefly a separate "code" agent (read → run tests → LLM review) got merged
into the existing review graph instead — three near-identical "code" concepts
would have confused both the router and future maintenance. review now optionally
runs a file's tests first (test_path) and feeds the output to the analysis as
primary evidence. Dispatchable directly from chat (tools/review.py), consistent
with the single-conversation, zero-tab UI constraint — no dedicated form was added.

Real bugs found and fixed, in the order they were hit

  1. Path confinementreview (and, once retested, files) treated a leading
    / as an escape attempt or read from the host filesystem root instead of the
    workspace. Fixed in both: /hello.go and hello.go now mean the same
    workspace-root file.
  2. Router ambiguity, review vs files — traced to the prompt itself, not
    history: review's own first worked example was "relire" with no request for
    feedback, teaching the model that the bare verb meant review. Rewritten with an
    explicit contrastive example on each side.
  3. review returning 8 characters instead of an analysis — root cause:
    reusing the router's JSON-first parser on a prompt that asked for plain text.
    A small model conditioned on the router's JSON habit answered with a degenerate
    JSON echo, and the parser "successfully" extracted it as if it were real. Fixed
    with a dedicated response cleaner (text_cleaning.py, shared with research
    after the same bug reappeared there independently) that conditionally unwraps a
    JSON-wrapped answer only when the content looks substantive, otherwise shows the
    raw text so a bad response is visibly wrong instead of silently truncated.
  4. web_fetch swamped by navigation chrome on a real Wikipedia fetch — fixed
    by excluding nav/header/footer/aside, not just script/style.
  5. web_fetch with zero router description — produced malformed content
    ("unsupported scheme: ''"). Given a description + example like every other tool.
  6. web_search result dumped raw instead of continuing to a synthesis step —
    missing "done":false on the worked examples. Fixed, then hit loop-guard
    repeats on the next step regardless (prose-only hint, then an explicit JSON
    example, both failed) — which is what motivated building research instead of
    continuing to patch the router-chaining approach. The loop guard also gained a
    graceful degrade-to-previous-result fallback for web_search (mirroring the
    existing one for memory), for the cases where research isn't in play.
  7. research's first live run: search+fetch worked (no loop), but the
    synthesis was still shown wrapped in router JSON — the unwrap fix from V3.5 hardening #3 had
    never been ported over. Shared the logic properly this time.
  8. Two long-standing open items closed: the model has no reliable notion of
    "today" (confused 2025/2026 in a live research synthesis) — now injected into
    all three user-facing prompts; and a vague file reference ("améliore le
    contenu", naming nothing) could make the model invent content instead of
    reading the real file — the router now knows to resolve that against the most
    recent real path already visible in conversation history.

Also

  • git stays strictly read-only — a write (commit/push) is deliberately never a
    router-reachable decision, only a separate human-confirmed flow.
  • .env.example and README.md (Configuration, Tools, Roadmap, architecture
    file tree) updated — they'd fallen behind since v3.7.
  • httpx2 added as a dev dependency, clearing a StarletteDeprecationWarning.

Testing

344 tests (up from ~230 at the start of this branch), ruff check and
ruff format --check clean, zero warnings under pytest -W error.

Separate allowlist from the general shell tool (TEST_ALLOWED_COMMANDS,
default pytest+ruff) and dedicated timeout (TEST_TIMEOUT), so 'run the
tests' / 'lint this' stay first-class router intents with their own
purpose-built safety boundary rather than depending on whatever
SHELL_ALLOWED_COMMANDS happens to allow.

5 tests: allowed runner, reported failure, blocked runner, empty
command, and explicit proof that the shell allowlist has no bearing
here.
Fetches a URL and returns tag-stripped text. Two protection layers,
both important given Forge's own network position (NiPoGi behind
WireGuard, alongside other personal services):

- SSRF guard (always on, NOT configurable): every IP a hostname
  resolves to is checked against loopback/private/link-local/
  reserved/multicast ranges before any request is made. Redirects
  are not followed automatically for the same reason (a 200 from an
  allowed host could otherwise redirect to an internal address).
- Optional domain allowlist (WEB_FETCH_ALLOWED_DOMAINS), empty by
  default -- any public domain is fetchable subject to the guard
  above.

HTML-to-text extraction uses stdlib html.parser only, no new heavy
dependency (bs4/lxml).

Known residual limitation documented in the module docstring: the IP
check and the actual request are two separate DNS lookups, so DNS
rebinding between the two is not fully closed -- acceptable for a
personal single-user tool.

13 tests using requests-mock (new dev dependency): SSRF cases
(loopback/private/link-local/unresolvable), domain allowlist,
content-type handling, HTML stripping, redirect non-following, HTTP
errors.
Fixes: StarletteDeprecationWarning: Using httpx with starlette.testclient
is deprecated; install httpx2 instead.

httpx2 is Pydantic's maintained fork; starlette.testclient (via
fastapi.testclient) has preferred it since 1.2.0/1.3.1. Dev-only --
no runtime behavior change, plain httpx elsewhere in the app is
unaffected. Confirmed the full suite runs with zero warnings under
-W error after this change.
Merges what was briefly a separate 'code' agent into the existing
review graph instead of keeping a parallel concept -- it was the
same read+review flow with one extra step, and three near-identical
'code' things (tools/code.py stub, graphs/review.py, agents/code.py)
would have been confusing for both the router and future maintenance.

- graphs/review.py: optional test_path parameter. Without it,
  behavior is byte-identical to before (2-step read_file -> llm_review).
  With it, a run_tests node runs pytest via the dedicated test tool
  and feeds the output to the review as primary evidence.
- api.py: ReviewRequest gains an optional test_path field.
- cli.py: 'forge review <file> [question] [--tests <path>]'.
- tools/review.py (new): dispatchable wrapper, content = JSON
  {"file_path","question","test_path"}. This is the actual point of
  the change -- Forge's UI is a single conversational page with zero
  tabs, so a review must be reachable by just asking for it in chat,
  not only via CLI/API. Wired into the router prompt (description +
  two worked examples, one with test_path) the same way files/memory
  already are.

Verified end to end with mocked LLMs: a plain chat message routes to
review and returns the analysis, no separate call needed.

284 tests (was 275), lint/format clean, zero warnings under -W error.
Real bug hit in production use: a chat-dispatched review of
'/hello.go' failed with 'File not found: /hello.go' (resolved
against the actual filesystem root -- there was no confinement at
all), while the same request without the leading slash routed
differently (to 'files') and worked, since files.py is properly
confined.

graphs.review.run() itself is intentionally left unconfined -- it's
also called directly by the CLI (a human-typed path, same trust
level as shell access) and by the /review API endpoint (its own
tempfile, outside the workspace by design). Confinement now lives at
the tools/review.py dispatch boundary instead, where router output on
untrusted chat text actually needs it -- same threat model as
tools/files.py.

A leading '/' is stripped rather than rejected, so '/hello.go' and
'hello.go' resolve to the same workspace-root file -- this is also
what actually closes the escape: Path(workspace) / '/etc/passwd'
would otherwise silently discard the workspace prefix (pathlib joins
replace the left side entirely when the right side is absolute), so
stripping first is what guarantees the join always stays relative.

5 new tests (was test_review_tool.py's 3, now 8): confinement on
file_path and test_path, the leading-slash case that was actually
observed, and traversal rejection for both fields.

287 tests, lint/format clean.
Same bug as the review fix in the previous commit, just surfacing in
files.py instead: '/hello.go' was rejected as 'escaping the
workspace' while 'hello.go' worked, even though both should resolve
to the same file at the workspace root. Root cause is the same
pathlib gotcha (Path(workspace) / '/x' discards the workspace prefix
entirely when the right-hand side is absolute), just previously
manifesting as a correct-but-unhelpful rejection here instead of a
silent unconfined read (review had no confinement at all yet).

Observed live: router chose 'files' for the same read request with
and without a leading slash, and only one of the two actually worked.

Traversal protection (../../etc/passwd) is untouched -- lstrip('/')
only affects a leading slash, resolve()+relative_to() still catches
'..' escapes exactly as before.

1 new regression test (test_files_leading_slash_means_workspace_root).
288 tests, lint/format clean.
Real bug hit in production use: 'relis hello.go et donne moi ton avis'
routed correctly to review, but the answer shown was just 'hello.go'
(8 chars) -- no actual analysis.

Root cause confirmed via logs: the review LLM call returned 61 raw
chars, but _llm_review_node was feeding that through
router.parser.parse_router_output -- the parser built to extract a
{"tool":...,"content":...} decision out of router output. A small
model heavily fine-tuned on the router's JSON habit apparently
answered the review prompt (which explicitly asks for plain text, no
JSON) with a degenerate JSON echo instead of real analysis, e.g.
{"tool":"chat","content":"hello.go"}. The router parser dutifully
'succeeded' at extracting that content as the decision, silently
discarding everything else -- producing a tiny, plausible-looking but
meaningless result with no visible sign anything had gone wrong.

Fix: review now has its own minimal response cleaner
(_clean_review_response) instead of reusing the router's JSON-first
extraction cascade. It only strips <think> blocks and a leaked-prompt
echo; a stray JSON blob, if the model still produces one, is shown
as-is rather than unwrapped -- a visibly wrong response beats a
silently truncated one that happens to look valid.

Side effect, also a real fix: review answers were being silently
capped at 400 chars (router.parser's _MAX_FALLBACK_CHARS, sized for
tool decisions, not analysis prose) any time the model answered in
plain text -- which was every successful case, since the prompt asks
for plain text. Review now has its own 4000-char ceiling.

Also added: review.raw_output is now logged unconditionally
(mirrors orchestrator.py's router.raw_output), since this call
previously had zero raw-output visibility -- the exact garbage the
model produced in the reported bug couldn't be confirmed from logs,
only inferred from the length mismatch.

3 new tests: the exact degenerate-JSON-echo case, think-block
stripping, and the 400-char cap removal. 291 tests, lint/format
clean.
The previous fix (stop reusing router's JSON parser) made the failure
visible instead of silent -- confirmed live: the raw JSON echo
({"tool":"code","content":"hello.go","done":true}) is now shown
as-is, exactly as designed. But the underlying cause was still
unaddressed: the model kept answering in router JSON despite the
prompt already saying 'plain text (no JSON)'.

Every other place in this codebase where a small local model needed
to follow an instruction reliably, a bare instruction wasn't enough
-- it needed a concrete worked example (files:write, files read-then-
write, memory recall rephrasing). The review prompt had zero examples
of its own expected output shape, just an instruction. Same fix
applied here: a GOOD ANSWER example plus two explicitly labeled
NEVER DO THIS shapes (both {"tool":"chat",...} and
{"tool":"code",...}, since the router picked 'code' this time,
'chat' the first time -- the model isn't fixating on one specific
tool name, it's fixating on the JSON shape itself).

Also added both new label markers (GOOD ANSWER, NEVER DO THIS) to
the leaked-prompt detector, in case the model echoes the example
scaffolding verbatim instead of writing its own review.

Tradeoff worth noting: the prompt is now ~3x longer (roughly 400 ->
1180 chars before file content), which costs real prompt-processing
time on the Deck's APU every review call. Judged worth it given the
alternative was a meaningless response every time.

1 new test locking the prompt's GOOD ANSWER / NEVER DO THIS content
in place. 292 tests, lint/format clean.

Not yet proven against the live model -- next real test on hardware
is what actually validates this, the same way the previous two
review bugs were only confirmed/found through real usage.
Retested live after the prompt fix (previous commit): the model kept
wrapping its answer in router-style JSON despite the explicit
GOOD ANSWER / NEVER DO THIS example -- but this time the wrapped
'content' was a genuine multi-sentence review, not a degenerate echo.
The few-shot example alone wasn't enough to break the JSON habit
entirely; it just stopped producing garbage inside the wrapper.

Now have two real, contrasting data points to calibrate against:
- Bug #1: {"tool":"chat","content":"hello.go"} -- 1 word, 8 chars,
  clearly not a review. Must NOT be trusted/unwrapped.
- Bug #2: {"tool":"chat","content":"The code is correct and
  follows..."} -- 40 words of genuine analysis. Should be unwrapped
  to clean prose, not shown as a JSON blob to the user.

_try_unwrap_router_json(): if the whole cleaned response is a
{"content":...} object, unwrap it when content is >= 8 words or
>= 40 chars (comfortably separates both real cases seen so far), else
leave the raw JSON visible -- same 'visibly wrong beats silently
truncated' principle as the previous two review fixes, just narrowed
to genuinely degenerate cases instead of every JSON-shaped response.
Both paths log a warning either way, since the model wrapping at all
is still the underlying issue GBNF grammar would fully close later
if this heuristic proves insufficient.

2 new tests (the exact substantive and degenerate shapes observed
live). 294 tests, lint/format clean.

Still not fully proven against the live model in this exact form --
next real test on hardware is what validates it.
Real ambiguity hit in production use: the exact same phrasing
('Relis hello.go') routed to files one time and review another,
across otherwise-similar conversation turns. Root cause found in the
router prompt itself, not in conversation-history noise (that was
briefly suspected and ruled out -- a router-side sliding history
window was considered and rejected, since it would reintroduce the
FIFO cache-invalidation problem v3.8 specifically fixed):

review's own FIRST worked example was 'Peux-tu relire X ?' with no
request for feedback, mapped to review. files' own example set had
no bare 'relis X' case at all. So the model had literally been shown
that the verb alone means review, while never being shown the
opposite anchor -- not a coin-flip, a real gap in the few-shot set.

Fix:
- review's first example now pairs 'relire' with an explicit
  request for feedback ('et me donner ton avis'), not the bare verb.
- files gets a new fourth example: 'Relis hello.go' (bare) -> read.
- Both tool descriptions spell out the distinction explicitly: the
  verb 'lire'/'relire' alone -> files:read; paired with a request for
  an opinion/analysis -> review.

Same tools, same verb, opposite target -- now anchored by whether
feedback is requested, in both the description and a worked example
on each side, consistent with the pattern already proven throughout
this session (this model needs to see the shape, not just read a
rule about it).

1 new test locking the disambiguation in place. 295 tests, lint/format
clean.
…style

Real bug hit testing web_fetch on a Wikipedia page: the output was
almost entirely navigation menu and a 179-language picker list --
the actual article content got pushed past the 6000-char output cap
before it ever appeared. script/style/noscript/head being skipped
wasn't enough; real sites put their non-content chrome in semantic
nav/header/footer/aside tags, which were being extracted as regular
text.

Added those four tags to _SKIP_TAGS. Still stdlib html.parser only,
no new dependency.

1 new test reproducing the shape of the real page (nav + header +
article + aside + footer), asserting the article content survives
and all four chrome tags are excluded.

Separately (not a web_fetch bug, noted but not addressed here): the
original request was 'actualité sur l'IA' and the fetched URL was
Wikipedia's general AI overview article, which was never going to
have current news regardless of extraction quality -- a URL-selection
issue upstream of this tool, not a fetch/extraction issue.

296 tests, lint/format clean.
Real bug hit in production use: web_fetch was registered as a tool
but had zero entry in TOOL_DESCRIPTIONS/_TOOL_EXAMPLES, so the router
fell back to the generic 'content is the input this tool expects'
wording. Result: the router picked web_fetch but produced malformed
content (empty or non-URL text), surfacing as
'[error] unsupported scheme: "" (only http/https)'.

Added a description explaining the expected shape (a single URL) and
a positive worked example, same pattern as every other tool in this
file.

Also addresses a second, separate real failure from the same
session: asked for 'les dernières actualités en bourse', the router
picked web_fetch, guessed a URL, and got HTTP 404. web_fetch fetches
a URL it's given -- it has no search capability, so any URL for a
vague 'latest news' request is a guess with no grounding, and a
guess is very likely wrong. Added an explicit warning in the
description plus a second, contrastive example: a vague news request
should map to 'chat' (answer honestly that browsing/search isn't
available), not a fabricated URL.

2 new tests: description+examples present when enabled, both absent
when web_fetch isn't in ENABLED_TOOLS. 298 tests, lint/format clean.
Real case hit in production use: fetching boursorama.com/bourse/
truncated at the old 512KB default before reaching the actual
content (indices, news) -- confirmed by manually inspecting the page
that the real content exists in the raw HTML, just pushed far down
past a large navigation megamenu.

Root cause is NOT something raising this value fully fixes: the
megamenu is wrapped in plain, non-semantic <div>s rather than
<nav>/<header>, which tools/web_fetch.py's tag-based extractor can't
distinguish from real content (it filters by tag name only, not
role/class/heuristics). A proper 'main content' extraction heuristic
(à la Readability/trafilatura) would need a real dependency, which
this tool has deliberately avoided throughout -- not adding one now.

Raised WEB_FETCH_MAX_BYTES default from 512KB to 2MB as a cheap,
honest partial mitigation (gives more raw bytes a chance to reach the
real content on heavy pages), and documented the actual limitation
in config.py so it doesn't get rediscovered as a mystery later:
web_fetch is reliable on simpler/semantic sites (docs, articles,
wikis), best-effort on heavy non-semantic corporate portals.

No test changes needed -- no existing test pinned the old byte-cap
value. 298 tests, lint/format clean.
New tool, distinct from web_fetch: queries a search index for ranked
results (title/URL/snippet) given a query, rather than fetching a
page whose URL is already known. Requires a self-hosted SearXNG
instance (SEARXNG_URL, default http://127.0.0.1:8888) -- not a cloud
search API, consistent with Forge's self-hosting posture (same
reasoning as running its own llama.cpp/embedding servers). SearXNG
needs "json" added to its own search.formats in settings.yml
(disabled by default upstream to discourage scraping public
instances; safe on a private, self-hosted one).

Router integration follows the same pattern as every other tool:
- TOOL_DESCRIPTIONS + two worked examples (plain query, and the
  'actualités en bourse' case that previously had nowhere good to go)
- A step_context steering hint after a web_search result, same
  loop-guard reasoning as the existing memory/files hints -- but with
  a genuine two-way branch (answer from snippets vs fetch one result
  for full detail) instead of forcing a single path, since forcing
  either one would be wrong depending on whether the snippets already
  answer the question

Also corrects web_fetch's own description and example set, which
had become stale: it used to teach a flat chat refusal for
'actualités en bourse' (accurate when Forge had no search capability
at all) -- now points to web_search first when enabled, only falling
back to a chat refusal when web_search isn't. The old refusal example
moved to web_search's own example set, where it's now correctly
answered.

16 new tests (8 for the tool itself via requests-mock, 8 for the
router prompt integration). 311 tests, lint/format clean.

Not yet validated against a live SearXNG instance or the real model
-- next real test once SearXNG is deployed is what actually confirms
the routing and chaining behavior.
Real bug hit in production use: SearXNG worked, the router correctly
picked web_search with a sensible query -- but the raw results list
(5 titles/URLs/snippets) was shown to the user verbatim instead of a
synthesized answer.

Root cause: neither web_search worked example set 'done':false, so
the orchestrator treated the search itself as a complete, single-step
answer and returned its raw output directly. The step_context
steering hint added alongside web_search (telling the model to answer
from the snippets or fetch a specific result) only ever gets a chance
to fire on a SECOND step -- which never happened, since nothing told
the router a second step was needed in the first place. Exactly the
same shape of bug 'done':false already exists to solve for memory's
'recall' action; web_search's own examples just never actually used
it.

Both examples now end with ,"done":false, matching the recall
pattern. 1 new test asserts every web_search example line in the
prompt includes it, so this can't silently regress again.

312 tests, lint/format clean.

Not yet re-validated against the live model -- next real 'actualité
du jeu vidéo' style test is what confirms the two-step chain actually
produces a synthesized answer instead of a raw list.
Real bug hit in production use, immediately after the previous
done:false fix: the router correctly chained into a second step this
time, but repeated web_search with the same query instead of
following the steering hint -- tripping the loop guard
('repeated call to tool=web_search with identical content') instead
of producing an answer.

Root cause: the hint was prose-only ('respond with tool:chat and
answer naturally...'). Every other case in this file where a small
local model needed to reliably follow a multi-step instruction
needed the exact JSON shape shown, not just described -- proven
repeatedly on this same file (memory's recall hint, review's
GOOD ANSWER example, web_search's own done:false fix earlier today).
The web_search hint was the one spot that still only had prose.

Rewrote it as two explicit, numbered JSON templates -- one for
answering from the snippets, one for fetching a specific result --
instead of a single descriptive paragraph, so the model has something
to copy the shape of rather than paraphrase into its own.

Test assertions updated for the new wording; 312 tests, lint/format
clean.

Not yet re-validated against the live model -- next real test is
what actually confirms the loop no longer happens.
Two prompt-engineering attempts at the web_search steering hint
(prose-only, then an explicit worked JSON example) both failed to
stop the model from repeating an identical web_search call on the
second step -- confirmed live both times. Also ruled out a KV-cache
correctness bug (the same architecture-level issue root-caused for
this model in v3.8) by disabling LLAMA_CPP_CACHE_PROMPT and
reproducing the exact same repeated output with fresh, uncached
processing. This is a genuine self-correction limit of this model
class for 'recognize you already tried X, do something else,' not a
fixable prompt-wording or infra problem.

Rather than a third guess at prompt phrasing, extended the existing
memory-tool loop-guard fallback (orchestrator.py) to also cover
web_search: a repeated identical call degrades to the already-
successful previous result (the raw search listing) instead of
surfacing the internal 'Stopped: the router tried to repeat the same
step.' message. Same reasoning as memory's own fallback, same code
path, just widened from a single tool to a small tuple -- every other
tool still hard-fails on a repeat, since that's still a genuine bug
signal there.

Honest tradeoff: this doesn't give a synthesized answer when the loop
happens, it gives back the raw search results -- strictly better than
an error, not a full fix for synthesis reliability with this model.

2 tests: the new web_search fallback (mirrors the existing memory
one), and the existing 'other tools still hard-fail' test renamed and
kept passing unchanged in behavior. 313 tests, lint/format clean.
… call

This is the real fix for the web_search chaining problem, not another
prompt tweak. Two different steering-hint designs for a router-decided
second step after web_search both failed live (the model repeated the
identical search instead of following either option), and disabling
prompt caching reproduced the exact same failure, ruling out the
KV-cache bug already known for this model (v3.8) -- this is a genuine
small-model limit at multi-step self-correction, not a fixable prompt
or infra problem.

The fix removes the decision from the router's hands entirely:

- forge/tools/web_search.py refactored to expose search() -> list[dict]
  (structured results), with run() now a thin formatter on top for
  direct chat/router dispatch. No behavior change for existing callers.
- forge/graphs/research.py (new): search -> fetch top N result URLs
  (via web_fetch.run(), a failed individual fetch is skipped, not
  fatal) -> single LLM call synthesizing one answer from snippets +
  fetched excerpts. Same deterministic-sequence pattern already proven
  for graphs/review.py, and reuses its anti-JSON-habit prompt
  technique (GOOD ANSWER / NEVER DO THIS) plus its own dedicated
  response cleaner (not the router's JSON-first parser).
- forge/tools/research.py (new): dispatchable wrapper, content = plain
  query text (no JSON needed, single field). Single call from the
  router's perspective -- never done:false, never a second step.
- Router prompt: three-way hierarchy now taught explicitly --
  research is the default for an actual answer about something
  current (moved the 'actualités en bourse' example here, no
  done:false); web_search is only for when the user wants links/
  sources themselves, not an answer; web_fetch defers to research
  now instead of the stale 'use web_search then decide' advice.
- New config: RESEARCH_FETCH_TOP_N (default 3), RESEARCH_FETCH_CHARS_PER_RESULT
  (default 1500).

24 new tests (11 web_search incl. the new search() structured API,
8 for the research graph's nodes/edges/error paths, 3 for the
dispatch wrapper) plus 5 router prompt tests rewritten for the new
three-way hierarchy. 328 tests, lint/format clean.

Not yet validated against a live SearXNG + model -- next real
'actualité du jeu vidéo' test is what confirms this actually produces
a synthesized answer without ever looping.
Real bug hit on research's very first live run: the search+fetch
worked correctly (single call, no loop), but the synthesized answer
-- a genuine, substantive multi-paragraph response about 2025 game
releases -- was shown wrapped in {"tool":"chat","content":"..."}
instead of clean prose.

Root cause: graphs/research.py's _clean_synthesis_response was
written with its own copy of the think-block-stripping and leak-
marker logic from graphs/review.py, but the conditional-unwrap fix
(review's third iteration on this exact problem) was never ported
over -- two independently duplicated implementations drifted the
moment one of them got a fix the other didn't.

Fix: extracted the shared logic into forge/text_cleaning.py
(strip_think_blocks, try_unwrap_router_json) and both graphs/review.py
and graphs/research.py now import it instead of keeping their own
copies. Prompt-specific leak markers stay defined separately in each
file (tied to each prompt's own wording, not shared behavior -- same
reasoning as TOOL_DESCRIPTIONS duplication in router/prompt.py).

7 new tests for the shared module directly, 1 new test locking in
research's substantive-unwrap case (mirrors review's existing test).
336 tests, lint/format clean.

Not yet re-validated against the live model -- next real research
run is what confirms the synthesized answer now displays as clean
prose instead of raw JSON.
Caught while checking whether v3.10 was ready to close: .env.example
hadn't been touched all session (test/web_fetch/web_search/research
config was completely undocumented there), and README.md's Roadmap
table still listed v3.7 as 'current' -- v3.8, v3.9, and this entire
v3.10 session were missing from both the Configuration/Tools tables
and the Roadmap.

- .env.example: new sections for the test tool, review graph,
  web_fetch, web_search (SearXNG), and research, following the same
  style/detail level as every existing section (container-networking
  note repeated for SEARXNG_URL, same as LLAMA_CPP_URL already had).
- README.md Configuration table: all new env vars added.
- README.md Tools table: review/test/web_fetch/web_search/research
  rows added; git's row now explains why it stays read-only-only by
  design (a write has real cost if the router hallucinates).
- New prose section explaining why research exists alongside
  web_search -- the router-chaining reliability problem and why the
  fix is architectural (a graph) rather than another prompt attempt,
  same style as the existing grammar-constrained-decoding writeup.
- CLI usage example for review --tests, /review's test_path field
  noted in the API table.
- Roadmap table: v3.7 marked done, v3.8/v3.9 added as done, v3.10
  added as current with a summary of this session's actual scope.

No code changes -- 336 tests still passing, lint/format clean.
…ions

Follow-up to the previous doc commit -- missed two spots on the first
pass: the file-tree listing under Architecture still only showed
graphs/review.py (with a stale description, pre-dating its optional
test_path step) and no graphs/research.py or text_cleaning.py at all;
and the very first 'Core Concept' tool list at the top of the README
was still frozen at the v3.2 tool set (chat/code/files/shell/git),
missing memory (v3.7) and everything from this session.

Both updated to the full current tool/module set.
Two long-standing open items closed at the user's request.

1. Date awareness: the model has no reliable notion of 'today,' only
   a stale training-time sense of it -- confirmed live (confused 2025
   and 2026 in a research synthesis) until the user manually stated
   the date in their own prompt. forge/context_info.py centralizes
   today_line() (a single ISO-date prompt line, deliberately the
   machine's local naive date rather than UTC -- Forge runs as one
   instance on the user's own machine, so 'today' means today where
   that machine is) and it's now injected into all three prompts that
   generate user-facing text: the router, review's analysis prompt,
   and research's synthesis prompt (the one where this actually
   mattered live).

2. Vague file reference resolution (open since v3.9): asking Forge to
   'analyse le contenu' or 'améliore-le', referring implicitly to a
   file mentioned earlier without naming it again, could make the
   model answer from imagined content instead of ever reading the
   real file -- a cross-turn reference resolution problem, harder
   than a single missing worked example since it spans multiple
   turns of conversation history, not one call. The history block
   (router/prompt.py's _format_history) already contains the real
   path in any persisted files/review confirmation
   (e.g. '[ok] written 9 bytes to notes.py') -- the model just had no
   instruction to go find and reuse it. Added a standing instruction
   (not step_context-gated like the read/write hints below it, since
   this spans turns, not steps within one run) telling the model to
   resolve a vague reference against the most recent real path already
   visible in history, and to read the file first if it doesn't
   already have the current content.

Both verified not to disturb the KV-cache-stability contract from
v3.8: the file-reference instruction is a pure function of
alone (present iff history is non-empty), same as the rest of
_format_history, and the date line only changes once per calendar day
-- test_history_block_is_stable_regardless_of_step_context still
passes unchanged.

7 new tests (context_info module, date line present in all three
prompts, vague-reference instruction present/absent by history
presence). 343 tests, lint/format clean.

Not yet validated against the live model for either fix -- next real
test on hardware is what confirms the model actually acts on the
grounding/instruction rather than just having it available in the
prompt.
CI failure on the PR: 'executable not found: pytest' -- 2 tests
failing in GitHub Actions while passing locally.

Root cause: the subprocess is deliberately run with a minimal,
hardcoded env (PATH=/usr/local/bin:/usr/bin:/bin, no leaked host
secrets/tokens -- same pattern as tools/shell.py) and the runner
lookup relied on that same restricted PATH. That's fine for coreutils
(ls, cat, grep all reliably live in /usr/bin), but pytest/ruff are
pip-installed console scripts that can land anywhere depending on how
Python was set up -- a venv, --user, or (this case) GitHub Actions'
own hostedtoolcache via actions/setup-python. My own sandbox happened
to have pytest at /usr/local/bin (a system-wide install), which is
exactly why this passed there and nowhere else.

Fix: resolve the runner's absolute path via shutil.which() against
the real process PATH (which does include wherever pip actually put
it), then invoke that resolved path directly. The subprocess still
runs with the same minimal, hardcoded env as before -- only the
lookup changed, not the sandboxing.

1 new test: symlinks the real pytest into a directory that is
nowhere in the hardcoded PATH, points /home/claude/.npm-global/bin:/home/claude/.local/bin:/root/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin at only that directory,
and confirms the tool still finds and runs it -- reproduces the exact
CI failure shape locally rather than just asserting the fix
abstractly.

344 tests, lint/format clean.
@Kurtisone
Kurtisone merged commit fe33cd1 into main Aug 3, 2026
2 checks passed
@Kurtisone
Kurtisone deleted the v3.10-agents-tools branch August 3, 2026 18:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant