Jelajahi Sumber

subagent-inprocess: stop the structured nudge loop once cancellation lands

A cancel that lands AFTER a child's clean turn end but before the nudge
continuation runs clears nothing — child.cancel() only kills queued or
running work — so the loop's turn-state check alone let a later
child.send() spend a fresh post-cancellation turn (and even capture a
structured result the caller had already abandoned). The loop condition
now also reads the run's own cancelled flag, re-evaluated after every
whenIdle(), through an accessor because closure assignments are
invisible to control-flow narrowing (an inline read lints always-true).
readResult keeps the outcome honest on this path: a completed-but-
uncaptured turn maps to aborted, not error, when a cancel is why the
nudging stopped — the cancel contract outranks the schema shortfall.
Tianyi Cui 3 bulan lalu
induk
melakukan
3987425547

+ 18 - 6
packages/subagent/subagent-inprocess/src/index.ts

@@ -171,6 +171,10 @@ export function startInProcessRun(
   // `turn/end` is logged — settles as `aborted` (honoring the cancel contract)
   // rather than falling through to the no-turn `error` mapping.
   let cancelled = false
+  // An accessor, not an inline read: `cancelled` mutates from closures (the
+  // abort listener, run.cancel), which control-flow narrowing cannot see — an
+  // inline `!cancelled` in the nudge condition reads as always-true.
+  const isCancelled = (): boolean => cancelled
   const requestCancel = (reason: string): void => {
     cancelled = true
     child.cancel(reason)
@@ -191,12 +195,17 @@ export function startInProcessRun(
         // Nudge loop: a child that finished a turn CLEANLY without calling
         // structured_output gets re-prompted, up to the backend-configured
         // retry count. An errored/aborted turn is not nudged — its failure is
-        // the honest result. (This also covers a cancel: a cancelled turn ends
-        // `aborted`, and a pre-turn cancel leaves no `turn/end` at all, so
-        // neither reads `completed`.)
+        // the honest result (a cancelled turn ends `aborted`, and a pre-turn
+        // cancel leaves no `turn/end` at all, so neither reads `completed`).
+        // `!cancelled` closes the remaining window: a cancel landing AFTER a
+        // clean turn end clears nothing — `child.cancel()` only kills
+        // queued/running work — so without it the next `send` would spend a
+        // fresh post-cancellation turn; the condition re-evaluates after
+        // every `whenIdle()`, so a mid-nudge cancel stops the loop at the
+        // next boundary too.
         let nudges = options.structuredNudgeRetries
         while (
-          structured.captured(child) === undefined && nudges > 0
+          !isCancelled() && structured.captured(child) === undefined && nudges > 0
           && lastOwnTurnEnd(child, seedLength)?.data.reason.kind === 'completed'
         ) {
           nudges -= 1
@@ -204,7 +213,7 @@ export function startInProcessRun(
           await child.whenIdle()
         }
       }
-      return readResult(child, seedLength, cancelled, structured ? { captured: structured.captured(child) } : undefined)
+      return readResult(child, seedLength, isCancelled(), structured ? { captured: structured.captured(child) } : undefined)
     } finally {
       request.signal?.removeEventListener('abort', onAbort)
       if (structured) {
@@ -266,7 +275,10 @@ function readResult(
     : toStopReason(lastEnd?.data.reason)
   if (structured) {
     if (structured.captured) return { output, structured: structured.captured.value, stopReason }
-    if (stopReason === 'completed') return { output, stopReason: 'error' }
+    // No capture on a cleanly-completed turn: an ERROR when the run was left
+    // to finish (the nudges ran out), but ABORTED when a cancel is why the
+    // nudging stopped — the cancel contract outranks the schema shortfall.
+    if (stopReason === 'completed') return { output, stopReason: cancelled ? 'aborted' : 'error' }
   }
   return { output, stopReason }
 }

+ 19 - 0
packages/subagent/subagent-inprocess/tests/structured.spec.ts

@@ -179,6 +179,25 @@ describe('in-process structured output', () => {
     await run.dispose()
   })
 
+  it('a cancel landing after a clean turn end stops the nudge loop: no post-cancellation turn is spent', async () => {
+    const { ctx, parent, adapter } = await setup([textResponse('prose, no capture')], { nudges: 3 })
+    const run = ctx.subagents.start('spawn', structuredRequest(parent))
+    const child = ctx.agents.get(run.id)!
+    // Cancel synchronously inside the first turn's end recording — after the
+    // turn reads `completed`, before the nudge continuation resumes. The turn
+    // state alone cannot see this cancel (`child.cancel()` only clears
+    // queued/running work), so without the loop's own cancelled check the
+    // next send would spend a fresh child turn after the caller cancelled.
+    ctx.on('session/event', (session, event) => {
+      if (session === child.session && event.type === 'turn/end') run.cancel('cancelled between turn end and nudge')
+    })
+    const result = await run.result
+    expect(result.stopReason).toBe('aborted')
+    // Exactly one model request: the nudge turn never ran.
+    expect(adapter.requests.length).toBe(1)
+    await run.dispose()
+  })
+
   it('rejects a schema outside the subset loud, before any child exists', async () => {
     const { ctx, parent } = await setup([])
     expect(() => ctx.subagents.start('spawn', structuredRequest(parent, {