diff --git a/aisdk/ai/builder/builder.go b/aisdk/ai/builder/builder.go index 5450b2bb..ea8be0bb 100644 --- a/aisdk/ai/builder/builder.go +++ b/aisdk/ai/builder/builder.go @@ -26,6 +26,8 @@ type ResponseBuilder struct { resp api.Response // Map of tool call ID to index in Content array (for parallel tool calls) toolCallIndices map[string]int + // Set of tool call IDs that have received a complete ToolCallEvent. + completedToolCalls map[string]bool // Error encountered during event processing err error @@ -46,9 +48,10 @@ func NewResponseBuilder() *ResponseBuilder { Warnings: []api.CallWarning{}, FinishReason: api.FinishReason(""), }, - toolCallIndices: make(map[string]int), - currentState: noState, - err: nil, + toolCallIndices: make(map[string]int), + completedToolCalls: make(map[string]bool), + currentState: noState, + err: nil, } } @@ -155,11 +158,28 @@ func (b *ResponseBuilder) addReasoningSignature(e *api.ReasoningSignatureEvent) // addToolCall adds a tool call event to the response. func (b *ResponseBuilder) addToolCall(e *api.ToolCallEvent) error { // Check for duplicate tool call ID - if _, exists := b.toolCallIndices[e.ToolCallID]; exists { + if b.completedToolCalls[e.ToolCallID] { return fmt.Errorf("duplicate tool call ID: %s", e.ToolCallID) } b.currentState = toolCallState + b.completedToolCalls[e.ToolCallID] = true + + // Providers such as OpenAI stream a tool call as a series of + // ToolCallDeltaEvents followed by a final ToolCallEvent with the same ID + // and the complete arguments. In that case, finalize the existing block + // instead of treating the final event as a duplicate. + if idx, exists := b.toolCallIndices[e.ToolCallID]; exists { + if toolCall, ok := b.resp.Content[idx].(*api.ToolCallBlock); ok { + if e.ToolName != "" { + toolCall.ToolName = e.ToolName + } + if len(e.Args) > 0 { + toolCall.Args = slices.Clone(e.Args) + } + } + return nil + } // Create new tool call block toolCall := &api.ToolCallBlock{ diff --git a/aisdk/ai/builder/builder_test.go b/aisdk/ai/builder/builder_test.go index cc322242..50af3096 100644 --- a/aisdk/ai/builder/builder_test.go +++ b/aisdk/ai/builder/builder_test.go @@ -257,6 +257,43 @@ func TestResponseBuilder(t *testing.T) { }, }, }, + { + // Mirrors the OpenAI Responses stream: output_item.added and + // function_call_arguments.delta produce deltas, then + // output_item.done produces a final ToolCallEvent with the same ID. + name: "tool call deltas followed by final tool call", + events: []api.StreamEvent{ + &api.ToolCallDeltaEvent{ + ToolCallID: "call_1", + ToolName: "get_weather", + ArgsDelta: json.RawMessage(``), + }, + &api.ToolCallDeltaEvent{ + ToolCallID: "call_1", + ToolName: "get_weather", + ArgsDelta: json.RawMessage(`{"location":`), + }, + &api.ToolCallDeltaEvent{ + ToolCallID: "call_1", + ToolName: "get_weather", + ArgsDelta: json.RawMessage(`"Paris"}`), + }, + &api.ToolCallEvent{ + ToolCallID: "call_1", + ToolName: "get_weather", + Args: json.RawMessage(`{"location":"Paris"}`), + }, + }, + expected: &api.Response{ + Content: []api.ContentBlock{ + &api.ToolCallBlock{ + ToolCallID: "call_1", + ToolName: "get_weather", + Args: json.RawMessage(`{"location":"Paris"}`), + }, + }, + }, + }, { name: "multiple function calls", events: []api.StreamEvent{ @@ -477,3 +514,46 @@ func TestResponseBuilder_ValueTypes(t *testing.T) { }) } } + +func TestResponseBuilder_DuplicateToolCall(t *testing.T) { + b := NewResponseBuilder() + ev := &api.ToolCallEvent{ + ToolCallID: "call_1", + ToolName: "get_weather", + Args: json.RawMessage(`{}`), + } + assert.NoError(t, b.AddEvent(ev)) + assert.ErrorContains(t, b.AddEvent(ev), "duplicate tool call ID") +} + +// The final ToolCallEvent is authoritative: it must update the block built +// from deltas in place (not append a second block), and its complete args +// must replace whatever the deltas accumulated. +func TestResponseBuilder_FinalToolCallReplacesDeltaArgs(t *testing.T) { + b := NewResponseBuilder() + events := []api.StreamEvent{ + &api.ToolCallDeltaEvent{ + ToolCallID: "call_1", + ToolName: "get_weather", + ArgsDelta: json.RawMessage(`{"location":"Par`), + }, + &api.ToolCallEvent{ + ToolCallID: "call_1", + ToolName: "get_weather", + Args: json.RawMessage(`{"location":"Paris","unit":"C"}`), + }, + } + for _, ev := range events { + assert.NoError(t, b.AddEvent(ev)) + } + + resp, err := b.Build() + assert.NoError(t, err) + assert.Equal(t, []api.ContentBlock{ + &api.ToolCallBlock{ + ToolCallID: "call_1", + ToolName: "get_weather", + Args: json.RawMessage(`{"location":"Paris","unit":"C"}`), + }, + }, resp.Content) +} diff --git a/aisdk/ai/provider/anthropic/codec/encode_tools_test.go b/aisdk/ai/provider/anthropic/codec/encode_tools_test.go index 7c624475..d438eb63 100644 --- a/aisdk/ai/provider/anthropic/codec/encode_tools_test.go +++ b/aisdk/ai/provider/anthropic/codec/encode_tools_test.go @@ -18,6 +18,22 @@ func TestEncodeFunctionTool(t *testing.T) { wantErr bool want string // Expected JSON output }{ + { + name: "function tool without input schema", + input: api.FunctionTool{ + Name: "now", + Description: "Returns the current time", + }, + want: `{ + "type": "tool", + "name": "now", + "description": "Returns the current time", + "input_schema": { + "type": "object", + "properties": {} + } + }`, + }, { name: "simple function tool", input: api.FunctionTool{ diff --git a/aisdk/ai/provider/anthropic/codec/jsonschema.go b/aisdk/ai/provider/anthropic/codec/jsonschema.go index 92a06fd5..f40dfb5e 100644 --- a/aisdk/ai/provider/anthropic/codec/jsonschema.go +++ b/aisdk/ai/provider/anthropic/codec/jsonschema.go @@ -62,6 +62,15 @@ func normalizeSchemaMap(schemaMap map[string]interface{}) { // encodeInputSchema converts the JSON schema definition to Anthropic's schema format func encodeInputSchema(schema *jsonschema.Schema) (anthropic.BetaToolInputSchemaParam, error) { + // InputSchema is optional on FunctionTool. A tool without one takes no + // arguments, which Anthropic expresses as an object with no properties. + if schema == nil { + return anthropic.BetaToolInputSchemaParam{ + Type: "object", + Properties: map[string]any{}, + }, nil + } + // Verify the schema type is "object" // TODO: When the Anthropic SDK supports other type values or union types, // we should update this to pass through the original type information. diff --git a/specs/bug-scan.md b/specs/bug-scan.md index 284e300a..fe444435 100644 --- a/specs/bug-scan.md +++ b/specs/bug-scan.md @@ -94,5 +94,6 @@ This is a static review of the code. Every finding was re-read in the source, an 1. **envsec secrets hygiene (#1–3, #6):** add `$` to the name regex, write secret and credential files `0600`, and fix the `--format` default. Each is a one-line fix with high payoff. 2. **Auth panics (#4, #5):** use a comma-ok assertion for `id_token` and nil-check `IDClaims()` everywhere. Together these unblock token refresh for every Jetify CLI. 3. **aisdk OpenAI/Anthropic correctness (#7–11):** these break basic tool calling, no-arg tools, and multi-turn reasoning. + - ✅ **Fixed** #7, #8 in [jetify-com/opensource#616](https://github.com/jetify-com/opensource/pull/616). #9–11 are still open. 4. **`pkg/osdir` and `runx` (#12–18):** small, contained fixes, each of which should get a regression test. 5. **SSE hardening (#21, #23–25)**, then the remaining P2 and P3 items.