From 626e32a7fc7b85fdc21285afe8095dd225a31dc5 Mon Sep 17 00:00:00 2001 From: Jaime Still Date: Tue, 17 Feb 2026 07:58:50 -0500 Subject: [PATCH 1/2] streaming tools protocol: native ToolCall format, ToolsStream, StreamingChunk.ToolCalls (#23) --- .../.archive/23-streaming-tools-protocol.md | 208 ++++++++++++++++++ .../sessions/23-streaming-tools-protocol.md | 62 ++++++ .../plans/drifting-meandering-hartmanis.md | 111 ++++++++++ agent/agent.go | 12 + agent/agent_test.go | 10 +- agent/client/client_test.go | 10 +- agent/mock/agent.go | 14 ++ agent/mock/agent_test.go | 60 ++++- agent/mock/client_test.go | 6 +- agent/mock/helpers.go | 45 +++- cmd/kernel/main.go | 2 +- cmd/prompt-agent/main.go | 2 +- core/protocol/message.go | 65 ++---- core/protocol/protocol_test.go | 131 +++++------ core/response/response_test.go | 120 +++++++++- core/response/streaming.go | 16 +- kernel/kernel.go | 15 +- kernel/kernel_test.go | 32 +-- session/session_test.go | 18 +- 19 files changed, 748 insertions(+), 191 deletions(-) create mode 100644 .claude/context/guides/.archive/23-streaming-tools-protocol.md create mode 100644 .claude/context/sessions/23-streaming-tools-protocol.md create mode 100644 .claude/plans/drifting-meandering-hartmanis.md diff --git a/.claude/context/guides/.archive/23-streaming-tools-protocol.md b/.claude/context/guides/.archive/23-streaming-tools-protocol.md new file mode 100644 index 0000000..20fe540 --- /dev/null +++ b/.claude/context/guides/.archive/23-streaming-tools-protocol.md @@ -0,0 +1,208 @@ +# 23 - Streaming tools protocol + +## Problem Context + +The kernel loop needs streaming support for tool calls before the HTTP interface is established (Objective #2). The Agent interface has `ChatStream` and `VisionStream` but no `ToolsStream`. While streaming routing infrastructure exists (`ParseToolsStreamChunk`, `Protocol.Tools.SupportsStreaming()`, `client.ExecuteStream()`), the `StreamingChunk.Delta` struct lacks a `ToolCalls` field — tool call data from streaming responses would be silently dropped. + +Additionally, `ToolCall` uses a flat canonical format with custom `MarshalJSON`/`UnmarshalJSON` to bridge the nested LLM API format. This indirection introduced a streaming bug (continuation chunks with `function.arguments` but no `function.name` were silently dropped) and adds serialization complexity that deviates from the external standard. Adopting the native LLM API format eliminates both issues. + +## Architecture Approach + +Address gaps bottom-up per the project's design principle: fix core types at the lowest affected dependency level, then add the agent interface method. + +- **ToolCall adopts native LLM API format** — eliminates custom JSON methods, aligns with OpenAI/Ollama wire format, streaming continuation chunks work naturally. +- **`ToolFunction` named type** — avoids verbose anonymous struct literals. +- **`NewToolCall` constructor** — encapsulates `Type: "function"` default, keeps call sites clean. +- **`ToolCallRecord` embeds `protocol.ToolCall`** — eliminates field duplication between the wire type and the execution record. +- **Accumulation of partial tool call arguments** into complete `ToolCall` objects is a consumer concern (kernel loop, issue #26). + +## Implementation + +### Step 1: Refactor ToolCall to native LLM API format + +**File:** `core/protocol/message.go` + +Remove the `encoding/json` import. + +Replace the `ToolCall` struct (lines 19-23), `MarshalJSON` method (lines 27-46), and `UnmarshalJSON` method (lines 51-72) with: + +```go +type ToolFunction struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +type ToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function ToolFunction `json:"function"` +} + +func NewToolCall(id, name, arguments string) ToolCall { + return ToolCall{ + ID: id, + Type: "function", + Function: ToolFunction{ + Name: name, + Arguments: arguments, + }, + } +} +``` + +### Step 2: Embed ToolCall in ToolCallRecord and update kernel loop + +**File:** `kernel/kernel.go` + +Replace `ToolCallRecord` (lines 33-40) with: + +```go +type ToolCallRecord struct { + protocol.ToolCall + Iteration int // Loop cycle in which the call occurred. + Result string // Tool execution output. + IsError bool // Whether execution returned an error. +} +``` + +In the tool call processing loop (lines 193-228), update record construction (lines 196-201) from: + +```go +record := ToolCallRecord{ + Iteration: iteration + 1, + ID: tc.ID, + Name: tc.Name, + Arguments: tc.Arguments, +} +``` + +to: + +```go +record := ToolCallRecord{ + ToolCall: tc, + Iteration: iteration + 1, +} +``` + +Update field access in the loop: +- Line 194: `tc.Name` → `tc.Function.Name` +- Line 205: `tc.Name` → `tc.Function.Name` +- Line 206: `json.RawMessage(tc.Arguments)` → `json.RawMessage(tc.Function.Arguments)` + +### Step 3: Update CLI output for embedded ToolCallRecord + +**File:** `cmd/kernel/main.go` + +Line 78 — update ToolCallRecord field access: + +```go +fmt.Printf(" [%d] %s(%s)\n", i+1, tc.Function.Name, tc.Function.Arguments) +``` + +**File:** `cmd/prompt-agent/main.go` + +Line 186 — update protocol.ToolCall field access: + +```go +fmt.Printf(" - %s(%s)\n", toolCall.Function.Name, toolCall.Function.Arguments) +``` + +### Step 4: Extend StreamingChunk with ToolCalls support + +**File:** `core/response/streaming.go` + +Add `protocol` import: + +```go +import ( + "encoding/json" + "fmt" + + "github.com/tailored-agentic-units/kernel/core/protocol" +) +``` + +Add `ToolCalls` field to the anonymous Delta struct inside `StreamingChunk` (lines 18-21): + +```go +Delta struct { + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ToolCalls []protocol.ToolCall `json:"tool_calls,omitempty"` +} `json:"delta"` +``` + +Add `ToolCalls()` accessor method after the existing `Content()` method: + +```go +func (c *StreamingChunk) ToolCalls() []protocol.ToolCall { + if len(c.Choices) > 0 { + return c.Choices[0].Delta.ToolCalls + } + return nil +} +``` + +Update `ParseToolsStreamChunk` comment to replace "Tools protocol uses the same streaming format as chat." with "Tools streaming chunks include tool call deltas in the Delta field." + +### Step 5: Add ToolsStream to Agent interface and implementation + +**File:** `agent/agent.go` + +Add to the `Agent` interface after the `Tools` method (after line 61): + +```go +ToolsStream(ctx context.Context, prompt []protocol.Message, tools []protocol.Tool, opts ...map[string]any) (<-chan *response.StreamingChunk, error) +``` + +Add implementation after the `Tools` method (after line 237): + +```go +func (a *agent) ToolsStream(ctx context.Context, prompt []protocol.Message, tools []protocol.Tool, opts ...map[string]any) (<-chan *response.StreamingChunk, error) { + messages := a.initMessages(prompt) + options := a.mergeOptions(protocol.Tools, opts...) + options["stream"] = true + + req := request.NewTools(a.provider, a.model, messages, tools, options) + + return a.client.ExecuteStream(ctx, req) +} +``` + +### Step 6: Add ToolsStream to MockAgent + +**File:** `agent/mock/agent.go` + +Add after the `Tools` method (after line 204): + +```go +func (m *MockAgent) ToolsStream(ctx context.Context, prompt []protocol.Message, tools []protocol.Tool, opts ...map[string]any) (<-chan *response.StreamingChunk, error) { + if m.streamError != nil { + return nil, m.streamError + } + + ch := make(chan *response.StreamingChunk, len(m.streamChunks)) + for i := range m.streamChunks { + ch <- &m.streamChunks[i] + } + close(ch) + + return ch, nil +} +``` + +## Validation Criteria + +- [ ] `ToolCall` uses native LLM API format with `ToolFunction` named type +- [ ] No custom `MarshalJSON`/`UnmarshalJSON` on `ToolCall` +- [ ] `NewToolCall` constructor produces correctly-typed instances +- [ ] `ToolCallRecord` embeds `protocol.ToolCall`, no duplicate fields +- [ ] All runtime field access sites updated (`.Function.Name`, `.Function.Arguments`) +- [ ] `StreamingChunk.Delta` includes `ToolCalls` field +- [ ] `StreamingChunk.ToolCalls()` accessor returns tool calls from first choice +- [ ] `ToolsStream` method added to `Agent` interface +- [ ] `ToolsStream` implementation follows ChatStream/VisionStream pattern +- [ ] Mock agent updated with `ToolsStream` support +- [ ] All existing tests pass +- [ ] `go vet ./...` passes diff --git a/.claude/context/sessions/23-streaming-tools-protocol.md b/.claude/context/sessions/23-streaming-tools-protocol.md new file mode 100644 index 0000000..6793ef7 --- /dev/null +++ b/.claude/context/sessions/23-streaming-tools-protocol.md @@ -0,0 +1,62 @@ +# Session: Streaming Tools Protocol + +**Issue:** #23 +**Branch:** `23-streaming-tools-protocol` +**Objective:** #2 — Kernel Interface + +## Summary + +Added `ToolsStream` to the Agent interface and refactored `ToolCall` from a flat canonical format with custom JSON methods to the native LLM API format, aligning with the OpenAI-compatible wire format used by both Ollama and Azure providers. + +## Changes + +### Core type refactoring (`core/protocol/message.go`) + +- Replaced flat `ToolCall{ID, Name, Arguments}` with native format: `ToolCall{ID, Type, Function}` where `Function` is the new `ToolFunction{Name, Arguments}` named type +- Removed custom `MarshalJSON`/`UnmarshalJSON` methods — standard `encoding/json` handles the native format directly +- Added `NewToolCall(id, name, arguments)` constructor that sets `Type: "function"` automatically + +### Streaming tool call support (`core/response/streaming.go`) + +- Extended `StreamingChunk.Delta` with `ToolCalls []protocol.ToolCall` field +- Added `ToolCalls()` accessor method (mirrors the `Content()` pattern) +- Updated `ParseToolsStreamChunk` comment + +### Agent interface (`agent/agent.go`) + +- Added `ToolsStream` method to the `Agent` interface +- Added implementation following the `ChatStream`/`VisionStream` pattern + +### Mock agent (`agent/mock/agent.go`, `agent/mock/helpers.go`) + +- Added `ToolsStream` method to `MockAgent` +- Added `NewStreamingToolsAgent` helper for streaming tool call test scenarios +- Updated `NewStreamingChatAgent` anonymous struct to include `ToolCalls` field + +### Kernel runtime (`kernel/kernel.go`) + +- `ToolCallRecord` now embeds `protocol.ToolCall` instead of duplicating fields +- Updated field access throughout: `tc.Function.Name`, `tc.Function.Arguments` + +### CLI updates (`cmd/kernel/main.go`, `cmd/prompt-agent/main.go`) + +- Updated ToolCall field access in output formatting + +### Test updates (16 files modified) + +- Updated all ToolCall struct literals across test files to use `NewToolCall` or native format +- Updated all `.Name`/`.Arguments` field access to `.Function.Name`/`.Function.Arguments` +- Rewrote marshal/unmarshal tests for native format (removed flat format tests) +- Added new tests: `TestNewToolCall`, streaming chunk tool call tests, `ToolsStream` tests + +## Design Decisions + +- **Native LLM API format over flat canonical**: Eliminates custom JSON methods, fixes streaming continuation bug, aligns with external standards +- **`ToolFunction` named type**: Avoids verbose anonymous struct literals in call sites +- **`ToolCallRecord` embedding**: Eliminates field duplication between wire type and execution record +- **Accumulation deferred to #26**: Reassembling partial streaming arguments is a consumer concern for the kernel loop +- **CLI streaming deferred to #26**: `cmd/prompt-agent` and `cmd/kernel` streaming tool support deferred + +## Scope Boundary + +Tool call accumulation (reassembling partial streaming arguments into complete `ToolCall` objects) is explicitly not part of this issue — it belongs to #26 (Multi-session kernel) where the streaming-first kernel loop is implemented. diff --git a/.claude/plans/drifting-meandering-hartmanis.md b/.claude/plans/drifting-meandering-hartmanis.md new file mode 100644 index 0000000..713ec4b --- /dev/null +++ b/.claude/plans/drifting-meandering-hartmanis.md @@ -0,0 +1,111 @@ +# Plan: #23 — Streaming tools protocol + +## Context + +The kernel loop needs streaming support for tool calls before the HTTP interface is established (Objective #2). The Agent interface has `ChatStream` and `VisionStream` but no `ToolsStream`. While the streaming routing infrastructure exists (`ParseToolsStreamChunk`, `Protocol.Tools.SupportsStreaming()`, `client.ExecuteStream()`), the `StreamingChunk.Delta` struct lacks a `ToolCalls` field — meaning tool call data from streaming responses would be silently dropped. + +Additionally, `ToolCall.UnmarshalJSON` only recognizes the nested format when `function.name` is present. In OpenAI-compatible streaming, continuation chunks carry `function.arguments` without `function.name`, causing those fragments to fall through to the flat format and lose the arguments data. This needs fixing at the core level before `ToolsStream` can faithfully capture tool call deltas. + +## Approach + +Address gaps bottom-up per the project's design principle: fix core types first, then add the agent interface method. Accumulation of partial tool call arguments into complete `ToolCall` objects is a consumer concern (kernel loop, issue #26) — not part of this issue. + +## Implementation + +### Step 1: Fix ToolCall.UnmarshalJSON for streaming continuation chunks (`core/protocol/message.go`) + +The current condition `nested.Function.Name != ""` skips the nested path for continuation chunks that have `function.arguments` but no `function.name`. Fix by checking for any nested data: + +```go +if nested.Function.Name != "" || nested.Function.Arguments != "" { +``` + +### Step 2: Extend StreamingChunk.Delta with ToolCalls (`core/response/streaming.go`) + +Add `ToolCalls` field to the anonymous Delta struct inside StreamingChunk: + +```go +Delta struct { + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ToolCalls []protocol.ToolCall `json:"tool_calls,omitempty"` +} `json:"delta"` +``` + +This requires adding `"github.com/tailored-agentic-units/kernel/core/protocol"` to imports. + +Add a `ToolCalls()` accessor method (mirrors the `Content()` pattern): + +```go +func (c *StreamingChunk) ToolCalls() []protocol.ToolCall { + if len(c.Choices) > 0 { + return c.Choices[0].Delta.ToolCalls + } + return nil +} +``` + +Update `ParseToolsStreamChunk` comment to remove the incorrect "same format as chat" statement. + +### Step 3: Add ToolsStream to Agent interface (`agent/agent.go`) + +Add method signature after the `Tools` method (~line 61): + +```go +ToolsStream(ctx context.Context, prompt []protocol.Message, tools []protocol.Tool, opts ...map[string]any) (<-chan *response.StreamingChunk, error) +``` + +Add implementation after the `Tools` method (~line 237), following the ChatStream pattern: + +```go +func (a *agent) ToolsStream(ctx context.Context, prompt []protocol.Message, tools []protocol.Tool, opts ...map[string]any) (<-chan *response.StreamingChunk, error) { + messages := a.initMessages(prompt) + options := a.mergeOptions(protocol.Tools, opts...) + options["stream"] = true + + req := request.NewTools(a.provider, a.model, messages, tools, options) + + return a.client.ExecuteStream(ctx, req) +} +``` + +### Step 4: Add ToolsStream to MockAgent (`agent/mock/agent.go`) + +Add method mirroring ChatStream/VisionStream pattern: + +```go +func (m *MockAgent) ToolsStream(ctx context.Context, prompt []protocol.Message, tools []protocol.Tool, opts ...map[string]any) (<-chan *response.StreamingChunk, error) { + if m.streamError != nil { + return nil, m.streamError + } + + ch := make(chan *response.StreamingChunk, len(m.streamChunks)) + for i := range m.streamChunks { + ch <- &m.streamChunks[i] + } + close(ch) + + return ch, nil +} +``` + +### Step 5: Add NewStreamingToolsAgent helper (`agent/mock/helpers.go`) + +Add helper following the NewStreamingChatAgent pattern, producing chunks with tool call deltas. + +Update the struct literal in `NewStreamingChatAgent` to include the new `ToolCalls` field in the Delta anonymous struct (required for type compatibility). + +## Files Modified + +- `core/protocol/message.go` — Fix UnmarshalJSON for streaming continuation chunks +- `core/response/streaming.go` — Extend Delta struct, add ToolCalls() accessor +- `agent/agent.go` — Interface + implementation +- `agent/mock/agent.go` — Mock implementation +- `agent/mock/helpers.go` — NewStreamingToolsAgent helper, update existing struct literals + +## Verification + +- `go vet ./...` +- `go test ./...` +- New tests for streaming tool call chunks in `core/response/` +- New tests for ToolsStream in agent and mock packages diff --git a/agent/agent.go b/agent/agent.go index 5c8f3f6..5cb0605 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -60,6 +60,8 @@ type Agent interface { // Returns the parsed tools response with tool calls or an error. Tools(ctx context.Context, prompt []protocol.Message, tools []protocol.Tool, opts ...map[string]any) (*response.ToolsResponse, error) + ToolsStream(ctx context.Context, prompt []protocol.Message, tools []protocol.Tool, opts ...map[string]any) (<-chan *response.StreamingChunk, error) + // Embed executes an embeddings protocol request. // Returns the parsed embeddings response or an error. Embed(ctx context.Context, input string, opts ...map[string]any) (*response.EmbeddingsResponse, error) @@ -236,6 +238,16 @@ func (a *agent) Tools(ctx context.Context, prompt []protocol.Message, tools []pr return resp, nil } +func (a *agent) ToolsStream(ctx context.Context, prompt []protocol.Message, tools []protocol.Tool, opts ...map[string]any) (<-chan *response.StreamingChunk, error) { + messages := a.initMessages(prompt) + options := a.mergeOptions(protocol.Tools, opts...) + options["stream"] = true + + req := request.NewTools(a.provider, a.model, messages, tools, options) + + return a.client.ExecuteStream(ctx, req) +} + // Embed executes an embeddings protocol request. // Merges model's configured embeddings options with runtime opts. // Returns parsed EmbeddingsResponse or error. diff --git a/agent/agent_test.go b/agent/agent_test.go index cbe9484..395da2e 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -249,11 +249,7 @@ func TestAgent_Tools(t *testing.T) { Role: "assistant", Content: "", ToolCalls: []protocol.ToolCall{ - { - ID: "call_123", - Name: "get_weather", - Arguments: `{"location":"Boston"}`, - }, + protocol.NewToolCall("call_123", "get_weather", `{"location":"Boston"}`), }, }, }) @@ -323,8 +319,8 @@ func TestAgent_Tools(t *testing.T) { } toolCall := resp.Choices[0].Message.ToolCalls[0] - if toolCall.Name != "get_weather" { - t.Errorf("got function name %q, want %q", toolCall.Name, "get_weather") + if toolCall.Function.Name != "get_weather" { + t.Errorf("got function name %q, want %q", toolCall.Function.Name, "get_weather") } } diff --git a/agent/client/client_test.go b/agent/client/client_test.go index 6893818..01f2b86 100644 --- a/agent/client/client_test.go +++ b/agent/client/client_test.go @@ -125,11 +125,7 @@ func TestClient_Execute_Tools(t *testing.T) { Role: "assistant", Content: "", ToolCalls: []protocol.ToolCall{ - { - ID: "call_123", - Name: "get_weather", - Arguments: `{"location":"Boston"}`, - }, + protocol.NewToolCall("call_123", "get_weather", `{"location":"Boston"}`), }, }, }) @@ -201,8 +197,8 @@ func TestClient_Execute_Tools(t *testing.T) { } toolCall := toolsResp.Choices[0].Message.ToolCalls[0] - if toolCall.Name != "get_weather" { - t.Errorf("got function name %q, want %q", toolCall.Name, "get_weather") + if toolCall.Function.Name != "get_weather" { + t.Errorf("got function name %q, want %q", toolCall.Function.Name, "get_weather") } } diff --git a/agent/mock/agent.go b/agent/mock/agent.go index 506aa7f..88e510c 100644 --- a/agent/mock/agent.go +++ b/agent/mock/agent.go @@ -203,6 +203,20 @@ func (m *MockAgent) Tools(ctx context.Context, prompt []protocol.Message, tools return m.toolsResponse, m.toolsError } +func (m *MockAgent) ToolsStream(ctx context.Context, prompt []protocol.Message, tools []protocol.Tool, opts ...map[string]any) (<-chan *response.StreamingChunk, error) { + if m.streamError != nil { + return nil, m.streamError + } + + ch := make(chan *response.StreamingChunk, len(m.streamChunks)) + for i := range m.streamChunks { + ch <- &m.streamChunks[i] + } + close(ch) + + return ch, nil +} + // Embed returns the predetermined embeddings response. func (m *MockAgent) Embed(ctx context.Context, input string, opts ...map[string]any) (*response.EmbeddingsResponse, error) { return m.embeddingsResponse, m.embeddingsError diff --git a/agent/mock/agent_test.go b/agent/mock/agent_test.go index 68cee3c..4dbf726 100644 --- a/agent/mock/agent_test.go +++ b/agent/mock/agent_test.go @@ -111,11 +111,7 @@ func TestMockAgent_Tools(t *testing.T) { Role: "assistant", Content: "", ToolCalls: []protocol.ToolCall{ - { - ID: "call_123", - Name: "test_func", - Arguments: `{}`, - }, + protocol.NewToolCall("call_123", "test_func", `{}`), }, }, }) @@ -244,3 +240,57 @@ func TestNewStreamingChatAgent(t *testing.T) { t.Errorf("got content %q, want %q", content, "Hello, world!") } } + +func TestMockAgent_ToolsStream(t *testing.T) { + toolCallDeltas := [][]protocol.ToolCall{ + {protocol.NewToolCall("call_1", "search", `{"query":`)}, + {protocol.NewToolCall("", "", `"test"}`)}, + } + + agent := mock.NewStreamingToolsAgent("test-id", toolCallDeltas) + + stream, err := agent.ToolsStream(context.Background(), protocol.InitMessages(protocol.RoleUser, "test"), nil) + + if err != nil { + t.Fatalf("ToolsStream failed: %v", err) + } + + var allToolCalls []protocol.ToolCall + for chunk := range stream { + if chunk.Error != nil { + t.Fatalf("Stream error: %v", chunk.Error) + } + allToolCalls = append(allToolCalls, chunk.ToolCalls()...) + } + + if len(allToolCalls) != 2 { + t.Fatalf("got %d tool call deltas, want 2", len(allToolCalls)) + } + + if allToolCalls[0].Function.Name != "search" { + t.Errorf("got Function.Name %q, want %q", allToolCalls[0].Function.Name, "search") + } +} + +func TestNewStreamingToolsAgent(t *testing.T) { + agent := mock.NewStreamingToolsAgent("test-id", [][]protocol.ToolCall{ + {protocol.NewToolCall("call_1", "fn", "{}")}, + }) + + if agent.ID() != "test-id" { + t.Errorf("got ID %q, want %q", agent.ID(), "test-id") + } + + stream, err := agent.ToolsStream(context.Background(), nil, nil) + if err != nil { + t.Fatalf("ToolsStream failed: %v", err) + } + + count := 0 + for range stream { + count++ + } + if count != 1 { + t.Errorf("got %d chunks, want 1", count) + } +} diff --git a/agent/mock/client_test.go b/agent/mock/client_test.go index 72cacd4..6016490 100644 --- a/agent/mock/client_test.go +++ b/agent/mock/client_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/tailored-agentic-units/kernel/agent/mock" + "github.com/tailored-agentic-units/kernel/core/protocol" "github.com/tailored-agentic-units/kernel/core/response" ) @@ -46,8 +47,9 @@ func TestMockClient_ExecuteStream(t *testing.T) { chunk.Choices = make([]struct { Index int `json:"index"` Delta struct { - Role string `json:"role,omitempty"` - Content string `json:"content,omitempty"` + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ToolCalls []protocol.ToolCall `json:"tool_calls,omitempty"` } `json:"delta"` FinishReason *string `json:"finish_reason"` }, 1) diff --git a/agent/mock/helpers.go b/agent/mock/helpers.go index f374bae..457f2ad 100644 --- a/agent/mock/helpers.go +++ b/agent/mock/helpers.go @@ -41,15 +41,17 @@ func NewStreamingChatAgent(id string, chunks []string) *MockAgent { chunk.Choices = append(chunk.Choices, struct { Index int `json:"index"` Delta struct { - Role string `json:"role,omitempty"` - Content string `json:"content,omitempty"` + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ToolCalls []protocol.ToolCall `json:"tool_calls,omitempty"` } `json:"delta"` FinishReason *string `json:"finish_reason"` }{ Index: 0, Delta: struct { - Role string `json:"role,omitempty"` - Content string `json:"content,omitempty"` + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ToolCalls []protocol.ToolCall `json:"tool_calls,omitempty"` }{ Content: content, }, @@ -63,6 +65,41 @@ func NewStreamingChatAgent(id string, chunks []string) *MockAgent { ) } +// NewStreamingToolsAgent creates a MockAgent configured for streaming tool calls. +// Each tool call delta is delivered as a separate streaming chunk. +func NewStreamingToolsAgent(id string, toolCallDeltas [][]protocol.ToolCall) *MockAgent { + streamChunks := make([]response.StreamingChunk, len(toolCallDeltas)) + for i, tcs := range toolCallDeltas { + chunk := response.StreamingChunk{ + Model: "mock-model", + } + chunk.Choices = append(chunk.Choices, struct { + Index int `json:"index"` + Delta struct { + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ToolCalls []protocol.ToolCall `json:"tool_calls,omitempty"` + } `json:"delta"` + FinishReason *string `json:"finish_reason"` + }{ + Index: 0, + Delta: struct { + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ToolCalls []protocol.ToolCall `json:"tool_calls,omitempty"` + }{ + ToolCalls: tcs, + }, + }) + streamChunks[i] = chunk + } + + return NewMockAgent( + WithID(id), + WithStreamChunks(streamChunks, nil), + ) +} + // NewToolsAgent creates a MockAgent configured for tool calling. // Returns tool calls in the Tools response. func NewToolsAgent(id string, toolCalls []protocol.ToolCall) *MockAgent { diff --git a/cmd/kernel/main.go b/cmd/kernel/main.go index 342bcc1..609d6cb 100644 --- a/cmd/kernel/main.go +++ b/cmd/kernel/main.go @@ -75,7 +75,7 @@ func main() { if len(result.ToolCalls) > 0 { fmt.Println("\nTool Calls:") for i, tc := range result.ToolCalls { - fmt.Printf(" [%d] %s(%s)\n", i+1, tc.Name, tc.Arguments) + fmt.Printf(" [%d] %s(%s)\n", i+1, tc.Function.Name, tc.Function.Arguments) if tc.IsError { fmt.Printf(" error: %s\n", tc.Result) } else if len(tc.Result) > 200 { diff --git a/cmd/prompt-agent/main.go b/cmd/prompt-agent/main.go index 1a2a7f8..50d76cd 100644 --- a/cmd/prompt-agent/main.go +++ b/cmd/prompt-agent/main.go @@ -183,7 +183,7 @@ func executeTools(ctx context.Context, agent agent.Agent, prompt string, tools [ if len(message.ToolCalls) > 0 { fmt.Printf("\nTool Calls:\n") for _, toolCall := range message.ToolCalls { - fmt.Printf(" - %s(%s)\n", toolCall.Name, toolCall.Arguments) + fmt.Printf(" - %s(%s)\n", toolCall.Function.Name, toolCall.Function.Arguments) } } } diff --git a/core/protocol/message.go b/core/protocol/message.go index b4f3c7f..c940836 100644 --- a/core/protocol/message.go +++ b/core/protocol/message.go @@ -1,7 +1,5 @@ package protocol -import "encoding/json" - // Role identifies the sender of a conversation message. type Role string @@ -12,63 +10,26 @@ const ( RoleTool Role = "tool" ) -// ToolCall represents a tool invocation in conversation history. -// Fields are flat (ID, Name, Arguments) for direct use across the kernel. -// UnmarshalJSON transparently handles the nested LLM API format -// (function.name, function.arguments) so provider responses decode correctly. -type ToolCall struct { - ID string `json:"id"` +type ToolFunction struct { Name string `json:"name"` Arguments string `json:"arguments"` } -// MarshalJSON serializes to the nested LLM API format ({type, function: {name, arguments}}) -// ensuring round-trip fidelity with UnmarshalJSON for provider communication. -func (tc ToolCall) MarshalJSON() ([]byte, error) { - return json.Marshal(struct { - ID string `json:"id"` - Type string `json:"type"` - Function struct { - Name string `json:"name"` - Arguments string `json:"arguments"` - } `json:"function"` - }{ - ID: tc.ID, - Type: "function", - Function: struct { - Name string `json:"name"` - Arguments string `json:"arguments"` - }{ - Name: tc.Name, - Arguments: tc.Arguments, - }, - }) +type ToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function ToolFunction `json:"function"` } -// UnmarshalJSON handles both the nested LLM API format ({function: {name, arguments}}) -// and the flat kernel format ({name, arguments}). This allows provider responses to -// decode directly into the canonical ToolCall type. -func (tc *ToolCall) UnmarshalJSON(data []byte) error { - var nested struct { - ID string `json:"id"` - Function struct { - Name string `json:"name"` - Arguments string `json:"arguments"` - } `json:"function"` - } - if err := json.Unmarshal(data, &nested); err != nil { - return err - } - - if nested.Function.Name != "" { - tc.ID = nested.ID - tc.Name = nested.Function.Name - tc.Arguments = nested.Function.Arguments - return nil +func NewToolCall(id, name, arguments string) ToolCall { + return ToolCall{ + ID: id, + Type: "function", + Function: ToolFunction{ + Name: name, + Arguments: arguments, + }, } - - type plain ToolCall - return json.Unmarshal(data, (*plain)(tc)) } // Message represents a single message in a conversation. diff --git a/core/protocol/protocol_test.go b/core/protocol/protocol_test.go index 55051fc..7d710c3 100644 --- a/core/protocol/protocol_test.go +++ b/core/protocol/protocol_test.go @@ -164,8 +164,8 @@ func TestRole_Constants(t *testing.T) { func TestMessage_ToolCallFields(t *testing.T) { toolCalls := []protocol.ToolCall{ - {ID: "call_1", Name: "get_weather", Arguments: `{"city":"NYC"}`}, - {ID: "call_2", Name: "get_time", Arguments: `{"tz":"UTC"}`}, + protocol.NewToolCall("call_1", "get_weather", `{"city":"NYC"}`), + protocol.NewToolCall("call_2", "get_time", `{"tz":"UTC"}`), } msg := protocol.Message{ @@ -176,8 +176,8 @@ func TestMessage_ToolCallFields(t *testing.T) { if len(msg.ToolCalls) != 2 { t.Fatalf("got %d tool calls, want 2", len(msg.ToolCalls)) } - if msg.ToolCalls[0].Name != "get_weather" { - t.Errorf("got name %q, want %q", msg.ToolCalls[0].Name, "get_weather") + if msg.ToolCalls[0].Function.Name != "get_weather" { + t.Errorf("got name %q, want %q", msg.ToolCalls[0].Function.Name, "get_weather") } if msg.ToolCalls[1].ID != "call_2" { t.Errorf("got id %q, want %q", msg.ToolCalls[1].ID, "call_2") @@ -220,7 +220,7 @@ func TestMessage_JSON_OmitsEmptyToolFields(t *testing.T) { func TestMessage_JSON_IncludesToolFields(t *testing.T) { msg := protocol.Message{ Role: protocol.RoleAssistant, - ToolCalls: []protocol.ToolCall{{ID: "call_1", Name: "fn", Arguments: "{}"}}, + ToolCalls: []protocol.ToolCall{protocol.NewToolCall("call_1", "fn", "{}")}, } data, err := json.Marshal(msg) @@ -238,7 +238,7 @@ func TestMessage_JSON_IncludesToolFields(t *testing.T) { } } -func TestToolCall_UnmarshalJSON_NestedFormat(t *testing.T) { +func TestToolCall_Unmarshal_NativeFormat(t *testing.T) { data := `{ "id": "call_123", "type": "function", @@ -250,44 +250,24 @@ func TestToolCall_UnmarshalJSON_NestedFormat(t *testing.T) { var tc protocol.ToolCall if err := json.Unmarshal([]byte(data), &tc); err != nil { - t.Fatalf("UnmarshalJSON failed: %v", err) + t.Fatalf("Unmarshal failed: %v", err) } if tc.ID != "call_123" { t.Errorf("got ID %q, want %q", tc.ID, "call_123") } - if tc.Name != "get_weather" { - t.Errorf("got Name %q, want %q", tc.Name, "get_weather") + if tc.Type != "function" { + t.Errorf("got Type %q, want %q", tc.Type, "function") } - if tc.Arguments != `{"location":"Boston"}` { - t.Errorf("got Arguments %q, want %q", tc.Arguments, `{"location":"Boston"}`) + if tc.Function.Name != "get_weather" { + t.Errorf("got Function.Name %q, want %q", tc.Function.Name, "get_weather") } -} - -func TestToolCall_UnmarshalJSON_FlatFormat(t *testing.T) { - data := `{ - "id": "call_456", - "name": "search", - "arguments": "{\"query\":\"test\"}" - }` - - var tc protocol.ToolCall - if err := json.Unmarshal([]byte(data), &tc); err != nil { - t.Fatalf("UnmarshalJSON failed: %v", err) - } - - if tc.ID != "call_456" { - t.Errorf("got ID %q, want %q", tc.ID, "call_456") - } - if tc.Name != "search" { - t.Errorf("got Name %q, want %q", tc.Name, "search") - } - if tc.Arguments != `{"query":"test"}` { - t.Errorf("got Arguments %q, want %q", tc.Arguments, `{"query":"test"}`) + if tc.Function.Arguments != `{"location":"Boston"}` { + t.Errorf("got Function.Arguments %q, want %q", tc.Function.Arguments, `{"location":"Boston"}`) } } -func TestToolCall_UnmarshalJSON_InvalidJSON(t *testing.T) { +func TestToolCall_Unmarshal_InvalidJSON(t *testing.T) { var tc protocol.ToolCall err := json.Unmarshal([]byte(`{invalid}`), &tc) if err == nil { @@ -295,18 +275,18 @@ func TestToolCall_UnmarshalJSON_InvalidJSON(t *testing.T) { } } -func TestToolCall_UnmarshalJSON_EmptyObject(t *testing.T) { +func TestToolCall_Unmarshal_EmptyObject(t *testing.T) { var tc protocol.ToolCall if err := json.Unmarshal([]byte(`{}`), &tc); err != nil { - t.Fatalf("UnmarshalJSON failed: %v", err) + t.Fatalf("Unmarshal failed: %v", err) } - if tc.ID != "" || tc.Name != "" || tc.Arguments != "" { + if tc.ID != "" || tc.Type != "" || tc.Function.Name != "" || tc.Function.Arguments != "" { t.Errorf("expected empty ToolCall, got %+v", tc) } } -func TestToolCall_UnmarshalJSON_InArray(t *testing.T) { +func TestToolCall_Unmarshal_InArray(t *testing.T) { data := `[ { "id": "call_1", @@ -318,38 +298,37 @@ func TestToolCall_UnmarshalJSON_InArray(t *testing.T) { }, { "id": "call_2", - "name": "fn_b", - "arguments": "{\"x\":1}" + "type": "function", + "function": { + "name": "fn_b", + "arguments": "{\"x\":1}" + } } ]` var calls []protocol.ToolCall if err := json.Unmarshal([]byte(data), &calls); err != nil { - t.Fatalf("UnmarshalJSON failed: %v", err) + t.Fatalf("Unmarshal failed: %v", err) } if len(calls) != 2 { t.Fatalf("got %d calls, want 2", len(calls)) } - if calls[0].Name != "fn_a" { - t.Errorf("call[0] Name = %q, want %q", calls[0].Name, "fn_a") + if calls[0].Function.Name != "fn_a" { + t.Errorf("call[0] Function.Name = %q, want %q", calls[0].Function.Name, "fn_a") } - if calls[1].Name != "fn_b" { - t.Errorf("call[1] Name = %q, want %q", calls[1].Name, "fn_b") + if calls[1].Function.Name != "fn_b" { + t.Errorf("call[1] Function.Name = %q, want %q", calls[1].Function.Name, "fn_b") } } -func TestToolCall_MarshalJSON_NestedFormat(t *testing.T) { - tc := protocol.ToolCall{ - ID: "call_789", - Name: "get_weather", - Arguments: `{"location":"Boston"}`, - } +func TestToolCall_Marshal_NativeFormat(t *testing.T) { + tc := protocol.NewToolCall("call_789", "get_weather", `{"location":"Boston"}`) data, err := json.Marshal(tc) if err != nil { - t.Fatalf("MarshalJSON failed: %v", err) + t.Fatalf("Marshal failed: %v", err) } var raw map[string]any @@ -374,41 +353,49 @@ func TestToolCall_MarshalJSON_NestedFormat(t *testing.T) { if fn["arguments"] != `{"location":"Boston"}` { t.Errorf("got function.arguments %v, want %q", fn["arguments"], `{"location":"Boston"}`) } - - // Verify flat fields are NOT present at top level - if _, exists := raw["name"]; exists { - t.Error("name should not be at top level in nested format") - } - if _, exists := raw["arguments"]; exists { - t.Error("arguments should not be at top level in nested format") - } } -func TestToolCall_MarshalJSON_RoundTrip(t *testing.T) { - original := protocol.ToolCall{ - ID: "call_rt", - Name: "search", - Arguments: `{"query":"test","limit":10}`, - } +func TestToolCall_Marshal_RoundTrip(t *testing.T) { + original := protocol.NewToolCall("call_rt", "search", `{"query":"test","limit":10}`) data, err := json.Marshal(original) if err != nil { - t.Fatalf("MarshalJSON failed: %v", err) + t.Fatalf("Marshal failed: %v", err) } var restored protocol.ToolCall if err := json.Unmarshal(data, &restored); err != nil { - t.Fatalf("UnmarshalJSON failed: %v", err) + t.Fatalf("Unmarshal failed: %v", err) } if restored.ID != original.ID { t.Errorf("ID: got %q, want %q", restored.ID, original.ID) } - if restored.Name != original.Name { - t.Errorf("Name: got %q, want %q", restored.Name, original.Name) + if restored.Type != original.Type { + t.Errorf("Type: got %q, want %q", restored.Type, original.Type) + } + if restored.Function.Name != original.Function.Name { + t.Errorf("Function.Name: got %q, want %q", restored.Function.Name, original.Function.Name) + } + if restored.Function.Arguments != original.Function.Arguments { + t.Errorf("Function.Arguments: got %q, want %q", restored.Function.Arguments, original.Function.Arguments) + } +} + +func TestNewToolCall(t *testing.T) { + tc := protocol.NewToolCall("call_1", "get_weather", `{"city":"NYC"}`) + + if tc.ID != "call_1" { + t.Errorf("got ID %q, want %q", tc.ID, "call_1") + } + if tc.Type != "function" { + t.Errorf("got Type %q, want %q", tc.Type, "function") + } + if tc.Function.Name != "get_weather" { + t.Errorf("got Function.Name %q, want %q", tc.Function.Name, "get_weather") } - if restored.Arguments != original.Arguments { - t.Errorf("Arguments: got %q, want %q", restored.Arguments, original.Arguments) + if tc.Function.Arguments != `{"city":"NYC"}` { + t.Errorf("got Function.Arguments %q, want %q", tc.Function.Arguments, `{"city":"NYC"}`) } } diff --git a/core/response/response_test.go b/core/response/response_test.go index 291b4a9..af4c3e1 100644 --- a/core/response/response_test.go +++ b/core/response/response_test.go @@ -170,6 +170,122 @@ func TestStreamingChunk_Unmarshal(t *testing.T) { } } +func TestStreamingChunk_ToolCalls(t *testing.T) { + jsonData := `{ + "model": "gpt-4", + "choices": [{ + "index": 0, + "delta": { + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"location\":" + } + }] + }, + "finish_reason": null + }] + }` + + var chunk response.StreamingChunk + if err := json.Unmarshal([]byte(jsonData), &chunk); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + toolCalls := chunk.ToolCalls() + if len(toolCalls) != 1 { + t.Fatalf("got %d tool calls, want 1", len(toolCalls)) + } + + if toolCalls[0].ID != "call_1" { + t.Errorf("got ID %q, want %q", toolCalls[0].ID, "call_1") + } + if toolCalls[0].Function.Name != "get_weather" { + t.Errorf("got Function.Name %q, want %q", toolCalls[0].Function.Name, "get_weather") + } + if toolCalls[0].Function.Arguments != `{"location":` { + t.Errorf("got Function.Arguments %q, want %q", toolCalls[0].Function.Arguments, `{"location":`) + } +} + +func TestStreamingChunk_ToolCalls_EmptyChoices(t *testing.T) { + chunk := response.StreamingChunk{} + if toolCalls := chunk.ToolCalls(); toolCalls != nil { + t.Errorf("got %v, want nil", toolCalls) + } +} + +func TestStreamingChunk_ToolCalls_ContinuationChunk(t *testing.T) { + // Continuation chunks carry arguments without id/name + jsonData := `{ + "model": "gpt-4", + "choices": [{ + "index": 0, + "delta": { + "tool_calls": [{ + "id": "", + "type": "function", + "function": { + "name": "", + "arguments": "\"Boston\"}" + } + }] + }, + "finish_reason": null + }] + }` + + var chunk response.StreamingChunk + if err := json.Unmarshal([]byte(jsonData), &chunk); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + toolCalls := chunk.ToolCalls() + if len(toolCalls) != 1 { + t.Fatalf("got %d tool calls, want 1", len(toolCalls)) + } + + if toolCalls[0].Function.Arguments != `"Boston"}` { + t.Errorf("got Function.Arguments %q, want %q", toolCalls[0].Function.Arguments, `"Boston"}`) + } +} + +func TestParseToolsStreamChunk(t *testing.T) { + jsonData := []byte(`{ + "model": "gpt-4", + "choices": [{ + "index": 0, + "delta": { + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": { + "name": "search", + "arguments": "{}" + } + }] + }, + "finish_reason": null + }] + }`) + + chunk, err := response.ParseToolsStreamChunk(jsonData) + if err != nil { + t.Fatalf("ParseToolsStreamChunk failed: %v", err) + } + + toolCalls := chunk.ToolCalls() + if len(toolCalls) != 1 { + t.Fatalf("got %d tool calls, want 1", len(toolCalls)) + } + + if toolCalls[0].Function.Name != "search" { + t.Errorf("got Function.Name %q, want %q", toolCalls[0].Function.Name, "search") + } +} + func TestEmbeddingsResponse_Unmarshal(t *testing.T) { jsonData := `{ "object": "list", @@ -258,8 +374,8 @@ func TestToolsResponse_Unmarshal(t *testing.T) { t.Errorf("got tool call ID %q, want %q", toolCall.ID, "call_123") } - if toolCall.Name != "get_weather" { - t.Errorf("got function name %q, want %q", toolCall.Name, "get_weather") + if toolCall.Function.Name != "get_weather" { + t.Errorf("got function name %q, want %q", toolCall.Function.Name, "get_weather") } } diff --git a/core/response/streaming.go b/core/response/streaming.go index 90a8bf0..e0bf62f 100644 --- a/core/response/streaming.go +++ b/core/response/streaming.go @@ -3,6 +3,8 @@ package response import ( "encoding/json" "fmt" + + "github.com/tailored-agentic-units/kernel/core/protocol" ) // StreamingChunk represents a single chunk from a streaming protocol response. @@ -16,8 +18,9 @@ type StreamingChunk struct { Choices []struct { Index int `json:"index"` Delta struct { - Role string `json:"role,omitempty"` - Content string `json:"content,omitempty"` + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ToolCalls []protocol.ToolCall `json:"tool_calls,omitempty"` } `json:"delta"` FinishReason *string `json:"finish_reason"` } `json:"choices"` @@ -33,6 +36,13 @@ func (c *StreamingChunk) Content() string { return "" } +func (c *StreamingChunk) ToolCalls() []protocol.ToolCall { + if len(c.Choices) > 0 { + return c.Choices[0].Delta.ToolCalls + } + return nil +} + // ParseChatStreamChunk parses a streaming chat chunk from JSON bytes. func ParseChatStreamChunk(data []byte) (*StreamingChunk, error) { var chunk StreamingChunk @@ -49,7 +59,7 @@ func ParseVisionStreamChunk(data []byte) (*StreamingChunk, error) { } // ParseToolsStreamChunk parses a streaming tools chunk from JSON bytes. -// Tools protocol uses the same streaming format as chat. +// Tools streaming chunks include tool call deltas in the Delta field. func ParseToolsStreamChunk(data []byte) (*StreamingChunk, error) { var chunk StreamingChunk if err := json.Unmarshal(data, &chunk); err != nil { diff --git a/kernel/kernel.go b/kernel/kernel.go index 61f8272..ce70f65 100644 --- a/kernel/kernel.go +++ b/kernel/kernel.go @@ -29,12 +29,9 @@ type Result struct { ToolCalls []ToolCallRecord // Log of all tool invocations. } -// ToolCallRecord captures a single tool invocation within the loop. type ToolCallRecord struct { + protocol.ToolCall Iteration int // Loop cycle in which the call occurred. - ID string // Provider-assigned call identifier. - Name string // Tool name. - Arguments string // JSON-encoded arguments. Result string // Tool execution output. IsError bool // Whether execution returned an error. } @@ -191,19 +188,17 @@ func (k *Kernel) Run(ctx context.Context, prompt string) (*Result, error) { }) for _, tc := range choice.Message.ToolCalls { - k.log.Debug("tool call", "iteration", iteration+1, "name", tc.Name) + k.log.Debug("tool call", "iteration", iteration+1, "name", tc.Function.Name) record := ToolCallRecord{ + ToolCall: tc, Iteration: iteration + 1, - ID: tc.ID, - Name: tc.Name, - Arguments: tc.Arguments, } toolResult, toolErr := k.tools.Execute( ctx, - tc.Name, - json.RawMessage(tc.Arguments), + tc.Function.Name, + json.RawMessage(tc.Function.Arguments), ) if toolErr != nil { diff --git a/kernel/kernel_test.go b/kernel/kernel_test.go index f65e156..57ef3de 100644 --- a/kernel/kernel_test.go +++ b/kernel/kernel_test.go @@ -183,7 +183,7 @@ func TestRun_SingleToolCall(t *testing.T) { agent := newSequentialAgent( []*response.ToolsResponse{ makeToolsResponse([]protocol.ToolCall{ - {ID: "call_1", Name: "greet", Arguments: `{"name":"world"}`}, + protocol.NewToolCall("call_1", "greet", `{"name":"world"}`), }), makeFinalResponse("Done: hello world"), }, @@ -224,8 +224,8 @@ func TestRun_SingleToolCall(t *testing.T) { } tc := result.ToolCalls[0] - if tc.Name != "greet" { - t.Errorf("got tool name %q, want %q", tc.Name, "greet") + if tc.Function.Name != "greet" { + t.Errorf("got tool name %q, want %q", tc.Function.Name, "greet") } if tc.Result != "hello world" { t.Errorf("got tool result %q, want %q", tc.Result, "hello world") @@ -239,8 +239,8 @@ func TestRun_MultipleToolCalls(t *testing.T) { agent := newSequentialAgent( []*response.ToolsResponse{ makeToolsResponse([]protocol.ToolCall{ - {ID: "call_1", Name: "add", Arguments: `{"a":1,"b":2}`}, - {ID: "call_2", Name: "add", Arguments: `{"a":3,"b":4}`}, + protocol.NewToolCall("call_1", "add", `{"a":1,"b":2}`), + protocol.NewToolCall("call_2", "add", `{"a":3,"b":4}`), }), makeFinalResponse("3 and 7"), }, @@ -278,7 +278,7 @@ func TestRun_ToolExecutionError(t *testing.T) { agent := newSequentialAgent( []*response.ToolsResponse{ makeToolsResponse([]protocol.ToolCall{ - {ID: "call_1", Name: "fail", Arguments: `{}`}, + protocol.NewToolCall("call_1", "fail", `{}`), }), makeFinalResponse("I handled the error"), }, @@ -325,7 +325,7 @@ func TestRun_ToolExecutionError(t *testing.T) { func TestRun_MaxIterations(t *testing.T) { // Agent always returns tool calls, never a final response infiniteToolCall := makeToolsResponse([]protocol.ToolCall{ - {ID: "call_loop", Name: "loop", Arguments: `{}`}, + protocol.NewToolCall("call_loop", "loop", `{}`), }) responses := make([]*response.ToolsResponse, 5) @@ -371,7 +371,7 @@ func TestRun_ContextCancellation(t *testing.T) { agent := newSequentialAgent( []*response.ToolsResponse{ makeToolsResponse([]protocol.ToolCall{ - {ID: "call_1", Name: "slow", Arguments: `{}`}, + protocol.NewToolCall("call_1", "slow", `{}`), }), }, nil, @@ -636,7 +636,7 @@ func TestRun_ToolCallRecordFields(t *testing.T) { agent := newSequentialAgent( []*response.ToolsResponse{ makeToolsResponse([]protocol.ToolCall{ - {ID: "call_abc", Name: "mytool", Arguments: `{"x":1}`}, + protocol.NewToolCall("call_abc", "mytool", `{"x":1}`), }), makeFinalResponse("done"), }, @@ -674,11 +674,11 @@ func TestRun_ToolCallRecordFields(t *testing.T) { if tc.ID != "call_abc" { t.Errorf("got ID %q, want %q", tc.ID, "call_abc") } - if tc.Name != "mytool" { - t.Errorf("got name %q, want %q", tc.Name, "mytool") + if tc.Function.Name != "mytool" { + t.Errorf("got name %q, want %q", tc.Function.Name, "mytool") } - if tc.Arguments != `{"x":1}` { - t.Errorf("got arguments %q, want %q", tc.Arguments, `{"x":1}`) + if tc.Function.Arguments != `{"x":1}` { + t.Errorf("got arguments %q, want %q", tc.Function.Arguments, `{"x":1}`) } if tc.Result != "result_value" { t.Errorf("got result %q, want %q", tc.Result, "result_value") @@ -694,13 +694,13 @@ func TestRun_UnlimitedIterations(t *testing.T) { agent := newSequentialAgent( []*response.ToolsResponse{ makeToolsResponse([]protocol.ToolCall{ - {ID: "call_1", Name: "step", Arguments: `{}`}, + protocol.NewToolCall("call_1", "step", `{}`), }), makeToolsResponse([]protocol.ToolCall{ - {ID: "call_2", Name: "step", Arguments: `{}`}, + protocol.NewToolCall("call_2", "step", `{}`), }), makeToolsResponse([]protocol.ToolCall{ - {ID: "call_3", Name: "step", Arguments: `{}`}, + protocol.NewToolCall("call_3", "step", `{}`), }), makeFinalResponse("finished after 4 iterations"), }, diff --git a/session/session_test.go b/session/session_test.go index 6fa6518..2ea74a9 100644 --- a/session/session_test.go +++ b/session/session_test.go @@ -43,7 +43,7 @@ func TestSession_AddMessage_And_Messages(t *testing.T) { s := session.NewMemorySession() toolCalls := []protocol.ToolCall{ - {ID: "call_1", Name: "get_weather", Arguments: `{"city":"NYC"}`}, + protocol.NewToolCall("call_1", "get_weather", `{"city":"NYC"}`), } msg := protocol.Message{ @@ -69,8 +69,8 @@ func TestSession_AddMessage_And_Messages(t *testing.T) { if len(got.ToolCalls) != 1 { t.Fatalf("got %d tool calls, want 1", len(got.ToolCalls)) } - if got.ToolCalls[0].Name != "get_weather" { - t.Errorf("got tool call name %q, want %q", got.ToolCalls[0].Name, "get_weather") + if got.ToolCalls[0].Function.Name != "get_weather" { + t.Errorf("got tool call name %q, want %q", got.ToolCalls[0].Function.Name, "get_weather") } } @@ -134,7 +134,7 @@ func TestSession_Messages_ToolCalls(t *testing.T) { s.AddMessage(protocol.Message{ Role: protocol.RoleAssistant, ToolCalls: []protocol.ToolCall{ - {ID: "call_1", Name: "get_weather", Arguments: `{"city":"NYC"}`}, + protocol.NewToolCall("call_1", "get_weather", `{"city":"NYC"}`), }, }) @@ -184,20 +184,20 @@ func TestSession_Messages_ToolCalls_DefensiveCopy(t *testing.T) { s.AddMessage(protocol.Message{ Role: protocol.RoleAssistant, ToolCalls: []protocol.ToolCall{ - {ID: "call_1", Name: "original", Arguments: "{}"}, + protocol.NewToolCall("call_1", "original", "{}"), }, }) msgs := s.Messages() - msgs[0].ToolCalls[0].Name = "tampered" - msgs[0].ToolCalls = append(msgs[0].ToolCalls, protocol.ToolCall{ID: "call_2", Name: "extra"}) + msgs[0].ToolCalls[0].Function.Name = "tampered" + msgs[0].ToolCalls = append(msgs[0].ToolCalls, protocol.NewToolCall("call_2", "extra", "")) original := s.Messages() if len(original[0].ToolCalls) != 1 { t.Fatalf("got %d tool calls, want 1", len(original[0].ToolCalls)) } - if original[0].ToolCalls[0].Name != "original" { - t.Errorf("tool call name was mutated: got %q, want %q", original[0].ToolCalls[0].Name, "original") + if original[0].ToolCalls[0].Function.Name != "original" { + t.Errorf("tool call name was mutated: got %q, want %q", original[0].ToolCalls[0].Function.Name, "original") } } From 72016e52f379722a06610f6f1b30c8ac3354f63f Mon Sep 17 00:00:00 2001 From: Jaime Still Date: Tue, 17 Feb 2026 08:00:04 -0500 Subject: [PATCH 2/2] =?UTF-8?q?update=20objective=20tracking:=20#23=20?= =?UTF-8?q?=E2=86=92=20PR=20#30?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _project/objective.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_project/objective.md b/_project/objective.md index 294704e..116f96b 100644 --- a/_project/objective.md +++ b/_project/objective.md @@ -11,7 +11,7 @@ Establish the kernel's HTTP interface — the sole extensibility boundary throug | # | Title | Status | |---|-------|--------| -| 23 | Streaming tools protocol | Open | +| 23 | Streaming tools protocol | PR #30 | | 24 | Agent registry | Open | | 25 | Kernel observer | Open | | 26 | Multi-session kernel | Open |