fix(ai): buffer partial tool call identity (#37847)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
This commit is contained in:
parent
391cfbcfe7
commit
2b8d1998d6
4 changed files with 117 additions and 3 deletions
|
|
@ -164,8 +164,15 @@ export const OpenAIChatEvent = Schema.Struct({
|
||||||
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
|
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
|
||||||
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
|
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
|
||||||
|
|
||||||
|
interface PendingToolDelta {
|
||||||
|
readonly id?: string
|
||||||
|
readonly name?: string
|
||||||
|
readonly input: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ParserState {
|
export interface ParserState {
|
||||||
readonly tools: ToolStream.State<number>
|
readonly tools: ToolStream.State<number>
|
||||||
|
readonly pendingTools: Partial<Record<number, PendingToolDelta>>
|
||||||
readonly toolCallEvents: ReadonlyArray<LLMEvent>
|
readonly toolCallEvents: ReadonlyArray<LLMEvent>
|
||||||
readonly usage?: Usage
|
readonly usage?: Usage
|
||||||
readonly finishReason?: FinishReason
|
readonly finishReason?: FinishReason
|
||||||
|
|
@ -432,6 +439,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||||
const delta = choice?.delta
|
const delta = choice?.delta
|
||||||
const toolDeltas = delta?.tool_calls ?? []
|
const toolDeltas = delta?.tool_calls ?? []
|
||||||
let tools = state.tools
|
let tools = state.tools
|
||||||
|
let pendingTools = state.pendingTools
|
||||||
|
|
||||||
let lifecycle = state.lifecycle
|
let lifecycle = state.lifecycle
|
||||||
|
|
||||||
|
|
@ -450,11 +458,24 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||||
if (toolDeltas.length) lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
|
if (toolDeltas.length) lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
|
||||||
|
|
||||||
for (const tool of toolDeltas) {
|
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(
|
const result = ToolStream.appendOrStart(
|
||||||
ADAPTER,
|
ADAPTER,
|
||||||
tools,
|
tools,
|
||||||
tool.index,
|
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",
|
"OpenAI Chat tool call delta is missing id or name",
|
||||||
)
|
)
|
||||||
if (ToolStream.isError(result)) return yield* result
|
if (ToolStream.isError(result)) return yield* result
|
||||||
|
|
@ -463,6 +484,9 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||||
events.push(...result.events)
|
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
|
// Finalize accumulated tool inputs eagerly when finish_reason arrives so
|
||||||
// valid calls and malformed local calls settle independently.
|
// valid calls and malformed local calls settle independently.
|
||||||
const finished =
|
const finished =
|
||||||
|
|
@ -473,6 +497,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
tools: finished?.tools ?? tools,
|
tools: finished?.tools ?? tools,
|
||||||
|
pendingTools,
|
||||||
toolCallEvents: finished?.events ?? state.toolCallEvents,
|
toolCallEvents: finished?.events ?? state.toolCallEvents,
|
||||||
usage,
|
usage,
|
||||||
finishReason,
|
finishReason,
|
||||||
|
|
@ -512,6 +537,7 @@ export const protocol = Protocol.make({
|
||||||
event: Protocol.jsonEvent(OpenAIChatEvent),
|
event: Protocol.jsonEvent(OpenAIChatEvent),
|
||||||
initial: () => ({
|
initial: () => ({
|
||||||
tools: ToolStream.empty<number>(),
|
tools: ToolStream.empty<number>(),
|
||||||
|
pendingTools: {},
|
||||||
toolCallEvents: [],
|
toolCallEvents: [],
|
||||||
lifecycle: Lifecycle.initial(),
|
lifecycle: Lifecycle.initial(),
|
||||||
reasoningField: undefined,
|
reasoningField: undefined,
|
||||||
|
|
|
||||||
|
|
@ -140,8 +140,8 @@ export const appendOrStart = <K extends StreamKey>(
|
||||||
missingToolMessage: string,
|
missingToolMessage: string,
|
||||||
): AppendOutcome<K> | LLMError => {
|
): AppendOutcome<K> | LLMError => {
|
||||||
const current = tools[key]
|
const current = tools[key]
|
||||||
const id = delta.id ?? current?.id
|
const id = current?.id ?? delta.id
|
||||||
const name = delta.name ?? current?.name
|
const name = current?.name ?? delta.name
|
||||||
if (!id || !name) return eventError(route, missingToolMessage)
|
if (!id || !name) return eventError(route, missingToolMessage)
|
||||||
|
|
||||||
const tool = {
|
const tool = {
|
||||||
|
|
|
||||||
|
|
@ -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", () =>
|
it.effect("fails a streamed tool call when the provider ends without a finish reason", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = sseEvents(
|
const body = sseEvents(
|
||||||
|
|
|
||||||
|
|
@ -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", () =>
|
it.effect("fails appendExisting when the provider skipped the tool start", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
|
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue