Skip to content

Commit 4b19ea2

Browse files
authored
fix(llm): preserve response message phases (anomalyco#38452)
1 parent 553b42f commit 4b19ea2

7 files changed

Lines changed: 880 additions & 25 deletions

File tree

packages/llm/src/protocols/openai-responses.ts

Lines changed: 265 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,12 @@ type OpenAIResponsesInputContent = Schema.Schema.Type<typeof OpenAIResponsesInpu
4646
const OpenAIResponsesOutputText = Schema.Struct({
4747
type: Schema.tag("output_text"),
4848
text: Schema.String,
49+
annotations: Schema.Array(Schema.Unknown),
4950
})
5051

52+
const OpenAIResponsesMessagePhase = Schema.Literals(["commentary", "final_answer"])
53+
type OpenAIResponsesMessagePhase = Schema.Schema.Type<typeof OpenAIResponsesMessagePhase>
54+
5155
const OpenAIResponsesReasoningSummaryText = Schema.Struct({
5256
type: Schema.tag("summary_text"),
5357
text: Schema.String,
@@ -78,7 +82,19 @@ const OpenAIResponsesFunctionCallOutput = Schema.Union([
7882
const OpenAIResponsesInputItem = Schema.Union([
7983
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
8084
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }),
81-
Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }),
85+
Schema.Struct({
86+
role: Schema.tag("assistant"),
87+
content: Schema.String,
88+
phase: optionalNull(OpenAIResponsesMessagePhase),
89+
}),
90+
Schema.Struct({
91+
type: Schema.tag("message"),
92+
id: Schema.String,
93+
status: Schema.Literals(["in_progress", "completed", "incomplete"]),
94+
role: Schema.tag("assistant"),
95+
content: Schema.Array(OpenAIResponsesOutputText),
96+
phase: optionalNull(OpenAIResponsesMessagePhase),
97+
}),
8298
OpenAIResponsesReasoningItem,
8399
OpenAIResponsesItemReference,
84100
Schema.Struct({
@@ -194,7 +210,9 @@ const OpenAIResponsesStreamItem = Schema.Struct({
194210
server_label: Schema.optional(Schema.String),
195211
output: Schema.optional(Schema.Unknown),
196212
error: Schema.optional(Schema.Unknown),
213+
content: Schema.optional(Schema.Array(Schema.Unknown)),
197214
encrypted_content: optionalNull(Schema.String),
215+
phase: optionalNull(OpenAIResponsesMessagePhase),
198216
})
199217
type OpenAIResponsesStreamItem = Schema.Schema.Type<typeof OpenAIResponsesStreamItem>
200218

@@ -212,7 +230,9 @@ const OpenAIResponsesErrorPayload = Schema.Struct({
212230
const OpenAIResponsesEvent = Schema.Struct({
213231
type: Schema.String,
214232
delta: Schema.optional(Schema.String),
233+
text: Schema.optional(Schema.String),
215234
item_id: Schema.optional(Schema.String),
235+
content_index: Schema.optional(Schema.Number),
216236
summary_index: Schema.optional(Schema.Number),
217237
item: Schema.optional(OpenAIResponsesStreamItem),
218238
response: Schema.optional(
@@ -237,10 +257,18 @@ interface ParserState {
237257
readonly tools: ToolStream.State<string>
238258
readonly hasFunctionCall: boolean
239259
readonly lifecycle: Lifecycle.State
260+
readonly messageItems: Readonly<Record<string, MessageStreamItem>>
261+
readonly messageContentIDs: ReadonlySet<string>
262+
readonly nextMessageContentID: number
240263
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
241264
readonly store: boolean | undefined
242265
}
243266

267+
interface MessageStreamItem {
268+
readonly providerMetadata?: ProviderMetadata
269+
readonly content: Readonly<Record<number, { readonly id: string; readonly text: string }>>
270+
}
271+
244272
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
245273

246274
interface ReasoningStreamItem {
@@ -298,6 +326,26 @@ const lowerReasoning = (part: ReasoningPart): OpenAIResponsesReasoningInput | un
298326
}
299327
}
300328

329+
const messagePhase = (part: TextPart): OpenAIResponsesMessagePhase | null | undefined => {
330+
const phase = part.providerMetadata?.openai?.phase
331+
return phase === "commentary" || phase === "final_answer" || phase === null ? phase : undefined
332+
}
333+
334+
const messageItemID = (part: TextPart) => {
335+
const itemID = part.providerMetadata?.openai?.itemId
336+
return typeof itemID === "string" && itemID.length > 0 ? itemID : undefined
337+
}
338+
339+
const messageStatus = (part: TextPart) => {
340+
const status = part.providerMetadata?.openai?.status
341+
return status === "in_progress" || status === "completed" || status === "incomplete" ? status : undefined
342+
}
343+
344+
const messageAnnotations = (part: TextPart) => {
345+
const annotations = part.providerMetadata?.openai?.annotations
346+
return Array.isArray(annotations) ? annotations : []
347+
}
348+
301349
const hostedToolItemID = (part: ToolResultPart) => {
302350
const openai = part.providerMetadata?.openai
303351
return ProviderShared.isRecord(openai) && typeof openai.itemId === "string" && openai.itemId.length > 0
@@ -368,17 +416,49 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
368416
}
369417

370418
if (message.role === "assistant") {
419+
const inputStart = input.length
371420
const content: TextPart[] = []
421+
let phase: OpenAIResponsesMessagePhase | null | undefined
422+
let itemID: string | undefined
423+
let status: "in_progress" | "completed" | "incomplete" | undefined
372424
const reasoningItems: Record<string, OpenAIResponsesReasoningReplay> = {}
373425
const reasoningReferences = new Set<string>()
374426
const hostedToolReferences = new Set<string>()
375427
const flushText = () => {
376428
if (content.length === 0) return
377-
input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) })
429+
input.push(
430+
itemID
431+
? {
432+
type: "message",
433+
id: itemID,
434+
status: status ?? "completed",
435+
role: "assistant",
436+
content: content.map((part) => ({
437+
type: "output_text",
438+
text: part.text,
439+
annotations: messageAnnotations(part),
440+
})),
441+
...(phase !== undefined ? { phase } : {}),
442+
}
443+
: {
444+
role: "assistant",
445+
content: ProviderShared.joinText(content),
446+
...(phase !== undefined ? { phase } : {}),
447+
},
448+
)
378449
content.splice(0, content.length)
450+
phase = undefined
451+
itemID = undefined
452+
status = undefined
379453
}
380454
for (const part of message.content) {
381455
if (part.type === "text") {
456+
const nextPhase = messagePhase(part)
457+
const nextItemID = messageItemID(part)
458+
if (content.length > 0 && (phase !== nextPhase || itemID !== nextItemID)) flushText()
459+
phase = nextPhase
460+
itemID = nextItemID
461+
status = messageStatus(part) ?? status
382462
content.push(part)
383463
continue
384464
}
@@ -429,6 +509,20 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
429509
])
430510
}
431511
flushText()
512+
if (store === false && Object.values(reasoningItems).some((item) => typeof item.encrypted_content !== "string"))
513+
input.splice(
514+
inputStart,
515+
input.length - inputStart,
516+
...input.slice(inputStart).map((item) =>
517+
"type" in item && item.type === "message"
518+
? {
519+
role: "assistant" as const,
520+
content: ProviderShared.joinText(item.content),
521+
...(item.phase !== undefined ? { phase: item.phase } : {}),
522+
}
523+
: item,
524+
),
525+
)
432526
continue
433527
}
434528

@@ -612,15 +706,134 @@ const NO_EVENTS: StepResult["1"] = []
612706
// the protocol's `terminal` predicate stay in sync.
613707
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
614708

615-
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
616-
if (!event.delta) return [state, NO_EVENTS]
709+
const messageMetadata = (item: OpenAIResponsesStreamItem, id: string, previous?: ProviderMetadata) => {
710+
const openai = previous?.openai
711+
const phase = item.phase !== undefined ? item.phase : openai?.phase
712+
const status =
713+
item.status === "in_progress" || item.status === "completed" || item.status === "incomplete"
714+
? item.status
715+
: openai?.status
716+
return openaiMetadata({
717+
itemId: id,
718+
...(phase === "commentary" || phase === "final_answer" || phase === null ? { phase } : {}),
719+
...(status === "in_progress" || status === "completed" || status === "incomplete" ? { status } : {}),
720+
})
721+
}
722+
723+
const messageContentMetadata = (
724+
providerMetadata: ProviderMetadata,
725+
item: OpenAIResponsesStreamItem,
726+
index: number,
727+
): ProviderMetadata => {
728+
const content = item.content?.[index]
729+
if (!ProviderShared.isRecord(content) || content.type !== "output_text" || !Array.isArray(content.annotations))
730+
return providerMetadata
731+
return openaiMetadata({ ...providerMetadata.openai, annotations: content.annotations })
732+
}
733+
734+
const ensureMessageContent = (state: ParserState, event: OpenAIResponsesEvent) => {
735+
const itemID = event.item_id ?? "text-0"
736+
const index = event.content_index ?? 0
737+
const item = state.messageItems[itemID] ?? { content: {} }
738+
const existing = item.content[index]
739+
if (existing) return { state, itemID, index, item, content: existing }
740+
const findID = (next: number): readonly [string, number] => {
741+
const id = `openai-text-${next}`
742+
return state.messageContentIDs.has(id) ? findID(next + 1) : [id, next + 1]
743+
}
744+
const [id, nextMessageContentID] =
745+
index === 0 && !state.messageContentIDs.has(itemID)
746+
? ([itemID, state.nextMessageContentID] as const)
747+
: findID(state.nextMessageContentID)
748+
const content = { id, text: "" }
749+
const nextItem = { ...item, content: { ...item.content, [index]: content } }
750+
return {
751+
state: {
752+
...state,
753+
messageItems: { ...state.messageItems, [itemID]: nextItem },
754+
messageContentIDs: new Set([...state.messageContentIDs, id]),
755+
nextMessageContentID,
756+
},
757+
itemID,
758+
index,
759+
item: nextItem,
760+
content,
761+
}
762+
}
763+
764+
const updateMessageContent = (
765+
state: ParserState,
766+
itemID: string,
767+
index: number,
768+
content: { readonly id: string; readonly text: string },
769+
): ParserState => ({
770+
...state,
771+
messageItems: {
772+
...state.messageItems,
773+
[itemID]: {
774+
...state.messageItems[itemID],
775+
content: { ...state.messageItems[itemID]?.content, [index]: content },
776+
},
777+
},
778+
})
779+
780+
const closeOtherMessageContent = (state: ParserState, events: LLMEvent[], item: MessageStreamItem, index: number) =>
781+
Object.entries(item.content).reduce(
782+
(lifecycle, entry) =>
783+
Number(entry[0]) === index ? lifecycle : Lifecycle.textEnd(lifecycle, events, entry[1].id, item.providerMetadata),
784+
state.lifecycle,
785+
)
786+
787+
const appendOutputText = (state: ParserState, event: OpenAIResponsesEvent, text: string): StepResult => {
788+
const ensured = ensureMessageContent(state, event)
617789
const events: LLMEvent[] = []
790+
const lifecycle = Lifecycle.textStart(
791+
closeOtherMessageContent(ensured.state, events, ensured.item, ensured.index),
792+
events,
793+
ensured.content.id,
794+
ensured.item.providerMetadata,
795+
)
618796
return [
619-
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) },
797+
{
798+
...updateMessageContent(ensured.state, ensured.itemID, ensured.index, {
799+
...ensured.content,
800+
text: ensured.content.text + text,
801+
}),
802+
lifecycle: Lifecycle.textDelta(lifecycle, events, ensured.content.id, text),
803+
},
620804
events,
621805
]
622806
}
623807

808+
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
809+
if (!event.delta) return [state, NO_EVENTS]
810+
return appendOutputText(state, event, event.delta)
811+
}
812+
813+
const onOutputTextDone = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
814+
if (event.text === undefined) return [state, NO_EVENTS]
815+
const ensured = ensureMessageContent(state, event)
816+
if (event.text === ensured.content.text) {
817+
if (ensured.state.lifecycle.text.has(ensured.content.id)) return [ensured.state, NO_EVENTS]
818+
const events: LLMEvent[] = []
819+
return [
820+
{
821+
...ensured.state,
822+
lifecycle: Lifecycle.textStart(
823+
closeOtherMessageContent(ensured.state, events, ensured.item, ensured.index),
824+
events,
825+
ensured.content.id,
826+
ensured.item.providerMetadata,
827+
),
828+
},
829+
events,
830+
]
831+
}
832+
if (event.text.startsWith(ensured.content.text))
833+
return appendOutputText(ensured.state, event, event.text.slice(ensured.content.text.length))
834+
return [ensured.state, NO_EVENTS]
835+
}
836+
624837
const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
625838
if (!event.delta) return [state, NO_EVENTS]
626839
const events: LLMEvent[] = []
@@ -655,6 +868,23 @@ const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) =>
655868
// best-effort, not guaranteed.
656869
const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
657870
const item = event.item
871+
if (item?.type === "message" && item.id) {
872+
const existing = state.messageItems[item.id]
873+
return [
874+
{
875+
...state,
876+
messageItems: {
877+
...state.messageItems,
878+
[item.id]: {
879+
...existing,
880+
providerMetadata: messageMetadata(item, item.id, existing?.providerMetadata),
881+
content: existing?.content ?? {},
882+
},
883+
},
884+
},
885+
NO_EVENTS,
886+
]
887+
}
658888
if (item && isReasoningItem(item)) {
659889
const events: LLMEvent[] = []
660890
return [
@@ -812,6 +1042,32 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
8121042
const item = event.item
8131043
if (!item) return [state, NO_EVENTS] satisfies StepResult
8141044

1045+
if (item.type === "message" && item.id) {
1046+
const events: LLMEvent[] = []
1047+
const itemID = item.id
1048+
const messageItem = state.messageItems[itemID]
1049+
const { [itemID]: _finished, ...messageItems } = state.messageItems
1050+
const providerMetadata = messageMetadata(item, itemID, messageItem?.providerMetadata)
1051+
const lifecycle = Object.entries(messageItem?.content ?? {}).reduce(
1052+
(lifecycle, entry) =>
1053+
Lifecycle.textEnd(
1054+
lifecycle,
1055+
events,
1056+
entry[1].id,
1057+
messageContentMetadata(providerMetadata, item, Number(entry[0])),
1058+
),
1059+
state.lifecycle,
1060+
)
1061+
return [
1062+
{
1063+
...state,
1064+
lifecycle,
1065+
messageItems,
1066+
},
1067+
events,
1068+
] satisfies StepResult
1069+
}
1070+
8151071
if (item.type === "function_call") {
8161072
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
8171073
const tools = state.tools[item.id]
@@ -939,6 +1195,7 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => {
9391195
if (event.type === "response.reasoning_summary_part.done")
9401196
return Effect.succeed(onReasoningSummaryPartDone(state, event))
9411197
if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event))
1198+
if (event.type === "response.output_text.done") return Effect.succeed(onOutputTextDone(state, event))
9421199
if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event)
9431200
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
9441201
if (event.type === "response.completed" || event.type === "response.incomplete")
@@ -968,6 +1225,9 @@ export const protocol = Protocol.make({
9681225
hasFunctionCall: false,
9691226
tools: ToolStream.empty<string>(),
9701227
lifecycle: Lifecycle.initial(),
1228+
messageItems: {},
1229+
messageContentIDs: new Set<string>(),
1230+
nextMessageContentID: 0,
9711231
reasoningItems: {},
9721232
store: OpenAIOptions.store(request),
9731233
}),

0 commit comments

Comments
 (0)