384 lines
9.0 KiB
Go
384 lines
9.0 KiB
Go
package session
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"ollie/agent"
|
|
"ollie/backend"
|
|
olog "ollie/log"
|
|
"ollie/toolsrv"
|
|
)
|
|
|
|
// ManagerConfig configures the session manager.
|
|
type ManagerConfig struct {
|
|
Ctx context.Context
|
|
Log *olog.Logger
|
|
Sink *olog.Sink
|
|
AgentsDir string
|
|
SessionsDir string
|
|
MkdirAll func(string, os.FileMode) error
|
|
Yolo bool
|
|
}
|
|
|
|
// Manager manages the lifecycle of all sessions.
|
|
type Manager struct {
|
|
cfg ManagerConfig
|
|
mu sync.RWMutex
|
|
sessions map[string]*Session
|
|
}
|
|
|
|
// NewManager creates a session manager.
|
|
func NewManager(cfg ManagerConfig) *Manager {
|
|
return &Manager{
|
|
cfg: cfg,
|
|
sessions: make(map[string]*Session),
|
|
}
|
|
}
|
|
|
|
// Sessions returns a snapshot of all sessions.
|
|
func (m *Manager) Sessions() map[string]*Session {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
result := make(map[string]*Session, len(m.sessions))
|
|
for k, v := range m.sessions {
|
|
result[k] = v
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Lookup returns the session by name or ID, or nil.
|
|
func (m *Manager) Lookup(nameOrID string) *Session {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
if s := m.sessions[nameOrID]; s != nil {
|
|
return s
|
|
}
|
|
for _, sess := range m.sessions {
|
|
if sess.ID() == nameOrID {
|
|
return sess
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Register adds a session to the manager.
|
|
func (m *Manager) Register(name string, sess *Session) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.sessions[name] = sess
|
|
}
|
|
|
|
// Kill terminates a session and removes it from the manager.
|
|
func (m *Manager) Kill(name string) error {
|
|
m.mu.Lock()
|
|
sess := m.sessions[name]
|
|
if sess == nil {
|
|
m.mu.Unlock()
|
|
return fmt.Errorf("session not found: %s", name)
|
|
}
|
|
delete(m.sessions, name)
|
|
m.mu.Unlock()
|
|
|
|
var agentIDs []string
|
|
for _, ag := range sess.Agents() {
|
|
agentIDs = append(agentIDs, ag.ID())
|
|
}
|
|
sess.Close()
|
|
sess.Cancel()
|
|
if sess.Keeper() != nil {
|
|
sess.Keeper().Close()
|
|
} else if sess.Proc() != nil {
|
|
sess.Proc().Close()
|
|
}
|
|
|
|
m.cfg.Log.Info("killed session %s", name)
|
|
for _, agentID := range agentIDs {
|
|
PublishEvent("session."+sess.ID()+".agent."+agentID+".kill", "")
|
|
}
|
|
PublishEvent("session."+sess.ID()+".kill", "")
|
|
return nil
|
|
}
|
|
|
|
// Rename renames a session.
|
|
func (m *Manager) Rename(oldName, newName string) error {
|
|
m.mu.Lock()
|
|
sess, ok := m.sessions[oldName]
|
|
if !ok {
|
|
m.mu.Unlock()
|
|
return fmt.Errorf("session not found: %s", oldName)
|
|
}
|
|
if _, exists := m.sessions[newName]; exists {
|
|
m.mu.Unlock()
|
|
return fmt.Errorf("session already exists: %s", newName)
|
|
}
|
|
for _, ag := range sess.Agents() {
|
|
if ag.IsRunning() {
|
|
m.mu.Unlock()
|
|
return fmt.Errorf("cannot rename while agent is running")
|
|
}
|
|
}
|
|
sess.SetName(newName)
|
|
m.sessions[newName] = sess
|
|
delete(m.sessions, oldName)
|
|
m.mu.Unlock()
|
|
|
|
m.cfg.Log.Info("renamed session %s -> %s", oldName, newName)
|
|
PublishEvent("session."+sess.ID()+".rename", oldName+" "+newName)
|
|
return nil
|
|
}
|
|
|
|
// Shutdown interrupts all sessions, waits for idle, and closes them.
|
|
func (m *Manager) Shutdown() {
|
|
m.InterruptAll()
|
|
m.waitIdle(100*time.Millisecond, 5*time.Second)
|
|
|
|
m.mu.Lock()
|
|
names := make([]string, 0, len(m.sessions))
|
|
for name := range m.sessions {
|
|
names = append(names, name)
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
for _, name := range names {
|
|
m.mu.Lock()
|
|
sess := m.sessions[name]
|
|
delete(m.sessions, name)
|
|
m.mu.Unlock()
|
|
if sess != nil {
|
|
sess.Cancel()
|
|
sess.Close()
|
|
if sess.Keeper() != nil {
|
|
sess.Keeper().Close()
|
|
} else if sess.Proc() != nil {
|
|
sess.Proc().Close()
|
|
}
|
|
m.cfg.Log.Info("shutdown session %s", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// InterruptAll interrupts every active session.
|
|
func (m *Manager) InterruptAll() {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
for _, sess := range m.sessions {
|
|
for _, ag := range sess.Agents() {
|
|
ag.Interrupt(agent.ErrInterrupted)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *Manager) waitIdle(poll, timeout time.Duration) {
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
allIdle := true
|
|
m.mu.RLock()
|
|
for _, sess := range m.sessions {
|
|
for _, ag := range sess.Agents() {
|
|
if ag.State() != "idle" {
|
|
allIdle = false
|
|
break
|
|
}
|
|
}
|
|
if !allIdle {
|
|
break
|
|
}
|
|
}
|
|
m.mu.RUnlock()
|
|
if allIdle {
|
|
return
|
|
}
|
|
time.Sleep(poll)
|
|
}
|
|
m.cfg.Log.Warn("shutdown: timed out waiting for idle")
|
|
}
|
|
|
|
// BuildIndex builds the session index content.
|
|
func (m *Manager) BuildIndex() []byte {
|
|
var sb strings.Builder
|
|
m.mu.RLock()
|
|
names := make([]string, 0, len(m.sessions))
|
|
for name := range m.sessions {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
for _, name := range names {
|
|
sess := m.sessions[name]
|
|
paused := sess.IsPaused()
|
|
connected := sess.IsConnected()
|
|
cwd := sess.CWD()
|
|
var be, model string
|
|
var agentList []string
|
|
for _, ag := range sess.Agents() {
|
|
if be == "" {
|
|
be = ag.BackendName()
|
|
model = ag.ModelName()
|
|
}
|
|
agentList = append(agentList, fmt.Sprintf("%s:%s:%s", ag.Name(), ag.ID(), ag.State()))
|
|
}
|
|
agents := strings.Join(agentList, ";")
|
|
fmt.Fprintf(&sb, "%s\t%s\t%t\t%t\t%s\t%s\t%s\t%s\n",
|
|
name, sess.ID(), paused, connected, cwd, be, model, agents)
|
|
}
|
|
m.mu.RUnlock()
|
|
return []byte(sb.String())
|
|
}
|
|
|
|
// CreateEmpty creates an empty session (no agent).
|
|
func (m *Manager) CreateEmpty(name string) (*Session, error) {
|
|
sessID := NewSessionID()
|
|
if name == "" {
|
|
name = sessID
|
|
if idx := strings.IndexByte(sessID, '-'); idx > 0 {
|
|
name = sessID[:idx]
|
|
}
|
|
}
|
|
|
|
m.mu.RLock()
|
|
_, exists := m.sessions[name]
|
|
m.mu.RUnlock()
|
|
if exists {
|
|
return nil, fmt.Errorf("session already exists: %s", name)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(m.cfg.Ctx)
|
|
sess := NewEmpty(sessID, ctx, cancel)
|
|
|
|
// Spawn tool server
|
|
cwd, _ := os.Getwd()
|
|
var dialOpts []toolsrv.Option
|
|
if m.cfg.Yolo {
|
|
dialOpts = append(dialOpts, toolsrv.WithYolo())
|
|
}
|
|
proc, err := toolsrv.Spawn(ctx, cwd, dialOpts...)
|
|
if err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("spawn tool server: %w", err)
|
|
}
|
|
sess.SetProc(proc)
|
|
sess.SetKeeper(toolsrv.NewProcessKeeper(ctx, proc, func(ctx context.Context) (*toolsrv.Process, error) {
|
|
return toolsrv.Spawn(ctx, cwd, dialOpts...)
|
|
}))
|
|
conn, err := toolsrv.Dial(ctx, toolsrv.LocalAddr(proc.Socket))
|
|
if err != nil {
|
|
cancel()
|
|
proc.Close()
|
|
return nil, fmt.Errorf("dial tool server: %w", err)
|
|
}
|
|
sess.SetToolsConn(conn)
|
|
|
|
m.mu.Lock()
|
|
m.sessions[name] = sess
|
|
m.mu.Unlock()
|
|
|
|
m.cfg.Log.Info("created empty session %s (id=%s)", name, sessID)
|
|
PublishEvent("session."+sessID+".new", name)
|
|
return sess, nil
|
|
}
|
|
|
|
// NextUname generates a unique agent name (12 hex chars).
|
|
func NextUname() string {
|
|
b := make([]byte, 6)
|
|
rand.Read(b)
|
|
return fmt.Sprintf("%06x%06x", b[:3], b[3:6])
|
|
}
|
|
|
|
// CreateAgent creates a new agent within an existing session.
|
|
// Args: [profile] [key=value...] where keys include backend, model, remote.
|
|
func (m *Manager) CreateAgent(sessName string, args []string) (*agent.Agent, error) {
|
|
sess := m.Lookup(sessName)
|
|
if sess == nil {
|
|
return nil, fmt.Errorf("session not found: %s", sessName)
|
|
}
|
|
|
|
// Parse args
|
|
profile := "default"
|
|
var backendName, modelName, remoteTarget string
|
|
for _, arg := range args {
|
|
if k, v, ok := strings.Cut(arg, "="); ok {
|
|
switch k {
|
|
case "backend":
|
|
backendName = v
|
|
case "model":
|
|
modelName = v
|
|
case "remote":
|
|
remoteTarget = v
|
|
}
|
|
} else if profile == "default" {
|
|
profile = arg
|
|
}
|
|
}
|
|
|
|
cfg := LoadAgentConfig(m.cfg.AgentsDir, profile, nil)
|
|
|
|
uname := NextUname()
|
|
sessID := sess.ID()
|
|
cwd := sess.CWD()
|
|
|
|
// Set up tool server - reuse existing session infrastructure
|
|
infra, err := SetupToolServer(ToolServerConfig{
|
|
Ctx: sess.Ctx(),
|
|
CWD: cwd,
|
|
RemoteTarget: remoteTarget,
|
|
Yolo: m.cfg.Yolo,
|
|
ReuseFrom: &InfraConfig{
|
|
Proc: sess.Proc(),
|
|
Keeper: sess.Keeper(),
|
|
ToolsConn: sess.ToolsConn(),
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("setup tool server: %w", err)
|
|
}
|
|
|
|
// Build prompt layers
|
|
layers := BuildPromptLayers(cfg, cwd, sessID, uname, infra.PromptEnv, "")
|
|
|
|
// Load tools
|
|
LoadAutoLoadTools(cfg, infra.ToolsConn, sessID, uname, func(f string, a ...any) {
|
|
m.cfg.Log.Error("session %s agent %s: "+f, append([]any{sessID, uname}, a...)...)
|
|
})
|
|
|
|
// Build runtime
|
|
env := []string{"OLLIE_SESSION_ID=" + sessID, "OLLIE_UNAME=" + uname}
|
|
env = append(env, infra.PromptEnv...)
|
|
rt := agent.BuildRuntime(cfg, infra.ToolsConn, cwd, env, layers.SystemPrompt, layers.OpModel, layers.EnvBlock)
|
|
|
|
// Apply backend/model overrides to runtime
|
|
if backendName != "" {
|
|
if be, err := backend.NewWithName(backendName); err == nil {
|
|
rt.Backend = be
|
|
}
|
|
}
|
|
if modelName != "" {
|
|
rt.Backend.SetModel(modelName)
|
|
}
|
|
|
|
// Create agent
|
|
ag := agent.NewAgent(agent.AgentCfg{
|
|
History: nil, // new agent starts with empty history
|
|
Runtime: rt,
|
|
Profile: profile,
|
|
AgentsDir: m.cfg.AgentsDir,
|
|
AgentID: uname,
|
|
CWD: cwd,
|
|
BaseLayers: layers.BaseLayers(),
|
|
PromptEnvExtra: infra.PromptEnv,
|
|
NewToolServer: infra.NewToolServer,
|
|
NewBackend: backend.NewWithName,
|
|
Log: m.cfg.Sink.NewLogger("agent"),
|
|
SessionID: sessID,
|
|
})
|
|
|
|
sess.AddAgent(ag)
|
|
m.cfg.Log.Info("created agent %s in session %s (profile=%s)", uname, sessName, profile)
|
|
return ag, nil
|
|
}
|