From 777230dcd134d59a36a7200b8004e7742792b822 Mon Sep 17 00:00:00 2001 From: Administrator <1280842908@qq.com> Date: Wed, 18 Mar 2026 14:46:20 +0800 Subject: [PATCH] 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. --- pkg/agent/loop.go | 27 +++++++++++++ pkg/agent/subturn.go | 4 ++ pkg/agent/turn_state.go | 73 +++++++++++++++++++++++++++++++++++ pkg/commands/builtin.go | 1 + pkg/commands/cmd_subagents.go | 42 ++++++++++++++++++++ pkg/commands/runtime.go | 1 + 6 files changed, 148 insertions(+) create mode 100644 pkg/commands/cmd_subagents.go diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index d9f9e6371..02253b753 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -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") diff --git a/pkg/agent/subturn.go b/pkg/agent/subturn.go index 7a9cb3304..b3fe71518 100644 --- a/pkg/agent/subturn.go +++ b/pkg/agent/subturn.go @@ -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) diff --git a/pkg/agent/turn_state.go b/pkg/agent/turn_state.go index 62c3cf69b..ff2bf0d68 100644 --- a/pkg/agent/turn_state.go +++ b/pkg/agent/turn_state.go @@ -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 { diff --git a/pkg/commands/builtin.go b/pkg/commands/builtin.go index aed6a1874..31a5a8ced 100644 --- a/pkg/commands/builtin.go +++ b/pkg/commands/builtin.go @@ -13,5 +13,6 @@ func BuiltinDefinitions() []Definition { switchCommand(), checkCommand(), clearCommand(), + subagentsCommand(), } } diff --git a/pkg/commands/cmd_subagents.go b/pkg/commands/cmd_subagents.go new file mode 100644 index 000000000..29321823c --- /dev/null +++ b/pkg/commands/cmd_subagents.go @@ -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)) + }, + } +} diff --git a/pkg/commands/runtime.go b/pkg/commands/runtime.go index 037184686..10f77edbd 100644 --- a/pkg/commands/runtime.go +++ b/pkg/commands/runtime.go @@ -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