fix(agent): support async onLoopDetected callbacks and prevent orphaned tool_use events

- Await onLoopDetected callback result so async functions work correctly
  instead of silently falling through to 'continue'
- Move loop detection before yielding tool_use events so terminate mode
  never emits tool_use without a matching tool_result
This commit is contained in:
JackChen 2026-04-05 12:54:18 +08:00
parent cc957b3148
commit 56b8cef158
2 changed files with 14 additions and 9 deletions

View File

@ -310,14 +310,13 @@ export class AgentRunner {
yield { type: 'text', data: turnText } satisfies StreamEvent
}
// Announce each tool-use block the model requested.
// Extract tool-use blocks for detection and execution.
const toolUseBlocks = extractToolUseBlocks(response.content)
for (const block of toolUseBlocks) {
yield { type: 'tool_use', data: block } satisfies StreamEvent
}
// ------------------------------------------------------------------
// Step 2.5: Loop detection — check before executing tools.
// Step 2.5: Loop detection — check before yielding tool_use events
// so that terminate mode doesn't emit orphaned tool_use without
// matching tool_result.
// ------------------------------------------------------------------
let injectWarning = false
let injectWarningKind: 'tool_repetition' | 'text_repetition' = 'tool_repetition'
@ -331,7 +330,7 @@ export class AgentRunner {
options.onWarning?.(info.detail)
const action = typeof loopAction === 'function'
? loopAction(info)
? await loopAction(info)
: loopAction
if (action === 'terminate') {
@ -363,6 +362,12 @@ export class AgentRunner {
break
}
// Announce each tool-use block the model requested (after loop
// detection, so terminate mode never emits unpaired events).
for (const block of toolUseBlocks) {
yield { type: 'tool_use', data: block } satisfies StreamEvent
}
// ------------------------------------------------------------------
// Step 4: Execute all tool calls in PARALLEL.
//

View File

@ -254,10 +254,10 @@ export interface LoopDetectionConfig {
* - `'warn'` inject a "you appear stuck" message, give the LLM one
* more chance; terminate if the loop persists (default)
* - `'terminate'` stop the run immediately
* - `function` custom callback; return `'continue'`, `'inject'`, or
* `'terminate'` to control the outcome
* - `function` custom callback (sync or async); return `'continue'`,
* `'inject'`, or `'terminate'` to control the outcome
*/
readonly onLoopDetected?: 'warn' | 'terminate' | ((info: LoopDetectionInfo) => 'continue' | 'inject' | 'terminate')
readonly onLoopDetected?: 'warn' | 'terminate' | ((info: LoopDetectionInfo) => 'continue' | 'inject' | 'terminate' | Promise<'continue' | 'inject' | 'terminate'>)
}
/** Diagnostic payload emitted when a loop is detected. */