fix(session): exclude orphaned interrupted tools from run-loop continuation (#26178)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
This commit is contained in:
@@ -81,6 +81,12 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc
|
|||||||
const log = Log.create({ service: "session.prompt" })
|
const log = Log.create({ service: "session.prompt" })
|
||||||
const elog = EffectLogger.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 {
|
export interface Interface {
|
||||||
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
|
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
|
||||||
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts, Image.Error>
|
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts, Image.Error>
|
||||||
@@ -1257,12 +1263,13 @@ export const layer = Layer.effect(
|
|||||||
const lastAssistantMsg = msgs.findLast(
|
const lastAssistantMsg = msgs.findLast(
|
||||||
(msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id,
|
(msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id,
|
||||||
)
|
)
|
||||||
// Some providers return "stop" even when the assistant message contains tool calls.
|
// Some providers return "stop" even when the assistant message contains
|
||||||
// Keep the loop running so tool results can be sent back to the model.
|
// tool calls. Keep the loop running so tool results can be sent back to
|
||||||
// Skip provider-executed tool parts — those were fully handled within the
|
// the model, but ignore cleanup-marked interrupted orphans.
|
||||||
// provider's stream (e.g. DWS Agent Platform) and don't need a re-loop.
|
|
||||||
const hasToolCalls =
|
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 (
|
if (
|
||||||
lastAssistant?.finish &&
|
lastAssistant?.finish &&
|
||||||
@@ -1270,6 +1277,16 @@ export const layer = Layer.effect(
|
|||||||
!hasToolCalls &&
|
!hasToolCalls &&
|
||||||
lastUser.id < lastAssistant.id
|
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")
|
yield* slog.info("exiting loop")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -457,6 +457,35 @@ noLLMServer.instance(
|
|||||||
{ config: cfg },
|
{ 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", () =>
|
it.instance("loop calls LLM and returns assistant message", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { llm } = yield* useServerConfig(providerCfg)
|
const { llm } = yield* useServerConfig(providerCfg)
|
||||||
|
|||||||
Reference in New Issue
Block a user