From 54e4f446359ac5259f59fdc5b7f66da438a91030 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Tue, 31 Mar 2026 22:53:40 +0200 Subject: [PATCH] Add agent package: observe/decide/act/update/terminate loop Implements the core agent loop with two state backends: - Session: ephemeral in-memory, lives for one process lifetime - BeadState: bead-backed via 9beads tool scripts (claim/read/complete) Loop is backend-agnostic; takes a ToolExecutor and emits OutputMsgs. MaxSteps defaults to 1 for simple prompts; callers set higher for tasks. Co-Authored-By: Claude Sonnet 4.6 --- agent/bead.go | 109 ++++++++++++++++++++++++++++++++++++++++ agent/loop.go | 128 +++++++++++++++++++++++++++++++++++++++++++++++ agent/session.go | 43 ++++++++++++++++ agent/state.go | 32 ++++++++++++ 4 files changed, 312 insertions(+) create mode 100644 agent/bead.go create mode 100644 agent/loop.go create mode 100644 agent/session.go create mode 100644 agent/state.go diff --git a/agent/bead.go b/agent/bead.go new file mode 100644 index 0000000..56ec1d5 --- /dev/null +++ b/agent/bead.go @@ -0,0 +1,109 @@ +package agent + +import ( + "encoding/json" + "fmt" + "strings" + + "ollie/backend" +) + +// BeadRef identifies a bead by its mount and ID. +type BeadRef struct { + Mount string // e.g. "pcloudcc-lneely" + ID string // e.g. "bd-abc" +} + +// beadJSON is the wire format returned by read_bead.sh --property json. +type beadJSON struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + Assignee string `json:"assignee"` + Priority int `json:"priority"` + Blockers []string `json:"blockers,omitempty"` + Labels []string `json:"labels,omitempty"` + CloseReason string `json:"close_reason,omitempty"` +} + +// ScriptRunner executes a named tool script with the given arguments, +// returning its stdout. Implemented by the caller using ollie/exec. +type ScriptRunner func(tool string, args []string) (string, error) + +// BeadState is a bead-backed State implementation. +// The bead provides the goal (title + description) and tracks status. +// Conversation history is kept in-memory for the duration of the session. +type BeadState struct { + ref BeadRef + run ScriptRunner + bead beadJSON + history []backend.Message + complete bool +} + +// LoadBead reads a bead from 9beads and returns a BeadState ready for use. +// It claims the bead before returning. +func LoadBead(ref BeadRef, run ScriptRunner) (*BeadState, error) { + out, err := run("read_bead.sh", []string{ + "--mount", ref.Mount, + "--id", ref.ID, + "--property", "json", + }) + if err != nil { + return nil, fmt.Errorf("read bead %s/%s: %w", ref.Mount, ref.ID, err) + } + + var b beadJSON + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &b); err != nil { + return nil, fmt.Errorf("parse bead JSON: %w", err) + } + + if _, err := run("claim_bead.sh", []string{ + "--mount", ref.Mount, + "--id", ref.ID, + }); err != nil { + return nil, fmt.Errorf("claim bead %s/%s: %w", ref.Mount, ref.ID, err) + } + + goal := b.Title + if b.Description != "" { + goal = b.Title + "\n\n" + b.Description + } + + return &BeadState{ + ref: ref, + run: run, + bead: b, + history: []backend.Message{ + {Role: "user", Content: goal}, + }, + }, nil +} + +func (s *BeadState) Goal() string { return s.history[0].Content } +func (s *BeadState) History() []backend.Message { return s.history } +func (s *BeadState) IsComplete() bool { return s.complete } + +func (s *BeadState) Update(assistant backend.Message, results []ToolResult) error { + s.history = append(s.history, assistant) + for _, r := range results { + s.history = append(s.history, backend.Message{ + Role: "tool", + Content: r.Content, + ToolCallID: r.ToolCallID, + }) + } + return nil +} + +func (s *BeadState) MarkComplete() error { + if _, err := s.run("complete_bead.sh", []string{ + "--mount", s.ref.Mount, + "--id", s.ref.ID, + }); err != nil { + return fmt.Errorf("complete bead %s/%s: %w", s.ref.Mount, s.ref.ID, err) + } + s.complete = true + return nil +} diff --git a/agent/loop.go b/agent/loop.go new file mode 100644 index 0000000..ff3bdde --- /dev/null +++ b/agent/loop.go @@ -0,0 +1,128 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + + "ollie/backend" +) + +// ToolExecutor runs a named tool with the given JSON arguments and returns +// its output. Implemented by the caller using ollie/exec. +type ToolExecutor func(name string, args json.RawMessage) (string, error) + +// OutputMsg is emitted by the loop for each visible event: assistant replies, +// tool calls, tool results, and errors. +type OutputMsg struct { + Role string // "assistant" | "tool" | "error" + Name string // tool name, for Role=="tool" + Content string +} + +// Config holds everything the loop needs to run. +type Config struct { + Backend backend.Backend + Model string + Tools []backend.Tool + Exec ToolExecutor + MaxSteps int // 0 → default 1 for sessions, caller sets higher for beads + Output func(msg OutputMsg) // nil → discard +} + +// Loop implements observe → decide → act → update → terminate. +type Loop struct { + cfg Config +} + +// New creates a Loop from the given Config. +func New(cfg Config) *Loop { + return &Loop{cfg: cfg} +} + +// Run executes the agent loop against state until the goal is complete or +// MaxSteps is exhausted. +func (l *Loop) Run(ctx context.Context, state State) error { + maxSteps := l.cfg.MaxSteps + if maxSteps <= 0 { + maxSteps = 1 + } + + for step := range maxSteps { + // 1. Observe: history is the context; already up to date in state. + + // 2. Decide: call the model. + resp, err := l.cfg.Backend.Chat(ctx, l.cfg.Model, state.History(), l.cfg.Tools) + if err != nil { + return fmt.Errorf("step %d decide: %w", step, err) + } + + // 3. Act: execute any tool calls. + var results []ToolResult + for _, tc := range resp.Message.ToolCalls { + 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}) + } + + if len(resp.Message.ToolCalls) == 0 && resp.Message.Content != "" { + l.emit(OutputMsg{Role: "assistant", Content: resp.Message.Content}) + } + + // 4. Update: append assistant message and tool results to state. + if err := state.Update(resp.Message, results); err != nil { + return fmt.Errorf("step %d update: %w", step, err) + } + + // 5. Terminate? + if l.shouldStop(resp, step, maxSteps) { + if resp.StopReason == "stop" { + if err := state.MarkComplete(); err != nil { + return fmt.Errorf("mark complete: %w", err) + } + } + break + } + } + + return nil +} + +func (l *Loop) shouldStop(resp *backend.Response, step, maxSteps int) bool { + // Always stop at maxSteps. + if step >= maxSteps-1 { + return true + } + // Stop when the model is done and has no pending tool calls. + if resp.StopReason == "stop" && len(resp.Message.ToolCalls) == 0 { + return true + } + return false +} + +func (l *Loop) emit(msg OutputMsg) { + if l.cfg.Output != nil { + l.cfg.Output(msg) + } +} diff --git a/agent/session.go b/agent/session.go new file mode 100644 index 0000000..97112b4 --- /dev/null +++ b/agent/session.go @@ -0,0 +1,43 @@ +package agent + +import "ollie/backend" + +// Session is an ephemeral in-memory state backend. +// It lives only for the duration of the process; nothing is persisted. +type Session struct { + goal string + history []backend.Message + complete bool +} + +// NewSession creates a Session with the goal already placed as the first +// user message in history, so the loop can call backend.Chat immediately. +func NewSession(goal string) *Session { + return &Session{ + goal: goal, + history: []backend.Message{ + {Role: "user", Content: goal}, + }, + } +} + +func (s *Session) Goal() string { return s.goal } +func (s *Session) History() []backend.Message { return s.history } +func (s *Session) IsComplete() bool { return s.complete } + +func (s *Session) Update(assistant backend.Message, results []ToolResult) error { + s.history = append(s.history, assistant) + for _, r := range results { + s.history = append(s.history, backend.Message{ + Role: "tool", + Content: r.Content, + ToolCallID: r.ToolCallID, + }) + } + return nil +} + +func (s *Session) MarkComplete() error { + s.complete = true + return nil +} diff --git a/agent/state.go b/agent/state.go new file mode 100644 index 0000000..7b5a647 --- /dev/null +++ b/agent/state.go @@ -0,0 +1,32 @@ +package agent + +import "ollie/backend" + +// ToolResult holds the output of a single tool call. +type ToolResult struct { + ToolCallID string // may be empty (Ollama does not always set this) + Name string + Content string + IsError bool +} + +// State is the interface both ephemeral and bead-backed state must satisfy. +// The loop reads from it on Observe, writes to it on Update. +type State interface { + // Goal returns the prompt or task description driving this session. + Goal() string + + // History returns the full conversation history for the current session. + // The first entry is always the initial user message containing the goal. + History() []backend.Message + + // Update appends the assistant's reply and any tool results to the history. + // Called once per loop iteration after Act completes. + Update(assistant backend.Message, results []ToolResult) error + + // MarkComplete records that the goal has been achieved. + MarkComplete() error + + // IsComplete returns true once MarkComplete has been called. + IsComplete() bool +}