This repository has been archived on 2026-08-16. You can view files and clone it, but cannot push or open issues or pull requests.
ollie-core/agent/loop.go

201 lines
4.5 KiB
Go

package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"ollie/backend"
)
type ToolExecutor func(name string, args json.RawMessage) (string, error)
type OutputFn func(msg OutputMsg)
type OutputMsg struct {
Role string
Name string
Content string
}
type Config struct {
Backend backend.Backend
Model string
Tools []backend.Tool
Exec ToolExecutor
MaxSteps int
Output OutputFn
SystemPrompt string
}
type Loop struct {
cfg Config
lastUsage backend.Usage
streamed bool
skippedCalls map[string]bool
}
func New(cfg Config) *Loop {
return &Loop{cfg: cfg, skippedCalls: make(map[string]bool)}
}
func (l *Loop) Run(ctx context.Context, state State) error {
maxSteps := l.cfg.MaxSteps
if maxSteps <= 0 {
maxSteps = 1
}
sb, streaming := l.cfg.Backend.(backend.StreamingBackend)
for step := range maxSteps {
l.streamed = false
history := state.History()
if l.cfg.SystemPrompt != "" {
history = append([]backend.Message{{Role: "system", Content: l.cfg.SystemPrompt}}, history...)
}
var resp *backend.Response
if streaming {
msg, err := l.runStreamStep(ctx, sb, l.cfg.Model, history, l.cfg.Tools)
if err != nil {
return fmt.Errorf("step %d decide: %w", step, err)
}
resp = &backend.Response{
Message: msg,
StopReason: decideStopReason(msg),
Usage: l.lastUsage,
}
} 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.
var results []ToolResult
for _, tc := range resp.Message.ToolCalls {
if !l.skippedCalls[tc.Name] {
l.emit(OutputMsg{Role: "call", Name: tc.Name, Content: string(tc.Arguments)})
}
var content string
var isErr bool
if l.cfg.Exec != nil {
out, err := l.cfg.Exec(tc.Name, tc.Arguments)
if err != nil {
content = fmt.Sprintf("error: %v", err)
isErr = true
} else {
content = out
}
} else {
content = "error: no tool executor configured"
isErr = true
}
results = append(results, ToolResult{
ToolCallID: tc.ID,
Name: tc.Name,
Content: content,
IsError: isErr,
})
l.emit(OutputMsg{Role: "tool", Name: tc.Name, Content: content})
}
// Only emit the full assistant text if we did NOT stream it.
if !l.streamed && resp.Message.Content != "" {
l.emit(OutputMsg{Role: "assistant", Content: resp.Message.Content})
}
// Emit usage only when there are actual token counts (skips
// intermediate steps and avoids [↑0 ↓0 tokens] noise).
if resp.Usage.InputTokens > 0 || resp.Usage.OutputTokens > 0 {
l.emit(OutputMsg{
Role: "usage",
Content: fmt.Sprintf("↑%d ↓%d tokens", resp.Usage.InputTokens, resp.Usage.OutputTokens),
})
}
if err := state.Update(resp.Message, results); err != nil {
return fmt.Errorf("step %d update: %w", step, err)
}
if l.shouldStop(resp, step, maxSteps) {
if resp.StopReason == "stop" {
if err := state.MarkComplete(); err != nil {
return fmt.Errorf("mark complete: %w", err)
}
}
break
}
}
l.skippedCalls = make(map[string]bool)
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 {
l.skippedCalls[tc.Name] = true
accumulatedTcs = append(accumulatedTcs, tc)
}
if ev.Done {
l.lastUsage = ev.Usage
msg.Content = content.String()
msg.Role = "assistant"
msg.ToolCalls = accumulatedTcs
return msg, nil
}
}
l.emit(OutputMsg{Role: "error", Content: "stream ended without done event"})
msg.Content = content.String()
msg.Role = "assistant"
return msg, nil
}
func decideStopReason(m backend.Message) string {
if len(m.ToolCalls) > 0 {
return "tool_calls"
}
return "stop"
}
func (l *Loop) emit(msg OutputMsg) {
if l.cfg.Output != nil {
l.cfg.Output(msg)
}
}
func (l *Loop) shouldStop(resp *backend.Response, step, maxSteps int) bool {
return resp.StopReason == "stop" || step >= maxSteps-1
}