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()
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -13,5 +13,6 @@ func BuiltinDefinitions() []Definition {
|
||||||
switchCommand(),
|
switchCommand(),
|
||||||
checkCommand(),
|
checkCommand(),
|
||||||
clearCommand(),
|
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
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue