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 <noreply@anthropic.com>
This commit is contained in:
parent
127f428620
commit
54e4f44635
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
Reference in New Issue