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:
parent
e20ff43f8b
commit
777230dcd1
6 changed files with 148 additions and 0 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -13,5 +13,6 @@ func BuiltinDefinitions() []Definition {
|
|||
switchCommand(),
|
||||
checkCommand(),
|
||||
clearCommand(),
|
||||
subagentsCommand(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
42
pkg/commands/cmd_subagents.go
Normal file
42
pkg/commands/cmd_subagents.go
Normal 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))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue