Skip to content

Commit 748fcb7

Browse files
edevilrekram1-node
andauthored
fix(session): exclude orphaned interrupted tools from run-loop continuation (#26178)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
1 parent d5f397a commit 748fcb7

2 files changed

Lines changed: 51 additions & 5 deletions

File tree

packages/opencode/src/session/prompt.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc
8181
const log = Log.create({ service: "session.prompt" })
8282
const elog = EffectLogger.create({ service: "session.prompt" })
8383

84+
function isOrphanedInterruptedTool(part: MessageV2.ToolPart) {
85+
// cleanup() marks abandoned tool_use blocks this way after retries/aborts.
86+
// They are not pending work and must not trigger an assistant-prefill request.
87+
return part.state.status === "error" && part.state.metadata?.interrupted === true
88+
}
89+
8490
export interface Interface {
8591
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
8692
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts, Image.Error>
@@ -1257,19 +1263,30 @@ export const layer = Layer.effect(
12571263
const lastAssistantMsg = msgs.findLast(
12581264
(msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id,
12591265
)
1260-
// Some providers return "stop" even when the assistant message contains tool calls.
1261-
// Keep the loop running so tool results can be sent back to the model.
1262-
// Skip provider-executed tool parts — those were fully handled within the
1263-
// provider's stream (e.g. DWS Agent Platform) and don't need a re-loop.
1266+
// Some providers return "stop" even when the assistant message contains
1267+
// tool calls. Keep the loop running so tool results can be sent back to
1268+
// the model, but ignore cleanup-marked interrupted orphans.
12641269
const hasToolCalls =
1265-
lastAssistantMsg?.parts.some((part) => part.type === "tool" && !part.metadata?.providerExecuted) ?? false
1270+
lastAssistantMsg?.parts.some(
1271+
(part) => part.type === "tool" && !part.metadata?.providerExecuted && !isOrphanedInterruptedTool(part),
1272+
) ?? false
12661273

12671274
if (
12681275
lastAssistant?.finish &&
12691276
!["tool-calls"].includes(lastAssistant.finish) &&
12701277
!hasToolCalls &&
12711278
lastUser.id < lastAssistant.id
12721279
) {
1280+
const orphan = lastAssistantMsg?.parts.find(
1281+
(part): part is MessageV2.ToolPart => part.type === "tool" && isOrphanedInterruptedTool(part),
1282+
)
1283+
if (orphan) {
1284+
yield* slog.warn("loop exit with orphaned interrupted tool", {
1285+
messageID: lastAssistant.id,
1286+
tool: orphan.tool,
1287+
callID: orphan.callID,
1288+
})
1289+
}
12731290
yield* slog.info("exiting loop")
12741291
break
12751292
}

packages/opencode/test/session/prompt.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,35 @@ noLLMServer.instance(
457457
{ config: cfg },
458458
)
459459

460+
it.instance("loop exits without an LLM request for interrupted orphan tool calls", () =>
461+
Effect.gen(function* () {
462+
const { llm } = yield* useServerConfig(providerCfg)
463+
const prompt = yield* SessionPrompt.Service
464+
const sessions = yield* Session.Service
465+
const chat = yield* sessions.create({ title: "Pinned" })
466+
const seeded = yield* seed(chat.id, { finish: "stop" })
467+
yield* sessions.updatePart({
468+
id: PartID.ascending(),
469+
messageID: seeded.assistant.id,
470+
sessionID: chat.id,
471+
type: "tool",
472+
callID: "interrupted-call",
473+
tool: "edit",
474+
state: {
475+
status: "error",
476+
input: {},
477+
error: "Tool execution aborted",
478+
metadata: { interrupted: true },
479+
time: { start: 1, end: 2 },
480+
},
481+
})
482+
483+
const result = yield* prompt.loop({ sessionID: chat.id })
484+
expect(result.info.id).toBe(seeded.assistant.id)
485+
expect(yield* llm.hits).toHaveLength(0)
486+
}),
487+
)
488+
460489
it.instance("loop calls LLM and returns assistant message", () =>
461490
Effect.gen(function* () {
462491
const { llm } = yield* useServerConfig(providerCfg)

0 commit comments

Comments
 (0)