fix(session): exclude orphaned interrupted tools from run-loop continuation (#26178)
Some checks are pending
containers / build (push) Waiting to run
deploy / deploy (push) Waiting to run
generate / generate (push) Waiting to run
nix-eval / nix-eval (push) Waiting to run
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Waiting to run
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Waiting to run
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Waiting to run
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Waiting to run
nix-hashes / update-hashes (push) Blocked by required conditions
publish / version (push) Waiting to run
publish / build-cli (push) Blocked by required conditions
publish / sign-cli-windows (push) Blocked by required conditions
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Blocked by required conditions
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Blocked by required conditions
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Blocked by required conditions
publish / publish (push) Blocked by required conditions
storybook / storybook build (push) Waiting to run
test / e2e (linux) (push) Waiting to run
typecheck / typecheck (push) Waiting to run
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Blocked by required conditions
test / unit (linux) (push) Waiting to run
test / unit (windows) (push) Waiting to run
test / e2e (windows) (push) Waiting to run

Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
This commit is contained in:
André Cruz 2026-05-25 06:47:55 +01:00 committed by GitHub
parent d5f397a2da
commit 748fcb7ebd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 51 additions and 5 deletions

View file

@ -81,6 +81,12 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc
const log = Log.create({ service: "session.prompt" })
const elog = EffectLogger.create({ service: "session.prompt" })
function isOrphanedInterruptedTool(part: MessageV2.ToolPart) {
// cleanup() marks abandoned tool_use blocks this way after retries/aborts.
// They are not pending work and must not trigger an assistant-prefill request.
return part.state.status === "error" && part.state.metadata?.interrupted === true
}
export interface Interface {
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts, Image.Error>
@ -1257,12 +1263,13 @@ export const layer = Layer.effect(
const lastAssistantMsg = msgs.findLast(
(msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id,
)
// Some providers return "stop" even when the assistant message contains tool calls.
// Keep the loop running so tool results can be sent back to the model.
// Skip provider-executed tool parts — those were fully handled within the
// provider's stream (e.g. DWS Agent Platform) and don't need a re-loop.
// Some providers return "stop" even when the assistant message contains
// tool calls. Keep the loop running so tool results can be sent back to
// the model, but ignore cleanup-marked interrupted orphans.
const hasToolCalls =
lastAssistantMsg?.parts.some((part) => part.type === "tool" && !part.metadata?.providerExecuted) ?? false
lastAssistantMsg?.parts.some(
(part) => part.type === "tool" && !part.metadata?.providerExecuted && !isOrphanedInterruptedTool(part),
) ?? false
if (
lastAssistant?.finish &&
@ -1270,6 +1277,16 @@ export const layer = Layer.effect(
!hasToolCalls &&
lastUser.id < lastAssistant.id
) {
const orphan = lastAssistantMsg?.parts.find(
(part): part is MessageV2.ToolPart => part.type === "tool" && isOrphanedInterruptedTool(part),
)
if (orphan) {
yield* slog.warn("loop exit with orphaned interrupted tool", {
messageID: lastAssistant.id,
tool: orphan.tool,
callID: orphan.callID,
})
}
yield* slog.info("exiting loop")
break
}

View file

@ -457,6 +457,35 @@ noLLMServer.instance(
{ config: cfg },
)
it.instance("loop exits without an LLM request for interrupted orphan tool calls", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })
const seeded = yield* seed(chat.id, { finish: "stop" })
yield* sessions.updatePart({
id: PartID.ascending(),
messageID: seeded.assistant.id,
sessionID: chat.id,
type: "tool",
callID: "interrupted-call",
tool: "edit",
state: {
status: "error",
input: {},
error: "Tool execution aborted",
metadata: { interrupted: true },
time: { start: 1, end: 2 },
},
})
const result = yield* prompt.loop({ sessionID: chat.id })
expect(result.info.id).toBe(seeded.assistant.id)
expect(yield* llm.hits).toHaveLength(0)
}),
)
it.instance("loop calls LLM and returns assistant message", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)