ollie/session/session.go

590 lines
14 KiB
Go

package session
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
"ollie/agent"
"ollie/backend"
olog "ollie/log"
"ollie/paths"
"ollie/toolsrv"
)
// Config is the configuration for creating a session with an agent.
type Config struct {
Backend backend.Backend
ModelName string
Profile string // config profile name (e.g. "default")
AgentsDir string
SessionsDir string
SessionID string
AgentID string
CWD string
History *agent.History
Runtime *agent.Runtime
NewToolServer func() toolsrv.Runner
NewBackend func(string) (backend.Backend, error)
Log *olog.Logger
MaxSteps int
ReadPlanStep func() string
ListHandlers map[string]func() []string
PromptEnvExtra []string
Remote string
BaseLayers []string
Output agent.EventHandler
}
// Session owns all session-level state: identity, agents, tool server lifecycle.
type Session struct {
// Identity
id string // immutable UUID, set at creation
name string // mutable friendly name, defaults to id prefix
uname string // immutable user principal (numeric UID)
// Context lifecycle
ctx context.Context
cancel context.CancelFunc
// Agent state
agents []*agent.Agent
log *olog.Logger
auditLog *olog.Logger
sessionsDir string
listHandlers map[string]func() []string
remote string
// Environment
envMu sync.RWMutex
env map[string]string
plan []byte
prevPrompt string
// Tool server lifecycle
proc *toolsrv.Process // toolsrv subprocess
keeper *toolsrv.ProcessKeeper // resilient process manager
toolsConn toolsrv.Runner // multiplexed RPC connection
paused bool // true when paused
// Tool restrictions
disallowTools map[string]struct{}
// Synchronization
mu sync.RWMutex
// Autosave
saveMu sync.Mutex
saveDirty bool
saveTimer *time.Timer
}
// NewSessionID generates a UUIDv4 session identifier.
func NewSessionID() string {
b := make([]byte, 16)
rand.Read(b) //nolint:errcheck
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 10xx
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
// NextUncheckedStep returns the first unchecked step from plan bytes.
func NextUncheckedStep(data []byte) string {
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "- [ ]") {
return strings.TrimSpace(trimmed[5:])
}
}
return ""
}
var sweepTmpOnce sync.Once
func ollieTmpDir() string {
if p := os.Getenv("OLLIE_TMP_PATH"); p != "" {
return p
}
return filepath.Join(os.TempDir(), "ollie")
}
func sweepStaleTmpDirs() {
sweepTmpOnce.Do(func() {
base := ollieTmpDir()
os.RemoveAll(base) //nolint:errcheck
os.MkdirAll(base, 0700) //nolint:errcheck
})
}
// New creates a session with an owned agent from the given configuration.
func New(cfg Config) *Session {
sweepStaleTmpDirs()
if cfg.ModelName != "" {
cfg.Backend.SetModel(cfg.ModelName)
}
if cfg.NewBackend == nil {
cfg.NewBackend = backend.NewWithName
}
rt := cfg.Runtime
if rt == nil {
rt = &agent.Runtime{}
}
rt.Backend = cfg.Backend
if cfg.MaxSteps > 0 {
rt.MaxSteps = cfg.MaxSteps
}
if cfg.SessionID != "" {
os.MkdirAll(filepath.Join(ollieTmpDir(), cfg.SessionID), 0700) //nolint:errcheck
}
log := cfg.Log
if log == nil {
log = olog.NewWriter("core", olog.LevelError+1, io.Discard, io.Discard)
}
auditLog := log.Sub("audit")
name := cfg.SessionID
if idx := strings.IndexByte(cfg.SessionID, '-'); idx > 0 {
name = cfg.SessionID[:idx]
}
a := &Session{
id: cfg.SessionID,
name: name,
env: make(map[string]string),
agents: make([]*agent.Agent, 0, 1),
log: log,
auditLog: auditLog,
sessionsDir: cfg.SessionsDir,
remote: cfg.Remote,
listHandlers: cfg.ListHandlers,
}
ag := agent.NewAgent(agent.AgentCfg{
History: cfg.History,
Runtime: rt,
Profile: cfg.Profile,
AgentsDir: cfg.AgentsDir,
AgentID: cfg.AgentID,
CWD: paths.ExpandHome(cfg.CWD),
BaseLayers: cfg.BaseLayers,
PromptEnvExtra: cfg.PromptEnvExtra,
NewToolServer: cfg.NewToolServer,
NewBackend: cfg.NewBackend,
Output: cfg.Output,
Log: log,
AuditLog: auditLog,
SessionID: cfg.SessionID,
StartupMsgs: rt.Messages,
ReadPlanStep: cfg.ReadPlanStep,
SaveSession: a.saveSession,
FlushSave: a.flushSave,
})
a.agents = append(a.agents, ag)
ag.SetSessionEnv(a.id)
return a
}
// FindAgent returns the agent matching the given name or ID, or nil.
func (a *Session) FindAgent(nameOrID string) *agent.Agent {
for _, ag := range a.agents {
if ag.Name() == nameOrID || ag.ID() == nameOrID {
return ag
}
}
return nil
}
// Agents returns the full agent slice.
func (a *Session) Agents() []*agent.Agent { return a.agents }
// AgentAt returns the agent at the given index, or nil.
func (a *Session) AgentAt(idx int) *agent.Agent {
if idx < 0 || idx >= len(a.agents) {
return nil
}
return a.agents[idx]
}
// AgentCount returns the number of agents.
func (a *Session) AgentCount() int { return len(a.agents) }
// SaveFuncs returns save and flush callbacks suitable for use as
// agent.AgentCfg.SaveSession and FlushSave when adding agents
// to this session.
func (a *Session) SaveFuncs() (save func(), flush func()) {
return a.saveSession, a.flushSave
}
// AddAgent appends an agent to the session.
func (a *Session) AddAgent(ag *agent.Agent) {
a.agents = append(a.agents, ag)
ag.SetSessionEnv(a.id)
}
// RemoveAgent removes an agent by ID and closes it.
// Returns false if the agent was not found.
func (a *Session) RemoveAgent(id string) bool {
idx := -1
for i, ag := range a.agents {
if ag.ID() == id {
idx = i
break
}
}
if idx < 0 {
return false
}
a.agents[idx].Close()
a.agents = append(a.agents[:idx], a.agents[idx+1:]...)
return true
}
// Close releases resources for this session.
func (a *Session) Close() {
a.log.Debug("Close() session=%q", a.id)
a.flushSave()
for _, ag := range a.agents {
ag.Close()
}
if a.id != "" {
os.RemoveAll(filepath.Join(ollieTmpDir(), a.id)) //nolint:errcheck
}
}
// SetEnv stores a session-scoped variable and propagates to all agents.
func (a *Session) SetEnv(key, value string) {
a.envMu.Lock()
a.env[key] = value
a.envMu.Unlock()
for _, ag := range a.agents {
ag.SetEnv(key, value)
}
}
// CWD returns the current working directory for tool execution
// (from the first agent).
func (a *Session) CWD() string {
if len(a.agents) > 0 {
if c := a.agents[0].Cwd(); c != "" {
return c
}
}
wd, _ := os.Getwd()
return wd
}
// Remote returns the remote target (e.g., SSH host) if any.
func (a *Session) Remote() string {
return a.remote
}
// SetCWD validates and sets the working directory on all agents.
func (a *Session) SetCWD(dir string) error {
dir = paths.ExpandHome(dir)
if dir != "" {
if _, err := os.Stat(dir); err != nil {
return fmt.Errorf("cwd: %w", err)
}
}
for _, ag := range a.agents {
ag.SetCWD(dir)
}
return nil
}
// SetSessionID renames the session.
func (a *Session) SetSessionID(newID string) error {
oldID := a.id
if oldID == newID {
return nil
}
if a.sessionsDir != "" && oldID != "" {
for _, suffix := range []string{".json", ".compaction.jsonl"} {
oldPath := a.activeSessionPath(oldID, suffix)
if _, err := os.Stat(oldPath); err == nil {
if err := os.Rename(oldPath, a.activeSessionPath(newID, suffix)); err != nil {
return fmt.Errorf("rename %s: %w", suffix, err)
}
}
}
}
a.id = newID
for _, ag := range a.agents {
ag.RenamePreamble(oldID, newID)
}
oldTemp := filepath.Join(ollieTmpDir(), oldID)
newTemp := filepath.Join(ollieTmpDir(), newID)
if _, err := os.Stat(oldTemp); err == nil {
os.Rename(oldTemp, newTemp) //nolint:errcheck
}
for _, ag := range a.agents {
ag.SetSessionEnv(newID)
}
return nil
}
func (a *Session) activeSessionPath(id, suffix string) string {
return filepath.Join(a.sessionsDir, "active", id+suffix)
}
func (a *Session) saveSession() {
a.saveMu.Lock()
a.saveDirty = true
if a.saveTimer == nil {
a.saveTimer = time.AfterFunc(2*time.Second, a.flushSave)
}
a.saveMu.Unlock()
}
func (a *Session) flushSave() {
a.saveMu.Lock()
dirty := a.saveDirty
a.saveDirty = false
if a.saveTimer != nil {
a.saveTimer.Stop()
a.saveTimer = nil
}
a.saveMu.Unlock()
if !dirty || a.id == "" || a.sessionsDir == "" {
return
}
// Use PersistSession for consistent format (writes Name field correctly)
if err := PersistSession(a.Name()); err != nil {
a.log.Error("session save: %v", err)
}
}
// SaveSession writes the current session state.
// The path argument is ignored; session is saved via PersistSession.
func (a *Session) SaveSession(path string) error {
return PersistSession(a.Name())
}
// --- Identity accessors ---
// ID returns the immutable session UUID.
func (a *Session) ID() string { return a.id }
// Name returns the mutable friendly name.
func (a *Session) Name() string {
a.mu.RLock()
defer a.mu.RUnlock()
return a.name
}
// SetName sets the friendly name.
func (a *Session) SetName(name string) {
a.mu.Lock()
defer a.mu.Unlock()
a.name = name
}
// Uname returns the immutable user principal.
func (a *Session) Uname() string { return a.uname }
// SetUname sets the user principal (should only be called during init).
func (a *Session) SetUname(uname string) {
a.mu.Lock()
defer a.mu.Unlock()
a.uname = uname
}
// --- Context lifecycle ---
// Ctx returns the session context.
func (a *Session) Ctx() context.Context { return a.ctx }
// Cancel cancels the session context.
func (a *Session) Cancel() {
if a.cancel != nil {
a.cancel()
}
}
// SetContext sets the session context and cancel func.
func (a *Session) SetContext(ctx context.Context, cancel context.CancelFunc) {
a.ctx = ctx
a.cancel = cancel
}
// --- Tool server lifecycle ---
// Proc returns the tool server process.
func (a *Session) Proc() *toolsrv.Process {
a.mu.RLock()
defer a.mu.RUnlock()
return a.proc
}
// SetProc sets the tool server process.
func (a *Session) SetProc(proc *toolsrv.Process) {
a.mu.Lock()
defer a.mu.Unlock()
a.proc = proc
}
// Keeper returns the process keeper.
func (a *Session) Keeper() *toolsrv.ProcessKeeper {
a.mu.RLock()
defer a.mu.RUnlock()
return a.keeper
}
// SetKeeper sets the process keeper.
func (a *Session) SetKeeper(keeper *toolsrv.ProcessKeeper) {
a.mu.Lock()
defer a.mu.Unlock()
a.keeper = keeper
}
// ToolsConn returns the tool server connection.
func (a *Session) ToolsConn() toolsrv.Runner {
a.mu.RLock()
defer a.mu.RUnlock()
return a.toolsConn
}
// SetToolsConn sets the tool server connection.
func (a *Session) SetToolsConn(conn toolsrv.Runner) {
a.mu.Lock()
defer a.mu.Unlock()
a.toolsConn = conn
}
// IsPaused returns true if the session is paused.
func (a *Session) IsPaused() bool {
a.mu.RLock()
defer a.mu.RUnlock()
return a.paused
}
// IsConnected returns true if the tool server connection is alive.
func (a *Session) IsConnected() bool {
a.mu.RLock()
conn := a.toolsConn
a.mu.RUnlock()
if conn == nil {
return false
}
if pinger, ok := conn.(interface{ Ping() error }); ok {
return pinger.Ping() == nil
}
return true
}
// Pause stops the tool server to save resources.
func (a *Session) Pause() error {
a.mu.Lock()
defer a.mu.Unlock()
if a.paused {
return fmt.Errorf("session already paused")
}
if a.keeper != nil {
a.keeper.Close()
} else if a.proc != nil {
a.proc.Close()
}
if a.toolsConn != nil {
if c, ok := a.toolsConn.(interface{ Close() }); ok {
c.Close()
}
a.toolsConn = nil
}
a.paused = true
return nil
}
// Resume restarts the tool server after a Pause.
func (a *Session) Resume() error {
a.mu.Lock()
defer a.mu.Unlock()
if !a.paused {
return fmt.Errorf("session not paused")
}
if a.keeper == nil {
return fmt.Errorf("no process keeper available")
}
conn, err := a.keeper.Dial()
if err != nil {
return fmt.Errorf("resume failed: %w", err)
}
a.toolsConn = conn
a.paused = false
return nil
}
// --- Tool loading ---
// SetDisallowTools sets the tool disallow list.
func (a *Session) SetDisallowTools(disallow map[string]struct{}) {
a.mu.Lock()
defer a.mu.Unlock()
a.disallowTools = disallow
}
// LoadTool loads a tool into the tool server.
func (a *Session) LoadTool(name string, ag *agent.Agent) error {
name = strings.TrimSpace(name)
if name == "" {
return nil
}
a.mu.RLock()
_, blocked := a.disallowTools[name]
a.mu.RUnlock()
if blocked {
return fmt.Errorf("tool %q is disallowed for this session", name)
}
var runner toolsrv.Runner
if a.toolsConn != nil {
runner = a.toolsConn
} else if ag != nil {
runner = ag.ToolServer()
}
return LoadToolOnRunner(runner, name)
}
// LoadToolOnRunner loads a tool on the given runner.
func LoadToolOnRunner(runner toolsrv.Runner, name string) error {
if runner == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
args, _ := json.Marshal(map[string]string{"name": name})
if _, err := runner.CallTool(ctx, "tool_load", json.RawMessage(args)); err != nil {
return fmt.Errorf("remote load: %w", err)
}
return nil
}
// --- Constructor for empty session ---
// NewEmpty creates an empty session without an agent.
func NewEmpty(id string, ctx context.Context, cancel context.CancelFunc) *Session {
name := id
if idx := strings.IndexByte(id, '-'); idx > 0 {
name = id[:idx]
}
return &Session{
id: id,
name: name,
ctx: ctx,
cancel: cancel,
env: make(map[string]string),
agents: make([]*agent.Agent, 0),
}
}