Skip to content

Commit ccc6a04

Browse files
committed
feat(agent): auto-continue on silent-fail tool calls too
Promissory-pattern catch covered 'let me try another approach' but missed the related failure: agent gave a confident final answer ('There are no orphan nodes') based on a tool call that exited non-zero with empty stdout (e.g. 'graph json | grep -q ...'). Add a second auto-continue trigger: last tool exit != 0 AND last tool stdout is empty AND model wrote a non-empty final answer. Forces the model to re-derive its conclusion via a different query (e.g. run-js with explicit return) before answering.
1 parent c6dd156 commit ccc6a04

1 file changed

Lines changed: 35 additions & 18 deletions

File tree

src/agent/llm/session.ts

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -589,34 +589,51 @@ export async function streamSession(
589589
combinedText += finalText
590590
const toolsDuringTurn = toolCalls.length - before
591591

592-
// Auto-continue heuristic: the model wrote a promissory phrase
593-
// ("let me try another approach", "I'll investigate further", …)
594-
// but stopped emitting tool calls. Inject a continue prompt and
595-
// re-stream — up to MAX_AUTOCONTINUE times.
592+
// Auto-continue heuristics — force another tool round when:
593+
// (a) the model wrote a promissory phrase but stopped, OR
594+
// (b) the model gave a confident final answer that rests on a
595+
// tool call which produced no stdout and a non-zero exit
596+
// (the silent-grep / empty-pipe failure mode).
597+
const lastTool = toolCalls[toolCalls.length - 1]
598+
const silentFail =
599+
lastTool !== undefined &&
600+
lastTool.exitCode !== 0 &&
601+
lastTool.stdout.trim().length === 0
602+
const promissory =
603+
finalText.length > 0 && PROMISSORY_PATTERN.test(finalText)
596604
const stalled =
597-
finalText.length > 0 &&
598-
PROMISSORY_PATTERN.test(finalText) &&
599-
// If the model just made a tool call this turn AND ended with the
600-
// promise, it's mid-iteration — give it one more push. If it
601-
// didn't make any tool call this turn, it's definitely stuck.
602-
autoContinues < MAX_AUTOCONTINUE
605+
autoContinues < MAX_AUTOCONTINUE &&
606+
(promissory || (silentFail && finalText.length > 0))
603607

604608
if (!stalled) break
605609

606610
autoContinues++
611+
let nudge: string
612+
if (silentFail && !promissory) {
613+
nudge =
614+
'[auto-continue] Your last tool call exited ' +
615+
String(lastTool?.exitCode) +
616+
' with empty stdout — your final answer rests on a query that ' +
617+
'returned nothing. Verify your conclusion with a different ' +
618+
'approach (e.g. run-js with a return statement) before answering. ' +
619+
'Do not narrate now — run the verifying tool call.'
620+
} else if (toolsDuringTurn === 0) {
621+
nudge =
622+
'[auto-continue] You promised follow-up action but stopped without ' +
623+
'executing it. Run the next tool call now — do not narrate, act.'
624+
} else {
625+
nudge =
626+
'[auto-continue] Your last turn surfaced something that needs ' +
627+
'another step. Take that step now via run_shell — no preamble.'
628+
}
607629
messages = [
608630
...messages,
609631
{ role: 'assistant', content: finalText },
610-
{
611-
role: 'user',
612-
content:
613-
toolsDuringTurn === 0
614-
? '[auto-continue] You promised follow-up action but stopped without executing it. Run the next tool call now — do not narrate, act.'
615-
: '[auto-continue] Your last turn surfaced something that needs another step. Take that step now via run_shell — no preamble.'
616-
}
632+
{ role: 'user', content: nudge }
617633
]
634+
const reason = silentFail && !promissory ? 'silent-fail' : 'promissory'
618635
onDelta(
619-
`\n\n[agent: auto-continuing — promissory stop detected, attempt ${autoContinues}/${MAX_AUTOCONTINUE}]\n\n`
636+
`\n\n[agent: auto-continuing — ${reason} detected, attempt ${autoContinues}/${MAX_AUTOCONTINUE}]\n\n`
620637
)
621638
}
622639

0 commit comments

Comments
 (0)