Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 51 additions & 7 deletions connectors/gemini/delegate.js
Original file line number Diff line number Diff line change
Expand Up @@ -815,21 +815,52 @@ 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 = [];
startStep = 1;
}

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
Expand Down Expand Up @@ -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.]`,
});
}

Expand Down
Loading