feat(tools): add SpawnStatusTool for reporting subagent statuses (#1540)
* feat(tools): add SpawnStatusTool for reporting subagent statuses * feat(tools): enhance SpawnStatusTool to restrict task visibility by conversation context * feat(tests): add Unicode result truncation and channel filtering tests for SpawnStatusTool * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * feat(tools): enhance SpawnStatusTool with task ID validation and sorting by creation timestamp * feat(tools): update SpawnStatusTool description and parameter documentation for clarity * refactor(tests): improve comments for clarity in ChannelFiltering test case * fix(tools): update no subagents message for clarity and remove unnecessary locking in runTask * fix(tools): improve description clarity for SpawnStatusTool regarding task context * feat(tools): add spawn_status tool configuration and registration * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(agent): improve subagent management for spawn and spawn_status tools * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(tests): update ResultTruncation_Unicode test to use valid CJK character --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: lxowalle <83055338+lxowalle@users.noreply.github.com>
This commit is contained in:
parent
e41423483e
commit
b402888bfa
7 changed files with 641 additions and 11 deletions
|
|
@ -225,20 +225,26 @@ func registerSharedTools(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn tool with allowlist checker
|
// Spawn and spawn_status tools share a SubagentManager.
|
||||||
if cfg.Tools.IsToolEnabled("spawn") {
|
// Construct it when either tool is enabled (both require subagent).
|
||||||
if cfg.Tools.IsToolEnabled("subagent") {
|
spawnEnabled := cfg.Tools.IsToolEnabled("spawn")
|
||||||
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)
|
spawnStatusEnabled := cfg.Tools.IsToolEnabled("spawn_status")
|
||||||
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
|
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)
|
spawnTool := tools.NewSpawnTool(subagentManager)
|
||||||
currentAgentID := agentID
|
currentAgentID := agentID
|
||||||
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
|
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
|
||||||
return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
|
return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
|
||||||
})
|
})
|
||||||
agent.Tools.Register(spawnTool)
|
agent.Tools.Register(spawnTool)
|
||||||
} else {
|
|
||||||
logger.WarnCF("agent", "spawn tool requires subagent to be enabled", nil)
|
|
||||||
}
|
}
|
||||||
|
if spawnStatusEnabled {
|
||||||
|
agent.Tools.Register(tools.NewSpawnStatusTool(subagentManager))
|
||||||
|
}
|
||||||
|
} else if (spawnEnabled || spawnStatusEnabled) && !cfg.Tools.IsToolEnabled("subagent") {
|
||||||
|
logger.WarnCF("agent", "spawn/spawn_status tools require subagent to be enabled", nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -751,6 +751,7 @@ type ToolsConfig struct {
|
||||||
ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
|
ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
|
||||||
SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
|
SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
|
||||||
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
|
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_"`
|
SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
|
||||||
Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"`
|
Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"`
|
||||||
WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"`
|
WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"`
|
||||||
|
|
@ -1112,6 +1113,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
|
||||||
return t.ReadFile.Enabled
|
return t.ReadFile.Enabled
|
||||||
case "spawn":
|
case "spawn":
|
||||||
return t.Spawn.Enabled
|
return t.Spawn.Enabled
|
||||||
|
case "spawn_status":
|
||||||
|
return t.SpawnStatus.Enabled
|
||||||
case "spi":
|
case "spi":
|
||||||
return t.SPI.Enabled
|
return t.SPI.Enabled
|
||||||
case "subagent":
|
case "subagent":
|
||||||
|
|
|
||||||
|
|
@ -522,6 +522,9 @@ func DefaultConfig() *Config {
|
||||||
Spawn: ToolConfig{
|
Spawn: ToolConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
},
|
},
|
||||||
|
SpawnStatus: ToolConfig{
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
SPI: ToolConfig{
|
SPI: ToolConfig{
|
||||||
Enabled: false, // Hardware tool - Linux only
|
Enabled: false, // Hardware tool - Linux only
|
||||||
},
|
},
|
||||||
|
|
|
||||||
178
pkg/tools/spawn_status.go
Normal file
178
pkg/tools/spawn_status.go
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
406
pkg/tools/spawn_status_test.go
Normal file
406
pkg/tools/spawn_status_test.go
Normal file
|
|
@ -0,0 +1,406 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_Name(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
workspace := t.TempDir()
|
||||||
|
manager := NewSubagentManager(provider, "test-model", workspace)
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
|
||||||
|
if tool.Name() != "spawn_status" {
|
||||||
|
t.Errorf("Expected name 'spawn_status', got '%s'", tool.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_Description(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
workspace := t.TempDir()
|
||||||
|
manager := NewSubagentManager(provider, "test-model", workspace)
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
|
||||||
|
desc := tool.Description()
|
||||||
|
if desc == "" {
|
||||||
|
t.Error("Description should not be empty")
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.ToLower(desc), "subagent") {
|
||||||
|
t.Errorf("Description should mention 'subagent', got: %s", desc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_Parameters(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
workspace := t.TempDir()
|
||||||
|
manager := NewSubagentManager(provider, "test-model", workspace)
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
|
||||||
|
params := tool.Parameters()
|
||||||
|
if params["type"] != "object" {
|
||||||
|
t.Errorf("Expected type 'object', got: %v", params["type"])
|
||||||
|
}
|
||||||
|
props, ok := params["properties"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("Expected 'properties' to be a map")
|
||||||
|
}
|
||||||
|
if _, hasTaskID := props["task_id"]; !hasTaskID {
|
||||||
|
t.Error("Expected 'task_id' parameter in properties")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_NilManager(t *testing.T) {
|
||||||
|
tool := &SpawnStatusTool{manager: nil}
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{})
|
||||||
|
if !result.IsError {
|
||||||
|
t.Error("Expected error result when manager is nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_Empty(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
workspace := t.TempDir()
|
||||||
|
manager := NewSubagentManager(provider, "test-model", workspace)
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{})
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("Expected success, got error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "No subagents") {
|
||||||
|
t.Errorf("Expected 'No subagents' message, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_ListAll(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
workspace := t.TempDir()
|
||||||
|
manager := NewSubagentManager(provider, "test-model", workspace)
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
manager.mu.Lock()
|
||||||
|
manager.tasks["subagent-1"] = &SubagentTask{
|
||||||
|
ID: "subagent-1",
|
||||||
|
Task: "Do task A",
|
||||||
|
Label: "task-a",
|
||||||
|
Status: "running",
|
||||||
|
Created: now,
|
||||||
|
}
|
||||||
|
manager.tasks["subagent-2"] = &SubagentTask{
|
||||||
|
ID: "subagent-2",
|
||||||
|
Task: "Do task B",
|
||||||
|
Label: "task-b",
|
||||||
|
Status: "completed",
|
||||||
|
Result: "Done successfully",
|
||||||
|
Created: now,
|
||||||
|
}
|
||||||
|
manager.tasks["subagent-3"] = &SubagentTask{
|
||||||
|
ID: "subagent-3",
|
||||||
|
Task: "Do task C",
|
||||||
|
Status: "failed",
|
||||||
|
Result: "Error: something went wrong",
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("Expected success, got error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary header
|
||||||
|
if !strings.Contains(result.ForLLM, "3 total") {
|
||||||
|
t.Errorf("Expected total count in header, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Individual task IDs
|
||||||
|
for _, id := range []string{"subagent-1", "subagent-2", "subagent-3"} {
|
||||||
|
if !strings.Contains(result.ForLLM, id) {
|
||||||
|
t.Errorf("Expected task %s in output, got:\n%s", id, result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status values
|
||||||
|
for _, status := range []string{"running", "completed", "failed"} {
|
||||||
|
if !strings.Contains(result.ForLLM, status) {
|
||||||
|
t.Errorf("Expected status '%s' in output, got:\n%s", status, result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result content
|
||||||
|
if !strings.Contains(result.ForLLM, "Done successfully") {
|
||||||
|
t.Errorf("Expected result text in output, got:\n%s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_GetByID(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
manager.tasks["subagent-42"] = &SubagentTask{
|
||||||
|
ID: "subagent-42",
|
||||||
|
Task: "Specific task",
|
||||||
|
Label: "my-task",
|
||||||
|
Status: "failed",
|
||||||
|
Result: "Something went wrong",
|
||||||
|
Created: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{"task_id": "subagent-42"})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("Expected success, got error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "subagent-42") {
|
||||||
|
t.Errorf("Expected task ID in output, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "failed") {
|
||||||
|
t.Errorf("Expected status 'failed' in output, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "Something went wrong") {
|
||||||
|
t.Errorf("Expected result text in output, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "my-task") {
|
||||||
|
t.Errorf("Expected label in output, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_GetByID_NotFound(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{"task_id": "nonexistent-999"})
|
||||||
|
if !result.IsError {
|
||||||
|
t.Errorf("Expected error for nonexistent task, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "nonexistent-999") {
|
||||||
|
t.Errorf("Expected task ID in error message, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_TaskID_NonString(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
|
||||||
|
for _, badVal := range []any{42, 3.14, true, map[string]any{"x": 1}, []string{"a"}} {
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{"task_id": badVal})
|
||||||
|
if !result.IsError {
|
||||||
|
t.Errorf("Expected error for task_id=%T(%v), got success: %s", badVal, badVal, result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "task_id must be a string") {
|
||||||
|
t.Errorf("Expected type-error message, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_ResultTruncation(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
|
|
||||||
|
longResult := strings.Repeat("X", 500)
|
||||||
|
manager.mu.Lock()
|
||||||
|
manager.tasks["subagent-1"] = &SubagentTask{
|
||||||
|
ID: "subagent-1",
|
||||||
|
Task: "Long task",
|
||||||
|
Status: "completed",
|
||||||
|
Result: longResult,
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{"task_id": "subagent-1"})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("Unexpected error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
// Output should be shorter than the raw result due to truncation
|
||||||
|
if len(result.ForLLM) >= len(longResult) {
|
||||||
|
t.Errorf("Expected result to be truncated, but ForLLM is %d chars", len(result.ForLLM))
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "…") {
|
||||||
|
t.Errorf("Expected truncation indicator '…' in output, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_ResultTruncation_Unicode(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
|
|
||||||
|
// Each CJK rune is 3 bytes; 400 runes = 1200 bytes — well over the 300-rune limit.
|
||||||
|
cjkChar := string(rune(0x5b57))
|
||||||
|
longResult := strings.Repeat(cjkChar, 400)
|
||||||
|
manager.mu.Lock()
|
||||||
|
manager.tasks["subagent-1"] = &SubagentTask{
|
||||||
|
ID: "subagent-1",
|
||||||
|
Task: "Unicode task",
|
||||||
|
Status: "completed",
|
||||||
|
Result: longResult,
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{"task_id": "subagent-1"})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("Unexpected error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "…") {
|
||||||
|
t.Errorf("Expected truncation indicator in output")
|
||||||
|
}
|
||||||
|
// The truncated result must be valid UTF-8 (no split rune boundaries).
|
||||||
|
if !strings.Contains(result.ForLLM, cjkChar) {
|
||||||
|
t.Errorf("Expected CJK runes to appear intact in output")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_StatusCounts(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
for i, status := range []string{"running", "running", "completed", "failed", "canceled"} {
|
||||||
|
id := fmt.Sprintf("subagent-%d", i+1)
|
||||||
|
manager.tasks[id] = &SubagentTask{ID: id, Task: "t", Status: status}
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("Unexpected error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
// The summary line should mention all statuses that have counts
|
||||||
|
for _, want := range []string{"Running:", "Completed:", "Failed:", "Canceled:"} {
|
||||||
|
if !strings.Contains(result.ForLLM, want) {
|
||||||
|
t.Errorf("Expected %q in summary, got:\n%s", want, result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_SortByCreatedTimestamp(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
manager.mu.Lock()
|
||||||
|
// Intentionally insert with out-of-order IDs and timestamps that reflect
|
||||||
|
// true spawn order: subagent-2 was spawned first, subagent-10 second.
|
||||||
|
manager.tasks["subagent-10"] = &SubagentTask{
|
||||||
|
ID: "subagent-10", Task: "second", Status: "running",
|
||||||
|
Created: now + 1,
|
||||||
|
}
|
||||||
|
manager.tasks["subagent-2"] = &SubagentTask{
|
||||||
|
ID: "subagent-2", Task: "first", Status: "running",
|
||||||
|
Created: now,
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("Unexpected error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
pos2 := strings.Index(result.ForLLM, "subagent-2")
|
||||||
|
pos10 := strings.Index(result.ForLLM, "subagent-10")
|
||||||
|
if pos2 < 0 || pos10 < 0 {
|
||||||
|
t.Fatalf("Both task IDs should appear in output:\n%s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if pos2 > pos10 {
|
||||||
|
t.Errorf("Expected subagent-2 (created first) to appear before subagent-10, but got:\n%s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_ChannelFiltering_ListAll(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
manager.tasks["subagent-1"] = &SubagentTask{
|
||||||
|
ID: "subagent-1", Task: "mine", Status: "running",
|
||||||
|
OriginChannel: "telegram", OriginChatID: "chat-A",
|
||||||
|
}
|
||||||
|
manager.tasks["subagent-2"] = &SubagentTask{
|
||||||
|
ID: "subagent-2", Task: "other user", Status: "running",
|
||||||
|
OriginChannel: "telegram", OriginChatID: "chat-B",
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
|
||||||
|
// Caller is chat-A — should only see subagent-1.
|
||||||
|
ctx := WithToolContext(context.Background(), "telegram", "chat-A")
|
||||||
|
result := tool.Execute(ctx, map[string]any{})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("Unexpected error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "subagent-1") {
|
||||||
|
t.Errorf("Expected own task in output, got:\n%s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if strings.Contains(result.ForLLM, "subagent-2") {
|
||||||
|
t.Errorf("Should NOT see other chat's task, got:\n%s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_ChannelFiltering_GetByID(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
manager.tasks["subagent-99"] = &SubagentTask{
|
||||||
|
ID: "subagent-99", Task: "secret", Status: "completed", Result: "private data",
|
||||||
|
OriginChannel: "slack", OriginChatID: "room-Z",
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
|
||||||
|
// Different chat trying to look up subagent-99 by ID.
|
||||||
|
ctx := WithToolContext(context.Background(), "slack", "room-OTHER")
|
||||||
|
result := tool.Execute(ctx, map[string]any{"task_id": "subagent-99"})
|
||||||
|
|
||||||
|
if !result.IsError {
|
||||||
|
t.Errorf("Expected error (cross-chat lookup blocked), got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_ChannelFiltering_NoContext(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
manager.tasks["subagent-1"] = &SubagentTask{
|
||||||
|
ID: "subagent-1", Task: "t", Status: "completed",
|
||||||
|
OriginChannel: "telegram", OriginChatID: "chat-A",
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
|
||||||
|
// No ToolContext injected (e.g. a direct programmatic call that bypasses
|
||||||
|
// WithToolContext entirely) — callerChannel and callerChatID are both "".
|
||||||
|
// Note: the normal CLI path uses ProcessDirectWithChannel("cli", "direct"),
|
||||||
|
// which *does* inject a non-empty context; this test covers the case where
|
||||||
|
// no context injection happens at all.
|
||||||
|
// The filter conditions require a non-empty caller value, so all tasks pass through.
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{})
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("Unexpected error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "subagent-1") {
|
||||||
|
t.Errorf("Expected task visible from no-context caller, got:\n%s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -109,9 +109,6 @@ func (sm *SubagentManager) Spawn(
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, callback AsyncCallback) {
|
func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, callback AsyncCallback) {
|
||||||
task.Status = "running"
|
|
||||||
task.Created = time.Now().UnixMilli()
|
|
||||||
|
|
||||||
// Build system prompt for subagent
|
// Build system prompt for subagent
|
||||||
systemPrompt := `You are a subagent. Complete the given task independently and report the result.
|
systemPrompt := `You are a subagent. Complete the given task independently and report the result.
|
||||||
You have access to tools - use them as needed to complete your task.
|
You have access to tools - use them as needed to complete your task.
|
||||||
|
|
@ -219,6 +216,18 @@ func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) {
|
||||||
return task, ok
|
return task, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTaskCopy returns a copy of the task with the given ID, taken under the
|
||||||
|
// read lock, so the caller receives a consistent snapshot with no data race.
|
||||||
|
func (sm *SubagentManager) GetTaskCopy(taskID string) (SubagentTask, bool) {
|
||||||
|
sm.mu.RLock()
|
||||||
|
defer sm.mu.RUnlock()
|
||||||
|
task, ok := sm.tasks[taskID]
|
||||||
|
if !ok {
|
||||||
|
return SubagentTask{}, false
|
||||||
|
}
|
||||||
|
return *task, true
|
||||||
|
}
|
||||||
|
|
||||||
func (sm *SubagentManager) ListTasks() []*SubagentTask {
|
func (sm *SubagentManager) ListTasks() []*SubagentTask {
|
||||||
sm.mu.RLock()
|
sm.mu.RLock()
|
||||||
defer sm.mu.RUnlock()
|
defer sm.mu.RUnlock()
|
||||||
|
|
@ -230,6 +239,19 @@ func (sm *SubagentManager) ListTasks() []*SubagentTask {
|
||||||
return tasks
|
return tasks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListTaskCopies returns value copies of all tasks, taken under the read lock,
|
||||||
|
// so callers receive consistent snapshots with no data race.
|
||||||
|
func (sm *SubagentManager) ListTaskCopies() []SubagentTask {
|
||||||
|
sm.mu.RLock()
|
||||||
|
defer sm.mu.RUnlock()
|
||||||
|
|
||||||
|
copies := make([]SubagentTask, 0, len(sm.tasks))
|
||||||
|
for _, task := range sm.tasks {
|
||||||
|
copies = append(copies, *task)
|
||||||
|
}
|
||||||
|
return copies
|
||||||
|
}
|
||||||
|
|
||||||
// SubagentTool executes a subagent task synchronously and returns the result.
|
// SubagentTool executes a subagent task synchronously and returns the result.
|
||||||
// Unlike SpawnTool which runs tasks asynchronously, SubagentTool waits for completion
|
// Unlike SpawnTool which runs tasks asynchronously, SubagentTool waits for completion
|
||||||
// and returns the result directly in the ToolResult.
|
// and returns the result directly in the ToolResult.
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,12 @@ var toolCatalog = []toolCatalogEntry{
|
||||||
Category: "agents",
|
Category: "agents",
|
||||||
ConfigKey: "spawn",
|
ConfigKey: "spawn",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "spawn_status",
|
||||||
|
Description: "Query the status of spawned subagents.",
|
||||||
|
Category: "agents",
|
||||||
|
ConfigKey: "spawn_status",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "i2c",
|
Name: "i2c",
|
||||||
Description: "Interact with I2C hardware devices exposed on the host.",
|
Description: "Interact with I2C hardware devices exposed on the host.",
|
||||||
|
|
@ -205,7 +211,7 @@ func buildToolSupport(cfg *config.Config) []toolSupportItem {
|
||||||
reasonCode = "requires_skills"
|
reasonCode = "requires_skills"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "spawn":
|
case "spawn", "spawn_status":
|
||||||
if cfg.Tools.IsToolEnabled(entry.ConfigKey) {
|
if cfg.Tools.IsToolEnabled(entry.ConfigKey) {
|
||||||
if cfg.Tools.IsToolEnabled("subagent") {
|
if cfg.Tools.IsToolEnabled("subagent") {
|
||||||
status = "enabled"
|
status = "enabled"
|
||||||
|
|
@ -300,6 +306,12 @@ func applyToolState(cfg *config.Config, toolName string, enabled bool) error {
|
||||||
if enabled {
|
if enabled {
|
||||||
cfg.Tools.Subagent.Enabled = true
|
cfg.Tools.Subagent.Enabled = true
|
||||||
}
|
}
|
||||||
|
case "spawn_status":
|
||||||
|
cfg.Tools.SpawnStatus.Enabled = enabled
|
||||||
|
if enabled {
|
||||||
|
cfg.Tools.Spawn.Enabled = true
|
||||||
|
cfg.Tools.Subagent.Enabled = true
|
||||||
|
}
|
||||||
case "i2c":
|
case "i2c":
|
||||||
cfg.Tools.I2C.Enabled = enabled
|
cfg.Tools.I2C.Enabled = enabled
|
||||||
case "spi":
|
case "spi":
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue