agent,backend: drop non-streaming path

Both backends already implement streaming; the non-streaming fallback
was dead code that added complexity.

- Backend interface now requires only ChatStream; the separate
  StreamingBackend interface and Chat method are removed
- Both OpenAIBackend and OllamaBackend lose their Chat methods and the
  stream=bool parameter on their internal doChat helpers
- Loop.Run is simplified: one streaming path, no streamed flag, no
  skippedCalls map, no shouldStop helper, no runStreamStep indirection
- "call" events are now emitted solely in the act phase, not in the
  stream phase, so dedup tracking is no longer needed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-04-03 20:11:10 +02:00
parent f804001d37
commit 474eb84e2d
4 changed files with 78 additions and 292 deletions

View File

@ -30,14 +30,11 @@ type Config struct {
} }
type Loop struct { type Loop struct {
cfg Config cfg Config
lastUsage backend.Usage
streamed bool
skippedCalls map[string]bool
} }
func New(cfg Config) *Loop { func New(cfg Config) *Loop {
return &Loop{cfg: cfg, skippedCalls: make(map[string]bool)} return &Loop{cfg: cfg}
} }
func (l *Loop) Run(ctx context.Context, state State) error { func (l *Loop) Run(ctx context.Context, state State) error {
@ -46,91 +43,83 @@ func (l *Loop) Run(ctx context.Context, state State) error {
maxSteps = 1 maxSteps = 1
} }
sb, streaming := l.cfg.Backend.(backend.StreamingBackend)
for step := range maxSteps { for step := range maxSteps {
l.streamed = false
history := state.History() history := state.History()
if l.cfg.SystemPrompt != "" { if l.cfg.SystemPrompt != "" {
history = append([]backend.Message{{Role: "system", Content: l.cfg.SystemPrompt}}, history...) history = append([]backend.Message{{Role: "system", Content: l.cfg.SystemPrompt}}, history...)
} }
var resp *backend.Response // Stream the assistant's response.
ch, err := l.cfg.Backend.ChatStream(ctx, l.cfg.Model, history, l.cfg.Tools)
if err != nil {
return fmt.Errorf("step %d: %w", step, err)
}
if streaming { var content strings.Builder
msg, err := l.runStreamStep(ctx, sb, l.cfg.Model, history, l.cfg.Tools) var toolCalls []backend.ToolCall
if err != nil { var usage backend.Usage
return fmt.Errorf("step %d decide: %w", step, err) var done bool
for ev := range ch {
if ev.Content != "" {
content.WriteString(ev.Content)
l.emit(OutputMsg{Role: "assistant", Content: ev.Content})
} }
resp = &backend.Response{ toolCalls = append(toolCalls, ev.ToolCalls...)
Message: msg, if ev.Done {
StopReason: decideStopReason(msg), usage = ev.Usage
Usage: l.lastUsage, done = true
} break
} else {
var err error
resp, err = l.cfg.Backend.Chat(ctx, l.cfg.Model, history, l.cfg.Tools)
if err != nil {
return fmt.Errorf("step %d decide: %w", step, err)
} }
} }
// Act: execute tool calls. if !done {
return fmt.Errorf("step %d: stream ended without done event", step)
}
// Announce and execute tool calls.
msg := backend.Message{Role: "assistant", Content: content.String(), ToolCalls: toolCalls}
var results []ToolResult var results []ToolResult
for _, tc := range resp.Message.ToolCalls {
// In the non-streaming path, emit the call announcement here.
// In the streaming path this was already emitted inside
// runStreamStep (once all arguments were accumulated), so skip it.
if !l.skippedCalls[tc.Name] {
l.emit(OutputMsg{Role: "call", Name: tc.Name, Content: string(tc.Arguments)})
}
var content string for _, tc := range toolCalls {
l.emit(OutputMsg{Role: "call", Name: tc.Name, Content: string(tc.Arguments)})
var result string
var isErr bool var isErr bool
if l.cfg.Exec != nil { if l.cfg.Exec != nil {
out, err := l.cfg.Exec(tc.Name, tc.Arguments) out, err := l.cfg.Exec(tc.Name, tc.Arguments)
if err != nil { if err != nil {
content = fmt.Sprintf("error: %v", err) result = fmt.Sprintf("error: %v", err)
isErr = true isErr = true
} else { } else {
content = out result = out
} }
} else { } else {
content = "error: no tool executor configured" result = "error: no tool executor configured"
isErr = true isErr = true
} }
results = append(results, ToolResult{ results = append(results, ToolResult{
ToolCallID: tc.ID, ToolCallID: tc.ID,
Name: tc.Name, Name: tc.Name,
Content: content, Content: result,
IsError: isErr, IsError: isErr,
}) })
l.emit(OutputMsg{Role: "tool", Name: tc.Name, Content: result})
l.emit(OutputMsg{Role: "tool", Name: tc.Name, Content: content})
} }
// Only emit the full assistant text if we did NOT stream it. // Emit usage when we have real token counts.
if !l.streamed && resp.Message.Content != "" { if usage.InputTokens > 0 || usage.OutputTokens > 0 {
l.emit(OutputMsg{Role: "assistant", Content: resp.Message.Content}) l.emit(OutputMsg{Role: "usage", Usage: usage})
} }
// Emit usage only when there are actual token counts (skips if err := state.Update(msg, results); err != nil {
// intermediate steps and avoids [↑0 ↓0 tokens] noise).
if resp.Usage.InputTokens > 0 || resp.Usage.OutputTokens > 0 {
l.emit(OutputMsg{
Role: "usage",
Usage: resp.Usage,
})
}
if err := state.Update(resp.Message, results); err != nil {
return fmt.Errorf("step %d update: %w", step, err) return fmt.Errorf("step %d update: %w", step, err)
} }
if l.shouldStop(resp, step, maxSteps) { // Stop when the model has nothing more to call.
if resp.StopReason == "stop" { if len(toolCalls) == 0 || step >= maxSteps-1 {
if len(toolCalls) == 0 {
if err := state.MarkComplete(); err != nil { if err := state.MarkComplete(); err != nil {
return fmt.Errorf("mark complete: %w", err) return fmt.Errorf("mark complete: %w", err)
} }
@ -139,76 +128,11 @@ func (l *Loop) Run(ctx context.Context, state State) error {
} }
} }
l.skippedCalls = make(map[string]bool)
return nil return nil
} }
func (l *Loop) runStreamStep(
ctx context.Context,
sb backend.StreamingBackend,
model string,
messages []backend.Message,
tools []backend.Tool,
) (msg backend.Message, err error) {
ch, err := sb.ChatStream(ctx, model, messages, tools)
if err != nil {
return msg, err
}
l.skippedCalls = make(map[string]bool)
l.streamed = true
var content strings.Builder
var accumulatedTcs []backend.ToolCall
for ev := range ch {
if ev.Content != "" {
content.WriteString(ev.Content)
l.emit(OutputMsg{Role: "assistant", Content: ev.Content})
}
for _, tc := range ev.ToolCalls {
accumulatedTcs = append(accumulatedTcs, tc)
}
if ev.Done {
l.lastUsage = ev.Usage
msg.Content = content.String()
msg.Role = "assistant"
msg.ToolCalls = accumulatedTcs
// Emit 'call' now that arguments are fully accumulated, and
// mark each as skipped so the Act loop does not re-emit it.
for _, tc := range accumulatedTcs {
l.emit(OutputMsg{Role: "call", Name: tc.Name, Content: string(tc.Arguments)})
l.skippedCalls[tc.Name] = true
}
return msg, nil
}
}
// Stream closed without a done event — treat as a transient error so the
// caller does not save partial/corrupt state. Any tool calls that arrived
// before the drop are attached to the message for diagnostic use, but
// because we return a non-nil error the outer Run loop will NOT call
// state.Update, keeping the session history clean for a retry.
msg.Content = content.String()
msg.Role = "assistant"
msg.ToolCalls = accumulatedTcs
return msg, fmt.Errorf("stream ended without done event")
}
func decideStopReason(m backend.Message) string {
if len(m.ToolCalls) > 0 {
return "tool_calls"
}
return "stop"
}
func (l *Loop) emit(msg OutputMsg) { func (l *Loop) emit(msg OutputMsg) {
if l.cfg.Output != nil { if l.cfg.Output != nil {
l.cfg.Output(msg) l.cfg.Output(msg)
} }
} }
func (l *Loop) shouldStop(resp *backend.Response, step, maxSteps int) bool {
return resp.StopReason == "stop" || step >= maxSteps-1
}

