881 lines
24 KiB
Go
881 lines
24 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"runtime"
|
|
"slices"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
|
|
"ollie/cmd/olliesrv/internal/backend"
|
|
lib9p "ollie/lib9p/client"
|
|
olog "ollie/log"
|
|
"ollie/paths"
|
|
"ollie/toolsrv"
|
|
)
|
|
|
|
// 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() *toolsrv.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)
|
|
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
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
}
|
|
|
|
// 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 }
|
|
|
|
// --- 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() *toolsrv.Conn, conn *toolsrv.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) {
|
|
if ag.runtime.ToolServer == nil {
|
|
return
|
|
}
|
|
ag.runtime.ToolServer.SetEnv(key, value)
|
|
}
|
|
|
|
// Close releases agent resources (dispatcher, execute server).
|
|
func (ag *Agent) Close() {
|
|
if ag.runtime.ToolServer == nil {
|
|
return
|
|
}
|
|
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()
|
|
// Rebuild the environment section with the new cwd
|
|
if ag.runtime != nil && ag.runtime.Preamble != nil {
|
|
isGitRepo := paths.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 ag.runtime == nil || ag.runtime.Preamble == nil || ag.runtime.ToolServer == nil {
|
|
return
|
|
}
|
|
if infos, err := ag.runtime.ToolServer.ListTools(); err == nil {
|
|
ag.runtime.Preamble.Set(SectionTools, renderTools(infos))
|
|
}
|
|
}
|
|
|
|
// 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() *toolsrv.Conn {
|
|
return ag.runtime.ToolServer
|
|
}
|
|
|
|
// BgProc represents a background process for external consumers.
|
|
type BgProc struct {
|
|
PID int
|
|
Tool string
|
|
Cmd string
|
|
Status string // "running" or "exited (N)"
|
|
}
|
|
|
|
// ListProcs returns a list of background processes from toolsrv.
|
|
func (ag *Agent) ListProcs() []BgProc {
|
|
ts := ag.ToolServer()
|
|
if ts == nil {
|
|
return nil
|
|
}
|
|
|
|
var out []BgProc
|
|
for _, raw := range ts.ListDetachedRaw() {
|
|
m, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
pid, _ := m["pid"].(int)
|
|
cmd, _ := m["command"].(string)
|
|
|
|
status := "running"
|
|
if exited, ok := m["exited"].(bool); ok && exited {
|
|
code := 0
|
|
if c, ok := m["exit_code"].(int); ok {
|
|
code = c
|
|
}
|
|
status = fmt.Sprintf("exited (%d)", code)
|
|
}
|
|
|
|
out = append(out, BgProc{
|
|
PID: pid,
|
|
Tool: "shell", // toolsrv doesn't track tool name currently
|
|
Cmd: cmd,
|
|
Status: status,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// SignalProc sends a signal to a background process.
|
|
// sig is "term" or "kill".
|
|
func (ag *Agent) SignalProc(pid int, sig string) error {
|
|
ts := ag.runtime.ToolServer
|
|
if ts == nil {
|
|
return fmt.Errorf("no tool server")
|
|
}
|
|
var s syscall.Signal
|
|
switch sig {
|
|
case "term":
|
|
s = syscall.SIGTERM
|
|
case "kill":
|
|
s = syscall.SIGKILL
|
|
default:
|
|
return fmt.Errorf("unknown signal: %s", sig)
|
|
}
|
|
return ts.SignalDetached(pid, s)
|
|
}
|
|
|
|
// ProcOutput returns the output of a background process.
|
|
func (ag *Agent) ProcOutput(pid int) (string, error) {
|
|
ts := ag.runtime.ToolServer
|
|
if ts == nil {
|
|
return "", fmt.Errorf("no tool server")
|
|
}
|
|
return ts.GetDetachedOutput(pid)
|
|
}
|
|
|
|
// DismissProc dismisses a background process.
|
|
func (ag *Agent) DismissProc(pid int) error {
|
|
ts := ag.runtime.ToolServer
|
|
if ts == nil {
|
|
return fmt.Errorf("no tool server")
|
|
}
|
|
if !ts.DismissDetached(pid) {
|
|
return fmt.Errorf("process %d not found", pid)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Detach detaches the current running process to background.
|
|
func (ag *Agent) Detach() bool {
|
|
if ag.runtime.ToolServer == nil {
|
|
return false
|
|
}
|
|
return ag.runtime.ToolServer.Detach()
|
|
}
|
|
|
|
// DetachedInfo describes a detached process.
|
|
type DetachedInfo struct {
|
|
PID int
|
|
Command string
|
|
Started int64
|
|
Exited bool
|
|
ExitCode int
|
|
}
|
|
|
|
// ListDetached returns info about all detached processes.
|
|
func (ag *Agent) ListDetached() []DetachedInfo {
|
|
if ag.runtime.ToolServer == nil {
|
|
return nil
|
|
}
|
|
raw := ag.runtime.ToolServer.ListDetachedRaw()
|
|
out := make([]DetachedInfo, 0, len(raw))
|
|
for _, r := range raw {
|
|
if m, ok := r.(map[string]any); ok {
|
|
di := DetachedInfo{}
|
|
if v, ok := m["pid"].(int); ok {
|
|
di.PID = v
|
|
}
|
|
if v, ok := m["command"].(string); ok {
|
|
di.Command = v
|
|
}
|
|
if v, ok := m["started"].(int64); ok {
|
|
di.Started = v
|
|
}
|
|
if v, ok := m["exited"].(bool); ok {
|
|
di.Exited = v
|
|
}
|
|
if v, ok := m["exit_code"].(int); ok {
|
|
di.ExitCode = v
|
|
}
|
|
out = append(out, di)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// SignalDetached sends a signal to a detached process.
|
|
func (ag *Agent) SignalDetached(pid int, signal int) error {
|
|
if ag.runtime.ToolServer == nil {
|
|
return fmt.Errorf("no execute server available")
|
|
}
|
|
return ag.runtime.ToolServer.SignalDetached(pid, syscall.Signal(signal))
|
|
}
|
|
|
|
// GetDetachedOutput reads output from a detached process.
|
|
func (ag *Agent) GetDetachedOutput(pid int) (string, error) {
|
|
if ag.runtime.ToolServer == nil {
|
|
return "", fmt.Errorf("no execute server available")
|
|
}
|
|
return ag.runtime.ToolServer.GetDetachedOutput(pid)
|
|
}
|
|
|
|
// DismissDetached removes a finished detached process.
|
|
func (ag *Agent) DismissDetached(pid int) bool {
|
|
if ag.runtime.ToolServer == nil {
|
|
return false
|
|
}
|
|
return ag.runtime.ToolServer.DismissDetached(pid)
|
|
}
|
|
|
|
// 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)
|
|
History *History
|
|
Runtime *Runtime
|
|
AgentsDir string
|
|
Cwd string // working directory for tool execution
|
|
SystemPrompt string
|
|
EnvBlock string
|
|
NewToolServer func() *toolsrv.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,
|
|
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
|
|
}
|