fs: add agent-level kill, remove activeAgentID from session

Add 'kill' command to agent ctl (removes agent from session).
Add Session.RemoveAgent() method.
Remove activeAgentID field, Agent(), SetActiveAgent, and dead
WaitChange — 'active agent' is a GUI concept, not a server one.
All callers updated to iterate Agents() or use FindAgent(id).
This commit is contained in:
Levi Neely 2026-08-03 10:37:29 +02:00
parent 3f1e8bfd8b
commit 911733bd8a
6 changed files with 74 additions and 103 deletions

View File

@ -410,6 +410,11 @@ func handleAgentCtl(a *agent.Agent, s *Session, data []byte) error {
return nil
}
switch cmd[0] {
case "kill":
if s.Core != nil {
s.Core.RemoveAgent(a.ID())
PostEvent("A kill session/" + s.Name() + "/agent/" + a.ID())
}
case "stop":
a.Interrupt(agent.ErrInterrupted)
case "compact":
@ -426,12 +431,6 @@ func handleAgentCtl(a *agent.Agent, s *Session, data []byte) error {
be.SetModel(strings.Join(cmd[1:], " "))
}
}
case "agents":
if len(cmd) > 1 {
if s.Core != nil {
s.Core.SetActiveAgent(strings.Join(cmd[1:], " "))
}
}
case "name":
if len(cmd) > 1 {
a.SetName(strings.Join(cmd[1:], " "))

View File

@ -179,9 +179,13 @@ func Rename(rs *rootState, old, new string) error {
rs.mu.Unlock()
return fmt.Errorf("session already exists: %s", new)
}
if sess.Core != nil && sess.Core.Agent().IsRunning() {
rs.mu.Unlock()
return fmt.Errorf("cannot rename while agent is running")
if sess.Core != nil {
for _, ag := range sess.Core.Agents() {
if ag.IsRunning() {
rs.mu.Unlock()
return fmt.Errorf("cannot rename while agent is running")
}
}
}
sess.name = new
rs.sessions[new] = sess
@ -231,8 +235,15 @@ func waitIdle(rs *rootState, poll, timeout time.Duration) {
allIdle := true
rs.mu.RLock()
for _, sess := range rs.sessions {
if sess.Core != nil && sess.Core.Agent().State() != "idle" {
allIdle = false
if sess.Core != nil {
for _, ag := range sess.Core.Agents() {
if ag.State() != "idle" {
allIdle = false
break
}
}
}
if !allIdle {
break
}
}
@ -252,7 +263,9 @@ func InterruptAll(root *Tree) {
defer rs.mu.RUnlock()
for _, sess := range rs.sessions {
if sess.Core != nil {
sess.Core.Agent().Interrupt(agent.ErrInterrupted)
for _, ag := range sess.Core.Agents() {
ag.Interrupt(agent.ErrInterrupted)
}
}
}
}
@ -617,7 +630,7 @@ func CreateAgent(rs *rootState, sessName string, args []string) error {
}
rs.mu.Unlock()
ag := sess.Core.Agent()
ag := sess.Core.FindAgent(uname)
ag.Submit(sess.SessionCtx, "/agent "+profile)
if name != "" {
ag.SetName(name)
@ -638,8 +651,9 @@ func CreateAgentFromRoot(root *Tree, sessName string, args []string) (string, er
rs.mu.RLock()
sess := rs.sessions[sessName]
rs.mu.RUnlock()
if sess == nil || sess.Core == nil {
if sess == nil || sess.Core == nil || sess.Core.AgentCount() == 0 {
return "", fmt.Errorf("agent not created")
}
return sess.Core.Agent().ID(), nil
agents := sess.Core.Agents()
return agents[len(agents)-1].ID(), nil
}

View File

@ -38,14 +38,14 @@ func persistSession(rs *rootState, id string) {
if !ok {
return
}
if sess.Core == nil || sess.Core.Agent() == nil {
if sess.Core == nil || sess.Core.AgentCount() == 0 {
// Empty session — no agent to persist. Silently skip.
return
}
dir := activeSessionsDir(rs)
os.MkdirAll(dir, 0700)
path := filepath.Join(dir, sess.id+".json")
ag := sess.Core.Agent()
ag := sess.Core.Agents()[0]
if err := ag.SaveFull(path, sess.id, sess.Name(), sess.Core.CWD(), ""); err != nil {
rs.cfg.Log.Error("persist session %s: %v", id, err)
}
@ -278,7 +278,7 @@ func restoreSession(rs *rootState, ps *agent.PersistedAgent) error {
sess.toolsConn = toolSrv
// Restore agent display name from persisted session.
ag := sess.Core.Agent()
ag := sess.Core.FindAgent(uname)
if ps.Agent != "" {
ag.SetName(ps.Agent)
}
@ -287,7 +287,6 @@ func restoreSession(rs *rootState, ps *agent.PersistedAgent) error {
// Pre-load autoLoad tools into the remote.
if cfg != nil {
ag := sess.Core.Agent()
for _, tl := range cfg.AutoLoad {
if err := sess.loadTool(tl, ag); err != nil {
rs.cfg.Log.Error("session %s: autoLoad tool %q: %v", sessID, tl, err)

View File

@ -96,7 +96,8 @@ func listSessions(ctx HandlerCtx) ([]FsNodeDecl, error) {
if err := CreateAgent(rs, s.Name(), args); err != nil {
return nil, fmt.Errorf("create agent: %w", err)
}
return []byte(s.Core.Agent().ID() + "\n"), nil
agents := s.Core.Agents()
return []byte(agents[len(agents)-1].ID() + "\n"), nil
})),
// {aid} — dynamic per-agent directory

View File

@ -123,7 +123,11 @@ func (sess *Session) CachedListModels() string {
sess.modelsMu.Unlock()
// Cache miss — fetch and store.
models := sess.Core.Agent().ListModels()
agents := sess.Core.Agents()
if len(agents) == 0 {
return ""
}
models := agents[0].ListModels()
result := strings.Join(models, "\n")
sess.modelsMu.Lock()
sess.modelsCache = result
@ -217,7 +221,9 @@ func (sess *Session) Cancel() {
}
func (sess *Session) Interrupt() {
sess.Core.Agent().Interrupt(agent.ErrInterrupted)
for _, ag := range sess.Core.Agents() {
ag.Interrupt(agent.ErrInterrupted)
}
}
// --- AgentLog methods ---

View File

@ -1,7 +1,6 @@
package session
import (
"context"
"crypto/rand"
"fmt"
"io"
@ -43,8 +42,7 @@ type Config struct {
}
// Session is the concrete session type. It owns session-level state and
// delegates agent operations to its owned agents. Operations that act on
// "the agent" (Agent(), CWD, etc.) use the agent identified by immutable activeAgentID.
// delegates agent operations to its owned agents.
type Session struct {
id string
envMu sync.RWMutex
@ -52,9 +50,8 @@ type Session struct {
plan []byte
prevPrompt string
agents []*agent.Agent
activeAgentID string // immutable ID of the agent returned by Agent()
log *olog.Logger
agents []*agent.Agent
log *olog.Logger
sessionsDir string
listHandlers map[string]func() []string
remote string
@ -165,39 +162,10 @@ func New(cfg Config) *Session {
})
a.agents = append(a.agents, ag)
a.activeAgentID = ag.ID()
ag.SetSessionEnv(a.id)
return a
}
// Agent returns the currently active agent by immutable ID.
// If no active agent is set, returns the first agent (backward compatible).
func (a *Session) Agent() *agent.Agent {
if len(a.agents) == 0 {
return nil
}
if a.activeAgentID != "" {
for _, ag := range a.agents {
if ag.ID() == a.activeAgentID {
return ag
}
}
}
// Fallback: first agent or match by ID
return a.agents[0]
}
// SetActiveAgent selects an agent by immutable ID.
func (a *Session) SetActiveAgent(id string) error {
for _, ag := range a.agents {
if ag.ID() == id {
a.activeAgentID = id
return nil
}
}
return fmt.Errorf("agent %q not found", id)
}
// 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 {
@ -229,15 +197,30 @@ func (a *Session) SaveFuncs() (save func(), flush func()) {
return a.saveSession, a.flushSave
}
// AddAgent appends an agent to the session and sets it as the active agent.
// AddAgent appends an agent to the session.
func (a *Session) AddAgent(ag *agent.Agent) {
a.agents = append(a.agents, ag)
if a.activeAgentID == "" {
a.activeAgentID = ag.ID()
}
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)
@ -261,10 +244,10 @@ func (a *Session) SetEnv(key, value string) {
}
// CWD returns the current working directory for tool execution
// (from the primary agent).
// (from the first agent).
func (a *Session) CWD() string {
if ag := a.Agent(); ag != nil {
if c := ag.Cwd(); c != "" {
if len(a.agents) > 0 {
if c := a.agents[0].Cwd(); c != "" {
return c
}
}
@ -272,7 +255,7 @@ func (a *Session) CWD() string {
return wd
}
// SetCWD validates and sets the working directory on the primary agent.
// SetCWD validates and sets the working directory on all agents.
func (a *Session) SetCWD(dir string) error {
dir = paths.ExpandHome(dir)
if dir != "" {
@ -280,7 +263,7 @@ func (a *Session) SetCWD(dir string) error {
return fmt.Errorf("cwd: %w", err)
}
}
if ag := a.Agent(); ag != nil {
for _, ag := range a.agents {
ag.SetCWD(dir)
}
return nil
@ -311,45 +294,13 @@ func (a *Session) SetSessionID(newID string) error {
if _, err := os.Stat(oldTemp); err == nil {
os.Rename(oldTemp, newTemp) //nolint:errcheck
}
if ag := a.Agent(); ag != nil {
for _, ag := range a.agents {
ag.SetSessionEnv(newID)
}
return nil
}
// WaitChange blocks until the named field changes from current.
func (a *Session) WaitChange(ctx context.Context, field, current string) (string, bool) {
ag := a.Agent()
if ag == nil {
return "", false
}
if field == agent.WatchState {
return ag.WaitChange(ctx, field, current)
}
// Other fields — read via agent methods, use agent's change signal.
read := func() string {
switch field {
case "usage":
return ag.UsageStr()
case "ctxsz":
return ag.CtxSz()
case "cwd":
return a.CWD()
case "agent":
return ag.ID()
}
return ""
}
stop := context.AfterFunc(ctx, func() { ag.BroadcastChange() })
defer stop()
for ctx.Err() == nil {
if v := read(); v != current {
return v, true
}
ag.WaitForChange(ctx)
}
return "", false
}
func (a *Session) activeSessionPath(id, suffix string) string {
return filepath.Join(a.sessionsDir, "active", id+suffix)
@ -381,7 +332,8 @@ func (a *Session) flushSave() {
a.log.Error("session save: %v", err)
return
}
if ag := a.Agent(); ag != nil {
if len(a.agents) > 0 {
ag := a.agents[0]
if err := ag.SaveFull(path, a.id, "", a.CWD(), a.remote); err != nil {
a.log.Error("session save: %v", err)
}
@ -391,8 +343,8 @@ func (a *Session) flushSave() {
// SaveSession writes the current session state to the given path.
// Returns nil if there is no agent (empty session).
func (a *Session) SaveSession(path string) error {
if ag := a.Agent(); ag != nil {
return ag.SaveFull(path, a.id, "", a.CWD(), a.remote)
if len(a.agents) > 0 {
return a.agents[0].SaveFull(path, a.id, "", a.CWD(), a.remote)
}
return nil
}