diff --git a/connectors/gemini/delegate.js b/connectors/gemini/delegate.js index 0181dc5..4753c3b 100644 --- a/connectors/gemini/delegate.js +++ b/connectors/gemini/delegate.js @@ -815,11 +815,33 @@ export async function runInvestigation({ task, max_steps = 6, resume_run_id }) { // before this field existed won't have it; fall back to whatever the // caller passed (may be undefined) rather than erroring. effectiveTask = checkpoint.task || task; + } else if (resume_run_id && !task) { + // A resume WAS requested but its checkpoint didn't load -- expired past + // the 1-hour TTL, Redis unavailable (checkpoint.js is deliberately + // fail-open, see its header), or an invalid/typo'd runId -- AND there is + // no task to fall back on either. This must NEVER be silently treated as + // "no resume was requested" and fall through to a fresh run: that + // previously produced a conversation seeded with `Task: undefined` (task + // is genuinely ignored on a live resume, so callers legitimately omit + // it), and the model burned several steps hunting blind for context + // instead of investigating (found via the 2026-07-26 checkpoint-miss + // test). Fail loudly and distinctly instead, so the caller can tell + // "your resume target is gone" apart from any other failure. + // + // If a task WAS provided alongside a resume_run_id that fails to load, + // this branch is skipped and the fresh-run branch below runs instead -- + // a legitimate defensive-caller pattern (passing the task as a fallback + // even on a resume call), kept intentionally per the fix plan. + throw new Error( + `resume_run_id "${resume_run_id}" has no live checkpoint -- it may have expired (1 hour TTL), Redis may be unavailable, or the id may be wrong. ` + + `There is no saved task to resume from. Start a new investigation by calling again with a task and no resume_run_id.` + ); } else { - // Either no resume_run_id was given, or the checkpoint had already - // expired/wasn't found -- start a fresh run either way. Requires a real - // `task` (the caller-facing tool in tools.js already guards against a - // missing task on a non-resumable call, so `task` is trustworthy here). + // Either no resume_run_id was given, or one was given with its checkpoint + // missing but a `task` supplied as a fallback (see branch above) -- + // start a fresh run either way. Requires a real `task` (the caller-facing + // tool in tools.js already guards against a missing task on a + // non-resumable call, so `task` is trustworthy here). runId = randomUUID(); contents = [{ role: "user", parts: [{ text: `${SYSTEM_PREAMBLE}\n\nTask: ${task}` }] }]; transcript = []; @@ -827,9 +849,18 @@ export async function runInvestigation({ task, max_steps = 6, resume_run_id }) { } for (let step = startStep; step <= cappedSteps; step++) { + // On the final allowed step, withhold the function-calling tools + // entirely instead of just reminding the model to wrap up: a text-only + // reminder wasn't reliable enough on its own (found via the 2026-07-26 + // test -- the model spent its very last step on another tool call + // anyway, and the run hit the cap with zero synthesized answer, not + // even an incomplete one). Without `tools` in the request body, Gemini + // structurally cannot return a functionCall part here, so this step is + // guaranteed to be a real text-answer attempt rather than another read. + const isFinalStep = step === cappedSteps; let candidate; try { - candidate = await geminiChat(contents, { tools: FUNCTION_DECLARATIONS }); + candidate = await geminiChat(contents, { tools: isFinalStep ? undefined : FUNCTION_DECLARATIONS }); } catch (err) { // The step-1..N-1 work already happened and is real -- don't throw it // away. Persist it (redundant with the save at the end of the prior @@ -895,9 +926,22 @@ export async function runInvestigation({ task, max_steps = 6, resume_run_id }) { // an honest one: the model is told to say it couldn't finish, rather // than presenting a rushed, incomplete answer as if it were complete. const remainingAfterThisStep = cappedSteps - step; - if (remainingAfterThisStep <= 1) { + if (remainingAfterThisStep === 2) { + // Earlier, softer nudge -- gives the model a chance to steer toward + // synthesis before the hard cutoff two notes down, instead of only + // finding out at the last possible moment. responseParts.push({ - text: `[SYSTEM NOTE: only ${remainingAfterThisStep} step(s) remain before this investigation is forced to stop. If you cannot fully complete the task -- including any specific format requested (e.g. an exhaustive table, per-item breakdown) -- in the remaining budget, say so explicitly and describe what's missing, rather than presenting a partial or reformatted-for-brevity answer as if it were complete.]`, + text: `[SYSTEM NOTE: only 2 step(s) remain after this one. Start wrapping up -- prioritize synthesizing what you've already found over opening new lines of investigation.]`, + }); + } else if (remainingAfterThisStep <= 1) { + // When remainingAfterThisStep is 0, the NEXT turn is the final step, + // which is called with no tools at all (see isFinalStep above) -- so + // this note can say so as a fact, not just a suggestion to wrap up. + const noToolsNote = remainingAfterThisStep === 0 + ? " The next turn will NOT include any tools -- a function call is not possible; you must answer in plain text now." + : ""; + responseParts.push({ + text: `[SYSTEM NOTE: only ${remainingAfterThisStep} step(s) remain before this investigation is forced to stop.${noToolsNote} If you cannot fully complete the task -- including any specific format requested (e.g. an exhaustive table, per-item breakdown) -- in the remaining budget, say so explicitly and describe what's missing, rather than presenting a partial or reformatted-for-brevity answer as if it were complete.]`, }); }