Skip to content

delegate_gemini: fix 5 error-coverage gaps found in review - #20

Merged
allocsys merged 6 commits into
mainfrom
fix/delegate-gemini-error-coverage
Jul 26, 2026
Merged

delegate_gemini: fix 5 error-coverage gaps found in review#20
allocsys merged 6 commits into
mainfrom
fix/delegate-gemini-error-coverage

Conversation

@allocsys

Copy link
Copy Markdown
Owner

Follow-up to PR #19. Found while auditing delegate_gemini's error paths for gaps (no live regression -- PR #19's fixes are working correctly), all fixed here:

1. max_steps: 0 (or negative) silently no-ops instead of erroring

Confirmed live before this fix:

delegate_gemini({ task: "...", max_steps: 0 })
→ "(Investigation stopped after reaching the step cap of 0 without a final answer...)"

Gemini is never called; the loop's for (step = startStep; step <= cappedSteps; ...) just never executes. Zod's z.number().optional() has no floor. Fix: runtime validation in tools.jsmax_steps must be a positive integer, returned as a clear isError before runInvestigation is ever called.

2. Transient vs. permanent Gemini failures got the identical message

A 429/503 (genuinely worth resuming) and a 400/malformed-request/missing-GEMINI_API_KEY/no-candidates-from-a-safety-block (will reproduce identically on retry) all produced the same "...resume_run_id to continue" message, actively steering callers toward a fix that won't work for the latter group. Fix: added isTransientGeminiError() (429/503 only, matching client.js's own fallback-cascade logic) and branched the failure message accordingly.

3. Resuming with max_steps lower than steps already completed silently discarded the checkpoint

If a checkpoint has stepsDone = 5 and you resume with max_steps: 2, startStep (6) > cappedSteps (2) means the loop body never runs, but the code fell straight through to deleteCheckpoint(runId) + a generic "reached the step cap" message — destroying 5 real completed steps with no way to recover them and no indication that's what happened. Fix: explicit guard right after checkpoint load — if there's no step budget left, leave the checkpoint alone and say so, with the existing transcript included.

4. Function-execution block wasn't guarded the way the geminiChat call was

The geminiChat() call has its own try/catch that checkpoints and returns a resumable failure. The loop right after it (fn.execute() + transcript building) didn't have the same protection — every current function happens to return a string today so it doesn't fire, but nothing enforced that, and any future violation would throw past runInvestigation entirely and land in tools.js's generic catch with no runId, losing the run's resumability. Fix: added a defensive typeof resultText !== "string" guard plus a wrapping try/catch that checkpoints and returns the same resumable-failure shape as a geminiChat failure.

5. err.message assumed every throw is a real Error

Minor robustness gap across both delegate.js and tools.js (including Delegate_web_fetch) — everything currently thrown is a real Error, but nothing guarded against a non-Error throw producing "Error: undefined". Fix: err?.message ?? String(err) everywhere a caught error's message is surfaced.

Testing status

Unit-reasoned through the code paths (item 1 was reproduced live before the fix, verified fixed after). Items 2-4 are defensive/edge-case fixes for conditions that are rare or hard to force live (a genuine non-429/503 Gemini failure, a checkpoint-then-lower-max_steps resume, a function returning non-string). Recommend a spot-check of item 1 live post-merge; the others are lower-risk since they only add guards around existing behavior rather than changing the happy path.

allocsys added 6 commits July 26, 2026 14:04
… always suggesting resume

callGenerateContentOnce/callGenerateContent already tag network-layer failures
with err.status (429/503 are the documented transient cases -- see client.js's
own model-cascade, which already only retries on 429). Everything else -- a
malformed request (400), auth/config problems (401/403, or no status at all
e.g. "GEMINI_API_KEY is not set"), or "Gemini returned no candidates" from a
safety/recitation block -- will reproduce identically on retry. The per-step
failure message previously treated all of these the same way and always told
the caller to resume, which is actively misleading for the non-transient
cases. Added isTransientGeminiError() and branched the message accordingly.
… failure message; guard err.message for non-Error throws
…ax_steps below steps already completed

Previously, resuming with a max_steps ceiling lower than checkpoint.stepsDone
meant startStep > cappedSteps, so the loop body never ran -- it fell straight
through to deleteCheckpoint() + the generic "stopped after reaching the step
cap" message, discarding a checkpoint that had real completed work in it
without ever surfacing that work or explaining what happened. Added an
explicit guard right after the checkpoint state is established: if there's
nothing new to do because the requested ceiling is already met/exceeded,
leave the checkpoint alone (still resumable with a higher max_steps) and
return the existing transcript with a message that says so plainly.
…pected throw doesn't lose checkpointed progress

Unlike the geminiChat call (which has its own try/catch that checkpoints and
returns a resumable failure), the loop over functionCalls -- fn.execute(),
building the transcript entry, JSON.stringify(args) -- had no equivalent
guard. Every current FUNCTIONS[].execute() happens to return a string today,
so resultText.length never throws in practice, but nothing enforced that
contract; a future function returning non-string (or a JSON.stringify throw
on unexpected args shape) would escape runInvestigation entirely uncaught,
land in tools.js's generic catch, and lose the run's runId/resume info even
though the model's turn (and possibly several prior steps) had already
completed. Added a defensive typeof guard on resultText and wrapped the
whole per-call loop in a try/catch that mirrors the geminiChat failure path:
checkpoint what's done and return a resumable failure instead of throwing
past this function.
… throws

max_steps had no floor check -- passing 0 or a negative number produced a
nonsensical "(Investigation stopped after reaching the step cap of 0/-N
without a final answer...)" instead of Gemini ever being called, with no
indication the input itself was invalid. Zod's z.number().optional() accepts
any finite number including 0/negatives/non-integers, so the guard has to be
a runtime check. Also broadened the existing task-required guard's error
message to mention this new check, and hardened `err.message` references to
fall back to String(err) in case anything ever throws a non-Error value.
…consistency with delegate_gemini's hardening
@vercel

vercel Bot commented Jul 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
madmcp Ready Ready Preview, Comment Jul 26, 2026 10:36am

@allocsys
allocsys merged commit ff192c7 into main Jul 26, 2026
4 checks passed
@allocsys
allocsys deleted the fix/delegate-gemini-error-coverage branch July 26, 2026 10:53
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