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

884 lines
25 KiB
Go

package agent
import (
"context"
"fmt"
"io"
"os"
"runtime"
"slices"
"strings"
"sync"
"sync/atomic"
"9fans.net/go/plan9"
p9client "9fans.net/go/plan9/client"
"ollie/cmd/olliesrv/internal/backend"
toolclient "ollie/cmd/olliesrv/internal/toolclient"
olog "ollie/log"
"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() *toolclient.ToolsrvConn
newBackend func(string) (backend.Backend, error)
currentAction atomic.Pointer[actionHandle]
closed atomic.Bool
actionMu sync.Mutex // serializes action registration with shutdown
warnedContext bool
resultCache resultCache
// Execution state — owned by the agent, protected by stateMu.
state string // "idle", "thinking", "calling: <tool>"
reply string // last assistant response
getCwd func() string // returns session working directory
id string // agent identity (unique principal)
parentID string // immutable ID of the agent that spawned this agent
depth int // sub-agent depth (0=top-level, 1=sub-agent, 2=sub-sub-agent)
activeChildren atomic.Int32 // number of currently-running child sub-agents
peers map[string]struct{} // peer agent names (same session)
peerMu sync.RWMutex
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
memoryWakePending atomic.Bool
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
chatStart int
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 }
// Depth returns the sub-agent depth (0=top-level).
func (ag *Agent) Depth() int { return ag.depth }
// SetDepth sets the sub-agent depth.
func (ag *Agent) SetDepth(d int) { ag.depth = d }
// IncChildren increments the active child sub-agent counter.
func (ag *Agent) IncChildren() { ag.activeChildren.Add(1) }
// DecChildren decrements the active child sub-agent counter.
func (ag *Agent) DecChildren() { ag.activeChildren.Add(-1) }
// ActiveChildren returns the number of currently-running child sub-agents.
func (ag *Agent) ActiveChildren() int32 { return ag.activeChildren.Load() }
// AddPeer adds a peer agent by name.
func (ag *Agent) AddPeer(name string) {
ag.peerMu.Lock()
if ag.peers == nil {
ag.peers = make(map[string]struct{})
}
ag.peers[name] = struct{}{}
ag.peerMu.Unlock()
}
// RemovePeer removes a peer agent by name.
func (ag *Agent) RemovePeer(name string) {
ag.peerMu.Lock()
delete(ag.peers, name)
ag.peerMu.Unlock()
}
// Peers returns the sorted list of peer agent names.
func (ag *Agent) Peers() []string {
ag.peerMu.RLock()
defer ag.peerMu.RUnlock()
out := make([]string, 0, len(ag.peers))
for name := range ag.peers {
out = append(out, name)
}
slices.Sort(out)
return out
}
// --- Chat log methods ---
const maxChatLogBytes = 16 * 1024 * 1024
// 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...)
if len(ag.chatLog) > maxChatLogBytes {
drop := len(ag.chatLog) - maxChatLogBytes
ag.chatLog = append([]byte(nil), ag.chatLog[drop:]...)
ag.chatStart += drop
}
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.chatStart
ag.chatMu.RUnlock()
}
ag.chatMu.RLock()
if offset < ag.chatStart {
offset = ag.chatStart
}
localOffset := offset - ag.chatStart
log := ag.chatLog
if len(log) <= localOffset {
ag.chatMu.RUnlock()
return nil, fmt.Sprintf("%d", offset), nil
}
data := make([]byte, len(log)-localOffset)
copy(data, log[localOffset:])
newOffset := ag.chatStart + 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()
}
// SwitchBackend replaces the active backend by name. The new backend is fully
// constructed before the runtime is changed, so a failed switch leaves the
// current backend usable. Backend changes are only safe between turns.
func (ag *Agent) SwitchBackend(name string) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("backend requires a name")
}
if ag.IsRunning() {
return fmt.Errorf("cannot switch backend while agent is running")
}
if ag.newBackend == nil {
return fmt.Errorf("backend switching unavailable")
}
be, err := ag.newBackend(name)
if err != nil {
return fmt.Errorf("backend %q: %w", name, err)
}
ag.runtime.Backend = be
ag.save()
ag.flush()
ag.notifyChange()
return nil
}
// 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() *toolclient.ToolsrvConn, conn *toolclient.ToolsrvConn) {
ag.newToolServer = newToolServer
if ag.runtime != nil {
ag.runtime.ToolServer = conn
}
}
// Cwd returns the agent's working directory (from session).
func (ag *Agent) Cwd() string {
if ag.getCwd != nil {
return ag.getCwd()
}
wd, _ := os.Getwd()
return wd
}
// SetGetCwd sets the CWD getter callback.
func (ag *Agent) SetGetCwd(fn func() string) {
ag.getCwd = fn
}
// 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 {
ag.actionMu.Lock()
defer ag.actionMu.Unlock()
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
done chan struct{}
}
// 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). It first prevents
// new work, cancels the active turn, and waits for that turn to release its
// references to the runtime before closing the tools connection.
func (ag *Agent) Close() {
ag.closed.Store(true)
ag.actionMu.Lock()
h := ag.currentAction.Load()
if h != nil {
h.cancel(context.Canceled)
}
ag.actionMu.Unlock()
if h != nil {
<-h.done
}
if ag.runtime != nil && ag.runtime.ToolServer != nil {
ag.runtime.ToolServer.Close()
}
}
// SetCWD updates the preamble environment section and toolserver CWD.
// Note: This updates local state only. To change session CWD, use session.SetCwd().
func (ag *Agent) SetCWD(dir string) {
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() *toolclient.ToolsrvConn {
return ag.runtime.ToolServer
}
// SetRuntime replaces the agent's runtime (used on session resume).
func (ag *Agent) SetRuntime(rt *Runtime) {
ag.runtime = rt
}
// SetCompactionModel sets the model used for context compaction.
func (ag *Agent) SetCompactionModel(model string) {
ag.runtime.CompactionModel = model
}
// CompactionModel returns the configured context compaction model.
func (ag *Agent) CompactionModel() string {
return ag.runtime.CompactionModel
}
// Queue pushes a prompt onto the agent's FIFO.
func (ag *Agent) Queue(prompt string) error {
return ag.fifo.Push(prompt)
}
// RequireMemoryWake makes the next top-level turn load persistent memory first.
func (ag *Agent) RequireMemoryWake() {
if ag.depth == 0 {
ag.memoryWakePending.Store(true)
}
}
func (ag *Agent) consumeMemoryWake() bool {
return ag.memoryWakePending.CompareAndSwap(true, false)
}
// 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)
ns := os.Getenv("NAMESPACE")
if ns == "" {
ns = p9client.Namespace()
}
user := os.Getenv("USER")
if user == "" {
user = "none"
}
aname := os.Getenv("OLLIE_UNAME")
if aname == "" {
aname = user
}
conn, err := p9client.Dial("unix", ns+"/ollie")
if err != nil {
ag.log.Error("feed consumer: dial: %v", err)
return
}
fsys, err := conn.Attach(nil, user, aname)
if err != nil {
conn.Close()
ag.log.Error("feed consumer: attach: %v", err)
return
}
feedDone := make(chan struct{})
go func() {
select {
case <-ctx.Done():
fsys.Close()
case <-feedDone:
}
}()
defer close(feedDone)
defer fsys.Close()
defer conn.Close()
for ctx.Err() == nil {
fid, err := fsys.Open(path, plan9.OREAD)
if err != nil {
return
}
data, err := io.ReadAll(fid)
fid.Close()
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
GetCwd func() string // returns session working directory
SystemPrompt string
EnvBlock string
NewToolServer func() *toolclient.ToolsrvConn
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,
getCwd: cfg.GetCwd,
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
}