Files
picoclaw/web/backend/api/tools.go
T
Desmond Foo b402888bfa 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>
2026-03-17 14:41:43 +08:00

336 lines
8.8 KiB
Go

package api
import (
"encoding/json"
"fmt"
"net/http"
"runtime"
"github.com/sipeed/picoclaw/pkg/config"
)
type toolCatalogEntry struct {
Name string
Description string
Category string
ConfigKey string
}
type toolSupportItem struct {
Name string `json:"name"`
Description string `json:"description"`
Category string `json:"category"`
ConfigKey string `json:"config_key"`
Status string `json:"status"`
ReasonCode string `json:"reason_code,omitempty"`
}
type toolSupportResponse struct {
Tools []toolSupportItem `json:"tools"`
}
type toolStateRequest struct {
Enabled bool `json:"enabled"`
}
var toolCatalog = []toolCatalogEntry{
{
Name: "read_file",
Description: "Read file content from the workspace or explicitly allowed paths.",
Category: "filesystem",
ConfigKey: "read_file",
},
{
Name: "write_file",
Description: "Create or overwrite files within the writable workspace scope.",
Category: "filesystem",
ConfigKey: "write_file",
},
{
Name: "list_dir",
Description: "Inspect directories and enumerate files available to the agent.",
Category: "filesystem",
ConfigKey: "list_dir",
},
{
Name: "edit_file",
Description: "Apply targeted edits to existing files without rewriting everything.",
Category: "filesystem",
ConfigKey: "edit_file",
},
{
Name: "append_file",
Description: "Append content to the end of an existing file.",
Category: "filesystem",
ConfigKey: "append_file",
},
{
Name: "exec",
Description: "Run shell commands inside the configured workspace sandbox.",
Category: "filesystem",
ConfigKey: "exec",
},
{
Name: "cron",
Description: "Schedule one-time or recurring reminders, jobs, and shell commands.",
Category: "automation",
ConfigKey: "cron",
},
{
Name: "web_search",
Description: "Search the web using the configured providers.",
Category: "web",
ConfigKey: "web",
},
{
Name: "web_fetch",
Description: "Fetch and summarize the contents of a webpage.",
Category: "web",
ConfigKey: "web_fetch",
},
{
Name: "message",
Description: "Send a follow-up message back to the active user or chat.",
Category: "communication",
ConfigKey: "message",
},
{
Name: "send_file",
Description: "Send an outbound file or media attachment to the active chat.",
Category: "communication",
ConfigKey: "send_file",
},
{
Name: "find_skills",
Description: "Search external skill registries for installable skills.",
Category: "skills",
ConfigKey: "find_skills",
},
{
Name: "install_skill",
Description: "Install a skill into the current workspace from a registry.",
Category: "skills",
ConfigKey: "install_skill",
},
{
Name: "spawn",
Description: "Launch a background subagent for long-running or delegated work.",
Category: "agents",
ConfigKey: "spawn",
},
{
Name: "spawn_status",
Description: "Query the status of spawned subagents.",
Category: "agents",
ConfigKey: "spawn_status",
},
{
Name: "i2c",
Description: "Interact with I2C hardware devices exposed on the host.",
Category: "hardware",
ConfigKey: "i2c",
},
{
Name: "spi",
Description: "Interact with SPI hardware devices exposed on the host.",
Category: "hardware",
ConfigKey: "spi",
},
{
Name: "tool_search_tool_regex",
Description: "Discover hidden MCP tools by regex search when tool discovery is enabled.",
Category: "discovery",
ConfigKey: "mcp.discovery.use_regex",
},
{
Name: "tool_search_tool_bm25",
Description: "Discover hidden MCP tools by semantic ranking when tool discovery is enabled.",
Category: "discovery",
ConfigKey: "mcp.discovery.use_bm25",
},
}
func (h *Handler) registerToolRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/tools", h.handleListTools)
mux.HandleFunc("PUT /api/tools/{name}/state", h.handleUpdateToolState)
}
func (h *Handler) handleListTools(w http.ResponseWriter, r *http.Request) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(toolSupportResponse{
Tools: buildToolSupport(cfg),
})
}
func (h *Handler) handleUpdateToolState(w http.ResponseWriter, r *http.Request) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
var req toolStateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
if err := applyToolState(cfg, r.PathValue("name"), req.Enabled); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := config.SaveConfig(h.configPath, cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func buildToolSupport(cfg *config.Config) []toolSupportItem {
items := make([]toolSupportItem, 0, len(toolCatalog))
for _, entry := range toolCatalog {
status := "disabled"
reasonCode := ""
switch entry.Name {
case "find_skills", "install_skill":
if cfg.Tools.IsToolEnabled(entry.ConfigKey) {
if cfg.Tools.IsToolEnabled("skills") {
status = "enabled"
} else {
status = "blocked"
reasonCode = "requires_skills"
}
}
case "spawn", "spawn_status":
if cfg.Tools.IsToolEnabled(entry.ConfigKey) {
if cfg.Tools.IsToolEnabled("subagent") {
status = "enabled"
} else {
status = "blocked"
reasonCode = "requires_subagent"
}
}
case "tool_search_tool_regex":
status, reasonCode = resolveDiscoveryToolSupport(cfg, cfg.Tools.MCP.Discovery.UseRegex)
case "tool_search_tool_bm25":
status, reasonCode = resolveDiscoveryToolSupport(cfg, cfg.Tools.MCP.Discovery.UseBM25)
case "i2c", "spi":
status, reasonCode = resolveHardwareToolSupport(cfg.Tools.IsToolEnabled(entry.ConfigKey))
default:
if cfg.Tools.IsToolEnabled(entry.ConfigKey) {
status = "enabled"
}
}
items = append(items, toolSupportItem{
Name: entry.Name,
Description: entry.Description,
Category: entry.Category,
ConfigKey: entry.ConfigKey,
Status: status,
ReasonCode: reasonCode,
})
}
return items
}
func resolveHardwareToolSupport(enabled bool) (string, string) {
if !enabled {
return "disabled", ""
}
if runtime.GOOS != "linux" {
return "blocked", "requires_linux"
}
return "enabled", ""
}
func resolveDiscoveryToolSupport(cfg *config.Config, methodEnabled bool) (string, string) {
if !cfg.Tools.IsToolEnabled("mcp") {
return "disabled", ""
}
if !cfg.Tools.MCP.Discovery.Enabled {
return "blocked", "requires_mcp_discovery"
}
if !methodEnabled {
return "disabled", ""
}
return "enabled", ""
}
func applyToolState(cfg *config.Config, toolName string, enabled bool) error {
switch toolName {
case "read_file":
cfg.Tools.ReadFile.Enabled = enabled
case "write_file":
cfg.Tools.WriteFile.Enabled = enabled
case "list_dir":
cfg.Tools.ListDir.Enabled = enabled
case "edit_file":
cfg.Tools.EditFile.Enabled = enabled
case "append_file":
cfg.Tools.AppendFile.Enabled = enabled
case "exec":
cfg.Tools.Exec.Enabled = enabled
case "cron":
cfg.Tools.Cron.Enabled = enabled
case "web_search":
cfg.Tools.Web.Enabled = enabled
case "web_fetch":
cfg.Tools.WebFetch.Enabled = enabled
case "message":
cfg.Tools.Message.Enabled = enabled
case "send_file":
cfg.Tools.SendFile.Enabled = enabled
case "find_skills":
cfg.Tools.FindSkills.Enabled = enabled
if enabled {
cfg.Tools.Skills.Enabled = true
}
case "install_skill":
cfg.Tools.InstallSkill.Enabled = enabled
if enabled {
cfg.Tools.Skills.Enabled = true
}
case "spawn":
cfg.Tools.Spawn.Enabled = enabled
if enabled {
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":
cfg.Tools.I2C.Enabled = enabled
case "spi":
cfg.Tools.SPI.Enabled = enabled
case "tool_search_tool_regex":
cfg.Tools.MCP.Discovery.UseRegex = enabled
if enabled {
cfg.Tools.MCP.Enabled = true
cfg.Tools.MCP.Discovery.Enabled = true
}
case "tool_search_tool_bm25":
cfg.Tools.MCP.Discovery.UseBM25 = enabled
if enabled {
cfg.Tools.MCP.Enabled = true
cfg.Tools.MCP.Discovery.Enabled = true
}
default:
return fmt.Errorf("tool %q cannot be updated", toolName)
}
return nil
}