Просмотр исходного кода

fix(ai): buffer partial tool call identity (#37847)

Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
opencode-agent[bot] 3 недель назад
Родитель
Сommit
2b8d1998d6

+ 27 - 1
packages/ai/src/protocols/openai-chat.ts

@@ -164,8 +164,15 @@ export const OpenAIChatEvent = Schema.Struct({
 export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
 type OpenAIChatRequestMessage = LLMRequest["messages"][number]
 
+interface PendingToolDelta {
+  readonly id?: string
+  readonly name?: string
+  readonly input: string
+}
+
 export interface ParserState {
   readonly tools: ToolStream.State<number>
+  readonly pendingTools: Partial<Record<number, PendingToolDelta>>
   readonly toolCallEvents: ReadonlyArray<LLMEvent>
   readonly usage?: Usage
   readonly finishReason?: FinishReason
@@ -432,6 +439,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
     const delta = choice?.delta
     const toolDeltas = delta?.tool_calls ?? []
     let tools = state.tools
+    let pendingTools = state.pendingTools
 
     let lifecycle = state.lifecycle
 
@@ -450,11 +458,24 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
     if (toolDeltas.length) lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
 
     for (const tool of toolDeltas) {
+      const current = tools[tool.index]
+      const pending = pendingTools[tool.index]
+      const id = current?.id ?? pending?.id ?? (tool.id || undefined)
+      const name = current?.name ?? pending?.name ?? (tool.function?.name || undefined)
+      const text = `${pending?.input ?? ""}${tool.function?.arguments ?? ""}`
+      if (!current && (!id || !name)) {
+        pendingTools = { ...pendingTools, [tool.index]: { id: id || undefined, name: name || undefined, input: text } }
+        continue
+      }
+      if (pending) {
+        pendingTools = { ...pendingTools }
+        delete pendingTools[tool.index]
+      }
       const result = ToolStream.appendOrStart(
         ADAPTER,
         tools,
         tool.index,
-        { id: tool.id ?? undefined, name: tool.function?.name ?? undefined, text: tool.function?.arguments ?? "" },
+        { id: id || undefined, name: name || undefined, text },
         "OpenAI Chat tool call delta is missing id or name",
       )
       if (ToolStream.isError(result)) return yield* result
@@ -463,6 +484,9 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
       events.push(...result.events)
     }
 
+    if (finishReason !== undefined && state.finishReason === undefined && Object.keys(pendingTools).length > 0)
+      return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat tool call delta is missing id or name")
+
     // Finalize accumulated tool inputs eagerly when finish_reason arrives so
     // valid calls and malformed local calls settle independently.
     const finished =
@@ -473,6 +497,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
     return [
       {
         tools: finished?.tools ?? tools,
+        pendingTools,
         toolCallEvents: finished?.events ?? state.toolCallEvents,
         usage,
         finishReason,
@@ -512,6 +537,7 @@ export const protocol = Protocol.make({
     event: Protocol.jsonEvent(OpenAIChatEvent),
     initial: () => ({
       tools: ToolStream.empty<number>(),
+      pendingTools: {},
       toolCallEvents: [],
       lifecycle: Lifecycle.initial(),
       reasoningField: undefined,

+ 2 - 2
packages/ai/src/protocols/utils/tool-stream.ts

@@ -140,8 +140,8 @@ export const appendOrStart = <K extends StreamKey>(
   missingToolMessage: string,
 ): AppendOutcome<K> | LLMError => {
   const current = tools[key]
-  const id = delta.id ?? current?.id
-  const name = delta.name ?? current?.name
+  const id = current?.id ?? delta.id
+  const name = current?.name ?? delta.name
   if (!id || !name) return eventError(route, missingToolMessage)
 
   const tool = {

+ 61 - 0
packages/ai/test/provider/openai-chat.test.ts

@@ -606,6 +606,67 @@ describe("OpenAI Chat route", () => {
     }),
   )
 
+  it.effect("ignores empty identity fields on later tool call deltas", () =>
+    Effect.gen(function* () {
+      const body = sseEvents(
+        deltaChunk({
+          tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: "{" } }],
+        }),
+        deltaChunk({
+          tool_calls: [{ index: 0, id: "", function: { name: "", arguments: '\"query\":\"weather\"}' } }],
+        }),
+        deltaChunk({}, "tool_calls"),
+      )
+      const response = yield* LLMClient.generate(
+        LLM.updateRequest(request, {
+          tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
+        }),
+      ).pipe(Effect.provide(fixedResponse(body)))
+
+      expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: { query: "weather" } }])
+    }),
+  )
+
+  it.effect("buffers tool call deltas until the function name arrives", () =>
+    Effect.gen(function* () {
+      const body = sseEvents(
+        deltaChunk({
+          tool_calls: [{ index: 0, id: "call_1", function: { arguments: "{" } }],
+        }),
+        deltaChunk({
+          tool_calls: [{ index: 0, function: { name: "lookup", arguments: '\"query\":' } }],
+        }),
+        deltaChunk({ tool_calls: [{ index: 0, function: { arguments: '\"weather\"}' } }] }),
+        deltaChunk({}, "tool_calls"),
+      )
+      const response = yield* LLMClient.generate(
+        LLM.updateRequest(request, {
+          tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
+        }),
+      ).pipe(Effect.provide(fixedResponse(body)))
+
+      expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: { query: "weather" } }])
+    }),
+  )
+
+  it.effect("fails when a buffered tool call never receives a function name", () =>
+    Effect.gen(function* () {
+      const body = sseEvents(
+        deltaChunk({
+          tool_calls: [{ index: 0, id: "call_1", function: { arguments: "{}" } }],
+        }),
+        deltaChunk({}, "tool_calls"),
+      )
+      const error = yield* LLMClient.generate(
+        LLM.updateRequest(request, {
+          tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
+        }),
+      ).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
+
+      expect(error.message).toContain("OpenAI Chat tool call delta is missing id or name")
+    }),
+  )
+
   it.effect("fails a streamed tool call when the provider ends without a finish reason", () =>
     Effect.gen(function* () {
       const body = sseEvents(

+ 27 - 0
packages/ai/test/tool-stream.test.ts

@@ -36,6 +36,33 @@ describe("ToolStream", () => {
     }),
   )
 
+  it.effect("keeps accumulated identity when later deltas contain empty strings", () =>
+    Effect.gen(function* () {
+      const first = ToolStream.appendOrStart(
+        ADAPTER,
+        ToolStream.empty<number>(),
+        0,
+        { id: "call_1", name: "lookup", text: '{"query"' },
+        "missing tool",
+      )
+      if (ToolStream.isError(first)) return yield* first
+      const second = ToolStream.appendOrStart(
+        ADAPTER,
+        first.tools,
+        0,
+        { id: "", name: "", text: ':"weather"}' },
+        "missing tool",
+      )
+      if (ToolStream.isError(second)) return yield* second
+      const finished = yield* ToolStream.finish(ADAPTER, second.tools, 0)
+
+      expect(finished.events).toEqual([
+        { type: "tool-input-end", id: "call_1", name: "lookup" },
+        { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
+      ])
+    }),
+  )
+
   it.effect("fails appendExisting when the provider skipped the tool start", () =>
     Effect.gen(function* () {
       const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")