ollie/cmd/olliesrv/internal/agent/agent.go

730 lines
21 KiB
Go

package agent
import (
"context"
"fmt"
"os"
"runtime"
"slices"
"sync"
"sync/atomic"
"ollie/cmd/olliesrv/internal/backend"
lib9p "ollie/lib9p/client"
olog "ollie/log"
toolsrvclient "ollie/toolsrv/client"
"ollie/util"
)
// Agent holds the state of the current Agent entity (the "agent"
// in the traditional sense). It is swappable: when the user runs /agent,
// a new Agent is built from the new agent config while the session
// host remains stable.
type Agent struct {
history *History
runtime *Runtime
name string // display name (defaults to id)
profile string // config profile name (e.g. "default")
agentsDir string
systemPrompt string // system prompt for /agent reloads
envBlock string // environment block for /agent reloads
newToolServer func() *toolsrvclient.Conn
newBackend func(string) (backend.Backend, error)
currentAction atomic.Pointer[actionHandle]
warnedContext bool
resultCache sync.Map
// Execution state — owned by the agent, protected by stateMu.
state string // "idle", "thinking", "calling: <tool>"
reply string // last assistant response
cwd string // working directory for tool execution
id string // agent identity (unique principal)
parentID string // immutable ID of the agent that spawned this agent
fifo Fifo // prompt queue
Feed Feed // streaming input gate (change-detecting)
toolCallCount atomic.Int64
pendingInject atomic.Pointer[string]
submitMu sync.Mutex // serializes Submit calls (commands + turns)
stateMu sync.RWMutex
signalMu sync.Mutex
signalCh chan struct{} // closed on state change; replaced with a fresh channel
// Event handler — receives events from this agent.
output EventHandler
log *olog.Logger
sessionID string // the owning session's ID
startupMessages []string
save func() // trigger debounced persistence
flush func() // immediately flush persistence
onStateChange func(agentID, state string) // optional callback for state changes
// Chat log — the rendered output stream, exposed via 9P.
chatMu sync.RWMutex
chatLog []byte
chatVers uint32
chatCond *sync.Cond
chatSignalMu sync.Mutex
chatSignalCh chan struct{}
plan []byte
}
// Backend returns the active backend from the runtime.
func (ag *Agent) Backend() backend.Backend {
return ag.runtime.Backend
}
// Name returns the agent's display name.
func (ag *Agent) Name() string { return ag.name }
// Profile returns the config profile name (e.g. "default").
func (ag *Agent) Profile() string { return ag.profile }
// Messages returns the conversation history messages.
func (ag *Agent) Messages() []backend.Message {
if ag.history == nil {
return nil
}
return ag.history.history()
}
// UsageStats holds token usage and cost information.
type UsageStats struct {
TotalInputTokens int
TotalCachedInputTokens int
TotalCacheCreationTokens int
TotalOutputTokens int
TotalRequests int
Estimated bool
LastTurnCostUSD float64
SessionCostUSD float64
CacheHitRatio float64
}
// Usage returns the agent's usage statistics.
func (ag *Agent) Usage() *UsageStats {
if ag.history == nil {
return nil
}
return &UsageStats{
TotalInputTokens: ag.history.TotalInputTokens,
TotalCachedInputTokens: ag.history.TotalCachedInputTokens,
TotalCacheCreationTokens: ag.history.TotalCacheCreationTokens,
TotalOutputTokens: ag.history.TotalOutputTokens,
TotalRequests: ag.history.TotalRequests,
Estimated: ag.history.Estimated,
LastTurnCostUSD: ag.history.LastTurnCostUSD,
SessionCostUSD: ag.history.SessionCostUSD,
CacheHitRatio: ag.history.cacheHitRatio(),
}
}
// SetName changes the agent's display name without reloading config.
func (ag *Agent) SetName(name string) {
ag.stateMu.Lock()
ag.name = name
ag.stateMu.Unlock()
}
// ID returns the agent's unique identity.
func (ag *Agent) ID() string { return ag.id }
// ParentID returns the immutable ID of the agent that spawned this agent.
func (ag *Agent) ParentID() string { return ag.parentID }
// --- Chat log methods ---
// AppendChat appends data to the chat log and notifies stream readers.
func (ag *Agent) AppendChat(data []byte) {
if len(data) == 0 {
return
}
ag.chatMu.Lock()
ag.chatLog = append(ag.chatLog, data...)
ag.chatVers++
ag.chatMu.Unlock()
ag.chatCond.Broadcast()
ag.chatSignalMu.Lock()
close(ag.chatSignalCh)
ag.chatSignalCh = make(chan struct{})
ag.chatSignalMu.Unlock()
}
// EnsureTrailingNewline appends a newline if the log doesn't end with one.
func (ag *Agent) EnsureTrailingNewline() {
ag.chatMu.Lock()
if len(ag.chatLog) > 0 && ag.chatLog[len(ag.chatLog)-1] != '\n' {
ag.chatLog = append(ag.chatLog, '\n')
}
ag.chatVers++
ag.chatMu.Unlock()
}
// ChatMu returns the chat log mutex for external locking (streaming).
func (ag *Agent) ChatMu() *sync.RWMutex { return &ag.chatMu }
// ChatCond returns the condvar for blocking chat readers.
func (ag *Agent) ChatCond() *sync.Cond { return ag.chatCond }
// ChatSignal returns the current chat signal channel (closed on new chat data).
func (ag *Agent) ChatSignal() <-chan struct{} {
ag.chatSignalMu.Lock()
ch := ag.chatSignalCh
ag.chatSignalMu.Unlock()
return ch
}
// ChatLog returns the raw chat log bytes (caller must hold ChatMu.RLock).
func (ag *Agent) ChatLog() []byte { return ag.chatLog }
// ChatRead returns new chat data since the given offset (base).
// Returns (data, nextBase, error). If no new data, returns empty data.
func (ag *Agent) ChatRead(base string) ([]byte, string, error) {
var offset int
if base != "" {
fmt.Sscanf(base, "%d", &offset)
} else {
ag.chatMu.RLock()
offset = len(ag.chatLog)
ag.chatMu.RUnlock()
}
ag.chatMu.RLock()
log := ag.chatLog
if len(log) <= offset {
ag.chatMu.RUnlock()
return nil, fmt.Sprintf("%d", offset), nil
}
data := make([]byte, len(log)-offset)
copy(data, log[offset:])
newOffset := len(log)
ag.chatMu.RUnlock()
return data, fmt.Sprintf("%d", newOffset), nil
}
// Plan returns a copy of the plan.
func (ag *Agent) Plan() []byte {
ag.chatMu.RLock()
p := make([]byte, len(ag.plan))
copy(p, ag.plan)
ag.chatMu.RUnlock()
return p
}
// SetPlan replaces the plan.
func (ag *Agent) SetPlan(data []byte) {
ag.chatMu.Lock()
ag.plan = make([]byte, len(data))
copy(ag.plan, data)
ag.chatMu.Unlock()
}
// BackendName returns the name of the active backend.
func (ag *Agent) BackendName() string {
if ag.runtime.Backend == nil {
return ""
}
return ag.runtime.Backend.Name()
}
// ModelName returns the name of the active model.
func (ag *Agent) ModelName() string {
if ag.runtime.Backend == nil {
return ""
}
return ag.runtime.Backend.Model()
}
// State returns the agent's current execution state.
func (ag *Agent) State() string {
ag.stateMu.RLock()
s := ag.state
ag.stateMu.RUnlock()
return s
}
// SetState sets the agent's execution state and notifies waiters.
func (ag *Agent) SetState(state string) {
ag.stateMu.Lock()
ag.state = state
ag.stateMu.Unlock()
ag.notifyChange()
if ag.onStateChange != nil {
ag.onStateChange(ag.id, state)
}
}
// Reply returns the agent's last assistant response.
func (ag *Agent) Reply() string {
ag.stateMu.RLock()
r := ag.reply
ag.stateMu.RUnlock()
return r
}
// SetOnStateChange sets a callback invoked whenever agent state changes.
func (ag *Agent) SetOnStateChange(fn func(agentID, state string)) {
ag.onStateChange = fn
}
// SetReply sets the agent's last response.
func (ag *Agent) setReply(reply string) {
ag.stateMu.Lock()
ag.reply = reply
ag.stateMu.Unlock()
}
// notifyChange wakes all goroutines waiting on state changes.
func (ag *Agent) notifyChange() {
ag.signalMu.Lock()
close(ag.signalCh)
ag.signalCh = make(chan struct{})
ag.signalMu.Unlock()
}
// SignalCh returns the current signal channel (closed on any change).
func (ag *Agent) SignalCh() <-chan struct{} {
ag.signalMu.Lock()
ch := ag.signalCh
ag.signalMu.Unlock()
return ch
}
// WaitChange blocks until the agent's state differs from current.
// Returns the new value and true, or ("", false) if ctx is cancelled.
func (ag *Agent) WaitChange(ctx context.Context, field, current string) (string, bool) {
for {
// Snapshot the signal channel before reading state.
ag.signalMu.Lock()
ch := ag.signalCh
ag.signalMu.Unlock()
var val string
switch field {
case WatchState:
val = ag.State()
case WatchFeed:
val = ag.Feed.Hash()
if val == "" {
val = current // no data yet — block
}
default:
return "", false
}
if val != current {
return val, true
}
// Wait for either a state change or context cancellation.
select {
case <-ch:
// Changed — loop to re-check.
case <-ctx.Done():
return "", false
}
}
}
// emit sends an event to the agent's output handler.
func (ag *Agent) emit(ev Event) {
if ag.output != nil {
ag.output(ev)
}
}
// SetToolServer updates the tool server connection and factory.
// Used when resuming a paused session that was restored without infra.
func (ag *Agent) SetToolServer(newToolServer func() *toolsrvclient.Conn, conn *toolsrvclient.Conn) {
ag.newToolServer = newToolServer
if ag.runtime != nil {
ag.runtime.ToolServer = conn
}
}
// CWD returns the agent's working directory.
func (ag *Agent) Cwd() string {
ag.stateMu.RLock()
c := ag.cwd
ag.stateMu.RUnlock()
return c
}
// effectiveCwd returns the agent's cwd, falling back to os.Getwd() if empty.
func (ag *Agent) effectiveCwd() string {
if c := ag.Cwd(); c != "" {
return c
}
wd, _ := os.Getwd()
return wd
}
// IsRunning returns true if the agent has an active turn in progress.
func (ag *Agent) IsRunning() bool {
return ag.currentAction.Load() != nil
}
// Interrupt cancels the current in-progress agent turn.
// Returns true if an action was running and was cancelled.
func (ag *Agent) Interrupt(cause error) bool {
if h := ag.currentAction.Load(); h != nil {
h.cancel(cause)
return true
}
return false
}
// Compact runs manual context compaction. Returns an error if the agent is
// running or compaction fails.
func (ag *Agent) Compact(ctx context.Context) error {
if ag.IsRunning() {
return fmt.Errorf("cannot compact while agent is running")
}
if ag.history == nil {
return nil
}
ag.SetState("compacting")
_, err := ag.runCompact(ctx, "manual")
ag.SetState("idle")
if err != nil {
return err
}
ag.save()
return nil
}
// Clear resets the agent history. Returns an error if the agent is running.
func (ag *Agent) Clear() error {
if ag.IsRunning() {
return fmt.Errorf("cannot clear while agent is running")
}
ag.history = nil
return nil
}
// SwitchProfile loads a new agent profile by name, replacing the runtime
// and clearing history. Returns an error if the agent is running or the
// profile cannot be loaded.
func (ag *Agent) SwitchProfile(name string) error {
if ag.IsRunning() {
return fmt.Errorf("cannot switch agent while agent is running")
}
cfgPath := AgentConfigPath(ag.agentsDir, name)
f, err := os.Open(cfgPath)
if err != nil {
return fmt.Errorf("agent %q: %w", name, err)
}
cfg, err := Load(f)
f.Close()
if err != nil {
return fmt.Errorf("agent %q: %w", name, err)
}
disp := ag.newToolServer()
if disp != nil && ag.id != "" {
disp.SetAgentID(ag.id)
}
env := []string{"OLLIE_SESSION_ID=" + ag.sessionID, "OLLIE_UNAME=" + ag.id}
rt := BuildRuntime(cfg, disp, ag.cwd, env, ag.systemPrompt, ag.envBlock)
if cfg.Backend != "" {
newBe, err := ag.newBackend(cfg.Backend)
if err != nil {
return fmt.Errorf("backend %q: %w", cfg.Backend, err)
}
if cfg.Model != "" {
newBe.SetModel(cfg.Model)
}
rt.Backend = newBe
} else {
rt.Backend = ag.runtime.Backend
if cfg.Model != "" {
rt.Backend.SetModel(cfg.Model)
}
}
ag.runtime = rt
ag.profile = name
ag.history = nil
ag.save()
ag.flush()
ag.notifyChange()
return nil
}
// Event is a typed output event emitted during an agent turn or in response
// to a command.
// Event carries a single piece of output from the agent loop to consumers
// (frontends, loggers, the turn orchestrator in turn.go).
//
// The Role field determines the event semantics:
//
// Role Name Content Emitted by
// ──────────── ───────── ────────────────────────── ──────────
// "user" — user input text turn.go (before run)
// "assistant" — streamed LLM text chunk loop.go (streamResponse)
// "reasoning" — <think>…</think> chunks loop.go (streamResponse)
// "call" tool name JSON args loop.go (execOne)
// "tool" tool name result text (may stream) loop.go (execOne)
// "usage" — "in out est cost cached creation" loop.go (streamResponse)
// "state" — "thinking"|"compacting"|… loop.go / turn.go
// "limitretry" — — loop.go (rate limit hit)
// "retry" — "HH:MM:SS" countdown loop.go (retryCountdown)
// "maxsteps" — step count as string loop.go (budget exhausted)
// "error" — error message turn.go / loop.go
// "info" — informational text turn.go (compaction, cost)
//
// turn.go intercepts events before forwarding to the external handler:
// - "assistant" → accumulates reply text
// - "call" → updates agent state to "calling: <name>"
// - "tool" → logs result
// - "state" → calls ag.SetState()
// - "limitretry"→ sets state to "limitretry"
// - "usage" → parses token counts, updates history
// - "error" → logs
type Event struct {
Role string
Name string
Content string
ResponseID string
OutputFormat string
}
// EventHandler receives events from the agent.
type EventHandler func(Event)
// actionHandle holds the cancel function for the current agent turn.
type actionHandle struct {
cancel context.CancelCauseFunc
}
// WatchField names supported by Agent.WaitChange.
const (
WatchState = "state"
WatchFeed = "feed"
)
// SetSessionEnv injects session env vars into the execute server.
func (ag *Agent) SetSessionEnv(sessionID string) {
if ag.runtime == nil || ag.runtime.ToolServer == nil {
return
}
ag.runtime.ToolServer.SetEnv("OLLIE_SESSION_ID", sessionID)
if ag.id != "" {
ag.runtime.ToolServer.SetAgentID(ag.id)
}
if u := os.Getenv("USER"); u != "" {
ag.runtime.ToolServer.SetEnv("USER", u)
}
}
// SetEnv stores an environment variable on the execute server.
func (ag *Agent) SetEnv(key, value string) {
ag.runtime.ToolServer.SetEnv(key, value)
}
// Close releases agent resources (dispatcher, execute server).
func (ag *Agent) Close() {
if ag.runtime.ToolServer != nil {
ag.runtime.ToolServer.Close()
}
}
// SetCWD updates the agent's working directory, preamble environment section, and dispatcher.
func (ag *Agent) SetCWD(dir string) {
ag.stateMu.Lock()
ag.cwd = dir
ag.stateMu.Unlock()
isGitRepo := util.IsGitRepo(dir)
ag.envBlock = EnvironmentBlock(dir, runtime.GOOS, isGitRepo, "")
ag.runtime.Preamble.Set(SectionEnv, ag.envBlock)
if ag.runtime.ToolServer != nil {
ag.runtime.ToolServer.SetCWD(dir)
}
ag.notifyChange()
}
// RefreshTools forces a refresh of the tool listing in the agent's preamble.
func (ag *Agent) RefreshTools() {
if infos, err := ag.runtime.ToolServer.ListTools(); err == nil {
ag.runtime.Preamble.Set(SectionTools, RenderTools(infos))
}
}
// SetToolsPreamble updates the tools section of the preamble.
func (ag *Agent) SetToolsPreamble(content string) {
ag.runtime.Preamble.Set(SectionTools, content)
}
// CtxSz returns a human-readable context size string.
func (ag *Agent) CtxSz() string {
if ag.history == nil {
return "no active session"
}
ctxLen := ag.runtime.Backend.ContextLength(context.Background())
if ctxLen <= 0 {
ctxLen = defaultContextLength
}
estimated := ag.history.estimateTokens()
pct := estimated * 100 / ctxLen
return fmt.Sprintf("%d / %d (%d%%)", estimated, ctxLen, pct)
}
// CostStr returns formatted cost information.
func (ag *Agent) CostStr() string {
if ag.history == nil {
return "no active session"
}
return fmt.Sprintf("costLast=$%.4f\ncostSession=$%.4f\n",
ag.history.LastTurnCostUSD, ag.history.SessionCostUSD)
}
// UsageStr returns formatted usage information.
func (ag *Agent) UsageStr() string {
if ag.history == nil {
return "no active session"
}
str := fmt.Sprintf("%d in, %d out, %d requests",
ag.history.TotalInputTokens, ag.history.TotalOutputTokens,
ag.history.TotalRequests)
if ag.history.TotalCachedInputTokens > 0 {
str += fmt.Sprintf(", %d cached", ag.history.TotalCachedInputTokens)
}
if ag.history.Estimated {
str += " [estimated]"
}
return str
}
// Context returns the full message context (system prompt + history).
func (ag *Agent) Context() []backend.Message {
var msgs []backend.Message
if ag.history != nil {
msgs = slices.Clone(ag.history.history())
}
if preamble := ag.runtime.PreambleString(); preamble != "" {
msgs = append([]backend.Message{{Role: "system", Content: preamble}}, msgs...)
}
return msgs
}
// SystemPrompt returns the rendered system prompt.
func (ag *Agent) SystemPrompt() string {
return ag.runtime.PreambleString()
}
// GenParams returns the current generation parameters.
func (ag *Agent) GenParams() backend.GenerationParams {
return ag.runtime.GenParams
}
// SetGenParams sets the generation parameters.
func (ag *Agent) SetGenParams(params backend.GenerationParams) {
ag.runtime.GenParams = params
}
// ListModels returns available models from the backend.
func (ag *Agent) ListModels() []string {
return ag.runtime.Backend.Models(context.Background())
}
// ToolServer returns the tool execution server, or nil if unavailable.
// Exported for use by the 9P filesystem layer to sync tool registries.
func (ag *Agent) ToolServer() *toolsrvclient.Conn {
return ag.runtime.ToolServer
}
// SetRuntime replaces the agent's runtime (used on session resume).
func (ag *Agent) SetRuntime(rt *Runtime) {
ag.runtime = rt
}
// Queue pushes a prompt onto the agent's FIFO.
func (ag *Agent) Queue(prompt string) {
ag.fifo.Push(prompt)
}
// PopQueue pops the next prompt from the FIFO.
func (ag *Agent) PopQueue() (string, bool) {
return ag.fifo.Pop()
}
// FeedWrite stores data in the feed and signals waiters.
func (ag *Agent) FeedWrite(data []byte) {
ag.Feed.Set(data)
ag.notifyChange()
}
// ConsumeFeed reads from the agent's feed file in a loop via 9P.
// Blocks until feed changes, submits the new data as a prompt.
// Exits when ctx is cancelled or on read error.
func ConsumeFeed(ctx context.Context, ag *Agent) {
path := fmt.Sprintf("session/%s/agent/%s/feed", ag.sessionID, ag.id)
client, err := lib9p.DialDefault()
if err != nil {
ag.log.Error("feed consumer: dial: %v", err)
return
}
go func() {
<-ctx.Done()
client.Close()
}()
for ctx.Err() == nil {
data, err := client.Read(path)
if err != nil {
return
}
if len(data) == 0 {
continue
}
ag.Submit(ctx, string(data))
ag.EnsureTrailingNewline()
}
}
// AgentParams holds the runtime dependencies for constructing a new Agent.
// Not to be confused with AgentConfig, which is the on-disk JSON schema.
type AgentParams struct {
ID string // unique agent identity (uname)
SessionID string // id of session the agent belongs to
Profile string // config profile name (e.g. "default" → agents/default.json)
ParentID string // immutable ID of the agent that spawned this agent
History *History
Runtime *Runtime
AgentsDir string
Cwd string // working directory for tool execution
SystemPrompt string
EnvBlock string
NewToolServer func() *toolsrvclient.Conn
NewBackend func(string) (backend.Backend, error)
Log *olog.Logger
StartupMsgs []string
Save func()
Flush func()
}
// NewAgent constructs an Agent from the given configuration.
// Post-construction wiring (SetSessionEnv) is handled internally —
// no additional calls are required after construction.
func NewAgent(cfg AgentParams) *Agent {
ag := &Agent{
history: cfg.History,
runtime: cfg.Runtime,
profile: cfg.Profile,
name: cfg.ID, // display name defaults to uname
agentsDir: cfg.AgentsDir,
id: cfg.ID,
parentID: cfg.ParentID,
cwd: cfg.Cwd,
systemPrompt: cfg.SystemPrompt,
envBlock: cfg.EnvBlock,
newToolServer: cfg.NewToolServer,
newBackend: cfg.NewBackend,
log: cfg.Log,
sessionID: cfg.SessionID,
startupMessages: cfg.StartupMsgs,
save: cfg.Save,
flush: cfg.Flush,
state: "idle",
}
ag.signalCh = make(chan struct{})
ag.chatSignalCh = make(chan struct{})
ag.chatCond = sync.NewCond(ag.chatMu.RLocker())
ag.initChatHandler()
ag.SetSessionEnv(cfg.SessionID)
return ag
}