Skip to content

Commit 49593c1

Browse files
authored
fix(core): settle interrupted assistant steps (#33266)
1 parent ff837fe commit 49593c1

5 files changed

Lines changed: 69 additions & 17 deletions

File tree

packages/core/src/session/runner/llm.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -305,14 +305,7 @@ export const layer = Layer.effect(
305305
const llmFailure = failure instanceof LLMError ? failure : undefined
306306
if (llmFailure && !publisher.hasProviderError()) {
307307
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
308-
yield* withPublication(
309-
events.publish(SessionEvent.Step.Failed, {
310-
sessionID: session.id,
311-
timestamp: yield* DateTime.now,
312-
assistantMessageID: yield* publisher.startAssistant(),
313-
error: { type: "unknown", message: llmFailure.reason.message },
314-
}),
315-
)
308+
yield* withPublication(publisher.failAssistant(llmFailure.reason.message))
316309
}
317310
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
318311
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
@@ -327,6 +320,8 @@ export const layer = Layer.effect(
327320
) {
328321
yield* FiberSet.clear(toolFibers)
329322
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
323+
if (publisher.hasActiveAssistant())
324+
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
330325
}
331326
if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
332327
const failure = Cause.squash(settled.cause)

packages/core/src/session/runner/publish-llm-event.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,14 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
6565
>()
6666
const timestamp = DateTime.now
6767
let assistantMessageID: SessionMessage.ID | undefined
68+
let assistantActive = false
69+
let assistantFailed = false
6870
let providerFailed = false
6971

7072
const startAssistant = Effect.fnUntraced(function* () {
7173
if (assistantMessageID !== undefined) return assistantMessageID
7274
assistantMessageID = SessionMessage.ID.create()
75+
assistantActive = true
7376
yield* events.publish(SessionEvent.Step.Started, {
7477
...input,
7578
assistantMessageID,
@@ -190,6 +193,20 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
190193
yield* flushFragments()
191194
})
192195

196+
const failAssistant = Effect.fnUntraced(function* (message: string) {
197+
if (assistantFailed) return
198+
yield* flush()
199+
const assistantMessageID = yield* startAssistant()
200+
assistantActive = false
201+
assistantFailed = true
202+
yield* events.publish(SessionEvent.Step.Failed, {
203+
sessionID: input.sessionID,
204+
timestamp: yield* timestamp,
205+
assistantMessageID,
206+
error: { type: "unknown", message },
207+
})
208+
})
209+
193210
const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* (
194211
message: string,
195212
hostedOnly = false,
@@ -375,6 +392,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
375392
}
376393
case "step-finish":
377394
yield* flush()
395+
assistantActive = false
378396
yield* events.publish(SessionEvent.Step.Ended, {
379397
sessionID: input.sessionID,
380398
timestamp: yield* timestamp,
@@ -388,24 +406,19 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
388406
return
389407
case "provider-error":
390408
providerFailed = true
391-
yield* flush()
392-
yield* events.publish(SessionEvent.Step.Failed, {
393-
sessionID: input.sessionID,
394-
timestamp: yield* timestamp,
395-
assistantMessageID: yield* startAssistant(),
396-
error: { type: "unknown", message: event.message },
397-
})
409+
yield* failAssistant(event.message)
398410
return
399411
}
400412
})
401413

402414
return {
403415
publish,
404416
flush,
417+
failAssistant,
405418
failUnsettledTools,
419+
hasActiveAssistant: () => assistantActive,
406420
hasAssistantStarted: () => assistantMessageID !== undefined,
407421
hasProviderError: () => providerFailed,
408-
startAssistant,
409422
assistantMessageID: assistantMessageIDForTool,
410423
}
411424
}

packages/core/src/session/runner/to-llm-message.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,21 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
8282
const result = toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined)
8383
return item.provider?.executed === true && result ? [call, result] : [call]
8484
})
85+
const meaningful = content.filter((part) => {
86+
if (part.type === "text") return part.text !== ""
87+
if (part.type !== "reasoning") return true
88+
return part.text !== "" || (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0)
89+
})
8590
const results = message.content
8691
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true)
8792
.map((item) => toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined))
8893
.filter((message) => message !== undefined)
8994
.map(Message.tool)
90-
return [Message.make({ id: message.id, role: "assistant", content, metadata: message.metadata }), ...results]
95+
if (meaningful.length === 0) return results
96+
return [
97+
Message.make({ id: message.id, role: "assistant", content: meaningful, metadata: message.metadata }),
98+
...results,
99+
]
91100
}
92101

93102
function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] {

packages/core/test/session-runner-message.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,39 @@ const id = (value: string) => SessionMessage.ID.make(`msg_${value}`)
1414
const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route })
1515

1616
describe("toLLMMessages", () => {
17+
test("omits empty assistant turns", () => {
18+
const assistant = (value: string, content: SessionMessage.Assistant["content"]) =>
19+
new SessionMessage.Assistant({
20+
id: id(value),
21+
type: "assistant",
22+
agent: "build",
23+
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
24+
content,
25+
time: { created, completed: created },
26+
})
27+
const messages = toLLMMessages(
28+
[
29+
assistant("empty", []),
30+
assistant("empty-text", [new SessionMessage.AssistantText({ type: "text", id: "empty", text: "" })]),
31+
assistant("empty-reasoning", [
32+
new SessionMessage.AssistantReasoning({ type: "reasoning", id: "empty-reasoning", text: "" }),
33+
]),
34+
assistant("text", [new SessionMessage.AssistantText({ type: "text", id: "text", text: "Partial" })]),
35+
assistant("reasoning", [
36+
new SessionMessage.AssistantReasoning({
37+
type: "reasoning",
38+
id: "reasoning",
39+
text: "",
40+
providerMetadata: { anthropic: { signature: "sig_1" } },
41+
}),
42+
]),
43+
],
44+
model,
45+
)
46+
47+
expect(messages.map((message) => message.id)).toEqual([id("text"), id("reasoning")])
48+
})
49+
1750
test("maps every top-level V2 Session message type", () => {
1851
const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" })
1952
const messages = toLLMMessages(

packages/core/test/session-runner.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,8 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
547547
{ type: "user", text: prompt },
548548
{
549549
type: "assistant",
550+
finish: "error",
551+
error: { type: "unknown", message: "Provider turn interrupted" },
550552
content: [
551553
kind === "tool input"
552554
? { type: "tool", id: fragmentID(kind, "interrupted"), state: { status: "error" } }

0 commit comments

Comments
 (0)