Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions aisdk/ai/builder/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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{
Expand Down
80 changes: 80 additions & 0 deletions aisdk/ai/builder/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}`),
},
Comment thread
mikeland73 marked this conversation as resolved.
Resolved
},
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{
Expand Down Expand Up @@ -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)
}
16 changes: 16 additions & 0 deletions aisdk/ai/provider/anthropic/codec/encode_tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
9 changes: 9 additions & 0 deletions aisdk/ai/provider/anthropic/codec/jsonschema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions specs/bug-scan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading