all: kill dead code, remove classifier system, clean up names

Dead code removed:
- agent: firstSentence, Checkpoint, WaitForChange, InitCond, cfgDir,
  execServer, SaveTo, SaveFull, saveTo, saveToFull, sanitizeMessages,
  LoadPersistedAgent, RestoreHistory, Restore, PersistedAgent type
- toolsrv: entire tier.go (MemoryTier, MemoryTierArgs, OutputFormat,
  CanParallelize on Server), same methods on Conn
- cmd/ollie-remote: can_parallelize, memory_tier, memory_tier_args RPC
  handlers
- toolsrv/rpc_integration_test: all classifier tests
- fs/lifecycle: state(root) dead helper

Renames:
- session.Session receiver: 'a' → 's' throughout
- SaveSession(path) → Save() (dead parameter removed)
- execServer() eliminated — callers use ag.runtime.ToolServer directly

The agent-package save/restore chain was entirely dead: PersistSession
in session/persist.go reads agent accessors directly and never called
SaveTo/SaveFull.
This commit is contained in:
Ollie Agent 2026-08-08 14:52:10 +02:00
parent b6694d409a
commit 63c4cd6173
11 changed files with 181 additions and 643 deletions

View File

@ -224,11 +224,6 @@ func (ag *Agent) WaitChange(ctx context.Context, field, current string) (string,
}
}
// InitCond initializes the changeCond. Must be called once after construction.
func (ag *Agent) InitCond() {
ag.changeCond = sync.NewCond(&ag.changeMu)
}
// emit sends an event to the agent's output handler.
func (ag *Agent) emit(ev Event) {
if ag.output != nil {
@ -311,19 +306,6 @@ func (ag *Agent) HasHistory() bool {
return ag.history != nil
}
// SaveTo saves the current history to the given path.
func (ag *Agent) SaveTo(path, name, cwd string) error {
if ag.history == nil {
return fmt.Errorf("no active session")
}
return ag.history.saveTo(path, name, ag.Name(), cwd)
}
// Restore restores agent history from a persisted session.
func (ag *Agent) Restore(ps *PersistedAgent) {
ag.history = RestoreHistory(ps)
}
// ToolCallCount returns the total number of tool calls executed.
func (ag *Agent) ToolCallCount() int64 {
return ag.toolCallCount.Load()
@ -511,19 +493,6 @@ func (ag *Agent) toolsNeedRefresh() bool {
return false
}
// SaveFull persists the full session state (history + metadata) to the given path.
func (ag *Agent) SaveFull(path, sessionID, sessionName, cwd, remote string) error {
// Switching profiles intentionally clears the conversation history, but
// the profile and display name still need to be persisted. Save an empty
// history in that case rather than dropping the entire metadata snapshot.
history := ag.history
if history == nil {
history = &History{}
}
return history.saveToFull(path, sessionID, sessionName, ag.id, ag.Name(), ag.profile,
ag.runtime.Backend.Name(), ag.runtime.Backend.Model(), cwd, remote)
}
// CtxSz returns a human-readable context size string.
func (ag *Agent) CtxSz() string {
if ag.history == nil {
@ -606,15 +575,10 @@ func (ag *Agent) ListModels() []string {
return ag.runtime.Backend.Models(context.Background())
}
// execServer returns the execute server, or nil if unavailable.
func (ag *Agent) execServer() toolsrv.Runner {
return ag.runtime.ToolServer
}
// 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.Runner {
return ag.execServer()
return ag.runtime.ToolServer
}
// Queue pushes a prompt onto the agent's FIFO.
@ -634,18 +598,9 @@ func (ag *Agent) BroadcastChange() {
ag.changeMu.Unlock()
}
// WaitForChange blocks until a state change is broadcast or ctx is cancelled.
func (ag *Agent) WaitForChange(ctx context.Context) {
ag.changeMu.Lock()
if ctx.Err() == nil {
ag.changeCond.Wait()
}
ag.changeMu.Unlock()
}
// Detach detaches the current running process to background.
func (ag *Agent) Detach() bool {
if srv := ag.execServer(); srv != nil {
if srv := ag.runtime.ToolServer; srv != nil {
if d, ok := srv.(interface{ Detach() bool }); ok {
return d.Detach()
}
@ -664,7 +619,7 @@ type DetachedInfo struct {
// ListDetached returns info about all detached processes.
func (ag *Agent) ListDetached() []DetachedInfo {
srv := ag.execServer()
srv := ag.runtime.ToolServer
if srv == nil {
return nil
}
@ -701,7 +656,7 @@ func (ag *Agent) ListDetached() []DetachedInfo {
// SignalDetached sends a signal to a detached process.
func (ag *Agent) SignalDetached(pid int, signal int) error {
if srv := ag.execServer(); srv != nil {
if srv := ag.runtime.ToolServer; srv != nil {
type signaler interface {
SignalDetached(int, syscall.Signal) error
}
@ -714,7 +669,7 @@ func (ag *Agent) SignalDetached(pid int, signal int) error {
// GetDetachedOutput reads output from a detached process.
func (ag *Agent) GetDetachedOutput(pid int) (string, error) {
if srv := ag.execServer(); srv != nil {
if srv := ag.runtime.ToolServer; srv != nil {
type outputGetter interface{ GetDetachedOutput(int) (string, error) }
if og, ok := srv.(outputGetter); ok {
return og.GetDetachedOutput(pid)
@ -725,7 +680,7 @@ func (ag *Agent) GetDetachedOutput(pid int) (string, error) {
// DismissDetached removes a finished detached process.
func (ag *Agent) DismissDetached(pid int) bool {
if srv := ag.execServer(); srv != nil {
if srv := ag.runtime.ToolServer; srv != nil {
type dismisser interface{ DismissDetached(int) bool }
if d, ok := srv.(dismisser); ok {
return d.DismissDetached(pid)

View File

@ -5,7 +5,6 @@ import (
"crypto/rand"
"encoding/json"
"fmt"
"os"
"strings"
"ollie/backend"
@ -33,31 +32,6 @@ Be concise. Capture what another LLM needs to seamlessly continue.`
warmIndexSize = 10
)
// PersistedAgent is the on-disk format for a saved session.
type PersistedAgent struct {
ID string `json:"id"`
// SessionName is the mutable display name of the containing session.
SessionName string `json:"sessionName,omitempty"`
Uname string `json:"uname,omitempty"` // agent's immutable 9P identity
Agent string `json:"agent,omitempty"`
Profile string `json:"profile,omitempty"`
Backend string `json:"backend,omitempty"`
Model string `json:"model,omitempty"`
CWD string `json:"cwd,omitempty"`
Remote string `json:"remote,omitempty"`
Messages []backend.Message `json:"messages"`
TaskState *TaskState `json:"taskState,omitempty"`
// Usage and cost tracking, persisted across restarts.
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"`
}
// TaskState is a compact structured overlay that summarizes the agent's
// current position in a task. Injected at the top of every turn so the
// model doesn't rely on full history recall.
@ -97,104 +71,6 @@ func (ts *TaskState) render() string {
return sb.String()
}
// saveTo writes the full message history to path as JSON.
func (s *History) saveTo(path, id, displayName, cwd string) error {
return s.saveToFull(path, id, "", "", displayName, "", "", "", cwd, "")
}
// saveToFull writes session state including backend/model info.
func sanitizeMessages(msgs []backend.Message) []backend.Message {
out := make([]backend.Message, 0, len(msgs))
for _, m := range msgs {
for j, tc := range m.ToolCalls {
if len(tc.Arguments) > 0 {
var dummy json.RawMessage
if err := json.Unmarshal(tc.Arguments, &dummy); err != nil {
// Corrupted arguments — replace with empty object so save succeeds.
m.ToolCalls[j].Arguments = json.RawMessage("{}")
}
}
}
out = append(out, m)
}
return out
}
func (s *History) saveToFull(path, id, sessionName, uname, displayName, profile, backendName, modelName, cwd, remote string) error {
ps := PersistedAgent{
ID: id,
SessionName: sessionName,
Uname: uname,
Agent: displayName,
Profile: profile,
Backend: backendName,
Model: modelName,
CWD: cwd,
Remote: remote,
Messages: sanitizeMessages(s.messages),
TaskState: s.TaskState,
TotalInputTokens: s.TotalInputTokens,
TotalCachedInputTokens: s.TotalCachedInputTokens,
TotalCacheCreationTokens: s.TotalCacheCreationTokens,
TotalOutputTokens: s.TotalOutputTokens,
TotalRequests: s.TotalRequests,
Estimated: s.Estimated,
LastTurnCostUSD: s.LastTurnCostUSD,
SessionCostUSD: s.SessionCostUSD,
}
data, err := json.Marshal(ps)
if err != nil {
return fmt.Errorf("session save: %w", err)
}
// Atomic write: write to temp file, then rename to preserve last good snapshot.
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, data, 0600); err != nil {
return fmt.Errorf("session save tmp: %w", err)
}
return os.Rename(tmpPath, path)
}
// LoadPersistedAgent reads a PersistedAgent from a JSON file.
func LoadPersistedAgent(path string) (*PersistedAgent, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var ps PersistedAgent
if err := json.Unmarshal(data, &ps); err != nil {
return nil, fmt.Errorf("parse session %s: %w", path, err)
}
return &ps, nil
}
// RestoreHistory reconstructs a Session from a persisted message list.
func RestoreHistory(ps *PersistedAgent) *History {
s := &History{
messages: ps.Messages,
TaskState: ps.TaskState,
TotalInputTokens: ps.TotalInputTokens,
TotalCachedInputTokens: ps.TotalCachedInputTokens,
TotalCacheCreationTokens: ps.TotalCacheCreationTokens,
TotalOutputTokens: ps.TotalOutputTokens,
TotalRequests: ps.TotalRequests,
Estimated: ps.Estimated,
LastTurnCostUSD: ps.LastTurnCostUSD,
SessionCostUSD: ps.SessionCostUSD,
}
for i := range s.messages {
if s.messages[i].Role == "assistant" && s.messages[i].ID == "" {
s.messages[i].ID = NewResponseID()
}
}
for _, m := range ps.Messages {
if m.Role == "user" {
s.goal = m.Content
break
}
}
return s
}
// RestoreHistoryFromMessages reconstructs a History from just the message list.
// Used for multi-agent session restore where each agent has separate messages.
func RestoreHistoryFromMessages(messages []backend.Message) *History {
@ -244,21 +120,6 @@ func newHistory(goal string) *History {
}
// Checkpoint forks the session: returns a new Session that inherits the given
// TaskState but starts with a clean message history. This enables narrow-context
// sub-agents that know what to do without inheriting all parent message noise.
func (s *History) Checkpoint(ts TaskState) *History {
child := &History{
goal: ts.Objective,
TaskState: &ts,
}
// Seed with a user message so the child has a valid initial turn.
child.messages = []backend.Message{{
Role: "user",
Content: ts.render(),
}}
return child
}
func (s *History) history() []backend.Message {
return s.messages
}

View File

@ -2,7 +2,8 @@ package agent
import (
"os"
"path/filepath"
"ollie/paths"
"gopkg.in/yaml.v3"
)
@ -19,8 +20,7 @@ type modelsConfig struct {
// loadModelsConfig reads and parses the models config file.
// Returns zero value if the file doesn't exist or is invalid.
func loadModelsConfig() modelsConfig {
path := filepath.Join(cfgDir(), "models.yaml")
data, err := os.ReadFile(path)
data, err := os.ReadFile(paths.CfgDir() + "/models.yaml")
if err != nil {
return modelsConfig{}
}
@ -30,12 +30,3 @@ func loadModelsConfig() modelsConfig {
}
return cfg
}
// cfgDir returns the ollie config directory, respecting XDG_CONFIG_HOME.
func cfgDir() string {
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, "ollie")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "ollie")
}

View File

@ -389,16 +389,3 @@ func infoEvent(text string) Event {
return Event{Role: "info", Content: text + "\n"}
}
// firstSentence returns the first sentence of s (up to the first period or
// newline), trimmed. Falls back to s truncated at 80 chars if no sentence end found.
func firstSentence(s string) string {
for i, r := range s {
if r == '.' || r == '\n' {
return strings.TrimSpace(s[:i+1])
}
}
if len(s) > 80 {
return s[:77] + "..."
}
return s
}

View File

@ -334,40 +334,6 @@ func handleRPC(ctx context.Context, srv *toolsrv.Server, req rpcRequest, enc *lo
ok := srv.DismissDetached(params.PID)
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: ok})
case "can_parallelize":
var params struct {
Name string `json:"name"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}})
return
}
ok := srv.CanParallelize(params.Name)
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: ok})
case "memory_tier":
var params struct {
Name string `json:"name"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}})
return
}
tier := srv.MemoryTier(params.Name)
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: tier})
case "memory_tier_args":
var params struct {
Name string `json:"name"`
Args json.RawMessage `json:"args"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}})
return
}
tier := srv.MemoryTierArgs(params.Name, params.Args)
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: tier})
case "tool_load":
var params struct {
Name string `json:"name"`

View File

@ -322,7 +322,7 @@ func writeSessionCtl(ctx HandlerCtx, data []byte) error {
},
"save": func(_ []string, _ HandlerCtx) error {
if ctx.Session.Core != nil {
ctx.Session.Core.SaveSession("")
ctx.Session.Core.Save()
}
return nil
},

View File

@ -57,10 +57,6 @@ func (rs *RootState) RenameNode(oldName, newName string) {
}
}
func state(root *Tree) *RootState {
return root.Data.(*RootState)
}
// Shutdown interrupts all sessions, waits for idle, persists, and closes.
func Shutdown(root *Tree) {
session.Shutdown()

View File

@ -155,7 +155,7 @@ func New(cfg Config) *Session {
name = cfg.SessionID[:idx]
}
a := &Session{
s := &Session{
id: cfg.SessionID,
name: name,
env: make(map[string]string),
@ -185,18 +185,18 @@ func New(cfg Config) *Session {
SessionID: cfg.SessionID,
StartupMsgs: rt.Messages,
ReadPlanStep: cfg.ReadPlanStep,
Save: a.saveSession,
Flush: a.flushSave,
Save: s.saveSession,
Flush: s.flushSave,
})
a.agents = append(a.agents, ag)
ag.SetSessionEnv(a.id)
return a
s.agents = append(s.agents, ag)
ag.SetSessionEnv(s.id)
return s
}
// 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 {
func (s *Session) FindAgent(nameOrID string) *agent.Agent {
for _, ag := range s.agents {
if ag.Name() == nameOrID || ag.ID() == nameOrID {
return ag
}
@ -205,37 +205,37 @@ func (a *Session) FindAgent(nameOrID string) *agent.Agent {
}
// Agents returns the full agent slice.
func (a *Session) Agents() []*agent.Agent { return a.agents }
func (s *Session) Agents() []*agent.Agent { return s.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) {
func (s *Session) AgentAt(idx int) *agent.Agent {
if idx < 0 || idx >= len(s.agents) {
return nil
}
return a.agents[idx]
return s.agents[idx]
}
// AgentCount returns the number of agents.
func (a *Session) AgentCount() int { return len(a.agents) }
func (s *Session) AgentCount() int { return len(s.agents) }
// SaveFuncs returns save and flush callbacks suitable for use as
// SaveFuncs returns the save and flush callbacks for use in
// agent.AgentCfg.Save and Flush when adding agents to this session.
func (a *Session) SaveFuncs() (save func(), flush func()) {
return a.saveSession, a.flushSave
func (s *Session) SaveFuncs() (save func(), flush func()) {
return s.saveSession, s.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)
func (s *Session) AddAgent(ag *agent.Agent) {
s.agents = append(s.agents, ag)
ag.SetSessionEnv(s.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 {
func (s *Session) RemoveAgent(id string) bool {
idx := -1
for i, ag := range a.agents {
for i, ag := range s.agents {
if ag.ID() == id {
idx = i
break
@ -244,61 +244,61 @@ func (a *Session) RemoveAgent(id string) bool {
if idx < 0 {
return false
}
a.agents[idx].Close()
a.agents = append(a.agents[:idx], a.agents[idx+1:]...)
s.agents[idx].Close()
s.agents = append(s.agents[:idx], s.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 {
func (s *Session) Close() {
s.log.Debug("Close() session=%q", s.id)
s.flushSave()
for _, ag := range s.agents {
ag.Close()
}
if a.id != "" {
os.RemoveAll(filepath.Join(ollieTmpDir(), a.id)) //nolint:errcheck
if s.id != "" {
os.RemoveAll(filepath.Join(ollieTmpDir(), s.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 {
func (s *Session) SetEnv(key, value string) {
s.envMu.Lock()
s.env[key] = value
s.envMu.Unlock()
for _, ag := range s.agents {
ag.SetEnv(key, value)
}
}
// Remote returns the remote target (e.g., SSH host) if any.
func (a *Session) Remote() string {
return a.remote
func (s *Session) Remote() string {
return s.remote
}
// SetRemote sets the remote target for this session.
func (a *Session) SetRemote(remote string) {
a.remote = remote
func (s *Session) SetRemote(remote string) {
s.remote = remote
}
// SetSessionID renames the session.
func (a *Session) SetSessionID(newID string) error {
oldID := a.id
func (s *Session) SetSessionID(newID string) error {
oldID := s.id
if oldID == newID {
return nil
}
if a.sessionsDir != "" && oldID != "" {
if s.sessionsDir != "" && oldID != "" {
for _, suffix := range []string{".json", ".compaction.jsonl"} {
oldPath := a.activeSessionPath(oldID, suffix)
oldPath := s.activeSessionPath(oldID, suffix)
if _, err := os.Stat(oldPath); err == nil {
if err := os.Rename(oldPath, a.activeSessionPath(newID, suffix)); err != nil {
if err := os.Rename(oldPath, s.activeSessionPath(newID, suffix)); err != nil {
return fmt.Errorf("rename %s: %w", suffix, err)
}
}
}
}
a.id = newID
for _, ag := range a.agents {
s.id = newID
for _, ag := range s.agents {
ag.RenamePreamble(oldID, newID)
}
oldTemp := filepath.Join(ollieTmpDir(), oldID)
@ -306,161 +306,160 @@ func (a *Session) SetSessionID(newID string) error {
if _, err := os.Stat(oldTemp); err == nil {
os.Rename(oldTemp, newTemp) //nolint:errcheck
}
for _, ag := range a.agents {
for _, ag := range s.agents {
ag.SetSessionEnv(newID)
}
return nil
}
func (a *Session) activeSessionPath(id, suffix string) string {
return filepath.Join(a.sessionsDir, "active", id+suffix)
func (s *Session) activeSessionPath(id, suffix string) string {
return filepath.Join(s.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)
func (s *Session) saveSession() {
s.saveMu.Lock()
s.saveDirty = true
if s.saveTimer == nil {
s.saveTimer = time.AfterFunc(2*time.Second, s.flushSave)
}
a.saveMu.Unlock()
s.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
func (s *Session) flushSave() {
s.saveMu.Lock()
dirty := s.saveDirty
s.saveDirty = false
if s.saveTimer != nil {
s.saveTimer.Stop()
s.saveTimer = nil
}
a.saveMu.Unlock()
if !dirty || a.id == "" || a.sessionsDir == "" {
s.saveMu.Unlock()
if !dirty || s.id == "" || s.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)
if err := PersistSession(s.Name()); err != nil {
s.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())
// Save writes the current session state via PersistSession.
func (s *Session) Save() error {
return PersistSession(s.Name())
}
// --- Identity accessors ---
// ID returns the immutable session UUID.
func (a *Session) ID() string { return a.id }
func (s *Session) ID() string { return s.id }
// Name returns the mutable friendly name.
func (a *Session) Name() string {
a.mu.RLock()
defer a.mu.RUnlock()
return a.name
func (s *Session) Name() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.name
}
// SetName sets the friendly name.
func (a *Session) SetName(name string) {
a.mu.Lock()
defer a.mu.Unlock()
a.name = name
func (s *Session) SetName(name string) {
s.mu.Lock()
defer s.mu.Unlock()
s.name = name
}
// Uname returns the immutable user principal.
func (a *Session) Uname() string { return a.uname }
func (s *Session) Uname() string { return s.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
func (s *Session) SetUname(uname string) {
s.mu.Lock()
defer s.mu.Unlock()
s.uname = uname
}
// --- Context lifecycle ---
// Ctx returns the session context.
func (a *Session) Ctx() context.Context { return a.ctx }
func (s *Session) Ctx() context.Context { return s.ctx }
// Cancel cancels the session context.
func (a *Session) Cancel() {
if a.cancel != nil {
a.cancel()
func (s *Session) Cancel() {
if s.cancel != nil {
s.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
func (s *Session) SetContext(ctx context.Context, cancel context.CancelFunc) {
s.ctx = ctx
s.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
func (s *Session) Proc() *toolsrv.Process {
s.mu.RLock()
defer s.mu.RUnlock()
return s.proc
}
// SetProc sets the tool server process.
func (a *Session) SetProc(proc *toolsrv.Process) {
a.mu.Lock()
defer a.mu.Unlock()
a.proc = proc
func (s *Session) SetProc(proc *toolsrv.Process) {
s.mu.Lock()
defer s.mu.Unlock()
s.proc = proc
}
// Keeper returns the process keeper.
func (a *Session) Keeper() *toolsrv.ProcessKeeper {
a.mu.RLock()
defer a.mu.RUnlock()
return a.keeper
func (s *Session) Keeper() *toolsrv.ProcessKeeper {
s.mu.RLock()
defer s.mu.RUnlock()
return s.keeper
}
// SetKeeper sets the process keeper.
func (a *Session) SetKeeper(keeper *toolsrv.ProcessKeeper) {
a.mu.Lock()
defer a.mu.Unlock()
a.keeper = keeper
func (s *Session) SetKeeper(keeper *toolsrv.ProcessKeeper) {
s.mu.Lock()
defer s.mu.Unlock()
s.keeper = keeper
}
// ToolsConn returns the tool server connection.
func (a *Session) ToolsConn() toolsrv.Runner {
a.mu.RLock()
defer a.mu.RUnlock()
return a.toolsConn
func (s *Session) ToolsConn() toolsrv.Runner {
s.mu.RLock()
defer s.mu.RUnlock()
return s.toolsConn
}
// SetToolsConn sets the tool server connection.
func (a *Session) SetToolsConn(conn toolsrv.Runner) {
a.mu.Lock()
defer a.mu.Unlock()
a.toolsConn = conn
func (s *Session) SetToolsConn(conn toolsrv.Runner) {
s.mu.Lock()
defer s.mu.Unlock()
s.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
func (s *Session) IsPaused() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.paused
}
// SetPaused sets the paused state directly (used during restore).
func (a *Session) SetPaused(paused bool) {
a.mu.Lock()
defer a.mu.Unlock()
a.paused = paused
func (s *Session) SetPaused(paused bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.paused = paused
}
// IsConnected returns true if the tool server connection is alive.
// Returns false if the session is paused.
func (a *Session) IsConnected() bool {
a.mu.RLock()
paused := a.paused
conn := a.toolsConn
a.mu.RUnlock()
func (s *Session) IsConnected() bool {
s.mu.RLock()
paused := s.paused
conn := s.toolsConn
s.mu.RUnlock()
if paused || conn == nil {
return false
}
@ -473,61 +472,61 @@ func (a *Session) IsConnected() bool {
// Pause stops the tool server to save resources.
// It cancels the session context (stopping all agent operations),
// kills the tool server process, and marks the session as paused.
func (a *Session) Pause() error {
a.mu.Lock()
defer a.mu.Unlock()
if a.paused {
func (s *Session) Pause() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.paused {
return fmt.Errorf("session already paused")
}
// Cancel the session context to stop all agent operations.
if a.cancel != nil {
a.cancel()
if s.cancel != nil {
s.cancel()
}
// Close the tool server process.
if a.keeper != nil {
a.keeper.Close()
} else if a.proc != nil {
a.proc.Close()
if s.keeper != nil {
s.keeper.Close()
} else if s.proc != nil {
s.proc.Close()
}
// Close any existing connection.
if a.toolsConn != nil {
if c, ok := a.toolsConn.(interface{ Close() }); ok {
if s.toolsConn != nil {
if c, ok := s.toolsConn.(interface{ Close() }); ok {
c.Close()
}
a.toolsConn = nil
s.toolsConn = nil
}
a.paused = true
s.paused = true
// Persist the paused state.
go a.saveSession()
go s.saveSession()
return nil
}
// Resume restarts the tool server after a Pause.
// It creates a fresh session context and respawns the tool server.
// For sessions restored in paused state (no keeper), it sets up the tool server from scratch.
func (a *Session) Resume() error {
a.mu.Lock()
defer a.mu.Unlock()
if !a.paused {
func (s *Session) Resume() error {
s.mu.Lock()
defer s.mu.Unlock()
if !s.paused {
return fmt.Errorf("session not paused")
}
// Create a fresh context since the old one was cancelled on pause.
ctx, cancel := context.WithCancel(serverCtx)
a.ctx = ctx
a.cancel = cancel
s.ctx = ctx
s.cancel = cancel
// If we have a keeper, use it to respawn. Otherwise set up from scratch.
if a.keeper != nil {
a.keeper.SetContext(ctx)
conn, err := a.keeper.Dial()
if s.keeper != nil {
s.keeper.SetContext(ctx)
conn, err := s.keeper.Dial()
if err != nil {
return fmt.Errorf("resume failed: %w", err)
}
a.toolsConn = conn
s.toolsConn = conn
} else {
// Session was restored paused without infra - set up per-agent.
for i, ag := range a.agents {
for i, ag := range s.agents {
cwd := ag.Cwd()
if cwd == "" {
cwd, _ = os.Getwd()
@ -535,18 +534,18 @@ func (a *Session) Resume() error {
// First agent spawns the tool server, others reuse
var reuseFrom *InfraConfig
if a.proc != nil {
if s.proc != nil {
reuseFrom = &InfraConfig{
Proc: a.proc,
Keeper: a.keeper,
ToolsConn: a.toolsConn,
Proc: s.proc,
Keeper: s.keeper,
ToolsConn: s.toolsConn,
}
}
infra, err := SetupToolServer(ToolServerConfig{
Ctx: ctx,
CWD: cwd,
RemoteTarget: a.remote,
RemoteTarget: s.remote,
Yolo: pkgYolo,
ReuseFrom: reuseFrom,
})
@ -556,45 +555,45 @@ func (a *Session) Resume() error {
// First agent sets session infra
if i == 0 {
a.proc = infra.Proc
a.keeper = infra.Keeper
a.toolsConn = infra.ToolsConn
s.proc = infra.Proc
s.keeper = infra.Keeper
s.toolsConn = infra.ToolsConn
}
ag.SetToolServer(infra.NewToolServer, infra.ToolsConn)
}
}
a.paused = false
s.paused = false
// Persist the resumed state.
go a.saveSession()
go s.saveSession()
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
func (s *Session) SetDisallowTools(disallow map[string]struct{}) {
s.mu.Lock()
defer s.mu.Unlock()
s.disallowTools = disallow
}
// LoadTool loads a tool into the tool server.
func (a *Session) LoadTool(name string, ag *agent.Agent) error {
func (s *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()
s.mu.RLock()
_, blocked := s.disallowTools[name]
s.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
if s.toolsConn != nil {
runner = s.toolsConn
} else if ag != nil {
runner = ag.ToolServer()
}

View File

@ -151,42 +151,6 @@ func (c *Conn) DismissDetached(pid int) bool {
return ok
}
// CanParallelize implements ParallelClassifier.
func (c *Conn) CanParallelize(name string) bool {
params, _ := json.Marshal(map[string]string{"name": name})
resp, err := c.call("can_parallelize", params)
if err != nil {
return false
}
var ok bool
json.Unmarshal(resp, &ok)
return ok
}
// MemoryTier implements MemoryTierClassifier.
func (c *Conn) MemoryTier(name string) string {
params, _ := json.Marshal(map[string]string{"name": name})
resp, err := c.call("memory_tier", params)
if err != nil {
return "hot"
}
var tier string
json.Unmarshal(resp, &tier)
return tier
}
// MemoryTierArgs implements MemoryTierClassifier.
func (c *Conn) MemoryTierArgs(name string, args json.RawMessage) string {
params, _ := json.Marshal(map[string]any{"name": name, "args": args})
resp, err := c.call("memory_tier_args", params)
if err != nil {
return "hot"
}
var tier string
json.Unmarshal(resp, &tier)
return tier
}
// Close shuts down the connection and subprocess.
func (c *Conn) Close() {
c.fail(fmt.Errorf("connection closed"))

View File

@ -130,40 +130,6 @@ func (ts *testServer) handleRPC(ctx context.Context, req rpcRequest) rpcResponse
ts.srv.SetAllowTools(params.Names)
return rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: json.RawMessage(`true`)}
case "can_parallelize":
var params struct {
Name string `json:"name"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
return rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}
}
ok := ts.srv.CanParallelize(params.Name)
result, _ := json.Marshal(ok)
return rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: result}
case "memory_tier":
var params struct {
Name string `json:"name"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
return rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}
}
tier := ts.srv.MemoryTier(params.Name)
result, _ := json.Marshal(tier)
return rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: result}
case "memory_tier_args":
var params struct {
Name string `json:"name"`
Args json.RawMessage `json:"args"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
return rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}}
}
tier := ts.srv.MemoryTierArgs(params.Name, params.Args)
result, _ := json.Marshal(tier)
return rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: result}
case "detach":
ok := ts.srv.Detach()
result, _ := json.Marshal(ok)
@ -449,78 +415,6 @@ func TestRPC_SetAllowTools(t *testing.T) {
}
}
func TestRPC_CanParallelize(t *testing.T) {
ts := newTestServer(t)
defer ts.srv.Close()
ctx := context.Background()
params, _ := json.Marshal(map[string]string{"name": "unknown_tool"})
req := rpcRequest{JSONRPC: "2.0", ID: 1, Method: "can_parallelize", Params: params}
resp := ts.handleRPC(ctx, req)
if resp.Error != nil {
t.Fatalf("unexpected error: %v", resp.Error.Message)
}
var result bool
if err := json.Unmarshal(resp.Result, &result); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
// Unknown tools default to not parallel-read-safe
if result {
t.Error("expected false for unknown tool")
}
}
func TestRPC_MemoryTier(t *testing.T) {
ts := newTestServer(t)
defer ts.srv.Close()
ctx := context.Background()
params, _ := json.Marshal(map[string]string{"name": "unknown_tool"})
req := rpcRequest{JSONRPC: "2.0", ID: 1, Method: "memory_tier", Params: params}
resp := ts.handleRPC(ctx, req)
if resp.Error != nil {
t.Fatalf("unexpected error: %v", resp.Error.Message)
}
var tier string
if err := json.Unmarshal(resp.Result, &tier); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
// Unknown tools default to "hot"
if tier != "hot" {
t.Errorf("expected tier='hot', got %q", tier)
}
}
func TestRPC_MemoryTierArgs(t *testing.T) {
ts := newTestServer(t)
defer ts.srv.Close()
ctx := context.Background()
params, _ := json.Marshal(map[string]any{
"name": "some_tool",
"args": json.RawMessage(`{"foo": "bar"}`),
})
req := rpcRequest{JSONRPC: "2.0", ID: 1, Method: "memory_tier_args", Params: params}
resp := ts.handleRPC(ctx, req)
if resp.Error != nil {
t.Fatalf("unexpected error: %v", resp.Error.Message)
}
var tier string
if err := json.Unmarshal(resp.Result, &tier); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
// Unknown tools default to "hot"
if tier != "hot" {
t.Errorf("expected tier='hot', got %q", tier)
}
}
func TestRPC_Detach_NoRunningProcess(t *testing.T) {
ts := newTestServer(t)
defer ts.srv.Close()
@ -700,17 +594,6 @@ func TestRPC_PipeConnection(t *testing.T) {
// Test set cwd
conn.SetCWD("/tmp/test")
// Test is_parallel_read
if conn.CanParallelize("unknown") {
t.Error("expected CanParallelize=false for unknown tool")
}
// Test result_tier
tier := conn.MemoryTier("unknown")
if tier != "hot" {
t.Errorf("expected tier='hot', got %q", tier)
}
// Clean up
cancel()
conn.Close()

View File

@ -1,64 +0,0 @@
package toolsrv
import (
"encoding/json"
)
// MemoryTier implements MemoryTierClassifier. Looks up the tool's tier
// from the registry, then from its .meta file.
func (e *Server) MemoryTier(name string) string {
if e.toolRegistry != nil && e.sessionID != "" {
if info, ok := e.toolRegistry.Lookup(e.sessionID, name); ok && info.Tier != "" {
return info.Tier
}
}
m, err := LoadMetaFile(name)
if err == nil && m != nil && m.Tier != "" {
return m.Tier
}
return "hot"
}
// OutputFormat returns the source-fence language for a tool's output.
// Empty means the output should be wrapped in a plaintext fence.
func (e *Server) OutputFormat(name string) string {
if e.toolRegistry != nil && e.sessionID != "" {
if info, ok := e.toolRegistry.Lookup(e.sessionID, name); ok {
return info.OutputFormat
}
}
m, err := LoadMetaFile(name)
if err == nil && m != nil {
if resolved := m.Resolve(); resolved != nil {
return resolved.OutputFormat
}
}
return ""
}
// MemoryTierArgs classifies the tier using both the outer tool name and its
// arguments. For shell, it returns warm. For promoted tools, it
// delegates to MemoryTier.
func (e *Server) MemoryTierArgs(name string, args json.RawMessage) string {
switch name {
case "shell":
return "warm"
default:
return e.MemoryTier(name)
}
}
// CanParallelize implements ParallelClassifier. Returns true when the
// named tool is marked readOnly in its .meta file.
func (e *Server) CanParallelize(name string) bool {
if e.toolRegistry != nil && e.sessionID != "" {
if info, ok := e.toolRegistry.Lookup(e.sessionID, name); ok {
return info.ReadOnly
}
}
m, err := LoadMetaFile(name)
if err == nil && m != nil {
return m.ReadOnly
}
return false
}