Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e60b456
feat(tools): add SpawnStatusTool for reporting subagent statuses
SHINE-six Mar 14, 2026
2ce7aeb
feat(tools): enhance SpawnStatusTool to restrict task visibility by c…
SHINE-six Mar 14, 2026
af6ef94
feat(tests): add Unicode result truncation and channel filtering test…
SHINE-six Mar 14, 2026
100da45
Potential fix for pull request finding
SHINE-six Mar 14, 2026
6d37001
feat(tools): enhance SpawnStatusTool with task ID validation and sort…
SHINE-six Mar 14, 2026
eb8c193
Merge branch 'main' of https://github.com/SHINE-six/picoclaw
SHINE-six Mar 14, 2026
e7518e8
feat(tools): update SpawnStatusTool description and parameter documen…
SHINE-six Mar 14, 2026
de6a04b
refactor(tests): improve comments for clarity in ChannelFiltering tes…
SHINE-six Mar 14, 2026
9a2cf38
fix(tools): update no subagents message for clarity and remove unnece…
SHINE-six Mar 15, 2026
12f570e
fix(tools): improve description clarity for SpawnStatusTool regarding…
SHINE-six Mar 15, 2026
70b9676
feat(tools): add spawn_status tool configuration and registration
SHINE-six Mar 15, 2026
7614f03
Potential fix for pull request finding
SHINE-six Mar 15, 2026
521db34
fix(agent): improve subagent management for spawn and spawn_status tools
SHINE-six Mar 15, 2026
a8f7c86
Potential fix for pull request finding
SHINE-six Mar 15, 2026
04d7d03
Merge branch 'main' of https://github.com/SHINE-six/picoclaw
SHINE-six Mar 15, 2026
c908b84
Potential fix for pull request finding
lxowalle Mar 15, 2026
4f3b7c6
fix(tests): update ResultTruncation_Unicode test to use valid CJK cha…
SHINE-six Mar 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions pkg/agent/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,20 +222,26 @@ func registerSharedTools(
}
}