View File

@ -10,9 +10,9 @@ import (
// Message is a single conversation turn. // Message is a single conversation turn.
type Message struct { type Message struct {
Role string `json:"role"` // "system" | "user" | "assistant" | "tool" Role string `json:"role"` // "system" | "user" | "assistant" | "tool"
Content string `json:"content"` Content string `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set by assistant when calling tools ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set by assistant when calling tools
ToolCallID string `json:"tool_call_id,omitempty"` // set on role=tool replies (required by OpenAI) ToolCallID string `json:"tool_call_id,omitempty"` // set on role=tool replies (required by OpenAI)
} }
@ -36,36 +36,21 @@ type Usage struct {
OutputTokens int OutputTokens int
} }
// Response is the model's reply for one Chat call. // StreamEvent is a single increment from a streaming chat call.
type Response struct {
Message Message
StopReason string // "stop" | "tool_calls" | "length" | ...
Usage Usage
}
// StreamEvent is a single increment from a streaming Chat call.
// Content is an incremental text delta (append, not replace). // Content is an incremental text delta (append, not replace).
// ToolCalls are complete calls ready for execution when non-empty. // ToolCalls accumulates complete calls; they may arrive on any event.
// The final event has Done==true and the fully assembled Message. // The final event has Done==true.
type StreamEvent struct { type StreamEvent struct {
Content string // incremental text delta (may be "") Content string // incremental text delta (may be "")
ToolCalls []ToolCall // complete tool calls, if any assembled this tick ToolCalls []ToolCall // complete tool calls assembled so far
Done bool Done bool
StopReason string // meaningful when Done==true StopReason string // meaningful when Done==true
Usage Usage // meaningful only when Done==true Usage Usage // meaningful only when Done==true
} }
// Backend is the interface all LLM providers must implement. // Backend is the interface all LLM providers must implement.
// Streaming is the only supported mode; backends that wrap blocking APIs
// should implement ChatStream as a single-event stream.
type Backend interface { type Backend interface {
// Chat sends messages to the model and returns its response.
// tools may be nil for plain completion requests.
Chat(ctx context.Context, model string, messages []Message, tools []Tool) (*Response, error)
}
// StreamingBackend is an optional interface; backends that support streaming
// should implement it. Use a type assertion to check.
type StreamingBackend interface {
// ChatStream returns a channel of incremental events. The channel is
// closed after the final Done event.
ChatStream(ctx context.Context, model string, messages []Message, tools []Tool) (<-chan StreamEvent, error) ChatStream(ctx context.Context, model string, messages []Message, tools []Tool) (<-chan StreamEvent, error)
} }

View File

@ -68,7 +68,7 @@ type ollamaChatResponse struct {
// -- implementation -- // -- implementation --
func (b *OllamaBackend) doChat(ctx context.Context, model string, messages []Message, tools []Tool, stream bool) (*http.Response, error) { func (b *OllamaBackend) ChatStream(ctx context.Context, model string, messages []Message, tools []Tool) (<-chan StreamEvent, error) {
wireMessages := make([]ollamaMessage, len(messages)) wireMessages := make([]ollamaMessage, len(messages))
for i, m := range messages { for i, m := range messages {
wireMessages[i] = ollamaMessage{Role: m.Role, Content: m.Content} wireMessages[i] = ollamaMessage{Role: m.Role, Content: m.Content}
@ -91,14 +91,12 @@ func (b *OllamaBackend) doChat(ctx context.Context, model string, messages []Mes
}) })
} }
req := ollamaChatRequest{ data, err := json.Marshal(ollamaChatRequest{
Model: model, Model: model,
Messages: wireMessages, Messages: wireMessages,
Tools: wireTools, Tools: wireTools,
Stream: stream, Stream: true,
} })
data, err := json.Marshal(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -113,57 +111,12 @@ func (b *OllamaBackend) doChat(ctx context.Context, model string, messages []Mes
if err != nil { if err != nil {
return nil, err return nil, err
} }
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
resp.Body.Close() resp.Body.Close()
return nil, fmt.Errorf("ollama HTTP %d: %s", resp.StatusCode, body) return nil, fmt.Errorf("ollama HTTP %d: %s", resp.StatusCode, body)
} }
return resp, nil
}
func (b *OllamaBackend) Chat(ctx context.Context, model string, messages []Message, tools []Tool) (*Response, error) {
resp, err := b.doChat(ctx, model, messages, tools, false)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var wire ollamaChatResponse
if err := json.NewDecoder(resp.Body).Decode(&wire); err != nil {
return nil, err
}
msg := Message{Role: wire.Message.Role, Content: wire.Message.Content}
for _, tc := range wire.Message.ToolCalls {
msg.ToolCalls = append(msg.ToolCalls, ToolCall{
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
})
}
stopReason := "stop"
if wire.DoneReason != "" {
stopReason = wire.DoneReason
}
if len(msg.ToolCalls) > 0 {
stopReason = "tool_calls"
}
return &Response{
Message: msg,
StopReason: stopReason,
Usage: Usage{InputTokens: wire.PromptEvalCount, OutputTokens: wire.EvalCount},
}, nil
}
func (b *OllamaBackend) ChatStream(ctx context.Context, model string, messages []Message, tools []Tool) (<-chan StreamEvent, error) {
resp, err := b.doChat(ctx, model, messages, tools, true)
if err != nil {
return nil, err
}
ch := make(chan StreamEvent, 8) ch := make(chan StreamEvent, 8)
go func() { go func() {
@ -198,22 +151,18 @@ func (b *OllamaBackend) ChatStream(ctx context.Context, model string, messages [
return return
} }
event := StreamEvent{} ev := StreamEvent{}
if wire.Message.Content != "" { if wire.Message.Content != "" {
event.Content = wire.Message.Content ev.Content = wire.Message.Content
} }
if len(wire.Message.ToolCalls) > 0 { for _, tc := range wire.Message.ToolCalls {
for _, tc := range wire.Message.ToolCalls { ev.ToolCalls = append(ev.ToolCalls, ToolCall{
event.ToolCalls = append(event.ToolCalls, ToolCall{ Name: tc.Function.Name,
Name: tc.Function.Name, Arguments: tc.Function.Arguments,
Arguments: tc.Function.Arguments, })
})
}
// Ollama puts tool calls alongside content in the same delta.
// Emit one event with both content and tool calls.
} }
if event.Content != "" || len(event.ToolCalls) > 0 { if ev.Content != "" || len(ev.ToolCalls) > 0 {
ch <- event ch <- ev
} }
} }
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {

View File

@ -63,10 +63,10 @@ type openAIStreamOptions struct {
} }
type openAIChatRequest struct { type openAIChatRequest struct {
Model string `json:"model"` Model string `json:"model"`
Messages []openAIMessage `json:"messages"` Messages []openAIMessage `json:"messages"`
Tools []openAITool `json:"tools,omitempty"` Tools []openAITool `json:"tools,omitempty"`
Stream bool `json:"stream"` Stream bool `json:"stream"`
StreamOptions *openAIStreamOptions `json:"stream_options,omitempty"` StreamOptions *openAIStreamOptions `json:"stream_options,omitempty"`
} }
@ -75,23 +75,12 @@ type openAIUsage struct {
CompletionTokens int `json:"completion_tokens"` CompletionTokens int `json:"completion_tokens"`
} }
type openAIChatResponse struct {
Choices []openAIChoice `json:"choices"`
Usage openAIUsage `json:"usage"`
}
type openAIDelta struct { type openAIDelta struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
ToolCalls []openAIToolCall `json:"tool_calls,omitempty"` ToolCalls []openAIToolCall `json:"tool_calls,omitempty"`
} }
type openAIChoice struct {
Delta openAIDelta `json:"delta,omitempty"`
Message openAIMessage `json:"message,omitempty"`
FinishReason string `json:"finish_reason"`
}
type openAIStreamChoice struct { type openAIStreamChoice struct {
Index int `json:"index"` Index int `json:"index"`
Delta openAIDelta `json:"delta"` Delta openAIDelta `json:"delta"`
@ -105,7 +94,7 @@ type openAIStreamResponse struct {
// -- implementation -- // -- implementation --
func (b *OpenAIBackend) doChat(ctx context.Context, model string, messages []Message, tools []Tool, stream bool) (*http.Response, error) { func (b *OpenAIBackend) ChatStream(ctx context.Context, model string, messages []Message, tools []Tool) (<-chan StreamEvent, error) {
wireMessages := make([]openAIMessage, len(messages)) wireMessages := make([]openAIMessage, len(messages))
for i, m := range messages { for i, m := range messages {
wireMessages[i] = openAIMessage{ wireMessages[i] = openAIMessage{
@ -138,16 +127,11 @@ func (b *OpenAIBackend) doChat(ctx context.Context, model string, messages []Mes
} }
req := openAIChatRequest{ req := openAIChatRequest{
Model: model, Model: model,
Messages: wireMessages, Messages: wireMessages,
Tools: wireTools, Tools: wireTools,
Stream: stream, Stream: true,
} StreamOptions: &openAIStreamOptions{IncludeUsage: true},
// Ask OpenAI to include token usage in the stream. Without this flag
// the API omits usage entirely from SSE chunks.
if stream {
req.StreamOptions = &openAIStreamOptions{IncludeUsage: true}
} }
data, err := json.Marshal(req) data, err := json.Marshal(req)
@ -168,56 +152,12 @@ func (b *OpenAIBackend) doChat(ctx context.Context, model string, messages []Mes
if err != nil { if err != nil {
return nil, err return nil, err
} }
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
resp.Body.Close() resp.Body.Close()
return nil, fmt.Errorf("openai HTTP %d: %s", resp.StatusCode, body) return nil, fmt.Errorf("openai HTTP %d: %s", resp.StatusCode, body)
} }
return resp, nil
}
func (b *OpenAIBackend) Chat(ctx context.Context, model string, messages []Message, tools []Tool) (*Response, error) {
resp, err := b.doChat(ctx, model, messages, tools, false)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var wire openAIChatResponse
if err := json.NewDecoder(resp.Body).Decode(&wire); err != nil {
return nil, err
}
if len(wire.Choices) == 0 {
return nil, fmt.Errorf("openai: empty choices in response")
}
choice := wire.Choices[0]
msg := Message{Role: choice.Message.Role, Content: choice.Message.Content}
for _, tc := range choice.Message.ToolCalls {
args := json.RawMessage(tc.Function.Arguments)
msg.ToolCalls = append(msg.ToolCalls, ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: args,
})
}
return &Response{
Message: msg,
StopReason: choice.FinishReason,
Usage: Usage{InputTokens: wire.Usage.PromptTokens, OutputTokens: wire.Usage.CompletionTokens},
}, nil
}
func (b *OpenAIBackend) ChatStream(ctx context.Context, model string, messages []Message, tools []Tool) (<-chan StreamEvent, error) {
resp, err := b.doChat(ctx, model, messages, tools, true)
if err != nil {
return nil, err
}
ch := make(chan StreamEvent, 8) ch := make(chan StreamEvent, 8)
go func() { go func() {
@ -233,8 +173,7 @@ func (b *OpenAIBackend) ChatStream(ctx context.Context, model string, messages [
} }
accum := make(map[int]*tcAccum) accum := make(map[int]*tcAccum)
// finishReason and accumulated usage are tracked separately because // finishReason and usage arrive in separate trailing chunks.
// OpenAI sends usage in a trailing chunk *after* the finish_reason chunk.
var finishReason string var finishReason string
var usage Usage var usage Usage
@ -244,8 +183,6 @@ func (b *OpenAIBackend) ChatStream(ctx context.Context, model string, messages [
continue continue
} }
if line == "data: [DONE]" { if line == "data: [DONE]" {
// Stream finished. Emit the Done event with whatever usage
// we have accumulated (may arrive before or after this sentinel).
ev := StreamEvent{ ev := StreamEvent{
Done: true, Done: true,
StopReason: finishReason, StopReason: finishReason,
@ -264,16 +201,13 @@ func (b *OpenAIBackend) ChatStream(ctx context.Context, model string, messages [
if !strings.HasPrefix(line, "data: ") { if !strings.HasPrefix(line, "data: ") {
continue continue
} }
payload := line[6:] // strip "data: "
var wire openAIStreamResponse var wire openAIStreamResponse
if err := json.Unmarshal([]byte(payload), &wire); err != nil { if err := json.Unmarshal([]byte(line[6:]), &wire); err != nil {
ch <- StreamEvent{Done: true, StopReason: fmt.Sprintf("stream decode: %v", err)} ch <- StreamEvent{Done: true, StopReason: fmt.Sprintf("stream decode: %v", err)}
return return
} }
// Accumulate usage from every chunk; it will be non-zero only on
// the trailing usage chunk that OpenAI sends after finish_reason.
if wire.Usage.PromptTokens > 0 { if wire.Usage.PromptTokens > 0 {
usage.InputTokens = wire.Usage.PromptTokens usage.InputTokens = wire.Usage.PromptTokens
} }
@ -282,23 +216,17 @@ func (b *OpenAIBackend) ChatStream(ctx context.Context, model string, messages [
} }
if len(wire.Choices) == 0 { if len(wire.Choices) == 0 {
// Choices-less chunk (e.g. the trailing usage-only chunk).
continue continue
} }
choice := wire.Choices[0] choice := wire.Choices[0]
// Record finish reason when it arrives.
if choice.FinishReason != "" { if choice.FinishReason != "" {
finishReason = choice.FinishReason finishReason = choice.FinishReason
} }
// Text content delta.
if choice.Delta.Content != "" { if choice.Delta.Content != "" {
ch <- StreamEvent{Content: choice.Delta.Content} ch <- StreamEvent{Content: choice.Delta.Content}
} }
// Tool call fragments — accumulate, do not emit yet.
for _, dtc := range choice.Delta.ToolCalls { for _, dtc := range choice.Delta.ToolCalls {
idx := dtc.Index idx := dtc.Index
if _, ok := accum[idx]; !ok { if _, ok := accum[idx]; !ok {