Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit fd37472

Browse files
committed
fix(huggingface): properly handle tool arguments when streaming without deltas
HuggingFace SDK emits tool-input-start/end but NOT tool-input-delta. The arguments come in the final tool-call event. The fix now: 1. Tracks tools that received actual deltas (toolsWithDeltas) 2. If tool-call arrives and tool was started but had no deltas, emit the arguments as a delta (HuggingFace-like behavior) 3. If tool-call arrives and tool had deltas, ignore it (fully streamed) 4. If tool-call arrives and tool wasn't started, emit full sequence
1 parent 1591a3c commit fd37472

2 files changed

Lines changed: 79 additions & 22 deletions

File tree

src/api/transform/__tests__/ai-sdk.spec.ts

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -573,11 +573,47 @@ describe("AI SDK conversion utilities", () => {
573573
expect(chunks[2]).toEqual({ type: "tool_call_end", id: "call_1" })
574574
})
575575

576+
it("handles HuggingFace-like providers (start/end but no deltas, args in tool-call)", () => {
577+
const processor = createAiSdkToolStreamProcessor()
578+
579+
// HuggingFace emits tool-input-start and tool-input-end but NOT tool-input-delta
580+
// The arguments come in the tool-call event
581+
const startChunks = [
582+
...processor({ type: "tool-input-start" as const, id: "call_1", toolName: "read_file" }),
583+
]
584+
const endChunks = [...processor({ type: "tool-input-end" as const, id: "call_1" })]
585+
586+
// tool-call should emit just the delta (arguments) since start/end were already emitted
587+
const toolCallChunks = [
588+
...processor({
589+
type: "tool-call" as const,
590+
toolCallId: "call_1",
591+
toolName: "read_file",
592+
input: { path: "test.ts" },
593+
} as any),
594+
]
595+
596+
expect(startChunks).toHaveLength(1)
597+
expect(startChunks[0]).toEqual({ type: "tool_call_start", id: "call_1", name: "read_file" })
598+
599+
expect(endChunks).toHaveLength(1)
600+
expect(endChunks[0]).toEqual({ type: "tool_call_end", id: "call_1" })
601+
602+
// The tool-call should emit just the delta with arguments
603+
expect(toolCallChunks).toHaveLength(1)
604+
expect(toolCallChunks[0]).toEqual({
605+
type: "tool_call_delta",
606+
id: "call_1",
607+
delta: '{"path":"test.ts"}',
608+
})
609+
})
610+
576611
it("handles multiple tool calls correctly", () => {
577612
const processor = createAiSdkToolStreamProcessor()
578613

579-
// First tool is streamed
614+
// First tool is fully streamed with deltas
580615
Array.from(processor({ type: "tool-input-start" as const, id: "call_1", toolName: "read_file" }))
616+
Array.from(processor({ type: "tool-input-delta" as const, id: "call_1", delta: '{"path":"a.ts"}' }))
581617
Array.from(processor({ type: "tool-input-end" as const, id: "call_1" }))
582618

583619
// Second tool is not streamed (non-streaming provider behavior)
@@ -590,17 +626,17 @@ describe("AI SDK conversion utilities", () => {
590626
} as any),
591627
]
592628

593-
// Second tool should be emitted
629+
// Second tool should be emitted with full start/delta/end
594630
expect(chunks).toHaveLength(3)
595631
expect(chunks[0]).toEqual({ type: "tool_call_start", id: "call_2", name: "write_to_file" })
596632

597-
// First tool's tool-call should be ignored
633+
// First tool's tool-call should be ignored (it had deltas)
598634
const ignoredChunks = [
599635
...processor({
600636
type: "tool-call" as const,
601637
toolCallId: "call_1",
602638
toolName: "read_file",
603-
input: {},
639+
input: { path: "a.ts" },
604640
} as any),
605641
]
606642
expect(ignoredChunks).toHaveLength(0)
@@ -610,11 +646,12 @@ describe("AI SDK conversion utilities", () => {
610646
const processor1 = createAiSdkToolStreamProcessor()
611647
const processor2 = createAiSdkToolStreamProcessor()
612648

613-
// Stream a tool with processor1
649+
// Stream a tool fully with processor1 (with delta)
614650
Array.from(processor1({ type: "tool-input-start" as const, id: "call_1", toolName: "test" }))
651+
Array.from(processor1({ type: "tool-input-delta" as const, id: "call_1", delta: "{}" }))
615652
Array.from(processor1({ type: "tool-input-end" as const, id: "call_1" }))
616653

617-
// processor1 should ignore tool-call for call_1
654+
// processor1 should ignore tool-call for call_1 (it had deltas)
618655
const p1Chunks = [
619656
...processor1({
620657
type: "tool-call" as const,

src/api/transform/ai-sdk.ts

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -386,14 +386,16 @@ export function* processAiSdkStreamPart(part: ExtendedStreamPart): Generator<Api
386386
export function createAiSdkToolStreamProcessor(): (
387387
part: ExtendedStreamPart,
388388
) => Generator<ApiStreamChunk, void, unknown> {
389-
// Track tool IDs that have been processed via streaming events
390-
const streamedToolIds = new Set<string>()
389+
// Track tool IDs that have been started via streaming events
390+
const startedToolIds = new Set<string>()
391+
// Track tool IDs that have received actual argument deltas
392+
const toolsWithDeltas = new Set<string>()
391393

392394
return function* processStreamPart(part: ExtendedStreamPart): Generator<ApiStreamChunk> {
393395
switch (part.type) {
394396
case "tool-input-start":
395-
// Track that this tool has streaming events
396-
streamedToolIds.add(part.id)
397+
// Track that this tool has started streaming
398+
startedToolIds.add(part.id)
397399
yield {
398400
type: "tool_call_start",
399401
id: part.id,
@@ -402,6 +404,8 @@ export function createAiSdkToolStreamProcessor(): (
402404
break
403405

404406
case "tool-input-delta":
407+
// Track that we received actual argument content for this tool
408+
toolsWithDeltas.add(part.id)
405409
yield {
406410
type: "tool_call_delta",
407411
id: part.id,
@@ -417,30 +421,46 @@ export function createAiSdkToolStreamProcessor(): (
417421
break
418422

419423
case "tool-call": {
420-
// Only emit tool-call if this tool wasn't already processed via streaming
424+
// Handle tool-call events - the logic depends on whether we got streaming deltas
421425
const toolCallPart = part as {
422426
type: "tool-call"
423427
toolCallId: string
424428
toolName: string
425429
input: unknown
426430
}
427-
if (!streamedToolIds.has(toolCallPart.toolCallId)) {
428-
// Emit as start/delta/end for consistency with streaming providers
431+
432+
// If we received deltas, the arguments were already streamed - ignore tool-call
433+
if (toolsWithDeltas.has(toolCallPart.toolCallId)) {
434+
break
435+
}
436+
437+
// If tool was started but no deltas received (like HuggingFace),
438+
// emit the arguments from tool-call as a delta
439+
if (startedToolIds.has(toolCallPart.toolCallId)) {
429440
const args = JSON.stringify(toolCallPart.input)
430-
yield {
431-
type: "tool_call_start",
432-
id: toolCallPart.toolCallId,
433-
name: toolCallPart.toolName,
434-
}
435441
yield {
436442
type: "tool_call_delta",
437443
id: toolCallPart.toolCallId,
438444
delta: args,
439445
}
440-
yield {
441-
type: "tool_call_end",
442-
id: toolCallPart.toolCallId,
443-
}
446+
break
447+
}
448+
449+
// Tool wasn't started via streaming - emit full start/delta/end sequence
450+
const args = JSON.stringify(toolCallPart.input)
451+
yield {
452+
type: "tool_call_start",
453+
id: toolCallPart.toolCallId,
454+
name: toolCallPart.toolName,
455+
}
456+
yield {
457+
type: "tool_call_delta",
458+
id: toolCallPart.toolCallId,
459+
delta: args,
460+
}
461+
yield {
462+
type: "tool_call_end",
463+
id: toolCallPart.toolCallId,
444464
}
445465
break
446466
}

0 commit comments

Comments
 (0)