Files
coder/coderd/x/chatd/subagent_catalog.go
T
Michael Suchacz f073323c89 refactor: unify subagent spawn behind spawn_subagent (#24535)
Unify the three subagent spawn tools (`spawn_agent`,
`spawn_explore_agent`, `spawn_computer_use_agent`) behind a single
`spawn_subagent` tool keyed by a `subagent_type` discriminant
(`general`, `explore`, `computer_use`). Mirrors the single-entry-point
pattern already used by `task` in mux while keeping `wait_agent`,
`message_agent`, and `close_agent` as separate lifecycle tools.

A new backend subagent definition catalog
(`coderd/x/chatd/subagent_catalog.go`) is the source of truth for tool
description, prompt guidance, availability rules (plan mode,
desktop/Anthropic gating), and child-chat option building.
`spawn_subagent` advertises only the types available in the current
context and validates `subagent_type` server-side; context inheritance
still flows through the existing `createChildSubagentChatWithOptions`
path. `wait_agent`, `message_agent`, and `close_agent` responses now
include a server-derived `subagent_type` so the UI stops inferring
lifecycle state from tool names.

The frontend gets a shared normalization helper
(`site/src/pages/AgentsPage/components/ChatElements/tools/subagentDescriptor.ts`)
that maps either legacy tool names or new `spawn_subagent` args into a
common descriptor (action, variant, icon, fallback copy). Legacy
transcripts still render identically; `Tool.tsx`, `SubagentTool.tsx`,
`ToolLabel.tsx`, `ToolIcon.tsx`, and `messageParsing.ts` now key off the
descriptor instead of hard-coded names. Existing UI copy is preserved
(`Spawning Explore agent...`, `Using the computer...`, computer-use
monitor icon and Open Desktop affordance).

> This PR was opened by Mux working on Mike's behalf.
2026-04-21 14:01:32 +02:00

279 lines
8.8 KiB
Go

package chatd
import (
"context"
"strings"
"charm.land/fantasy"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database"
)
const (
spawnAgentToolName = "spawn_agent"
subagentTypeGeneral = "general"
subagentTypeExplore = "explore"
subagentTypeComputerUse = "computer_use"
defaultSystemPromptPlanningGuidance = "1. Use " + spawnAgentToolName +
" with type=\"" + subagentTypeExplore +
"\" and wait_agent to research the codebase and gather context as needed. " +
"Reserve type=\"" + subagentTypeGeneral +
"\" for writable delegated work."
)
type spawnAgentArgs struct {
Type string `json:"type"`
Prompt string `json:"prompt"`
Title string `json:"title,omitempty"`
}
type subagentDefinition struct {
id string
description string
unavailableReason func(context.Context, *Server, database.Chat) string
buildOptions func(context.Context, *Server, database.Chat, uuid.UUID, string) (childSubagentChatOptions, error)
}
func allSubagentDefinitions() []subagentDefinition {
return []subagentDefinition{
{
id: subagentTypeGeneral,
description: "delegated work that may inspect or modify workspace files",
buildOptions: func(_ context.Context, _ *Server, _ database.Chat, _ uuid.UUID, _ string) (childSubagentChatOptions, error) {
return childSubagentChatOptions{}, nil
},
},
{
id: subagentTypeExplore,
description: "read-only discovery, code tracing, and system understanding",
buildOptions: func(ctx context.Context, p *Server, parent database.Chat, currentModelConfigID uuid.UUID, _ string) (childSubagentChatOptions, error) {
modelConfigID, err := p.resolveExploreSubagentModelConfigID(
ctx,
parent.OwnerID,
currentModelConfigID,
)
if err != nil {
return childSubagentChatOptions{}, err
}
clearPlanMode := database.NullChatPlanMode{}
return childSubagentChatOptions{
chatMode: database.NullChatMode{
ChatMode: database.ChatModeExplore,
Valid: true,
},
modelConfigIDOverride: &modelConfigID,
planModeOverride: &clearPlanMode,
}, nil
},
},
{
id: subagentTypeComputerUse,
description: "desktop GUI interaction, screenshots, and browser or app automation",
unavailableReason: func(ctx context.Context, p *Server, currentChat database.Chat) string {
if currentChat.PlanMode.Valid && currentChat.PlanMode.ChatPlanMode == database.ChatPlanModePlan {
return `type "computer_use" is unavailable in plan mode`
}
if !p.isAnthropicConfigured(ctx) || !p.isDesktopEnabled(ctx) {
return `type "computer_use" is unavailable because computer use is not configured`
}
return ""
},
buildOptions: func(_ context.Context, _ *Server, _ database.Chat, _ uuid.UUID, prompt string) (childSubagentChatOptions, error) {
return childSubagentChatOptions{
chatMode: database.NullChatMode{
ChatMode: database.ChatModeComputerUse,
Valid: true,
},
systemPrompt: computerUseSubagentSystemPrompt + "\n\n" + strings.TrimSpace(prompt),
}, nil
},
},
}
}
func subagentDefinitionsByID(ids ...string) []subagentDefinition {
defs := make([]subagentDefinition, 0, len(ids))
for _, id := range ids {
if def, ok := lookupSubagentDefinition(id); ok {
defs = append(defs, def)
}
}
return defs
}
func lookupSubagentDefinition(id string) (subagentDefinition, bool) {
for _, def := range allSubagentDefinitions() {
if def.id == id {
return def, true
}
}
return subagentDefinition{}, false
}
func availableSubagentDefinitions(
ctx context.Context,
p *Server,
currentChat database.Chat,
) []subagentDefinition {
defs := allSubagentDefinitions()
available := make([]subagentDefinition, 0, len(defs))
for _, def := range defs {
if def.unavailableReasonText(ctx, p, currentChat) == "" {
available = append(available, def)
}
}
return available
}
func availableSubagentTypeIDs(
ctx context.Context,
p *Server,
currentChat database.Chat,
) []string {
defs := availableSubagentDefinitions(ctx, p, currentChat)
ids := make([]string, 0, len(defs))
for _, def := range defs {
ids = append(ids, def.id)
}
return ids
}
func (d subagentDefinition) unavailableReasonText(
ctx context.Context,
p *Server,
currentChat database.Chat,
) string {
if d.unavailableReason == nil {
return ""
}
return d.unavailableReason(ctx, p, currentChat)
}
func resolveSubagentDefinition(
ctx context.Context,
p *Server,
currentChat database.Chat,
rawSubagentType string,
) (subagentDefinition, error) {
subagentType := strings.TrimSpace(rawSubagentType)
def, ok := lookupSubagentDefinition(subagentType)
if !ok {
return subagentDefinition{}, xerrors.Errorf(
"type must be one of: %s",
strings.Join(availableSubagentTypeIDs(ctx, p, currentChat), ", "),
)
}
if reason := def.unavailableReasonText(ctx, p, currentChat); reason != "" {
return subagentDefinition{}, xerrors.New(reason)
}
return def, nil
}
func validateSubagentSpawnParent(currentChat database.Chat) error {
if currentChat.ParentChatID.Valid {
return xerrors.New("delegated chats cannot create child subagents")
}
if isExploreSubagentMode(currentChat.Mode) {
return xerrors.New("explore chats cannot create child subagents")
}
return nil
}
func subagentTypeFromChat(chat database.Chat) string {
if !chat.Mode.Valid {
return subagentTypeGeneral
}
switch chat.Mode.ChatMode {
case database.ChatModeExplore:
return subagentTypeExplore
case database.ChatModeComputerUse:
return subagentTypeComputerUse
default:
return subagentTypeGeneral
}
}
func withSubagentType(result map[string]any, chat database.Chat) map[string]any {
if result == nil {
result = map[string]any{}
}
result["type"] = subagentTypeFromChat(chat)
return result
}
func subagentErrorResponse(err error, chat *database.Chat) fantasy.ToolResponse {
if chat == nil {
return fantasy.NewTextErrorResponse(err.Error())
}
return toolJSONErrorResponse(withSubagentType(map[string]any{
"error": err.Error(),
}, *chat))
}
func buildSpawnAgentDescription(
ctx context.Context,
p *Server,
currentChat database.Chat,
) string {
availableDefs := availableSubagentDefinitions(ctx, p, currentChat)
description := "Spawn a delegated child subagent to work on a clearly scoped, " +
"independent task in parallel. Use the type field to choose " +
"the right specialist. Available type values: " +
formatSubagentDefinitions(availableDefs) + ". Do not use this for " +
"simple or quick operations you can handle directly with execute, " +
"read_file, or write_file. Reserve writable subagents for tasks that " +
"require intellectual work such as code analysis, writing new code, or " +
"complex refactoring. Be careful when running parallel subagents: if " +
"two subagents modify the same files they will conflict with each " +
"other, so ensure parallel subagent tasks are independent. The child " +
"agent receives the same workspace tools but cannot spawn its own " +
"subagents. After spawning, use wait_agent to collect the result."
if currentChat.PlanMode.Valid && currentChat.PlanMode.ChatPlanMode == database.ChatPlanModePlan {
description += " During plan mode, general and explore subagents may use shell commands for exploration, such as cloning repositories, searching code, and running inspection commands, but they must not implement changes or intentionally modify workspace files."
}
return description
}
func formatSubagentDefinitions(defs []subagentDefinition) string {
return formatSubagentDefinitionsWithDescriptionOverrides(defs, nil)
}
func formatSubagentDefinitionsWithDescriptionOverrides(
defs []subagentDefinition,
descriptionOverrides map[string]string,
) string {
parts := make([]string, 0, len(defs))
for _, def := range defs {
description := def.description
if override, ok := descriptionOverrides[def.id]; ok {
description = override
}
parts = append(parts, def.id+" ("+description+")")
}
return strings.Join(parts, ", ")
}
func planningOverlaySubagentGuidance() string {
planModeDescriptions := map[string]string{
subagentTypeGeneral: "delegated investigation, planning support, and non-mutating exploration",
}
return "Use read_file, execute, process_output, list_templates, read_template, " +
spawnAgentToolName + ", and approved external MCP tools when available to gather context. " +
"Workspace MCP tools are not available in root plan mode, and side-effecting built-in tools such as process_list, process_signal, message_agent, close_agent, and computer-use actions remain unavailable. In Plan Mode, " +
spawnAgentToolName + " delegation is for investigation and planning " +
"support, not code writing or implementation. Allowed type " +
"values in Plan Mode: " +
formatSubagentDefinitionsWithDescriptionOverrides(
subagentDefinitionsByID(
subagentTypeGeneral,
subagentTypeExplore,
),
planModeDescriptions,
) + "."
}