329 lines
8.3 KiB
Go
329 lines
8.3 KiB
Go
package session
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
|
|
"ollie/agent"
|
|
"ollie/backend"
|
|
"ollie/toolsrv"
|
|
)
|
|
|
|
// --- Persistence ---
|
|
|
|
// PersistedSession is the on-disk format for a saved session with multiple agents.
|
|
type PersistedSession struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
CWD string `json:"cwd"`
|
|
Remote string `json:"remote,omitempty"`
|
|
Paused bool `json:"paused,omitempty"`
|
|
Agents []PersistedAgent `json:"agents"`
|
|
}
|
|
|
|
// PersistedAgent is per-agent state within a session.
|
|
type PersistedAgent struct {
|
|
ID string `json:"id"` // uname
|
|
Name string `json:"name"` // display name
|
|
Profile string `json:"profile"`
|
|
Backend string `json:"backend"`
|
|
Model string `json:"model"`
|
|
Messages []backend.Message `json:"messages"`
|
|
TaskState *agent.TaskState `json:"taskState,omitempty"`
|
|
// Usage tracking
|
|
TotalInputTokens int `json:"totalInputTokens,omitempty"`
|
|
TotalCachedInputTokens int `json:"totalCachedInputTokens,omitempty"`
|
|
TotalCacheCreationTokens int `json:"totalCacheCreationTokens,omitempty"`
|
|
TotalOutputTokens int `json:"totalOutputTokens,omitempty"`
|
|
TotalRequests int `json:"totalRequests,omitempty"`
|
|
Estimated bool `json:"estimated,omitempty"`
|
|
LastTurnCostUSD float64 `json:"lastTurnCostUSD,omitempty"`
|
|
SessionCostUSD float64 `json:"sessionCostUSD,omitempty"`
|
|
}
|
|
|
|
// ActiveSessionsDir returns the directory for persisted sessions.
|
|
func ActiveSessionsDir() string {
|
|
return filepath.Join(pkgSessionsDir, "active")
|
|
}
|
|
|
|
// PersistSession saves a session (with all agents) to disk.
|
|
func PersistSession(name string) error {
|
|
sess := Lookup(name)
|
|
if sess == nil {
|
|
return nil
|
|
}
|
|
agents := sess.Agents()
|
|
if len(agents) == 0 {
|
|
return nil
|
|
}
|
|
|
|
ps := PersistedSession{
|
|
ID: sess.ID(),
|
|
Name: sess.Name(),
|
|
CWD: sess.CWD(),
|
|
Remote: sess.Remote(),
|
|
Paused: sess.IsPaused(),
|
|
}
|
|
|
|
for _, ag := range agents {
|
|
pa := PersistedAgent{
|
|
ID: ag.ID(),
|
|
Name: ag.Name(),
|
|
Profile: ag.Profile(),
|
|
Backend: ag.BackendName(),
|
|
Model: ag.ModelName(),
|
|
Messages: ag.Messages(),
|
|
TaskState: ag.TaskState(),
|
|
}
|
|
if usage := ag.Usage(); usage != nil {
|
|
pa.TotalInputTokens = usage.TotalInputTokens
|
|
pa.TotalCachedInputTokens = usage.TotalCachedInputTokens
|
|
pa.TotalCacheCreationTokens = usage.TotalCacheCreationTokens
|
|
pa.TotalOutputTokens = usage.TotalOutputTokens
|
|
pa.TotalRequests = usage.TotalRequests
|
|
pa.Estimated = usage.Estimated
|
|
pa.LastTurnCostUSD = usage.LastTurnCostUSD
|
|
pa.SessionCostUSD = usage.SessionCostUSD
|
|
}
|
|
ps.Agents = append(ps.Agents, pa)
|
|
}
|
|
|
|
dir := ActiveSessionsDir()
|
|
os.MkdirAll(dir, 0700)
|
|
path := filepath.Join(dir, sess.ID()+".json")
|
|
|
|
data, err := json.MarshalIndent(ps, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, data, 0600)
|
|
}
|
|
|
|
// RemovePersistedSession removes a persisted session file.
|
|
func RemovePersistedSession(id string) {
|
|
path := filepath.Join(ActiveSessionsDir(), id+".json")
|
|
os.Remove(path)
|
|
}
|
|
|
|
// SaveAllSessions persists all active sessions.
|
|
func SaveAllSessions() {
|
|
for name := range Sessions() {
|
|
if err := PersistSession(name); err != nil {
|
|
pkgLog.Error("persist session %s: %v", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// RestoredAgent holds restored agent info for fs layer.
|
|
type RestoredAgent struct {
|
|
ID string
|
|
Remote string
|
|
Messages []backend.Message
|
|
}
|
|
|
|
// RestoredSession holds a restored session and metadata for fs layer.
|
|
type RestoredSession struct {
|
|
Session *Session
|
|
Agents []RestoredAgent
|
|
ToolCount int
|
|
}
|
|
|
|
// RestoreAllSessions restores all persisted sessions.
|
|
// Returns the restored sessions for the fs layer to wrap with SessionNodes.
|
|
func RestoreAllSessions() []*RestoredSession {
|
|
dir := ActiveSessionsDir()
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
var files []string
|
|
for _, e := range entries {
|
|
if strings.HasSuffix(e.Name(), ".json") {
|
|
files = append(files, filepath.Join(dir, e.Name()))
|
|
}
|
|
}
|
|
|
|
if len(files) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var mu sync.Mutex
|
|
var results []*RestoredSession
|
|
var wg sync.WaitGroup
|
|
for _, path := range files {
|
|
wg.Add(1)
|
|
go func(path string) {
|
|
defer wg.Done()
|
|
rs, err := restoreSessionFromFile(path)
|
|
if err != nil {
|
|
pkgLog.Error("restore session %s: %v; removing from active sessions", path, err)
|
|
os.Remove(path)
|
|
return
|
|
}
|
|
mu.Lock()
|
|
results = append(results, rs)
|
|
mu.Unlock()
|
|
}(path)
|
|
}
|
|
wg.Wait()
|
|
return results
|
|
}
|
|
|
|
func restoreSessionFromFile(path string) (*RestoredSession, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var ps PersistedSession
|
|
if err := json.Unmarshal(data, &ps); err != nil {
|
|
return nil, err
|
|
}
|
|
if ps.ID == "" || len(ps.Agents) == 0 {
|
|
return nil, fmt.Errorf("invalid session file: missing ID or agents")
|
|
}
|
|
return restoreMultiAgentSession(&ps)
|
|
}
|
|
|
|
func restoreMultiAgentSession(ps *PersistedSession) (*RestoredSession, error) {
|
|
cwd := ps.CWD
|
|
if cwd == "" {
|
|
cwd, _ = os.Getwd()
|
|
}
|
|
|
|
// For paused sessions, skip tool server setup entirely.
|
|
// It will be set up on Resume().
|
|
var infra *SessionInfra
|
|
if !ps.Paused {
|
|
var err error
|
|
infra, err = SetupToolServer(ToolServerConfig{
|
|
Ctx: serverCtx,
|
|
CWD: cwd,
|
|
RemoteTarget: ps.Remote,
|
|
Yolo: pkgYolo,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Create empty session
|
|
ctx, cancel := context.WithCancel(serverCtx)
|
|
sess := NewEmpty(ps.ID, ctx, cancel)
|
|
sess.SetName(ps.Name)
|
|
sess.SetRemote(ps.Remote)
|
|
if infra != nil {
|
|
sess.SetProc(infra.Proc)
|
|
sess.SetKeeper(infra.Keeper)
|
|
sess.SetToolsConn(infra.ToolsConn)
|
|
}
|
|
if ps.Paused {
|
|
sess.SetPaused(true)
|
|
}
|
|
|
|
var restoredAgents []RestoredAgent
|
|
|
|
// Build prompt env (works without live infra)
|
|
promptEnv := agent.PromptEnv(cwd)
|
|
if infra != nil {
|
|
promptEnv = infra.PromptEnv
|
|
}
|
|
|
|
// Restore each agent
|
|
for _, pa := range ps.Agents {
|
|
cfg := LoadAgentConfig(pkgAgentsDir, pa.Profile, nil)
|
|
|
|
be, err := backend.NewWithName(pa.Backend)
|
|
if err != nil {
|
|
pkgLog.Error("restore agent %s: backend %s: %v", pa.ID, pa.Backend, err)
|
|
continue
|
|
}
|
|
if pa.Model != "" {
|
|
be.SetModel(pa.Model)
|
|
}
|
|
|
|
// Build prompt layers
|
|
layers := BuildPromptLayers(cfg, cwd, ps.ID, pa.ID, promptEnv, "")
|
|
|
|
// Load tools (only if we have a connection)
|
|
if infra != nil && infra.ToolsConn != nil {
|
|
LoadAutoLoadTools(cfg, infra.ToolsConn, ps.ID, pa.ID, func(f string, a ...any) {
|
|
pkgLog.Error("session %s agent %s: "+f, append([]any{ps.ID, pa.ID}, a...)...)
|
|
})
|
|
}
|
|
|
|
env := []string{"OLLIE_SESSION_ID=" + ps.ID, "OLLIE_UNAME=" + pa.ID}
|
|
env = append(env, promptEnv...)
|
|
|
|
var toolsConn toolsrv.Runner
|
|
var newToolServer func() toolsrv.Runner
|
|
if infra != nil {
|
|
toolsConn = infra.ToolsConn
|
|
newToolServer = infra.NewToolServer
|
|
}
|
|
|
|
rt := agent.BuildRuntime(cfg, toolsConn, cwd, env, layers.SystemPrompt, layers.EnvBlock)
|
|
rt.Backend = be
|
|
|
|
// Create history from messages
|
|
history := agent.RestoreHistoryFromMessages(pa.Messages)
|
|
|
|
ag := agent.NewAgent(agent.AgentCfg{
|
|
History: history,
|
|
Runtime: rt,
|
|
Profile: pa.Profile,
|
|
AgentsDir: pkgAgentsDir,
|
|
AgentID: pa.ID,
|
|
CWD: cwd,
|
|
BaseLayers: layers.BaseLayers(),
|
|
PromptEnvExtra: promptEnv,
|
|
NewToolServer: newToolServer,
|
|
NewBackend: backend.NewWithName,
|
|
Log: pkgSink.NewLogger("agent"),
|
|
SessionID: ps.ID,
|
|
Save: sess.saveSession,
|
|
Flush: sess.flushSave,
|
|
})
|
|
ag.SetName(pa.Name)
|
|
|
|
sess.AddAgent(ag)
|
|
restoredAgents = append(restoredAgents, RestoredAgent{
|
|
ID: pa.ID,
|
|
Remote: ps.Remote,
|
|
Messages: pa.Messages,
|
|
})
|
|
}
|
|
|
|
if len(sess.Agents()) == 0 {
|
|
cancel()
|
|
return nil, fmt.Errorf("no agents restored")
|
|
}
|
|
|
|
// Set uname to first agent's ID
|
|
sess.SetUname(sess.Agents()[0].ID())
|
|
|
|
Register(sess.Name(), sess)
|
|
|
|
toolCount := 0
|
|
if infra != nil && infra.ToolsConn != nil {
|
|
if infos, err := infra.ToolsConn.ListTools(); err == nil {
|
|
toolCount = len(infos)
|
|
}
|
|
}
|
|
pkgLog.Info("restored session %s with %d agents, tools=%d, paused=%v", ps.ID, len(restoredAgents), toolCount, ps.Paused)
|
|
|
|
return &RestoredSession{
|
|
Session: sess,
|
|
Agents: restoredAgents,
|
|
ToolCount: toolCount,
|
|
}, nil
|
|
}
|
|
|
|
|