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()
},
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 {
if al.channelManager == nil {
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 = 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)
parentTS.mu.Lock()
parentTS.childTurnIDs = append(parentTS.childTurnIDs, childID)

View file

@ -2,6 +2,8 @@ package agent
import (
"context"
"fmt"
"strings"
"sync"
"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 ======================
func newTurnState(ctx context.Context, id string, parent *turnState) *turnState {

View file

@ -13,5 +13,6 @@ func BuiltinDefinitions() []Definition {
switchCommand(),
checkCommand(),
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
ListDefinitions func() []Definition
GetEnabledChannels func() []string
GetActiveTurn func() interface{} // Returning interface{} to avoid circular dependency with agent package
SwitchModel func(value string) (oldModel string, err error)
SwitchChannel func(value string) error
ClearHistory func() error