// Spawn tool with allowlist checker
if cfg.Tools.IsToolEnabled("spawn") {
if cfg.Tools.IsToolEnabled("subagent") {
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
// Spawn and spawn_status tools share a SubagentManager.
// Construct it when either tool is enabled (both require subagent).
spawnEnabled := cfg.Tools.IsToolEnabled("spawn")
spawnStatusEnabled := cfg.Tools.IsToolEnabled("spawn_status")
if (spawnEnabled || spawnStatusEnabled) && cfg.Tools.IsToolEnabled("subagent") {
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
if spawnEnabled {
spawnTool := tools.NewSpawnTool(subagentManager)
currentAgentID := agentID
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
})
agent.Tools.Register(spawnTool)
} else {
logger.WarnCF("agent", "spawn tool requires subagent to be enabled", nil)
}
if spawnStatusEnabled {
agent.Tools.Register(tools.NewSpawnStatusTool(subagentManager))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say this is a deliberate design choice, not a bug. spawn_status is a read-only companion to spawn β€” it has no independent purpose without the spawn tool. The pattern of
registering a status/query tool alongside its parent tool is reasonable. Other examples in the codebase show similar patterns (e.g., find_skills is tightly coupled to skills).

Adding a separate config toggle for spawn_status would be over-engineering β€” no user would want spawn enabled but spawn_status disabled. The current behavior
(implicitly enabled with spawn) is the correct default.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @SHINE-six , this tool is not necessary for all users. Please add a configuration option to enable whether this tool should be used and default is false.

Copy link
Contributor Author

@SHINE-six SHINE-six Mar 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lxowalle Since spawn and subagent is default to true, I think for this to default to true will be better? Often time usage with spawn (async), user/agents would need to check on it for task status progress

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hope tool list can be kept as small as possible, retaining only the necessary tools. I believe that in most cases, retrieving the status of subagents is only necessary when encountering issues. @SHINE-six

}
} else if (spawnEnabled || spawnStatusEnabled) && !cfg.Tools.IsToolEnabled("subagent") {
logger.WarnCF("agent", "spawn/spawn_status tools require subagent to be enabled", nil)
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,7 @@ type ToolsConfig struct {
ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
SpawnStatus ToolConfig `json:"spawn_status" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"`
SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"`
WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"`
Expand Down Expand Up @@ -1043,6 +1044,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
return t.ReadFile.Enabled
case "spawn":
return t.Spawn.Enabled
case "spawn_status":
return t.SpawnStatus.Enabled
case "spi":
return t.SPI.Enabled
case "subagent":
Expand Down
3 changes: 3 additions & 0 deletions pkg/config/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,9 @@ func DefaultConfig() *Config {
Spawn: ToolConfig{
Enabled: true,
},
SpawnStatus: ToolConfig{
Enabled: true,
},
SPI: ToolConfig{
Enabled: false, // Hardware tool - Linux only
},
Expand Down
178 changes: 178 additions & 0 deletions pkg/tools/spawn_status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package tools

import (
"context"
"fmt"
"sort"
"strings"
"time"
)

// SpawnStatusTool reports the status of subagents that were spawned via the
// spawn tool. It can query a specific task by ID, or list every known task with
// a summary count broken-down by status.
type SpawnStatusTool struct {
manager *SubagentManager
}

// NewSpawnStatusTool creates a SpawnStatusTool backed by the given manager.
func NewSpawnStatusTool(manager *SubagentManager) *SpawnStatusTool {
return &SpawnStatusTool{manager: manager}
}

func (t *SpawnStatusTool) Name() string {
return "spawn_status"
}

func (t *SpawnStatusTool) Description() string {
return "Get the status of spawned subagents. " +
"Returns a list of all subagents and their current state " +
"(running, completed, failed, or canceled), or retrieves details " +
"for a specific subagent task when task_id is provided. " +
"Results are scoped to the current conversation's channel and chat ID; " +
"all tasks are listed only when no channel/chat context is injected " +
"(e.g. direct programmatic calls via Execute)."
}

func (t *SpawnStatusTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"task_id": map[string]any{
"type": "string",
"description": "Optional task ID (e.g. \"subagent-1\") to inspect a specific " +
"subagent. When omitted, all visible subagents are listed.",
},
},
"required": []string{},
}
}

func (t *SpawnStatusTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
if t.manager == nil {
return ErrorResult("Subagent manager not configured")
}

// Derive the calling conversation's identity so we can scope results to the
// current chat only β€” preventing cross-conversation task leakage in
// multi-user deployments.
callerChannel := ToolChannel(ctx)
callerChatID := ToolChatID(ctx)

var taskID string
if rawTaskID, ok := args["task_id"]; ok && rawTaskID != nil {
taskIDStr, ok := rawTaskID.(string)
if !ok {
return ErrorResult("task_id must be a string")
}
taskID = strings.TrimSpace(taskIDStr)
}

if taskID != "" {
// GetTaskCopy returns a consistent snapshot under the manager lock,
// eliminating any data race with the concurrent subagent goroutine.
taskCopy, ok := t.manager.GetTaskCopy(taskID)
if !ok {
return ErrorResult(fmt.Sprintf("No subagent found with task ID: %s", taskID))
}

// Restrict lookup to tasks that belong to this conversation.
if callerChannel != "" && taskCopy.OriginChannel != "" && taskCopy.OriginChannel != callerChannel {
return ErrorResult(fmt.Sprintf("No subagent found with task ID: %s", taskID))
}
if callerChatID != "" && taskCopy.OriginChatID != "" && taskCopy.OriginChatID != callerChatID {
return ErrorResult(fmt.Sprintf("No subagent found with task ID: %s", taskID))
}

return NewToolResult(spawnStatusFormatTask(&taskCopy))
}

// ListTaskCopies returns consistent snapshots under the manager lock.
origTasks := t.manager.ListTaskCopies()
if len(origTasks) == 0 {
return NewToolResult("No subagents have been spawned yet.")
}

tasks := make([]*SubagentTask, 0, len(origTasks))
for i := range origTasks {
cpy := &origTasks[i]

// Filter to tasks that originate from the current conversation only.
if callerChannel != "" && cpy.OriginChannel != "" && cpy.OriginChannel != callerChannel {
continue
}
if callerChatID != "" && cpy.OriginChatID != "" && cpy.OriginChatID != callerChatID {
continue
}

tasks = append(tasks, cpy)
}

if len(tasks) == 0 {
return NewToolResult("No subagents found for this conversation.")
}

// Order by creation time (ascending) so spawning order is preserved.
// Fall back to ID string for tasks created in the same millisecond.
sort.Slice(tasks, func(i, j int) bool {
if tasks[i].Created != tasks[j].Created {
return tasks[i].Created < tasks[j].Created
}
return tasks[i].ID < tasks[j].ID
})

counts := map[string]int{}
for _, task := range tasks {
counts[task.Status]++
}

var sb strings.Builder
sb.WriteString(fmt.Sprintf("Subagent status report (%d total):\n", len(tasks)))
for _, status := range []string{"running", "completed", "failed", "canceled"} {
if n := counts[status]; n > 0 {
label := strings.ToUpper(status[:1]) + status[1:] + ":"
sb.WriteString(fmt.Sprintf(" %-10s %d\n", label, n))
}
}
sb.WriteString("\n")

for _, task := range tasks {
sb.WriteString(spawnStatusFormatTask(task))
sb.WriteString("\n\n")
}

return NewToolResult(strings.TrimRight(sb.String(), "\n"))
}

// spawnStatusFormatTask renders a single SubagentTask as a human-readable block.
func spawnStatusFormatTask(task *SubagentTask) string {
var sb strings.Builder

header := fmt.Sprintf("[%s] status=%s", task.ID, task.Status)
if task.Label != "" {
header += fmt.Sprintf(" label=%q", task.Label)
}
if task.AgentID != "" {
header += fmt.Sprintf(" agent=%s", task.AgentID)
}
if task.Created > 0 {
created := time.UnixMilli(task.Created).UTC().Format("2006-01-02 15:04:05 UTC")
header += fmt.Sprintf(" created=%s", created)
}
sb.WriteString(header)

if task.Task != "" {
sb.WriteString(fmt.Sprintf("\n task: %s", task.Task))
}
if task.Result != "" {
result := task.Result
const maxResultLen = 300
runes := []rune(result)
if len(runes) > maxResultLen {
result = string(runes[:maxResultLen]) + "…"
}
sb.WriteString(fmt.Sprintf("\n result: %s", result))
}

return sb.String()
}
Loading