diff options
| author | Adam Malczewski <[email protected]> | 2026-06-24 14:10:03 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-24 14:10:03 +0900 |
| commit | dabcbc79831052effc6ce990021feee07d661f7e (patch) | |
| tree | 3e74e16f36d6a675abe676f0d04ca169f65f0a71 /packages/kernel/src/runtime/run-turn.ts | |
| parent | b58fb8373a1f7311cead23aa9a4d1fcd6927634f (diff) | |
| download | dispatch-dabcbc79831052effc6ce990021feee07d661f7e.tar.gz dispatch-dabcbc79831052effc6ce990021feee07d661f7e.zip | |
fix(kernel+tool-shell): abort hanging tool calls without bricking the conversation
kernel: executeToolCall now races tool.execute against the abort signal
via Promise.race; on abort resolves (not rejects) with an "Aborted" result
so the step completes normally → finishReason "aborted" → turn seals
cleanly (done event) → finally clears activeTurns → conversation freed,
next message accepted. run-turn strips tool-call chunks from the assistant
message on abort (keeps text/thinking) and omits tool-result messages to
avoid persisting dangling tool calls that would 400 the provider next turn.
tool-shell: realSpawn spawns detached (own process group); on abort AND
timeout kills the entire group (process.kill(-pgid, SIGKILL)) and resolves
immediately — no child.on("close") dependency, so a grandchild holding the
pipes can't stall the spawn promise or leak.
Also: ORCHESTRATOR.md migrated to dispatch CLI summon mechanism; .skills
summary; bin/sync-env PATH injection; frontend handoff docs.
1453 vitest pass · tsc -b EXIT 0 · biome clean.
Diffstat (limited to 'packages/kernel/src/runtime/run-turn.ts')
| -rw-r--r-- | packages/kernel/src/runtime/run-turn.ts | 53 |
1 files changed, 45 insertions, 8 deletions
diff --git a/packages/kernel/src/runtime/run-turn.ts b/packages/kernel/src/runtime/run-turn.ts index 4a12e28..f5d80d3 100644 --- a/packages/kernel/src/runtime/run-turn.ts +++ b/packages/kernel/src/runtime/run-turn.ts @@ -89,6 +89,19 @@ function appendThinkingDelta(chunks: Chunk[], delta: string): void { } } +/** + * Remove tool-call chunks from an assistant message, returning a new message + * with only the non-tool-call chunks (text, thinking, error). Returns + * `undefined` when all chunks were tool-calls (so the caller can omit the + * message entirely). Used when a step is aborted to avoid persisting + * incomplete tool calls whose placeholder "Aborted" results would create + * orphaned `tool` messages in the next turn's history. + */ +function stripToolCallChunks(msg: ChatMessage): ChatMessage | undefined { + const stripped = msg.chunks.filter((c) => c.type !== "tool-call"); + return stripped.length > 0 ? { role: msg.role, chunks: stripped } : undefined; +} + interface StepContext { readonly provider: ProviderContract; readonly messages: ChatMessage[]; @@ -516,12 +529,36 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> { totalUsage = addUsage(totalUsage, stepResult.usage); lastStepUsage = stepResult.usage; - if (stepResult.assistantMessage !== undefined) { - messages.push(stepResult.assistantMessage); - resultMessages.push(stepResult.assistantMessage); + // When the signal is aborted mid-step, the tool results are + // placeholders ({ content: "Aborted", isError: true }). If these + // are persisted and included in the next turn's message history, + // the provider sees a `tool` role message without a preceding + // `assistant` message carrying `tool_calls` → 400 error. + // + // To prevent this, when the signal is aborted we: + // 1. Strip tool-call chunks from the assistant message (keep + // text/thinking/error chunks so the partial response is + // preserved). + // 2. Omit tool-result messages entirely (they are not persisted, + // not added to resultMessages, and not passed to onStepComplete). + // + // This keeps the conversation history clean: the assistant's + // partial text is preserved, but no incomplete tool calls are + // left dangling. The `done` event still carries + // `reason: "aborted"`, so the turn seals cleanly. + const stepAborted = signal.aborted; + const assistantMessage = + stepAborted && stepResult.assistantMessage !== undefined + ? stripToolCallChunks(stepResult.assistantMessage) + : stepResult.assistantMessage; + const toolMessages = stepAborted ? [] : stepResult.toolMessages; + + if (assistantMessage !== undefined) { + messages.push(assistantMessage); + resultMessages.push(assistantMessage); } - for (const msg of stepResult.toolMessages) { + for (const msg of toolMessages) { messages.push(msg); resultMessages.push(msg); } @@ -532,10 +569,10 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> { // SAME objects in resultMessages — the caller must NOT double-persist. if (input.onStepComplete !== undefined) { const stepMessages: ChatMessage[] = []; - if (stepResult.assistantMessage !== undefined) { - stepMessages.push(stepResult.assistantMessage); + if (assistantMessage !== undefined) { + stepMessages.push(assistantMessage); } - for (const msg of stepResult.toolMessages) { + for (const msg of toolMessages) { stepMessages.push(msg); } if (stepMessages.length > 0) { @@ -543,7 +580,7 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> { } } - if (signal.aborted) { + if (stepAborted) { finishReason = "aborted"; break; } |
