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
208 changes: 208 additions & 0 deletions .claude/context/guides/.archive/23-streaming-tools-protocol.md
Original file line number Diff line number Diff line change
@@ -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
62 changes: 62 additions & 0 deletions .claude/context/sessions/23-streaming-tools-protocol.md
Original file line number Diff line number Diff line change
@@ -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.
111 changes: 111 additions & 0 deletions .claude/plans/drifting-meandering-hartmanis.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion _project/objective.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading