feat(agent): implement /subagents command and fix sub-turn observability

- Added `/subagents` platform command to visualize the active task tree.
- Implemented GetAllActiveTurns and FormatTree in AgentLoop to support cross-session observability.
- Fixed a bug where sub-turns spawned via tools were not registered in the global `activeTurnStates` map, making them invisible to system queries.
- Enhanced tree rendering logic to identify and display "orphaned" subagents (children that outlive their parent turns).
- Registered the new command in `builtin.go` and injected the turn state provider into the commands runtime.

Modified Files:
- pkg/agent/turn_state.go: Added TurnInfo snapshotting and recursive tree formatting.
- pkg/agent/loop.go: Injected GetActiveTurn hook and implemented multi-root forest rendering.
- pkg/agent/subturn.go: Added child turn registration into activeTurnStates.
- pkg/commands/cmd_subagents.go: New command implementation.
- pkg/commands/builtin.go: Command registration.
This commit is contained in:
Administrator 2026-03-18 14:46:20 +08:00
parent e20ff43f8b
commit 777230dcd1
6 changed files with 148 additions and 0 deletions

View file

@ -2143,6 +2143,33 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
} }
return al.channelManager.GetEnabledChannels() return al.channelManager.GetEnabledChannels()
}, },
GetActiveTurn: func() interface{} {
turns := al.GetAllActiveTurns()
if len(turns) == 0 {
return nil
}
// Map to quickly check active turn existence
activeTurnMap := make(map[string]bool)
for _, t := range turns {
activeTurnMap[t.TurnID] = true
}
// Find effective roots (Depth == 0, OR parent is not active anymore)
var effectiveRoots []*TurnInfo
for _, t := range turns {
if t.Depth == 0 || !activeTurnMap[t.ParentTurnID] {
effectiveRoots = append(effectiveRoots, t)
}
}
var fullTree strings.Builder
for i, turnInfo := range effectiveRoots {
isLastRoot := (i == len(effectiveRoots)-1)
fullTree.WriteString(al.FormatTree(turnInfo, "", isLastRoot))
}
return fullTree.String()
},
SwitchChannel: func(value string) error { SwitchChannel: func(value string) error {
if al.channelManager == nil { if al.channelManager == nil {
return fmt.Errorf("channel manager not initialized") return fmt.Errorf("channel manager not initialized")

View file

@ -282,6 +282,10 @@ func spawnSubTurn(ctx context.Context, al *AgentLoop, parentTS *turnState, cfg S
childCtx = withTurnState(childCtx, childTS) childCtx = withTurnState(childCtx, childTS)
childCtx = WithAgentLoop(childCtx, al) // Propagate AgentLoop to child turn childCtx = WithAgentLoop(childCtx, al) // Propagate AgentLoop to child turn
// Register child turn state so GetAllActiveTurns/Subagents can find it
al.activeTurnStates.Store(childID, childTS)
defer al.activeTurnStates.Delete(childID)
// 5. Establish parent-child relationship (thread-safe) // 5. Establish parent-child relationship (thread-safe)
parentTS.mu.Lock() parentTS.mu.Lock()
parentTS.childTurnIDs = append(parentTS.childTurnIDs, childID) parentTS.childTurnIDs = append(parentTS.childTurnIDs, childID)

View file

@ -2,6 +2,8 @@ package agent
import ( import (
"context" "context"
"fmt"
"strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
@ -109,6 +111,77 @@ func (ts *turnState) Info() *TurnInfo {
} }
} }
// GetAllActiveTurns retrieves information about all currently active turns across all sessions.
func (al *AgentLoop) GetAllActiveTurns() []*TurnInfo {
var turns []*TurnInfo
al.activeTurnStates.Range(func(key, value interface{}) bool {
if ts, ok := value.(*turnState); ok {
turns = append(turns, ts.Info())
}
return true
})
return turns
}
// FormatTree recursively builds a string representation of the active turn tree.
func (al *AgentLoop) FormatTree(turnInfo *TurnInfo, prefix string, isLast bool) string {
if turnInfo == nil {
return ""
}
var sb strings.Builder
// Print current node
marker := "├── "
if isLast {
marker = "└── "
}
if turnInfo.Depth == 0 {
marker = "" // Root node no marker
}
status := "Running"
if turnInfo.IsFinished {
status = "Finished"
}
orphanMarker := ""
if turnInfo.Depth > 0 && prefix == "" {
orphanMarker = " (Orphaned)"
}
sb.WriteString(fmt.Sprintf("%s%s[%s] Depth:%d (%s)%s\n", prefix, marker, turnInfo.TurnID, turnInfo.Depth, status, orphanMarker))
// Prepare prefix for children
childPrefix := prefix
if turnInfo.Depth > 0 {
if isLast {
childPrefix += " "
} else {
childPrefix += "│ "
}
}
for i, childID := range turnInfo.ChildTurnIDs {
// Look up child turn state
childInfo := al.GetActiveTurn(childID)
if childInfo != nil {
isLastChild := (i == len(turnInfo.ChildTurnIDs)-1)
sb.WriteString(al.FormatTree(childInfo, childPrefix, isLastChild))
} else {
// Child might have already been removed from active states if it finished early
isLastChild := (i == len(turnInfo.ChildTurnIDs)-1)
cMarker := "├── "
if isLastChild {
cMarker = "└── "
}
sb.WriteString(fmt.Sprintf("%s%s[%s] (Completed/Cleaned Up)\n", childPrefix, cMarker, childID))
}
}
return sb.String()
}
// ====================== Helper Functions ====================== // ====================== Helper Functions ======================
func newTurnState(ctx context.Context, id string, parent *turnState) *turnState { func newTurnState(ctx context.Context, id string, parent *turnState) *turnState {

View file

@ -13,5 +13,6 @@ func BuiltinDefinitions() []Definition {
switchCommand(), switchCommand(),
checkCommand(), checkCommand(),
clearCommand(), clearCommand(),
subagentsCommand(),
} }
} }

View file

@ -0,0 +1,42 @@
package commands
import (
"context"
"fmt"
)
// TurnInfo is a mirrored struct from agent.TurnInfo to avoid circular dependencies.
type TurnInfo struct {
TurnID string
ParentTurnID string
Depth int
ChildTurnIDs []string
IsFinished bool
}
func subagentsCommand() Definition {
return Definition{
Name: "subagents",
Description: "Show running subagents and task tree",
Handler: func(ctx context.Context, req Request, rt *Runtime) error {
getTurnFn := rt.GetActiveTurn
if getTurnFn == nil {
return req.Reply("Runtime does not support querying active turns.")
}
turnRaw := getTurnFn()
if turnRaw == nil {
return req.Reply("No active tasks running in this session.")
}
if treeStr, ok := turnRaw.(string); ok {
if treeStr == "" {
return req.Reply("No active tasks running in this session.")
}
return req.Reply(fmt.Sprintf("🤖 **Active Subagents Tree**\n```text\n%s\n```", treeStr))
}
return req.Reply(fmt.Sprintf("🤖 **Active Subagents List**\n```text\n%+v\n```", turnRaw))
},
}
}

View file

@ -11,6 +11,7 @@ type Runtime struct {
ListAgentIDs func() []string ListAgentIDs func() []string
ListDefinitions func() []Definition ListDefinitions func() []Definition
GetEnabledChannels func() []string GetEnabledChannels func() []string
GetActiveTurn func() interface{} // Returning interface{} to avoid circular dependency with agent package
SwitchModel func(value string) (oldModel string, err error) SwitchModel func(value string) (oldModel string, err error)
SwitchChannel func(value string) error SwitchChannel func(value string) error
ClearHistory func() error ClearHistory func() error