diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index c36d5b5765..9b6ff51d28 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -5248,27 +5248,25 @@ func activeToolNamesForTurn( func allowedExploreToolNames(allTools []fantasy.AgentTool) []string { builtinExplorePolicy := map[string]bool{ - "read_file": true, - "write_file": false, - "edit_files": false, - "execute": true, - "process_output": true, - "process_list": false, - "process_signal": false, - "list_templates": false, - "read_template": false, - "create_workspace": false, - "start_workspace": false, - "propose_plan": false, - "spawn_agent": false, - "spawn_explore_agent": false, - "wait_agent": false, - "message_agent": false, - "close_agent": false, - "spawn_computer_use_agent": false, - "read_skill": true, - "read_skill_file": true, - "ask_user_question": false, + "read_file": true, + "write_file": false, + "edit_files": false, + "execute": true, + "process_output": true, + "process_list": false, + "process_signal": false, + "list_templates": false, + "read_template": false, + "create_workspace": false, + "start_workspace": false, + "propose_plan": false, + "spawn_agent": false, + "wait_agent": false, + "message_agent": false, + "close_agent": false, + "read_skill": true, + "read_skill_file": true, + "ask_user_question": false, } toolNames := make([]string, 0, len(allTools)) @@ -5358,7 +5356,7 @@ func buildSystemPrompt( isPlanModeTurn := behaviorContext.planMode.Valid && behaviorContext.planMode.ChatPlanMode == database.ChatPlanModePlan if isPlanModeTurn { if behaviorContext.isRootChat { - prompt = chatprompt.InsertSystem(prompt, PlanningOverlayPrompt) + prompt = chatprompt.InsertSystem(prompt, PlanningOverlayPrompt()) if behaviorContext.planModeInstructions != "" { prompt = chatprompt.InsertSystem(prompt, behaviorContext.planModeInstructions) } diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index 5f8e718c6f..1b03b846b8 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -178,11 +178,9 @@ func TestActiveToolNamesForTurn(t *testing.T) { "start_workspace", "propose_plan", "spawn_agent", - "spawn_explore_agent", "wait_agent", "message_agent", "close_agent", - "spawn_computer_use_agent", "read_skill", "read_skill_file", "ask_user_question", @@ -200,7 +198,6 @@ func TestActiveToolNamesForTurn(t *testing.T) { "start_workspace", "propose_plan", "spawn_agent", - "spawn_explore_agent", "wait_agent", "read_skill", "read_skill_file", @@ -223,7 +220,6 @@ func TestActiveToolNamesForTurn(t *testing.T) { "start_workspace", "propose_plan", "spawn_agent", - "spawn_explore_agent", "wait_agent", "read_skill", "read_skill_file", @@ -321,7 +317,6 @@ func TestAllowedExploreToolNames(t *testing.T) { "process_list", "process_signal", "spawn_agent", - "spawn_explore_agent", "wait_agent", "read_skill", "read_skill_file", @@ -348,7 +343,7 @@ func TestAllowedBehaviorToolNames(t *testing.T) { return tools } - allTools := makeTools("read_file", "custom_tool", "spawn_explore_agent") + allTools := makeTools("read_file", "custom_tool", "spawn_agent") exploreMode := database.NullChatMode{ ChatMode: database.ChatModeExplore, Valid: true, @@ -356,7 +351,7 @@ func TestAllowedBehaviorToolNames(t *testing.T) { t.Run("DefaultModeReturnsAllTools", func(t *testing.T) { t.Parallel() - require.Equal(t, []string{"read_file", "custom_tool", "spawn_explore_agent"}, allowedBehaviorToolNames( + require.Equal(t, []string{"read_file", "custom_tool", "spawn_agent"}, allowedBehaviorToolNames( allTools, database.NullChatMode{}, )) diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index b1ad259a04..def80c3312 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -237,7 +237,7 @@ func TestSubagentChatExcludesWorkspaceProvisioningTools(t *testing.T) { if callCount.Add(1) == 1 { // Root chat: model calls spawn_agent. return chattest.OpenAIStreamingResponse( - chattest.OpenAIToolCallChunk("spawn_agent", `{"prompt":"do the thing","title":"sub"}`), + chattest.OpenAIToolCallChunk("spawn_agent", `{"type":"general","prompt":"do the thing","title":"sub"}`), ) } // Subsequent calls (including the subagent): just reply. @@ -431,7 +431,7 @@ func TestPlanModeSubagentChatExcludesAskUserQuestion(t *testing.T) { if callCount.Add(1) == 1 { return chattest.OpenAIStreamingResponse( - chattest.OpenAIToolCallChunk("spawn_agent", `{"prompt":"inspect the codebase","title":"sub"}`), + chattest.OpenAIToolCallChunk("spawn_agent", `{"type":"general","prompt":"inspect the codebase","title":"sub"}`), ) } return chattest.OpenAIStreamingResponse( @@ -587,7 +587,7 @@ func TestExploreSubagentIsReadOnly(t *testing.T) { if callCount.Add(1) == 1 { return chattest.OpenAIStreamingResponse( - chattest.OpenAIToolCallChunk("spawn_explore_agent", `{"prompt":"investigate the codebase","title":"sub"}`), + chattest.OpenAIToolCallChunk("spawn_agent", `{"type":"explore","prompt":"investigate the codebase","title":"sub"}`), ) } return chattest.OpenAIStreamingResponse( @@ -631,7 +631,7 @@ func TestExploreSubagentIsReadOnly(t *testing.T) { sawRoot := false sawChild := false for _, tools := range toolsByCall { - if slice.Contains(tools, "spawn_explore_agent") { + if slice.Contains(tools, "spawn_agent") { sawRoot = true continue } @@ -652,7 +652,7 @@ func TestExploreSubagentIsReadOnly(t *testing.T) { var rootCalls, childCalls [][]string var rootRequests, childRequests []recordedOpenAIRequest for i, tools := range recorded { - if slice.Contains(tools, "spawn_explore_agent") { + if slice.Contains(tools, "spawn_agent") { rootCalls = append(rootCalls, tools) rootRequests = append(rootRequests, recordedRequests[i]) continue @@ -666,13 +666,11 @@ func TestExploreSubagentIsReadOnly(t *testing.T) { require.NotEmpty(t, rootRequests, "expected at least one root prompt") require.NotEmpty(t, childRequests, "expected at least one subagent prompt") require.Contains(t, rootCalls[0], "spawn_agent") - require.Contains(t, rootCalls[0], "spawn_explore_agent") require.Contains(t, rootCalls[0], "write_file") require.Contains(t, rootCalls[0], "edit_files") require.NotContains(t, childCalls[0], "write_file") require.NotContains(t, childCalls[0], "edit_files") require.NotContains(t, childCalls[0], "spawn_agent") - require.NotContains(t, childCalls[0], "spawn_explore_agent") require.NotContains(t, childCalls[0], "wait_agent") require.Contains(t, childCalls[0], "read_file") require.Contains(t, childCalls[0], "execute") @@ -1416,10 +1414,10 @@ func TestPlanTurnPromptContract(t *testing.T) { if msg.Role != "system" { continue } - // The overlay constant includes a placeholder that is replaced at + // The overlay prompt includes a placeholder that is replaced at // runtime, so strip only the stable body text before checking. overlayBody := strings.TrimSuffix( - chatd.PlanningOverlayPrompt, + chatd.PlanningOverlayPrompt(), "{{CODER_CHAT_PLAN_FILE_PATH_BLOCK}}", ) sanitized := strings.ReplaceAll(msg.Content, overlayBody, "") @@ -5499,7 +5497,7 @@ func TestComputerUseSubagentToolsAndModel(t *testing.T) { t.Cleanup(anthropicSrv.Close) // OpenAI mock for the root chat. The first streaming call - // triggers spawn_computer_use_agent; subsequent calls reply + // triggers spawn_agent; subsequent calls reply // with text. var openAICallCount atomic.Int32 openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { @@ -5509,8 +5507,8 @@ func TestComputerUseSubagentToolsAndModel(t *testing.T) { if openAICallCount.Add(1) == 1 { return chattest.OpenAIStreamingResponse( chattest.OpenAIToolCallChunk( - "spawn_computer_use_agent", - `{"prompt":"do the desktop thing","title":"cu-sub"}`, + "spawn_agent", + `{"type":"computer_use","prompt":"do the desktop thing","title":"cu-sub"}`, ), ) } @@ -5657,7 +5655,7 @@ func TestComputerUseSubagentToolsAndModel(t *testing.T) { // 5. Verify subagent tools are NOT present. subagentTools := []string{ - "spawn_agent", "spawn_computer_use_agent", + "spawn_agent", "wait_agent", "message_agent", "close_agent", } for _, tool := range subagentTools { diff --git a/coderd/x/chatd/chatprompt/chatprompt.go b/coderd/x/chatd/chatprompt/chatprompt.go index 7c14dab8be..af05de99ee 100644 --- a/coderd/x/chatd/chatprompt/chatprompt.go +++ b/coderd/x/chatd/chatprompt/chatprompt.go @@ -792,7 +792,12 @@ func toolResultContentToPart(content fantasy.ToolResultContent) codersdk.ChatMes case fantasy.ToolResultOutputContentError: isError = true if output.Error != nil { - result, _ = json.Marshal(map[string]any{"error": output.Error.Error()}) + raw := json.RawMessage(strings.TrimSpace(output.Error.Error())) + if isSubagentLifecycleToolName(content.ToolName) && hasErrorField(raw) { + result = raw + } else { + result, _ = json.Marshal(map[string]any{"error": output.Error.Error()}) + } } else { result = []byte(`{"error":""}`) } @@ -819,6 +824,25 @@ func toolResultContentToPart(content fantasy.ToolResultContent) codersdk.ChatMes return part } +// Keep in sync with coderd/x/chatd/subagent.go. +func isSubagentLifecycleToolName(name string) bool { + switch name { + case "spawn_agent", "wait_agent", "message_agent", "close_agent": + return true + default: + return false + } +} + +func hasErrorField(raw json.RawMessage) bool { + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + return false + } + _, ok := payload["error"] + return ok +} + func injectMissingToolResults(prompt []fantasy.Message) []fantasy.Message { result := make([]fantasy.Message, 0, len(prompt)) for i := 0; i < len(prompt); i++ { diff --git a/coderd/x/chatd/chatprompt/chatprompt_test.go b/coderd/x/chatd/chatprompt/chatprompt_test.go index ac98ebe99e..4bc9f50094 100644 --- a/coderd/x/chatd/chatprompt/chatprompt_test.go +++ b/coderd/x/chatd/chatprompt/chatprompt_test.go @@ -298,7 +298,7 @@ func TestInjectMissingToolResults_SkipsProviderExecuted(t *testing.T) { fantasy.ToolCallContent{ ToolCallID: "toolu_local", ToolName: "spawn_agent", - Input: `{"prompt":"test"}`, + Input: `{"type":"general","prompt":"test"}`, }, fantasy.ToolCallContent{ ToolCallID: "srvtoolu_websearch", @@ -310,7 +310,7 @@ func TestInjectMissingToolResults_SkipsProviderExecuted(t *testing.T) { localResult := mustMarshalToolResult(t, "toolu_local", "spawn_agent", - json.RawMessage(`{"status":"done"}`), + json.RawMessage(`{"status":"done","type":"general"}`), false, false, false, ) @@ -351,12 +351,12 @@ func TestInjectMissingToolResults_SkipsProviderExecuted(t *testing.T) { func TestInjectMissingToolUses_DropsProviderExecutedOrphans(t *testing.T) { t.Parallel() - // Step 1: assistant calls spawn_agent x2 + web_search (PE). + // Step 1: assistant calls spawn_agent + legacy spawn_agent + web_search (PE). step1Assistant := mustMarshalContent(t, []fantasy.Content{ fantasy.ToolCallContent{ ToolCallID: "toolu_A", ToolName: "spawn_agent", - Input: `{"prompt":"a"}`, + Input: `{"type":"general","prompt":"a"}`, }, fantasy.ToolCallContent{ ToolCallID: "toolu_B", @@ -373,7 +373,7 @@ func TestInjectMissingToolUses_DropsProviderExecutedOrphans(t *testing.T) { resultA := mustMarshalToolResult(t, "toolu_A", "spawn_agent", - json.RawMessage(`{"status":"done"}`), + json.RawMessage(`{"status":"done","type":"general"}`), false, false, false, ) resultB := mustMarshalToolResult(t, @@ -1151,6 +1151,55 @@ func TestAssistantWriteRoundTrip(t *testing.T) { require.Equal(t, "ephemeral", cc.Type) } +func TestStructuredToolErrorWritePreservesJSONObject(t *testing.T) { + t.Parallel() + + resultJSON := `{"error":"target chat is not a descendant of current chat","type":"explore"}` + sdkPart := chatprompt.PartFromContent(fantasy.ToolResultContent{ + ToolCallID: "call-1", + ToolName: "wait_agent", + Result: fantasy.ToolResultOutputContentError{ + Error: xerrors.New(resultJSON), + }, + }) + + require.True(t, sdkPart.IsError) + assert.JSONEq(t, resultJSON, string(sdkPart.Result)) +} + +func TestStructuredToolErrorWriteWrapsJSONObjectForNonSubagentTool(t *testing.T) { + t.Parallel() + + resultJSON := `{"error":"permission denied","detail":"nested payload"}` + sdkPart := chatprompt.PartFromContent(fantasy.ToolResultContent{ + ToolCallID: "call-1", + ToolName: "execute", + Result: fantasy.ToolResultOutputContentError{ + Error: xerrors.New(resultJSON), + }, + }) + + require.True(t, sdkPart.IsError) + assert.JSONEq(t, `{"error":"{\"error\":\"permission denied\",\"detail\":\"nested payload\"}"}`, + string(sdkPart.Result)) +} + +func TestStructuredToolErrorWriteWrapsJSONObjectWithoutErrorKey(t *testing.T) { + t.Parallel() + + resultJSON := `{"message":"error"}` + sdkPart := chatprompt.PartFromContent(fantasy.ToolResultContent{ + ToolCallID: "call-1", + ToolName: "wait_agent", + Result: fantasy.ToolResultOutputContentError{ + Error: xerrors.New(resultJSON), + }, + }) + + require.True(t, sdkPart.IsError) + assert.JSONEq(t, `{"error":"{\"message\":\"error\"}"}`, string(sdkPart.Result)) +} + // TestMixedFormatConversation verifies ConvertMessagesWithFiles // handles a realistic post-deploy conversation where legacy and new // storage formats coexist. diff --git a/coderd/x/chatd/prompt.go b/coderd/x/chatd/prompt.go index b6b129be73..23c42fcb9b 100644 --- a/coderd/x/chatd/prompt.go +++ b/coderd/x/chatd/prompt.go @@ -89,7 +89,7 @@ Propose a plan when: If no workspace is attached to this chat yet, create and start one first using create_workspace and start_workspace. Once a workspace is available: -1. Use spawn_explore_agent and wait_agent to research the codebase and gather context as needed. Reserve spawn_agent for writable delegated work. +` + defaultSystemPromptPlanningGuidance + ` 2. Use write_file to create a Markdown plan file at the absolute chat-specific path from the block below when it is available. @@ -101,22 +101,26 @@ When the block below is present, use that exact path. ` + defaultSystemPromptPlanPathBlockPlaceholder + ` ` -// PlanningOverlayPrompt contains plan-mode-only instructions appended -// when the chat is in plan mode. -const PlanningOverlayPrompt = `You are in Plan Mode. +var planningOverlayPrompt = `You are in Plan Mode. Every response must work toward producing a plan. The only intentional authored workspace artifact is the plan file at the path specified in the block below. You may use execute and process_output for exploration, including cloning repositories, searching code, and running inspection commands needed to build the plan. Do not use Plan Mode to implement the requested changes or intentionally modify project files outside the plan file. If no workspace is attached to this chat yet, create and start one with create_workspace and start_workspace before investigating. If the plan file already exists, read it first with read_file before replacing or refining it. -Use read_file, execute, process_output, list_templates, read_template, spawn_agent, 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 spawn_computer_use_agent remain unavailable. In Plan Mode, spawn_agent delegation is for investigation and planning support, not code writing or implementation. +` + planningOverlaySubagentGuidance() + ` Use write_file to create the plan file and edit_files to refine it. Use ask_user_question for structured clarification instead of freeform questions. When the plan is ready, call propose_plan with the plan file path. After a successful propose_plan call, stop immediately. Do not produce follow-up output. ` + defaultSystemPromptPlanPathBlockPlaceholder +// PlanningOverlayPrompt returns the plan-mode-only instructions appended +// when the chat is in plan mode. +func PlanningOverlayPrompt() string { + return planningOverlayPrompt +} + // Root plan mode may use approved external MCP tools, but delegated // plan-mode subagents stay on the narrower built-in-only boundary // because their trust boundary is narrower than the root chat's. diff --git a/coderd/x/chatd/recording_internal_test.go b/coderd/x/chatd/recording_internal_test.go index e6b8f47c3d..d3c852c1df 100644 --- a/coderd/x/chatd/recording_internal_test.go +++ b/coderd/x/chatd/recording_internal_test.go @@ -229,6 +229,7 @@ func TestWaitAgentComputerUseRecording(t *testing.T) { // Parse the response JSON and check for recording_file_id. var result map[string]any require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) + require.Equal(t, subagentTypeComputerUse, result["type"]) storedFileID, ok := result["recording_file_id"].(string) require.True(t, ok, "recording_file_id must be present in response") require.NotEmpty(t, storedFileID) @@ -312,6 +313,7 @@ func TestWaitAgentComputerUseRecordingWithThumbnail(t *testing.T) { var result map[string]any require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) + require.Equal(t, subagentTypeComputerUse, result["type"]) // Verify recording_file_id is present and valid. storedFileID, ok := result["recording_file_id"].(string) @@ -390,6 +392,7 @@ func TestWaitAgentNonComputerUseNoRecording(t *testing.T) { // Parse the response JSON and verify no recording_file_id. var result map[string]any require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) + require.Equal(t, subagentTypeGeneral, result["type"]) _, hasRecording := result["recording_file_id"] assert.False(t, hasRecording, "non-computer_use chat should not produce recording_file_id") } @@ -445,6 +448,7 @@ func TestWaitAgentRecordingStartFails(t *testing.T) { // Parse response JSON and assert no recording_file_id. var result map[string]any require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) + require.Equal(t, subagentTypeComputerUse, result["type"]) _, hasRecording := result["recording_file_id"] assert.False(t, hasRecording, "no recording_file_id when start fails") } diff --git a/coderd/x/chatd/subagent.go b/coderd/x/chatd/subagent.go index d632bcdbad..ae576da1fe 100644 --- a/coderd/x/chatd/subagent.go +++ b/coderd/x/chatd/subagent.go @@ -48,16 +48,6 @@ Guidelines: - If an action doesn't produce the expected result, try alternative approaches. - Report what you accomplished when done.` -type spawnAgentArgs struct { - Prompt string `json:"prompt"` - Title string `json:"title,omitempty"` -} - -type spawnComputerUseAgentArgs struct { - Prompt string `json:"prompt"` - Title string `json:"title,omitempty"` -} - type waitAgentArgs struct { ChatID string `json:"chat_id"` TimeoutSeconds *int `json:"timeout_seconds,omitempty"` @@ -164,130 +154,76 @@ func (p *Server) subagentTools( currentChat func() database.Chat, currentModelConfigID uuid.UUID, ) []fantasy.AgentTool { - var planMode database.NullChatPlanMode + currentChatSnapshot := database.Chat{} if currentChat != nil { - planMode = currentChat().PlanMode + currentChatSnapshot = currentChat() } - spawnAgentDescription := "Spawn a delegated child agent to work on a clearly scoped, " + - "independent task in parallel. Use this when the task is " + - "self-contained and would benefit from a separate agent " + - "(e.g. fixing a specific bug, writing a single module, " + - "running a migration). Do NOT use for simple or quick " + - "operations you can handle directly with execute, " + - "read_file, or write_file. For read-only investigation and " + - "codebase discovery, prefer spawn_explore_agent instead. " + - "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 planMode.Valid && planMode.ChatPlanMode == database.ChatPlanModePlan { - spawnAgentDescription += " During plan mode, spawned agents 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." - } - spawnExploreAgentDescription := "Spawn a read-only delegated child agent for discovery, code reading, and system understanding. Use this when you need investigation, tracing, codebase research, or architecture discovery without intentionally modifying workspace files. The child agent cannot spawn its own subagents and has a restricted toolset focused on reading files and inspection commands. After spawning, use wait_agent to collect the result." + spawnAgentDescription := buildSpawnAgentDescription( + ctx, + p, + currentChatSnapshot, + ) - tools := []fantasy.AgentTool{ + return []fantasy.AgentTool{ fantasy.NewAgentTool( - "spawn_agent", + spawnAgentToolName, spawnAgentDescription, func(ctx context.Context, args spawnAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { if currentChat == nil { return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil } - parent := currentChat() - if parent.ParentChatID.Valid { - return fantasy.NewTextErrorResponse("delegated chats cannot create child subagents"), nil - } - - parent, err := p.db.GetChatByID(ctx, parent.ID) + parent, err := p.loadSubagentSpawnParentChat(ctx, currentChat) if err != nil { return fantasy.NewTextErrorResponse(err.Error()), nil } - childChat, err := p.createChildSubagentChatWithOptions( + + definition, err := resolveSubagentDefinition( ctx, + p, parent, - args.Prompt, - args.Title, - childSubagentChatOptions{}, + args.Type, ) if err != nil { return fantasy.NewTextErrorResponse(err.Error()), nil } - return toolJSONResponse(map[string]any{ - "chat_id": childChat.ID.String(), - "title": childChat.Title, - "status": string(childChat.Status), - }), nil - }, - ), - fantasy.NewAgentTool( - "spawn_explore_agent", - spawnExploreAgentDescription, - func(ctx context.Context, args spawnAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { - if currentChat == nil { - return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil - } - - parent := currentChat() - if parent.ParentChatID.Valid { - return fantasy.NewTextErrorResponse("delegated chats cannot create child subagents"), nil - } - - parent, err := p.db.GetChatByID(ctx, parent.ID) - if err != nil { - return fantasy.NewTextErrorResponse(err.Error()), nil - } - modelConfigID, err := p.resolveExploreSubagentModelConfigID( + options, err := definition.buildOptions( ctx, - parent.OwnerID, + p, + parent, currentModelConfigID, - ) - if err != nil { - return fantasy.NewTextErrorResponse(err.Error()), nil - } - // Explore subagents operate independently of planning. - // Clear plan mode to prevent the child from inheriting - // parent planning behavior. - clearPlanMode := database.NullChatPlanMode{} - childChat, err := p.createChildSubagentChatWithOptions( - ctx, - parent, args.Prompt, - args.Title, - childSubagentChatOptions{ - chatMode: database.NullChatMode{ - ChatMode: database.ChatModeExplore, - Valid: true, - }, - modelConfigIDOverride: &modelConfigID, - planModeOverride: &clearPlanMode, - }, ) if err != nil { return fantasy.NewTextErrorResponse(err.Error()), nil } - return toolJSONResponse(map[string]any{ + childChat, err := p.createChildSubagentChatWithOptions( + ctx, + parent, + args.Prompt, + args.Title, + options, + ) + if err != nil { + return fantasy.NewTextErrorResponse(err.Error()), nil + } + + return toolJSONResponse(withSubagentType(map[string]any{ "chat_id": childChat.ID.String(), "title": childChat.Title, "status": string(childChat.Status), - }), nil + }, childChat)), nil }, ), fantasy.NewAgentTool( "wait_agent", "Wait until a spawned child agent finishes its task. "+ "Returns the agent's final response and status. "+ - "Call this after spawn_agent, spawn_explore_agent, or "+ - "spawn_computer_use_agent to collect the result before "+ - "continuing your own work.", + "Call this after "+spawnAgentToolName+" to collect the "+ + "result before continuing your own work.", func(ctx context.Context, args waitAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { if currentChat == nil { return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil @@ -304,34 +240,41 @@ func (p *Server) subagentTools( } parent := currentChat() - - // Authorize: the target chat must be a descendant - // of the current (parent) chat. - isDescendant, descErr := isSubagentDescendant(ctx, p.db, parent.ID, targetChatID) - if descErr != nil { - return fantasy.NewTextErrorResponse( - fmt.Sprintf("failed to verify subagent relationship: %v", descErr)), nil - } - if !isDescendant { - return fantasy.NewTextErrorResponse( - "target chat is not a subagent of the current chat"), nil - } - - // Check if the target is a computer_use subagent - // and start a desktop recording. Failures are - // best-effort warnings — recording never blocks - // the wait_agent flow. - var recordingID string - var agentConn workspacesdk.AgentConn - - targetChatInfo, lookupErr := p.db.GetChatByID(ctx, targetChatID) - if lookupErr != nil && !xerrors.Is(lookupErr, sql.ErrNoRows) { + var targetChatInfo *database.Chat + if chat, lookupErr := p.db.GetChatByID(ctx, targetChatID); lookupErr == nil { + targetChatInfo = &chat + } else if !xerrors.Is(lookupErr, sql.ErrNoRows) { p.logger.Warn(ctx, "unexpected error looking up chat for recording", slog.F("chat_id", targetChatID), slog.Error(lookupErr), ) } - isComputerUseChat := lookupErr == nil && targetChatInfo.Mode.Valid && + + // Authorize: the target chat must be a descendant + // of the current (parent) chat. + isDescendant, descErr := isSubagentDescendant(ctx, p.db, parent.ID, targetChatID) + if descErr != nil { + return subagentErrorResponse( + xerrors.New(fmt.Sprintf("failed to verify subagent relationship: %v", descErr)), + targetChatInfo, + ), nil + } + if !isDescendant { + return subagentErrorResponse( + ErrSubagentNotDescendant, + targetChatInfo, + ), nil + } + + // Check if the target is a computer_use subagent + // and start a desktop recording. Failures are + // best-effort warnings. Recording never blocks + // the wait_agent flow. + var recordingID string + var agentConn workspacesdk.AgentConn + + isComputerUseChat := targetChatInfo != nil && + targetChatInfo.Mode.Valid && targetChatInfo.Mode.ChatMode == database.ChatModeComputerUse && targetChatInfo.AgentID.Valid canRecord := isComputerUseChat && p.agentConnFn != nil @@ -348,7 +291,7 @@ func (p *Server) subagentTools( if startErr != nil { p.logger.Warn(ctx, "failed to start desktop recording", slog.Error(startErr)) - recordingID = "" // Don't try to stop. + recordingID = "" } } else { p.logger.Warn(ctx, "failed to get agent conn for recording", @@ -360,29 +303,28 @@ func (p *Server) subagentTools( ctx, parent.ID, targetChatID, timeout, ) - // On timeout/error, leave the recording running on - // the agent so the next wait_agent call continues - // it seamlessly. + // On timeout or error, leave the recording running on + // the agent so the next wait_agent call continues it. if awaitErr != nil { - return fantasy.NewTextErrorResponse(awaitErr.Error()), nil + return subagentErrorResponse(awaitErr, targetChatInfo), nil } // Only stop and store the recording on success. var recResult recordingResult if recordingID != "" && agentConn != nil { // Use a fresh context for cleanup so a canceled - // parent context doesn't prevent recording storage. + // parent context does not prevent recording storage. stopCtx, stopCancel := context.WithTimeout(context.WithoutCancel(ctx), 90*time.Second) defer stopCancel() recResult = p.stopAndStoreRecording(stopCtx, agentConn, recordingID, parent.ID, parent.OwnerID, parent.WorkspaceID) } - resp := map[string]any{ - "chat_id": targetChatID.String(), + resp := withSubagentType(map[string]any{ + "chat_id": targetChat.ID.String(), "title": targetChat.Title, "report": report, "status": string(targetChat.Status), - } + }, targetChat) if recResult.recordingFileID != "" { resp["recording_file_id"] = recResult.recordingFileID } @@ -410,6 +352,15 @@ func (p *Server) subagentTools( } parent := currentChat() + var targetChatInfo *database.Chat + if chat, lookupErr := p.db.GetChatByID(ctx, targetChatID); lookupErr == nil { + targetChatInfo = &chat + } else if !xerrors.Is(lookupErr, sql.ErrNoRows) { + p.logger.Warn(ctx, "unexpected error looking up chat for message", + slog.F("chat_id", targetChatID), + slog.Error(lookupErr), + ) + } busyBehavior := SendMessageBusyBehaviorQueue if args.Interrupt { busyBehavior = SendMessageBusyBehaviorInterrupt @@ -422,15 +373,15 @@ func (p *Server) subagentTools( busyBehavior, ) if err != nil { - return fantasy.NewTextErrorResponse(err.Error()), nil + return subagentErrorResponse(err, targetChatInfo), nil } - return toolJSONResponse(map[string]any{ - "chat_id": targetChatID.String(), + return toolJSONResponse(withSubagentType(map[string]any{ + "chat_id": targetChat.ID.String(), "title": targetChat.Title, "status": string(targetChat.Status), "interrupted": args.Interrupt, - }), nil + }, targetChat)), nil }, ), fantasy.NewAgentTool( @@ -449,80 +400,58 @@ func (p *Server) subagentTools( } parent := currentChat() + var targetChatInfo *database.Chat + if chat, lookupErr := p.db.GetChatByID(ctx, targetChatID); lookupErr == nil { + targetChatInfo = &chat + } else if !xerrors.Is(lookupErr, sql.ErrNoRows) { + p.logger.Warn(ctx, "unexpected error looking up chat for close", + slog.F("chat_id", targetChatID), + slog.Error(lookupErr), + ) + } targetChat, err := p.closeSubagent( ctx, parent.ID, targetChatID, ) if err != nil { - return fantasy.NewTextErrorResponse(err.Error()), nil + return subagentErrorResponse(err, targetChatInfo), nil } - return toolJSONResponse(map[string]any{ - "chat_id": targetChatID.String(), + return toolJSONResponse(withSubagentType(map[string]any{ + "chat_id": targetChat.ID.String(), "title": targetChat.Title, "terminated": true, "status": string(targetChat.Status), - }), nil + }, targetChat)), nil }, ), } +} - // Only include the computer use tool when an Anthropic - // provider is configured and desktop is enabled. - if p.isAnthropicConfigured(ctx) && p.isDesktopEnabled(ctx) { - tools = append(tools, fantasy.NewAgentTool( - "spawn_computer_use_agent", - "Spawn a dedicated computer use agent that can see the desktop "+ - "(take screenshots) and interact with it (mouse, keyboard, "+ - "scroll). The agent runs on a model optimized for computer "+ - "use and has the same workspace tools as a standard subagent "+ - "plus the native Anthropic computer tool. Use this for tasks "+ - "that require visual interaction with a desktop GUI (e.g. "+ - "browser automation, GUI testing, visual inspection). After "+ - "spawning, use wait_agent to collect the result.", - func(ctx context.Context, args spawnComputerUseAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { - if currentChat == nil { - return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil - } - - parent := currentChat() - if parent.ParentChatID.Valid { - return fantasy.NewTextErrorResponse("delegated chats cannot create child subagents"), nil - } - - parent, err := p.db.GetChatByID(ctx, parent.ID) - if err != nil { - return fantasy.NewTextErrorResponse(err.Error()), nil - } - - childChat, err := p.createChildSubagentChatWithOptions( - ctx, - parent, - args.Prompt, - args.Title, - childSubagentChatOptions{ - chatMode: database.NullChatMode{ - ChatMode: database.ChatModeComputerUse, - Valid: true, - }, - systemPrompt: computerUseSubagentSystemPrompt + "\n\n" + strings.TrimSpace(args.Prompt), - }, - ) - if err != nil { - return fantasy.NewTextErrorResponse(err.Error()), nil - } - - return toolJSONResponse(map[string]any{ - "chat_id": childChat.ID.String(), - "title": childChat.Title, - "status": string(childChat.Status), - }), nil - }, - )) +func (p *Server) loadSubagentSpawnParentChat( + ctx context.Context, + currentChat func() database.Chat, +) (database.Chat, error) { + parent := currentChat() + if err := validateSubagentSpawnParent(parent); err != nil { + return database.Chat{}, err } - return tools + reloadedParent, err := p.db.GetChatByID(ctx, parent.ID) + if err != nil { + p.logger.Warn(ctx, "failed to load parent chat for spawn_agent", + slog.F("chat_id", parent.ID), + slog.Error(err), + ) + return database.Chat{}, xerrors.New("failed to load parent chat") + } + parent = reloadedParent + if err := validateSubagentSpawnParent(parent); err != nil { + return database.Chat{}, err + } + + return parent, nil } func parseSubagentToolChatID(raw string) (uuid.UUID, error) { @@ -1148,3 +1077,9 @@ func toolJSONResponse(result map[string]any) fantasy.ToolResponse { } return fantasy.NewTextResponse(string(data)) } + +func toolJSONErrorResponse(result map[string]any) fantasy.ToolResponse { + resp := toolJSONResponse(result) + resp.IsError = true + return resp +} diff --git a/coderd/x/chatd/subagent_catalog.go b/coderd/x/chatd/subagent_catalog.go new file mode 100644 index 0000000000..68f8eb1f5e --- /dev/null +++ b/coderd/x/chatd/subagent_catalog.go @@ -0,0 +1,278 @@ +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, + ) + "." +} diff --git a/coderd/x/chatd/subagent_context_internal_test.go b/coderd/x/chatd/subagent_context_internal_test.go index 8090806822..49bd6b085f 100644 --- a/coderd/x/chatd/subagent_context_internal_test.go +++ b/coderd/x/chatd/subagent_context_internal_test.go @@ -480,13 +480,13 @@ func TestSpawnComputerUseAgentInheritsContext(t *testing.T) { parentChat := createParentChatWithInheritedContext(ctx, t, db, server) tools := server.subagentTools(ctx, func() database.Chat { return parentChat }, parentChat.LastModelConfigID) - tool := findToolByName(tools, "spawn_computer_use_agent") + tool := findToolByName(tools, spawnAgentToolName) require.NotNil(t, tool) resp, err := tool.Run(ctx, fantasy.ToolCall{ ID: "call-context", - Name: "spawn_computer_use_agent", - Input: `{"prompt":"inspect bindings"}`, + Name: spawnAgentToolName, + Input: `{"type":"computer_use","prompt":"inspect bindings"}`, }) require.NoError(t, err) require.False(t, resp.IsError, "expected success but got: %s", resp.Content) diff --git a/coderd/x/chatd/subagent_internal_test.go b/coderd/x/chatd/subagent_internal_test.go index f1ffb277f4..120de9f39b 100644 --- a/coderd/x/chatd/subagent_internal_test.go +++ b/coderd/x/chatd/subagent_internal_test.go @@ -308,7 +308,7 @@ func runSubagentTool( parentChat database.Chat, currentModelConfigID uuid.UUID, toolName string, - args spawnAgentArgs, + args any, ) fantasy.ToolResponse { t.Helper() @@ -347,11 +347,28 @@ func runSpawnAgentTool( server, parentChat, parentChat.LastModelConfigID, - "spawn_agent", + spawnAgentToolName, args, ) } +func requireSpawnAgentResponse(t *testing.T, resp fantasy.ToolResponse) struct { + ChatID string `json:"chat_id"` + SubagentType string `json:"type"` +} { + t.Helper() + require.False(t, resp.IsError, "expected success but got: %s", resp.Content) + + var result struct { + ChatID string `json:"chat_id"` + SubagentType string `json:"type"` + } + require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) + require.NotEmpty(t, result.ChatID, "response must contain chat_id") + require.NotEmpty(t, result.SubagentType, "response must contain type") + return result +} + func requireSpawnAgentChildChatID(t *testing.T, resp fantasy.ToolResponse) uuid.UUID { t.Helper() require.False(t, resp.IsError, "expected success but got: %s", resp.Content) @@ -367,6 +384,19 @@ func requireSpawnAgentChildChatID(t *testing.T, resp fantasy.ToolResponse) uuid. return childID } +func requireToolResponseMap( + t *testing.T, + resp fantasy.ToolResponse, + wantError bool, +) map[string]any { + t.Helper() + require.Equal(t, wantError, resp.IsError, "unexpected tool error state: %s", resp.Content) + + var result map[string]any + require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) + return result +} + func TestCreateChildSubagentChatCopiesPlanMode(t *testing.T) { t.Parallel() @@ -404,7 +434,7 @@ func TestCreateChildSubagentChatCopiesPlanMode(t *testing.T) { require.Equal(t, planMode, childChat.PlanMode) } -func TestSpawnAgent_InheritsParentModelWhenOmitted(t *testing.T) { +func TestSpawnAgent_GeneralInheritsParentModelWhenOmitted(t *testing.T) { t.Parallel() db, ps := dbtestutil.NewDB(t) @@ -417,9 +447,13 @@ func TestSpawnAgent_InheritsParentModelWhenOmitted(t *testing.T) { ) resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{ + Type: subagentTypeGeneral, Prompt: "delegate work", }) - childID := requireSpawnAgentChildChatID(t, resp) + result := requireSpawnAgentResponse(t, resp) + require.Equal(t, subagentTypeGeneral, result.SubagentType) + childID, err := uuid.Parse(result.ChatID) + require.NoError(t, err) childChat, err := db.GetChatByID(ctx, childID) require.NoError(t, err) @@ -458,7 +492,7 @@ func TestCreateChildSubagentChat_OverrideWorksWhenParentHasNoModel(t *testing.T) require.Equal(t, overrideModel.ID, childChat.LastModelConfigID) } -func TestSpawnExploreAgent_UsesConfiguredModelOverride(t *testing.T) { +func TestSpawnAgent_ExploreUsesConfiguredModelOverride(t *testing.T) { t.Parallel() db, ps := dbtestutil.NewDB(t) @@ -480,10 +514,13 @@ func TestSpawnExploreAgent_UsesConfiguredModelOverride(t *testing.T) { server, parentChat, parentChat.LastModelConfigID, - "spawn_explore_agent", - spawnAgentArgs{Prompt: "investigate the codebase"}, + spawnAgentToolName, + spawnAgentArgs{Type: subagentTypeExplore, Prompt: "investigate the codebase"}, ) - childID := requireSpawnAgentChildChatID(t, resp) + result := requireSpawnAgentResponse(t, resp) + require.Equal(t, subagentTypeExplore, result.SubagentType) + childID, err := uuid.Parse(result.ChatID) + require.NoError(t, err) childChat, err := db.GetChatByID(ctx, childID) require.NoError(t, err) @@ -493,7 +530,7 @@ func TestSpawnExploreAgent_UsesConfiguredModelOverride(t *testing.T) { require.False(t, childChat.PlanMode.Valid) } -func TestSpawnExploreAgent_FallsBackToCurrentTurnModel(t *testing.T) { +func TestSpawnAgent_ExploreFallsBackToCurrentTurnModel(t *testing.T) { t.Parallel() db, ps := dbtestutil.NewDB(t) @@ -514,8 +551,8 @@ func TestSpawnExploreAgent_FallsBackToCurrentTurnModel(t *testing.T) { server, parentChat, currentTurnModel.ID, - "spawn_explore_agent", - spawnAgentArgs{Prompt: "trace the request flow"}, + spawnAgentToolName, + spawnAgentArgs{Type: subagentTypeExplore, Prompt: "trace the request flow"}, ) childID := requireSpawnAgentChildChatID(t, resp) @@ -525,7 +562,7 @@ func TestSpawnExploreAgent_FallsBackToCurrentTurnModel(t *testing.T) { require.Equal(t, parentModel.ID, parentChat.LastModelConfigID) } -func TestSpawnExploreAgent_FallsBackOnInvalidUUID(t *testing.T) { +func TestSpawnAgent_ExploreFallsBackOnInvalidUUID(t *testing.T) { t.Parallel() db, ps := dbtestutil.NewDB(t) @@ -547,8 +584,8 @@ func TestSpawnExploreAgent_FallsBackOnInvalidUUID(t *testing.T) { server, parentChat, currentTurnModel.ID, - "spawn_explore_agent", - spawnAgentArgs{Prompt: "inspect the handler flow"}, + spawnAgentToolName, + spawnAgentArgs{Type: subagentTypeExplore, Prompt: "inspect the handler flow"}, ) childID := requireSpawnAgentChildChatID(t, resp) @@ -557,7 +594,7 @@ func TestSpawnExploreAgent_FallsBackOnInvalidUUID(t *testing.T) { require.Equal(t, currentTurnModel.ID, childChat.LastModelConfigID) } -func TestSpawnExploreAgent_FallsBackWhenOverrideIsUnavailable(t *testing.T) { +func TestSpawnAgent_ExploreFallsBackWhenOverrideIsUnavailable(t *testing.T) { t.Parallel() db, ps := dbtestutil.NewDB(t) @@ -582,8 +619,8 @@ func TestSpawnExploreAgent_FallsBackWhenOverrideIsUnavailable(t *testing.T) { server, parentChat, currentTurnModel.ID, - "spawn_explore_agent", - spawnAgentArgs{Prompt: "inspect the service boundaries"}, + spawnAgentToolName, + spawnAgentArgs{Type: subagentTypeExplore, Prompt: "inspect the service boundaries"}, ) childID := requireSpawnAgentChildChatID(t, resp) @@ -592,7 +629,7 @@ func TestSpawnExploreAgent_FallsBackWhenOverrideIsUnavailable(t *testing.T) { require.Equal(t, currentTurnModel.ID, childChat.LastModelConfigID) } -func TestSpawnExploreAgent_FallsBackWhenOverrideCredentialsAreUnavailable(t *testing.T) { +func TestSpawnAgent_ExploreFallsBackWhenOverrideCredentialsAreUnavailable(t *testing.T) { t.Parallel() db, ps := dbtestutil.NewDB(t) @@ -639,8 +676,8 @@ func TestSpawnExploreAgent_FallsBackWhenOverrideCredentialsAreUnavailable(t *tes server, parentChat, currentTurnModel.ID, - "spawn_explore_agent", - spawnAgentArgs{Prompt: "inspect provider credential handling"}, + spawnAgentToolName, + spawnAgentArgs{Type: subagentTypeExplore, Prompt: "inspect provider credential handling"}, ) childID := requireSpawnAgentChildChatID(t, resp) @@ -649,103 +686,93 @@ func TestSpawnExploreAgent_FallsBackWhenOverrideCredentialsAreUnavailable(t *tes require.Equal(t, currentTurnModel.ID, childChat.LastModelConfigID) } -func TestSpawnComputerUseAgent_NoAnthropicProvider(t *testing.T) { +func TestSpawnAgent_DescriptionListsAllAvailableTypes(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true)) + server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{ + Anthropic: "test-anthropic-key", + }) + + ctx := chatdTestContext(t) + user, org, model := seedInternalChatDeps(ctx, t, db) + parentChat := createInternalParentChat( + ctx, t, server, db, org.ID, user.ID, model.ID, "parent-description-all", + ) + + tools := server.subagentTools(ctx, func() database.Chat { return parentChat }, parentChat.LastModelConfigID) + tool := findToolByName(tools, spawnAgentToolName) + require.NotNil(t, tool, "spawn_agent tool must be present") + description := tool.Info().Description + require.Contains(t, description, subagentTypeGeneral) + require.Contains(t, description, subagentTypeExplore) + require.Contains(t, description, subagentTypeComputerUse) +} + +func TestSpawnAgent_DescriptionOmitsComputerUseWhenUnavailable(t *testing.T) { t.Parallel() db, ps := dbtestutil.NewDB(t) require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true)) - // No Anthropic key in ProviderAPIKeys. server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}) ctx := chatdTestContext(t) user, org, model := seedInternalChatDeps(ctx, t, db) - - // Create a root parent chat. - parent, err := server.CreateChat(ctx, CreateOptions{ - OrganizationID: org.ID, - OwnerID: user.ID, - Title: "parent-no-anthropic", - ModelConfigID: model.ID, - InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")}, - }) - require.NoError(t, err) - - // Re-fetch so LastModelConfigID is populated from the DB. - parentChat, err := db.GetChatByID(ctx, parent.ID) - require.NoError(t, err) + parentChat := createInternalParentChat( + ctx, t, server, db, org.ID, user.ID, model.ID, "parent-description-unavailable", + ) tools := server.subagentTools(ctx, func() database.Chat { return parentChat }, parentChat.LastModelConfigID) - tool := findToolByName(tools, "spawn_computer_use_agent") - assert.Nil(t, tool, "spawn_computer_use_agent tool must be omitted when Anthropic is not configured") + tool := findToolByName(tools, spawnAgentToolName) + require.NotNil(t, tool, "spawn_agent tool must be present") + description := tool.Info().Description + require.Contains(t, description, subagentTypeGeneral) + require.Contains(t, description, subagentTypeExplore) + require.NotContains(t, description, subagentTypeComputerUse) } -func TestSpawnComputerUseAgent_NotAvailableForChildChats(t *testing.T) { +func TestSpawnAgent_PlanModeDescriptionOmitsComputerUse(t *testing.T) { t.Parallel() db, ps := dbtestutil.NewDB(t) require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true)) - // Provide an Anthropic key so the provider check passes. server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{ Anthropic: "test-anthropic-key", }) ctx := chatdTestContext(t) user, org, model := seedInternalChatDeps(ctx, t, db) - - // Create a root parent chat. parent, err := server.CreateChat(ctx, CreateOptions{ - OrganizationID: org.ID, - OwnerID: user.ID, - Title: "root-parent", - ModelConfigID: model.ID, - InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")}, - }) - require.NoError(t, err) - - // Create a child chat under the parent. - child, err := server.CreateChat(ctx, CreateOptions{ OrganizationID: org.ID, OwnerID: user.ID, - ParentChatID: uuid.NullUUID{ - UUID: parent.ID, - Valid: true, + Title: "plan-parent-description", + ModelConfigID: model.ID, + PlanMode: database.NullChatPlanMode{ + ChatPlanMode: database.ChatPlanModePlan, + Valid: true, }, - RootChatID: uuid.NullUUID{ - UUID: parent.ID, - Valid: true, - }, - Title: "child-subagent", - ModelConfigID: model.ID, - InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("do something")}, + InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("plan this change")}, }) require.NoError(t, err) - - // Re-fetch the child so ParentChatID is populated. - childChat, err := db.GetChatByID(ctx, child.ID) - require.NoError(t, err) - require.True(t, childChat.ParentChatID.Valid, - "child chat must have a parent") - - // Get tools as if the child chat is the current chat. - tools := server.subagentTools(ctx, func() database.Chat { return childChat }, childChat.LastModelConfigID) - tool := findToolByName(tools, "spawn_computer_use_agent") - require.NotNil(t, tool, "spawn_computer_use_agent tool must be present") - - resp, err := tool.Run(ctx, fantasy.ToolCall{ - ID: "call-2", - Name: "spawn_computer_use_agent", - Input: `{"prompt":"open browser"}`, - }) + parentChat, err := db.GetChatByID(ctx, parent.ID) require.NoError(t, err) - assert.True(t, resp.IsError, "expected an error response") - assert.Contains(t, resp.Content, "delegated chats cannot create child subagents") + tools := server.subagentTools(ctx, func() database.Chat { return parentChat }, parentChat.LastModelConfigID) + tool := findToolByName(tools, spawnAgentToolName) + require.NotNil(t, tool, "spawn_agent tool must be present") + description := tool.Info().Description + require.Contains(t, description, subagentTypeGeneral) + require.Contains(t, description, subagentTypeExplore) + require.NotContains(t, description, subagentTypeComputerUse) + require.Contains(t, description, "must not implement changes or intentionally modify workspace files") } -func TestSpawnComputerUseAgent_DesktopDisabled(t *testing.T) { +func TestSpawnAgent_PlanModeRejectsComputerUse(t *testing.T) { t.Parallel() db, ps := dbtestutil.NewDB(t) + require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true)) server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{ Anthropic: "test-anthropic-key", }) @@ -753,27 +780,347 @@ func TestSpawnComputerUseAgent_DesktopDisabled(t *testing.T) { ctx := chatdTestContext(t) user, org, model := seedInternalChatDeps(ctx, t, db) parent, err := server.CreateChat(ctx, CreateOptions{ - OrganizationID: org.ID, - OwnerID: user.ID, - Title: "parent-desktop-disabled", - ModelConfigID: model.ID, - InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")}, + OrganizationID: org.ID, + OwnerID: user.ID, + Title: "plan-parent-computer-use-reject", + ModelConfigID: model.ID, + PlanMode: database.NullChatPlanMode{ + ChatPlanMode: database.ChatPlanModePlan, + Valid: true, + }, + InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("plan this change")}, }) require.NoError(t, err) parentChat, err := db.GetChatByID(ctx, parent.ID) require.NoError(t, err) - tools := server.subagentTools(ctx, func() database.Chat { return parentChat }, parentChat.LastModelConfigID) - tool := findToolByName(tools, "spawn_computer_use_agent") - assert.Nil(t, tool, "spawn_computer_use_agent tool must be omitted when desktop is disabled") + resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{ + Type: subagentTypeComputerUse, + Prompt: "open the browser and click around", + }) + require.True(t, resp.IsError) + require.Contains(t, resp.Content, `type "computer_use" is unavailable in plan mode`) } -func TestSpawnComputerUseAgent_UsesComputerUseModelNotParent(t *testing.T) { +func TestPlanningOverlaySubagentGuidance_UsesPlanModeSafeDescriptions(t *testing.T) { + t.Parallel() + + guidance := planningOverlaySubagentGuidance() + + require.Contains(t, guidance, subagentTypeGeneral) + require.Contains(t, guidance, subagentTypeExplore) + require.NotContains(t, guidance, subagentTypeComputerUse) + require.NotContains(t, guidance, "modify") + require.NotContains(t, guidance, "may inspect or modify workspace files") +} + +func TestSpawnAgent_InvalidTypeAndUnavailableTypeAreDistinct(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true)) + server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}) + + ctx := chatdTestContext(t) + user, org, model := seedInternalChatDeps(ctx, t, db) + parentChat := createInternalParentChat( + ctx, t, server, db, org.ID, user.ID, model.ID, "parent-invalid-type", + ) + + invalidResp := runSubagentTool( + ctx, + t, + server, + parentChat, + parentChat.LastModelConfigID, + spawnAgentToolName, + spawnAgentArgs{Type: "invalid", Prompt: "delegate work"}, + ) + require.True(t, invalidResp.IsError) + require.Contains(t, invalidResp.Content, "type must be one of: general, explore") + + unavailableResp := runSubagentTool( + ctx, + t, + server, + parentChat, + parentChat.LastModelConfigID, + spawnAgentToolName, + spawnAgentArgs{Type: subagentTypeComputerUse, Prompt: "open browser"}, + ) + require.True(t, unavailableResp.IsError) + require.Contains(t, unavailableResp.Content, `type "computer_use" is unavailable because computer use is not configured`) +} + +func TestSpawnAgent_BlankTypeReturnsValidOptions(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true)) + server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{ + Anthropic: "test-anthropic-key", + }) + + ctx := chatdTestContext(t) + user, org, model := seedInternalChatDeps(ctx, t, db) + parentChat := createInternalParentChat( + ctx, t, server, db, org.ID, user.ID, model.ID, "parent-blank-type", + ) + + tests := []struct { + name string + subagentType string + }{ + {name: "empty", subagentType: ""}, + {name: "space", subagentType: " "}, + {name: "whitespace", subagentType: "\n\t"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{ + Type: tt.subagentType, + Prompt: "delegate work", + }) + require.True(t, resp.IsError) + require.Contains(t, resp.Content, "type must be one of:") + require.Contains(t, resp.Content, subagentTypeGeneral) + require.Contains(t, resp.Content, subagentTypeExplore) + require.Contains(t, resp.Content, subagentTypeComputerUse) + }) + } +} + +func TestSpawnAgent_NotAvailableForChildChats(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true)) + server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{ + Anthropic: "test-anthropic-key", + }) + + ctx := chatdTestContext(t) + user, org, model := seedInternalChatDeps(ctx, t, db) + _, child := createParentChildChats(ctx, t, server, user, org, model) + + childChat, err := db.GetChatByID(ctx, child.ID) + require.NoError(t, err) + require.True(t, childChat.ParentChatID.Valid, "child chat must have a parent") + + tools := server.subagentTools(ctx, func() database.Chat { return childChat }, childChat.LastModelConfigID) + tool := findToolByName(tools, spawnAgentToolName) + require.NotNil(t, tool, "spawn_agent tool must be present") + + resp, err := tool.Run(ctx, fantasy.ToolCall{ + ID: "call-child", + Name: spawnAgentToolName, + Input: `{"type":"general","prompt":"open browser"}`, + }) + require.NoError(t, err) + require.True(t, resp.IsError) + require.Contains(t, resp.Content, "delegated chats cannot create child subagents") +} + +func TestSpawnAgent_NotAvailableForExploreChats(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}) + + ctx := chatdTestContext(t) + user, org, model := seedInternalChatDeps(ctx, t, db) + exploreChat, err := server.CreateChat(ctx, CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + Title: "root-explore", + ModelConfigID: model.ID, + ChatMode: database.NullChatMode{ + ChatMode: database.ChatModeExplore, + Valid: true, + }, + InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("inspect the codebase")}, + }) + require.NoError(t, err) + currentChat, err := db.GetChatByID(ctx, exploreChat.ID) + require.NoError(t, err) + + tools := server.subagentTools(ctx, func() database.Chat { return currentChat }, currentChat.LastModelConfigID) + tool := findToolByName(tools, spawnAgentToolName) + require.NotNil(t, tool, "spawn_agent tool must be present") + + resp, err := tool.Run(ctx, fantasy.ToolCall{ + ID: "call-explore", + Name: spawnAgentToolName, + Input: `{"type":"general","prompt":"delegate work"}`, + }) + require.NoError(t, err) + require.True(t, resp.IsError) + require.Contains(t, resp.Content, "explore chats cannot create child subagents") +} + +func TestSubagentLifecycleToolsIncludePersistedSubagentTypeAcrossVariants(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + variant string + }{ + {name: "General", variant: subagentTypeGeneral}, + {name: "Explore", variant: subagentTypeExplore}, + {name: "ComputerUse", variant: subagentTypeComputerUse}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + if tt.variant == subagentTypeComputerUse { + require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true)) + } + + providerKeys := chatprovider.ProviderAPIKeys{} + if tt.variant == subagentTypeComputerUse { + providerKeys = chatprovider.ProviderAPIKeys{Anthropic: "test-anthropic-key"} + } + server := newInternalTestServer(t, db, ps, providerKeys) + + ctx := chatdTestContext(t) + user, org, model := seedInternalChatDeps(ctx, t, db) + parentChat := createInternalParentChat( + ctx, + t, + server, + db, + org.ID, + user.ID, + model.ID, + "parent-lifecycle-"+tt.variant, + ) + + spawnResp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{ + Type: tt.variant, + Prompt: "delegate work", + }) + spawnResult := requireSpawnAgentResponse(t, spawnResp) + require.Equal(t, tt.variant, spawnResult.SubagentType) + childID, err := uuid.Parse(spawnResult.ChatID) + require.NoError(t, err) + + setChatStatus(ctx, t, db, childID, database.ChatStatusWaiting, "") + insertAssistantMessage(ctx, t, db, childID, model.ID, "task complete") + waitResult := requireToolResponseMap(t, runSubagentTool( + ctx, + t, + server, + parentChat, + parentChat.LastModelConfigID, + "wait_agent", + waitAgentArgs{ChatID: childID.String()}, + ), false) + require.Equal(t, tt.variant, waitResult["type"]) + + messageResult := requireToolResponseMap(t, runSubagentTool( + ctx, + t, + server, + parentChat, + parentChat.LastModelConfigID, + "message_agent", + messageAgentArgs{ChatID: childID.String(), Message: "follow up"}, + ), false) + require.Equal(t, tt.variant, messageResult["type"]) + + setChatStatus(ctx, t, db, childID, database.ChatStatusRunning, "") + closeResult := requireToolResponseMap(t, runSubagentTool( + ctx, + t, + server, + parentChat, + parentChat.LastModelConfigID, + "close_agent", + closeAgentArgs{ChatID: childID.String()}, + ), false) + require.Equal(t, tt.variant, closeResult["type"]) + }) + } +} + +func TestSubagentLifecycleToolErrorsIncludePersistedSubagentType(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}) + + ctx := chatdTestContext(t) + user, org, model := seedInternalChatDeps(ctx, t, db) + _, child := createParentChildChats(ctx, t, server, user, org, model) + unrelated, err := server.CreateChat(ctx, CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + Title: "unrelated-lifecycle-parent", + ModelConfigID: model.ID, + InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("other")}, + }) + require.NoError(t, err) + unrelatedChat, err := db.GetChatByID(ctx, unrelated.ID) + require.NoError(t, err) + + tests := []struct { + name string + toolName string + args any + wantError string + }{ + { + name: "WaitAgent", + toolName: "wait_agent", + args: waitAgentArgs{ChatID: child.ID.String()}, + wantError: ErrSubagentNotDescendant.Error(), + }, + { + name: "MessageAgent", + toolName: "message_agent", + args: messageAgentArgs{ChatID: child.ID.String(), Message: "follow up"}, + wantError: ErrSubagentNotDescendant.Error(), + }, + { + name: "CloseAgent", + toolName: "close_agent", + args: closeAgentArgs{ChatID: child.ID.String()}, + wantError: ErrSubagentNotDescendant.Error(), + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := requireToolResponseMap(t, runSubagentTool( + ctx, + t, + server, + unrelatedChat, + unrelatedChat.LastModelConfigID, + tt.toolName, + tt.args, + ), true) + require.Equal(t, subagentTypeGeneral, result["type"]) + require.Equal(t, tt.wantError, result["error"]) + }) + } +} + +func TestSpawnAgent_ComputerUseUsesComputerUseModelNotParent(t *testing.T) { t.Parallel() db, ps := dbtestutil.NewDB(t) require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true)) - // Provide an Anthropic key so the tool can proceed. server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{ Anthropic: "test-anthropic-key", }) @@ -782,25 +1129,14 @@ func TestSpawnComputerUseAgent_UsesComputerUseModelNotParent(t *testing.T) { user, org, model := seedInternalChatDeps(ctx, t, db) workspace, build, agent := seedWorkspaceBinding(t, db, user.ID) - // The parent uses an OpenAI model. - require.Equal(t, "openai", model.Provider, - "seed helper must create an OpenAI model") + require.Equal(t, "openai", model.Provider, "seed helper must create an OpenAI model") parent, err := server.CreateChat(ctx, CreateOptions{ - OrganizationID: org.ID, - OwnerID: user.ID, - WorkspaceID: uuid.NullUUID{ - UUID: workspace.ID, - Valid: true, - }, - BuildID: uuid.NullUUID{ - UUID: build.ID, - Valid: true, - }, - AgentID: uuid.NullUUID{ - UUID: agent.ID, - Valid: true, - }, + OrganizationID: org.ID, + OwnerID: user.ID, + WorkspaceID: uuid.NullUUID{UUID: workspace.ID, Valid: true}, + BuildID: uuid.NullUUID{UUID: build.ID, Valid: true}, + AgentID: uuid.NullUUID{UUID: agent.ID, Valid: true}, Title: "parent-openai", ModelConfigID: model.ID, InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")}, @@ -810,25 +1146,18 @@ func TestSpawnComputerUseAgent_UsesComputerUseModelNotParent(t *testing.T) { parentChat, err := db.GetChatByID(ctx, parent.ID) require.NoError(t, err) - tools := server.subagentTools(ctx, func() database.Chat { return parentChat }, parentChat.LastModelConfigID) - tool := findToolByName(tools, "spawn_computer_use_agent") - require.NotNil(t, tool) - - resp, err := tool.Run(ctx, fantasy.ToolCall{ - ID: "call-3", - Name: "spawn_computer_use_agent", - Input: `{"prompt":"take a screenshot"}`, - }) - require.NoError(t, err) - require.False(t, resp.IsError, "expected success but got: %s", resp.Content) - - // Parse the response to get the child chat ID. - var result map[string]any - require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) - childIDStr, ok := result["chat_id"].(string) - require.True(t, ok, "response must contain chat_id") - - childID, err := uuid.Parse(childIDStr) + resp := runSubagentTool( + ctx, + t, + server, + parentChat, + parentChat.LastModelConfigID, + spawnAgentToolName, + spawnAgentArgs{Type: subagentTypeComputerUse, Prompt: "take a screenshot"}, + ) + result := requireSpawnAgentResponse(t, resp) + require.Equal(t, subagentTypeComputerUse, result.SubagentType) + childID, err := uuid.Parse(result.ChatID) require.NoError(t, err) childChat, err := db.GetChatByID(ctx, childID) @@ -837,23 +1166,73 @@ func TestSpawnComputerUseAgent_UsesComputerUseModelNotParent(t *testing.T) { require.Equal(t, parentChat.WorkspaceID, childChat.WorkspaceID) require.Equal(t, parentChat.BuildID, childChat.BuildID) require.Equal(t, parentChat.AgentID, childChat.AgentID) - - // The child must have Mode=computer_use which causes - // runChat to override the model to the predefined computer - // use model instead of using the parent's model config. require.True(t, childChat.Mode.Valid) assert.Equal(t, database.ChatModeComputerUse, childChat.Mode.ChatMode) - - // The predefined computer use model is Anthropic, which - // differs from the parent's OpenAI model. This confirms - // that the child will not inherit the parent's model at - // runtime. assert.NotEqual(t, model.Provider, chattool.ComputerUseModelProvider, "computer use model provider must differ from parent model provider") assert.Equal(t, "anthropic", chattool.ComputerUseModelProvider) assert.NotEmpty(t, chattool.ComputerUseModelName) } +func TestSpawnAgent_ComputerUseInheritsMCPServerIDs(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true)) + server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{ + Anthropic: "test-anthropic-key", + }) + + ctx := chatdTestContext(t) + user, org, model := seedInternalChatDeps(ctx, t, db) + + mcpCfg, err := db.InsertMCPServerConfig(ctx, database.InsertMCPServerConfigParams{ + DisplayName: "MCP Test", + Slug: "mcp-test", + Url: "https://mcp.example.com", + Transport: "streamable_http", + AuthType: "none", + Availability: "default_off", + Enabled: true, + ToolAllowList: []string{}, + ToolDenyList: []string{}, + CreatedBy: user.ID, + UpdatedBy: user.ID, + }) + require.NoError(t, err) + + parentMCPIDs := []uuid.UUID{mcpCfg.ID} + + parent, err := server.CreateChat(ctx, CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + Title: "parent-cu-mcp", + ModelConfigID: model.ID, + MCPServerIDs: parentMCPIDs, + InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")}, + }) + require.NoError(t, err) + + parentChat, err := db.GetChatByID(ctx, parent.ID) + require.NoError(t, err) + + resp := runSubagentTool( + ctx, + t, + server, + parentChat, + parentChat.LastModelConfigID, + spawnAgentToolName, + spawnAgentArgs{Type: subagentTypeComputerUse, Prompt: "check the UI"}, + ) + childID := requireSpawnAgentChildChatID(t, resp) + + childChat, err := db.GetChatByID(ctx, childID) + require.NoError(t, err) + assert.ElementsMatch(t, parentMCPIDs, childChat.MCPServerIDs, + "computer use child chat must inherit MCP server IDs from parent") +} + func TestCreateChildSubagentChat_InheritsMCPServerIDs(t *testing.T) { t.Parallel() @@ -930,79 +1309,6 @@ func TestCreateChildSubagentChat_InheritsMCPServerIDs(t *testing.T) { "child chat must inherit MCP server IDs from parent") } -func TestSpawnComputerUseAgent_InheritsMCPServerIDs(t *testing.T) { - t.Parallel() - - db, ps := dbtestutil.NewDB(t) - require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true)) - server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{ - Anthropic: "test-anthropic-key", - }) - - ctx := chatdTestContext(t) - user, org, model := seedInternalChatDeps(ctx, t, db) - - // Insert an MCP server config. - mcpCfg, err := db.InsertMCPServerConfig(ctx, database.InsertMCPServerConfigParams{ - DisplayName: "MCP Test", - Slug: "mcp-test", - Url: "https://mcp.example.com", - Transport: "streamable_http", - AuthType: "none", - Availability: "default_off", - Enabled: true, - ToolAllowList: []string{}, - ToolDenyList: []string{}, - CreatedBy: user.ID, - UpdatedBy: user.ID, - }) - require.NoError(t, err) - - parentMCPIDs := []uuid.UUID{mcpCfg.ID} - - // Create a parent chat with MCP servers. - parent, err := server.CreateChat(ctx, CreateOptions{ - OrganizationID: org.ID, - OwnerID: user.ID, - Title: "parent-cu-mcp", - ModelConfigID: model.ID, - MCPServerIDs: parentMCPIDs, - InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")}, - }) - require.NoError(t, err) - - parentChat, err := db.GetChatByID(ctx, parent.ID) - require.NoError(t, err) - - // Call spawn_computer_use_agent via the tool. - tools := server.subagentTools(ctx, func() database.Chat { return parentChat }, parentChat.LastModelConfigID) - tool := findToolByName(tools, "spawn_computer_use_agent") - require.NotNil(t, tool) - - resp, err := tool.Run(ctx, fantasy.ToolCall{ - ID: "call-mcp", - Name: "spawn_computer_use_agent", - Input: `{"prompt":"check the UI"}`, - }) - require.NoError(t, err) - require.False(t, resp.IsError, "expected success but got: %s", resp.Content) - - // Parse the child chat ID from the response. - var result map[string]any - require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) - childIDStr, ok := result["chat_id"].(string) - require.True(t, ok) - - childID, err := uuid.Parse(childIDStr) - require.NoError(t, err) - - // Verify the child inherited MCP server IDs. - childChat, err := db.GetChatByID(ctx, childID) - require.NoError(t, err) - assert.ElementsMatch(t, parentMCPIDs, childChat.MCPServerIDs, - "computer use child chat must inherit MCP server IDs from parent") -} - func TestCreateChildSubagentChat_NoMCPServersStaysEmpty(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/subagent_test.go b/coderd/x/chatd/subagent_test.go index 6a4cb6f50a..157b872262 100644 --- a/coderd/x/chatd/subagent_test.go +++ b/coderd/x/chatd/subagent_test.go @@ -33,7 +33,7 @@ func TestSpawnComputerUseAgent_CreatesChildWithChatMode(t *testing.T) { }) require.NoError(t, err) - // Simulate what spawn_computer_use_agent does: set ChatMode + // Simulate what spawn_agent does: set ChatMode // to computer_use and provide a system prompt. prompt := "Use the desktop to open Firefox" diff --git a/docs/ai-coder/agents/architecture.md b/docs/ai-coder/agents/architecture.md index 282d0ed1bf..37cbfda95e 100644 --- a/docs/ai-coder/agents/architecture.md +++ b/docs/ai-coder/agents/architecture.md @@ -172,13 +172,13 @@ provider-native, and computer-use tools are not available. These tools manage sub-agents — child chats that work on independent tasks in parallel. -| Tool | What it does | -|----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `spawn_agent` | Delegates a task to a sub-agent with its own context window. | -| `wait_agent` | Waits for a sub-agent to finish and collects its result. | -| `message_agent` | Sends a follow-up message to a running sub-agent. | -| `close_agent` | Stops a running sub-agent. | -| `spawn_computer_use_agent` | Spawns a sub-agent with desktop interaction capabilities (screenshot, mouse, keyboard). Requires an Anthropic provider and the desktop feature to be enabled by an administrator. | +| Tool | What it does | +|---------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `spawn_agent` (`type=general` or `explore`) | Delegates a task to a sub-agent with its own context window. | +| `wait_agent` | Waits for a sub-agent to finish and collects its result. | +| `message_agent` | Sends a follow-up message to a running sub-agent. | +| `close_agent` | Stops a running sub-agent. | +| `spawn_agent` (`type=computer_use`) | Spawns a sub-agent with desktop interaction capabilities (screenshot, mouse, keyboard). Requires an Anthropic provider and the desktop feature to be enabled by an administrator. | ### Provider tools diff --git a/docs/ai-coder/agents/index.md b/docs/ai-coder/agents/index.md index 153953cf57..2891729a8d 100644 --- a/docs/ai-coder/agents/index.md +++ b/docs/ai-coder/agents/index.md @@ -229,29 +229,29 @@ model. Developers select from enabled models when starting a chat. The agent has access to a set of workspace tools that it uses to accomplish tasks: -| Tool | Description | -|----------------------------|--------------------------------------------------------------------------| -| `list_templates` | Browse available workspace templates | -| `read_template` | Get template details and configurable parameters | -| `create_workspace` | Create a workspace from a template | -| `start_workspace` | Start a stopped workspace for the current chat | -| `propose_plan` | Present a Markdown plan file for user review | -| `ask_user_question` | Ask the user structured clarification questions during plan mode | -| `read_file` | Read file contents from the workspace | -| `write_file` | Write a file to the workspace | -| `edit_files` | Perform search-and-replace edits across files | -| `execute` | Run shell commands in the workspace | -| `process_output` | Retrieve output from a background process | -| `process_list` | List all tracked processes in the workspace | -| `process_signal` | Send a signal (terminate/kill) to a tracked process | -| `spawn_agent` | Delegate a task to a sub-agent running in parallel | -| `wait_agent` | Wait for a sub-agent to complete and collect its result | -| `message_agent` | Send a follow-up message to a running sub-agent | -| `close_agent` | Stop a running sub-agent | -| `spawn_computer_use_agent` | Spawn a sub-agent with desktop interaction (screenshot, mouse, keyboard) | -| `read_skill` | Read the instructions for a workspace skill by name | -| `read_skill_file` | Read a supporting file from a skill's directory | -| `web_search` | Search the internet (provider-native, when enabled) | +| Tool | Description | +|---------------------------------------------|--------------------------------------------------------------------------| +| `list_templates` | Browse available workspace templates | +| `read_template` | Get template details and configurable parameters | +| `create_workspace` | Create a workspace from a template | +| `start_workspace` | Start a stopped workspace for the current chat | +| `propose_plan` | Present a Markdown plan file for user review | +| `ask_user_question` | Ask the user structured clarification questions during plan mode | +| `read_file` | Read file contents from the workspace | +| `write_file` | Write a file to the workspace | +| `edit_files` | Perform search-and-replace edits across files | +| `execute` | Run shell commands in the workspace | +| `process_output` | Retrieve output from a background process | +| `process_list` | List all tracked processes in the workspace | +| `process_signal` | Send a signal (terminate/kill) to a tracked process | +| `spawn_agent` (`type=general` or `explore`) | Delegate a task to a sub-agent running in parallel | +| `wait_agent` | Wait for a sub-agent to complete and collect its result | +| `message_agent` | Send a follow-up message to a running sub-agent | +| `close_agent` | Stop a running sub-agent | +| `spawn_agent` (`type=computer_use`) | Spawn a sub-agent with desktop interaction (screenshot, mouse, keyboard) | +| `read_skill` | Read the instructions for a workspace skill by name | +| `read_skill_file` | Read a supporting file from a skill's directory | +| `web_search` | Search the internet (provider-native, when enabled) | These tools connect to the workspace over the same secure connection used for web terminals and IDE access. No additional ports or services are required in @@ -259,12 +259,13 @@ the workspace. Platform tools (`list_templates`, `read_template`, `create_workspace`, `start_workspace`, `propose_plan`, `ask_user_question`) and orchestration tools (`spawn_agent`, -`wait_agent`, `message_agent`, `close_agent`, `spawn_computer_use_agent`) +`wait_agent`, `message_agent`, `close_agent`) are only available to root chats. Sub-agents do not have access to these tools and cannot create workspaces or spawn further sub-agents. -`spawn_computer_use_agent` additionally requires an Anthropic provider and -the virtual desktop feature to be enabled by an administrator. +`spawn_agent` with `type=computer_use` additionally requires an +Anthropic provider and the virtual desktop feature to be enabled by an +administrator. `read_skill` and `read_skill_file` are available when the workspace contains skills in its `.agents/skills/` directory. diff --git a/docs/ai-coder/agents/platform-controls/index.md b/docs/ai-coder/agents/platform-controls/index.md index 7bec338c43..a978d1cbad 100644 --- a/docs/ai-coder/agents/platform-controls/index.md +++ b/docs/ai-coder/agents/platform-controls/index.md @@ -107,7 +107,8 @@ See [MCP Servers](./mcp-servers.md) for configuration details. ### Virtual desktop Administrators can enable a virtual desktop within agent workspaces. -When enabled, agents can use `spawn_computer_use_agent` to interact with a +When enabled, agents can use `spawn_agent` with +`type=computer_use` to interact with a desktop environment using screenshots, mouse, and keyboard input. This setting is available under **Agents** > **Settings** > **Behavior**. diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index a396462d1c..4e11e1a4c1 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -831,6 +831,130 @@ export const WithComputerUseAgent: Story = { }, }; +export const WithMixedSubagentTranscript: Story = { + parameters: { + queries: buildQueries( + { + id: CHAT_ID, + ...baseChatFields, + title: "Mixed subagent transcript", + status: "completed", + }, + { + messages: [ + { + id: 1, + chat_id: CHAT_ID, + created_at: "2026-02-18T00:00:01.000Z", + role: "assistant", + content: [ + { + type: "tool-call", + tool_call_id: "legacy-spawn", + tool_name: "spawn_agent", + args: { title: "Legacy helper" }, + }, + { + type: "tool-result", + tool_call_id: "legacy-spawn", + tool_name: "spawn_agent", + result: { + chat_id: "legacy-child", + title: "Legacy helper", + status: "completed", + }, + }, + { + type: "tool-call", + tool_call_id: "unified-spawn", + tool_name: "spawn_agent", + args: { type: "explore" }, + }, + { + type: "tool-result", + tool_call_id: "unified-spawn", + tool_name: "spawn_agent", + result: { + chat_id: "explore-child", + type: "explore", + status: "completed", + }, + }, + { + type: "tool-call", + tool_call_id: "unified-wait", + tool_name: "wait_agent", + args: { chat_id: "explore-child" }, + }, + { + type: "tool-result", + tool_call_id: "unified-wait", + tool_name: "wait_agent", + result: { + chat_id: "explore-child", + type: "explore", + status: "completed", + }, + }, + { + type: "tool-call", + tool_call_id: "legacy-close", + tool_name: "close_agent", + args: { chat_id: "legacy-child" }, + }, + { + type: "tool-result", + tool_call_id: "legacy-close", + tool_name: "close_agent", + result: { + chat_id: "legacy-child", + type: "general", + status: "completed", + }, + }, + ], + }, + ], + queued_messages: [], + has_more: false, + }, + { diffUrl: undefined }, + ), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect( + canvas.getByText( + (_content, element) => + element?.tagName === "SPAN" && + element.textContent?.includes("Spawned") === true && + element.textContent?.includes("Legacy helper") === true, + ), + ).toBeInTheDocument(); + expect( + canvas.getAllByText(/Legacy helper/).length, + ).toBeGreaterThanOrEqual(2); + expect( + canvas.getByText( + (_content, element) => + element?.tagName === "SPAN" && + element.textContent?.includes("Spawned") === true && + element.textContent?.includes("Explore agent") === true, + ), + ).toBeInTheDocument(); + expect( + canvas.getByText( + (_content, element) => + element?.tagName === "SPAN" && + element.textContent?.includes("Waited for") === true && + element.textContent?.includes("Explore agent") === true, + ), + ).toBeInTheDocument(); + }); + }, +}; + /** Completed reasoning part renders inline. */ export const WithReasoningInline: Story = { parameters: { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index 9c13153f82..73b6a0c1e8 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -26,6 +26,7 @@ import { Tool, } from "../ChatElements"; import { WebSearchSources } from "../ChatElements/tools"; +import type { SubagentVariant } from "../ChatElements/tools/subagentDescriptor"; import { ImageLightbox } from "../ImageLightbox"; import { TextPreviewDialog } from "../TextPreviewDialog"; import { deriveMessageDisplayState } from "./messageHelpers"; @@ -128,7 +129,7 @@ export const BlockList: FC<{ keyPrefix: string; isStreaming?: boolean; subagentTitles?: Map; - computerUseSubagentIds?: Set; + subagentVariants?: Map; showDesktopPreviews?: boolean; subagentStatusOverrides?: Map; mcpServers?: readonly TypesGen.MCPServerConfig[]; @@ -147,7 +148,7 @@ export const BlockList: FC<{ keyPrefix, isStreaming = false, subagentTitles, - computerUseSubagentIds, + subagentVariants, showDesktopPreviews, subagentStatusOverrides, mcpServers, @@ -240,6 +241,7 @@ export const BlockList: FC<{ status="running" isError={false} subagentTitles={subagentTitles} + subagentVariants={subagentVariants} subagentStatusOverrides={subagentStatusOverrides} mcpServers={mcpServers} /> @@ -255,7 +257,7 @@ export const BlockList: FC<{ isError={tool.isError} killedBySignal={tool.killedBySignal} subagentTitles={subagentTitles} - computerUseSubagentIds={computerUseSubagentIds} + subagentVariants={subagentVariants} showDesktopPreviews={showDesktopPreviews} subagentStatusOverrides={ isStreaming ? subagentStatusOverrides : undefined @@ -308,7 +310,7 @@ export const BlockList: FC<{ isError={tool.isError} killedBySignal={tool.killedBySignal} subagentTitles={subagentTitles} - computerUseSubagentIds={computerUseSubagentIds} + subagentVariants={subagentVariants} showDesktopPreviews={showDesktopPreviews} subagentStatusOverrides={ isStreaming ? subagentStatusOverrides : undefined @@ -354,7 +356,7 @@ const ChatMessageItem = memo<{ urlTransform?: UrlTransform; mcpServers?: readonly TypesGen.MCPServerConfig[]; subagentTitles?: Map; - computerUseSubagentIds?: Set; + subagentVariants?: Map; showDesktopPreviews?: boolean; onSendAskUserQuestionResponse?: (message: string) => Promise | void; isChatCompleted?: boolean; @@ -380,7 +382,7 @@ const ChatMessageItem = memo<{ urlTransform, mcpServers, subagentTitles, - computerUseSubagentIds, + subagentVariants, showDesktopPreviews, }) => { const isUser = message.role === "user"; @@ -429,7 +431,7 @@ const ChatMessageItem = memo<{ tools={parsed.tools} keyPrefix={String(message.id)} subagentTitles={subagentTitles} - computerUseSubagentIds={computerUseSubagentIds} + subagentVariants={subagentVariants} showDesktopPreviews={showDesktopPreviews} onImplementPlan={onImplementPlan} onSendAskUserQuestionResponse={ @@ -814,6 +816,7 @@ const StickyUserMessage = memo<{ interface ConversationTimelineProps { parsedMessages: readonly ParsedMessageEntry[]; subagentTitles: Map; + subagentVariants?: Map; onEditUserMessage?: ( messageId: number, text: string, @@ -825,7 +828,6 @@ interface ConversationTimelineProps { isChatCompleted?: boolean; urlTransform?: UrlTransform; mcpServers?: readonly TypesGen.MCPServerConfig[]; - computerUseSubagentIds?: Set; showDesktopPreviews?: boolean; isTurnActive?: boolean; } @@ -834,6 +836,7 @@ export const ConversationTimeline = memo( ({ parsedMessages, subagentTitles, + subagentVariants, onEditUserMessage, editingMessageId, onImplementPlan, @@ -841,7 +844,6 @@ export const ConversationTimeline = memo( isChatCompleted, urlTransform, mcpServers, - computerUseSubagentIds, showDesktopPreviews, }) => { if (parsedMessages.length === 0) { @@ -937,7 +939,7 @@ export const ConversationTimeline = memo( hideActions={!isLastInChain} mcpServers={mcpServers} subagentTitles={subagentTitles} - computerUseSubagentIds={computerUseSubagentIds} + subagentVariants={subagentVariants} showDesktopPreviews={showDesktopPreviews} /> ); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx index f45a8d15b8..137996e4b5 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx @@ -4,6 +4,7 @@ import type * as TypesGen from "#/api/typesGenerated"; import { Alert, AlertDescription } from "#/components/Alert/Alert"; import { Button } from "#/components/Button/Button"; import type { ChatDetailError } from "../../utils/usageLimitMessage"; +import type { SubagentVariant } from "../ChatElements/tools/subagentDescriptor"; import { ChatStatusCallout } from "./ChatStatusCallout"; import { selectIsAwaitingFirstStreamChunk, @@ -36,7 +37,7 @@ interface LiveStreamTailContentProps { liveStatus: LiveStatusModel; startingResetKey?: string; subagentTitles: Map; - computerUseSubagentIds?: Set; + subagentVariants?: Map; subagentStatusOverrides: Map; urlTransform?: UrlTransform; mcpServers?: readonly TypesGen.MCPServerConfig[]; @@ -49,7 +50,7 @@ export const LiveStreamTailContent = ({ liveStatus, startingResetKey, subagentTitles, - computerUseSubagentIds, + subagentVariants, subagentStatusOverrides, urlTransform, mcpServers, @@ -83,7 +84,7 @@ export const LiveStreamTailContent = ({ liveStatus={liveStatus} startingResetKey={startingResetKey} subagentTitles={subagentTitles} - computerUseSubagentIds={computerUseSubagentIds} + subagentVariants={subagentVariants} subagentStatusOverrides={subagentStatusOverrides} urlTransform={urlTransform} mcpServers={mcpServers} @@ -113,7 +114,7 @@ interface LiveStreamTailProps { isTranscriptEmpty: boolean; startingResetKey?: string; subagentTitles: Map; - computerUseSubagentIds?: Set; + subagentVariants?: Map; urlTransform?: UrlTransform; mcpServers?: readonly TypesGen.MCPServerConfig[]; } @@ -124,7 +125,7 @@ export const LiveStreamTail = ({ isTranscriptEmpty, startingResetKey, subagentTitles, - computerUseSubagentIds, + subagentVariants, urlTransform, mcpServers, }: LiveStreamTailProps) => { @@ -161,7 +162,7 @@ export const LiveStreamTail = ({ liveStatus={liveStatus} startingResetKey={startingResetKey} subagentTitles={subagentTitles} - computerUseSubagentIds={computerUseSubagentIds} + subagentVariants={subagentVariants} subagentStatusOverrides={subagentStatusOverrides} urlTransform={urlTransform} mcpServers={mcpServers} diff --git a/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx b/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx index e7b67a6847..fb846e9f50 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx @@ -8,6 +8,7 @@ import { Response, Shimmer, } from "../ChatElements"; +import type { SubagentVariant } from "../ChatElements/tools/subagentDescriptor"; import { ChatStatusCallout } from "./ChatStatusCallout"; import { BlockList } from "./ConversationTimeline"; import type { LiveStatusModel } from "./liveStatusModel"; @@ -50,7 +51,7 @@ export const StreamingOutput: FC<{ streamState: StreamState | null; streamTools: readonly MergedTool[]; subagentTitles?: Map; - computerUseSubagentIds?: Set; + subagentVariants?: Map; subagentStatusOverrides?: Map; liveStatus: LiveStatusModel; startingResetKey?: string; @@ -60,7 +61,7 @@ export const StreamingOutput: FC<{ streamState, streamTools, subagentTitles, - computerUseSubagentIds, + subagentVariants, subagentStatusOverrides, liveStatus, startingResetKey, @@ -106,7 +107,7 @@ export const StreamingOutput: FC<{ keyPrefix="stream" isStreaming={isStreaming} subagentTitles={subagentTitles} - computerUseSubagentIds={computerUseSubagentIds} + subagentVariants={subagentVariants} subagentStatusOverrides={subagentStatusOverrides} urlTransform={urlTransform} mcpServers={mcpServers} diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts index 78069dffdf..5c3581c9c2 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import type { ChatMessage, ChatMessagePart } from "#/api/typesGenerated"; +import { getSubagentDescriptor } from "../ChatElements/tools/subagentDescriptor"; import { + buildSubagentMaps, mergeTools, parseMessageContent, parseMessagesWithMergedTools, @@ -25,7 +27,7 @@ describe("parseToolResultIsError", () => { ).toBe(true); }); - it("returns false for completed subagent even with error field", () => { + it("returns false for completed subagent tools, including legacy spawn_subagent, even with error field", () => { expect( parseToolResultIsError( "spawn_agent", @@ -54,6 +56,20 @@ describe("parseToolResultIsError", () => { { status: "completed" }, ), ).toBe(false); + expect( + parseToolResultIsError( + "spawn_subagent", + { error: "metadata" }, + { status: "completed" }, + ), + ).toBe(false); + expect( + parseToolResultIsError( + "close_agent", + { error: "metadata" }, + { status: "completed" }, + ), + ).toBe(false); }); }); @@ -511,3 +527,367 @@ describe("parseMessagesWithMergedTools — killedBySignal annotation", () => { expect(procOut?.killedBySignal).toBe("terminate"); }); }); + +describe("subagent transcript parsing", () => { + const msg = ( + id: number, + parts: ChatMessagePart[], + role: "assistant" | "user" = "assistant", + ): ChatMessage => ({ + id, + chat_id: "chat-1", + created_at: new Date().toISOString(), + role, + content: parts, + }); + + const toolCall = ( + id: string, + name: string, + args: Record = {}, + ): ChatMessagePart => ({ + type: "tool-call", + tool_call_id: id, + tool_name: name, + args: args as Record, + }); + + const toolResult = ( + id: string, + name: string, + result: Record, + ): ChatMessagePart => ({ + type: "tool-result", + tool_call_id: id, + tool_name: name, + result: result as Record, + }); + + const parseSubagents = (messages: readonly ChatMessage[]) => { + const parsedMessages = parseMessagesWithMergedTools(messages); + const { titles, variants } = buildSubagentMaps(parsedMessages); + return { + parsedMessages, + titles, + variants, + }; + }; + + it("keeps legacy spawn tool parsing intact", () => { + const { parsedMessages, titles, variants } = parseSubagents([ + msg(1, [ + toolCall("legacy-general", "spawn_agent", { + title: "Legacy general", + }), + toolResult("legacy-general", "spawn_agent", { + chat_id: "legacy-general-child", + title: "Legacy general", + status: "completed", + }), + ]), + msg(2, [ + toolCall("legacy-explore", "spawn_explore_agent", {}), + toolResult("legacy-explore", "spawn_explore_agent", { + chat_id: "legacy-explore-child", + status: "completed", + }), + ]), + msg(3, [ + toolCall("legacy-desktop", "spawn_computer_use_agent", { + title: "Legacy desktop", + }), + toolResult("legacy-desktop", "spawn_computer_use_agent", { + chat_id: "legacy-desktop-child", + title: "Legacy desktop", + status: "completed", + }), + ]), + ]); + + expect(parsedMessages[0]?.parsed.tools[0]?.name).toBe("spawn_agent"); + expect(parsedMessages[1]?.parsed.tools[0]?.name).toBe( + "spawn_explore_agent", + ); + expect(parsedMessages[2]?.parsed.tools[0]?.name).toBe( + "spawn_computer_use_agent", + ); + expect(titles.get("legacy-general-child")).toBe("Legacy general"); + expect(variants.get("legacy-general-child")).toBe("general"); + expect(variants.get("legacy-explore-child")).toBe("explore"); + expect(variants.get("legacy-desktop-child")).toBe("computer_use"); + }); + + it("keeps legacy spawn_subagent payload parsing intact", () => { + const { titles, variants } = parseSubagents([ + msg(1, [ + toolCall("legacy-unified", "spawn_subagent", { + subagent_type: "explore", + title: "Legacy unified", + }), + toolResult("legacy-unified", "spawn_subagent", { + chat_id: "legacy-unified-child", + subagent_type: "explore", + title: "Legacy unified", + status: "completed", + }), + ]), + ]); + + expect(titles.get("legacy-unified-child")).toBe("Legacy unified"); + expect(variants.get("legacy-unified-child")).toBe("explore"); + }); + + it("parses spawn_agent variants from args and results", () => { + const { titles, variants } = parseSubagents([ + msg(1, [ + toolCall("spawn-general", "spawn_agent", { + type: "general", + title: "General helper", + }), + toolResult("spawn-general", "spawn_agent", { + chat_id: "spawn-general-child", + type: "general", + title: "General helper", + status: "completed", + }), + ]), + msg(2, [ + toolCall("spawn-explore", "spawn_agent", { + type: "explore", + }), + toolResult("spawn-explore", "spawn_agent", { + chat_id: "spawn-explore-child", + type: "explore", + status: "completed", + }), + ]), + msg(3, [ + toolCall("spawn-desktop", "spawn_agent", { + type: "computer_use", + title: "Desktop helper", + }), + toolResult("spawn-desktop", "spawn_agent", { + chat_id: "spawn-desktop-child", + type: "computer_use", + title: "Desktop helper", + status: "completed", + }), + ]), + ]); + + expect(titles.get("spawn-general-child")).toBe("General helper"); + expect(variants.get("spawn-general-child")).toBe("general"); + expect(variants.get("spawn-explore-child")).toBe("explore"); + expect(variants.get("spawn-desktop-child")).toBe("computer_use"); + }); + + it("buildSubagentMaps merges mixed legacy and spawn_agent transcripts coherently", () => { + const parsedMessages = parseMessagesWithMergedTools([ + msg(1, [toolCall("legacy", "spawn_agent", { title: "Legacy helper" })]), + msg(2, [ + toolResult("legacy", "spawn_agent", { + chat_id: "legacy-child", + title: "Legacy helper", + status: "completed", + }), + ]), + msg(3, [ + toolCall("unified", "spawn_agent", { + type: "explore", + title: "Unified helper", + }), + ]), + msg(4, [ + toolResult("unified", "spawn_agent", { + chat_id: "unified-child", + type: "explore", + title: "Unified helper", + status: "completed", + }), + ]), + ]); + const { titles, variants } = buildSubagentMaps(parsedMessages); + + expect(parsedMessages[0]?.parsed.tools[0]?.result).toMatchObject({ + chat_id: "legacy-child", + title: "Legacy helper", + }); + expect(parsedMessages[2]?.parsed.tools[0]?.result).toMatchObject({ + chat_id: "unified-child", + title: "Unified helper", + type: "explore", + }); + expect(titles.get("legacy-child")).toBe("Legacy helper"); + expect(titles.get("unified-child")).toBe("Unified helper"); + expect(variants.get("legacy-child")).toBe("general"); + expect(variants.get("unified-child")).toBe("explore"); + }); + + it("includes close_agent in the shared subagent parsing path", () => { + const { variants } = parseSubagents([ + msg(1, [ + toolCall("close-tool", "close_agent", { chat_id: "closing-child" }), + toolResult("close-tool", "close_agent", { + chat_id: "closing-child", + type: "explore", + status: "completed", + }), + ]), + ]); + + expect(variants.get("closing-child")).toBe("explore"); + }); + + it("tracks computer-use variants for legacy and spawn_agent tools", () => { + const { variants } = parseSubagents([ + msg(1, [ + toolCall("legacy-desktop", "spawn_computer_use_agent", {}), + toolResult("legacy-desktop", "spawn_computer_use_agent", { + chat_id: "legacy-desktop-child", + status: "completed", + }), + ]), + msg(2, [ + toolCall("unified-desktop", "spawn_agent", { + type: "computer_use", + }), + toolResult("unified-desktop", "spawn_agent", { + chat_id: "unified-desktop-child", + type: "computer_use", + status: "completed", + }), + ]), + ]); + + expect(variants.get("legacy-desktop-child")).toBe("computer_use"); + expect(variants.get("unified-desktop-child")).toBe("computer_use"); + }); + + it("prefers lifecycle result type metadata when present", () => { + const { variants } = parseSubagents([ + msg(1, [ + toolCall("wait-tool", "wait_agent", { chat_id: "wait-child" }), + toolResult("wait-tool", "wait_agent", { + chat_id: "wait-child", + type: "explore", + status: "completed", + }), + ]), + msg(2, [ + toolCall("message-tool", "message_agent", { + chat_id: "message-child", + message: "continue", + }), + toolResult("message-tool", "message_agent", { + chat_id: "message-child", + type: "computer_use", + status: "completed", + }), + ]), + msg(3, [ + toolCall("close-tool", "close_agent", { chat_id: "close-child" }), + toolResult("close-tool", "close_agent", { + chat_id: "close-child", + type: "general", + status: "completed", + }), + ]), + ]); + + expect(variants.get("wait-child")).toBe("explore"); + expect(variants.get("message-child")).toBe("computer_use"); + expect(variants.get("close-child")).toBe("general"); + }); + + it("preserves lifecycle variants inferred from earlier spawn history", () => { + const { titles, variants } = parseSubagents([ + msg(1, [ + toolCall("spawn-tool", "spawn_agent", { + type: "explore", + title: "Inspect repository", + }), + toolResult("spawn-tool", "spawn_agent", { + chat_id: "history-child", + type: "explore", + title: "Inspect repository", + status: "completed", + }), + ]), + msg(2, [ + toolCall("wait-tool", "wait_agent", { chat_id: "history-child" }), + toolResult("wait-tool", "wait_agent", { + chat_id: "history-child", + status: "completed", + }), + ]), + msg(3, [ + toolCall("message-tool", "message_agent", { + chat_id: "history-child", + message: "keep going", + }), + toolResult("message-tool", "message_agent", { + chat_id: "history-child", + status: "completed", + }), + ]), + msg(4, [ + toolCall("close-tool", "close_agent", { chat_id: "history-child" }), + toolResult("close-tool", "close_agent", { + chat_id: "history-child", + status: "completed", + }), + ]), + ]); + + expect(titles.get("history-child")).toBe("Inspect repository"); + expect(variants.get("history-child")).toBe("explore"); + }); +}); + +describe("getSubagentDescriptor", () => { + it("uses the inferred variant for lifecycle tools without explicit metadata", () => { + const lifecycleTools = [ + { name: "wait_agent", action: "wait" }, + { name: "message_agent", action: "message" }, + { name: "close_agent", action: "close" }, + ] as const; + + for (const tool of lifecycleTools) { + const descriptor = getSubagentDescriptor({ + name: tool.name, + args: { chat_id: "desktop-child" }, + result: { chat_id: "desktop-child", status: "running" }, + inferredVariant: "computer_use", + }); + + expect(descriptor).toMatchObject({ + action: tool.action, + variant: "computer_use", + iconKind: "monitor", + supportsDesktopAffordance: true, + }); + } + }); + + it("falls back to the general lifecycle variant without an inference", () => { + const lifecycleToolNames = [ + "wait_agent", + "message_agent", + "close_agent", + ] as const; + + for (const name of lifecycleToolNames) { + const descriptor = getSubagentDescriptor({ + name, + args: { chat_id: "general-child" }, + result: { chat_id: "general-child", status: "running" }, + }); + + expect(descriptor).toMatchObject({ + variant: "general", + iconKind: "bot", + supportsDesktopAffordance: false, + }); + } + }); +}); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts index c69025bef3..c748dc9bce 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts @@ -1,5 +1,12 @@ import type * as TypesGen from "#/api/typesGenerated"; import { asRecord, asString } from "../ChatElements/runtimeTypeUtils"; +import { + getProvidedSubagentTitle, + getSubagentChatId, + getSubagentDescriptor, + isSubagentToolName, + type SubagentVariant, +} from "../ChatElements/tools/subagentDescriptor"; import { appendTextBlock } from "./blockUtils"; import type { MergedTool, @@ -18,13 +25,6 @@ const appendText = (current: string, next: string): string => { return `${current}${next}`; }; -const isSubagentToolName = (name: string): boolean => - name === "spawn_agent" || - name === "spawn_explore_agent" || - name === "spawn_computer_use_agent" || - name === "wait_agent" || - name === "message_agent"; - const isCompletedSubagentResult = ( toolName: string, result: unknown, @@ -325,51 +325,50 @@ export const parseMessagesWithMergedTools = ( return rawParsed; }; -export const buildSubagentTitles = ( +export const buildSubagentMaps = ( parsedMessages: readonly ParsedMessageEntry[], -): Map => { - const map = new Map(); - for (const { parsed } of parsedMessages) { - for (const tool of parsed.tools) { - if ( - tool.name !== "spawn_agent" && - tool.name !== "spawn_explore_agent" && - tool.name !== "spawn_computer_use_agent" - ) { - continue; - } - const rec = asRecord(tool.result); - if (!rec) { - continue; - } - const chatId = asString(rec.chat_id); - const title = asString(rec.title); - if (chatId && title) { - map.set(chatId, title); - } - } - } - return map; -}; +): { + titles: Map; + variants: Map; +} => { + const titles = new Map(); + const variants = new Map(); -export const buildComputerUseSubagentIds = ( - parsedMessages: readonly ParsedMessageEntry[], -): Set => { - const ids = new Set(); for (const { parsed } of parsedMessages) { for (const tool of parsed.tools) { - if (tool.name !== "spawn_computer_use_agent") { + if (!isSubagentToolName(tool.name)) { continue; } - const rec = asRecord(tool.result); - if (!rec) { + + const chatId = getSubagentChatId({ + args: tool.args, + result: tool.result, + }); + if (!chatId) { continue; } - const chatId = asString(rec.chat_id); - if (chatId) { - ids.add(chatId); + + const descriptor = getSubagentDescriptor({ + name: tool.name, + args: tool.args, + result: tool.result, + inferredVariant: variants.get(chatId), + }); + if (!descriptor) { + continue; + } + + variants.set(chatId, descriptor.variant); + + const providedTitle = getProvidedSubagentTitle({ + args: tool.args, + result: tool.result, + }); + if (providedTitle) { + titles.set(chatId, providedTitle); } } } - return ids; + + return { titles, variants }; }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx index b86a50587f..c8ccee3aca 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx @@ -17,6 +17,7 @@ import { Shimmer } from "../Shimmer"; import { useDesktopPanel } from "./DesktopPanelContext"; import { InlineDesktopPreview } from "./InlineDesktopPreview"; import { RecordingPreview } from "./RecordingPreview"; +import type { SubagentAction, SubagentDescriptor } from "./subagentDescriptor"; import { isSubagentSuccessStatus, shortDurationMs, @@ -24,45 +25,33 @@ import { } from "./utils"; const SUBAGENT_VERBS: Record< - string, + SubagentAction, { completed: string; running: string; error: string; timeout: string } > = { - spawn_agent: { + spawn: { completed: "Spawned ", running: "Spawning ", error: "Failed to spawn ", timeout: "Timed out spawning ", }, - spawn_explore_agent: { - completed: "Spawned ", - running: "Spawning ", - error: "Failed to spawn ", - timeout: "Timed out spawning ", - }, - wait_agent: { + wait: { completed: "Waited for ", running: "Waiting for ", error: "Failed waiting for ", timeout: "Timed out waiting for ", }, - message_agent: { + message: { completed: "Messaged ", running: "Messaging ", error: "Failed to message ", timeout: "Timed out messaging ", }, - close_agent: { + close: { completed: "Terminated ", running: "Terminating ", error: "Failed to terminate ", timeout: "Timed out terminating ", }, - spawn_computer_use_agent: { - completed: "Spawned ", - running: "Spawning ", - error: "Failed to spawn ", - timeout: "Timed out spawning ", - }, }; /** @@ -72,8 +61,7 @@ const SUBAGENT_VERBS: Record< function getSubagentLabel( showDesktopPreview: boolean | undefined, toolStatus: ToolStatus, - variant: "default" | "computer-use", - toolName: string, + descriptor: SubagentDescriptor, title: string, isTimeout: boolean, ): React.ReactNode { @@ -85,8 +73,8 @@ function getSubagentLabel( ); } if ( - variant === "computer-use" && - toolName === "wait_agent" && + descriptor.variant === "computer_use" && + descriptor.action === "wait" && toolStatus === "completed" ) { return ( @@ -96,17 +84,16 @@ function getSubagentLabel( ); } + const phase = isTimeout + ? "timeout" + : toolStatus === "completed" + ? "completed" + : toolStatus === "error" + ? "error" + : "running"; return ( <> - {SUBAGENT_VERBS[toolName]?.[ - isTimeout - ? "timeout" - : toolStatus === "completed" - ? "completed" - : toolStatus === "error" - ? "error" - : "running" - ] ?? ""} + {SUBAGENT_VERBS[descriptor.action][phase]} {title} ); @@ -124,18 +111,18 @@ const SubagentStatusIcon: React.FC<{ toolStatus: ToolStatus; isError: boolean; isTimeout: boolean; - variant?: "default" | "computer-use"; + iconKind?: SubagentDescriptor["iconKind"]; showDesktopPreview?: boolean; }> = ({ subagentStatus, toolStatus, isError, isTimeout, - variant = "default", + iconKind = "bot", showDesktopPreview = false, }) => { const subagentCompleted = isSubagentSuccessStatus(subagentStatus); - const DefaultIcon = variant === "computer-use" ? MonitorIcon : BotIcon; + const DefaultIcon = iconKind === "monitor" ? MonitorIcon : BotIcon; if (isTimeout && !subagentCompleted) { return ; } @@ -162,7 +149,7 @@ const SubagentStatusIcon: React.FC<{ * "View Agent" link navigates to the sub-agent chat. */ export const SubagentTool: React.FC<{ - toolName: string; + descriptor: SubagentDescriptor; title: string; chatId: string; subagentStatus: string; @@ -175,13 +162,12 @@ export const SubagentTool: React.FC<{ isTimeout?: boolean; /** Show an inline VNC desktop preview (for computer-use subagents). */ showDesktopPreview?: boolean; - variant?: "default" | "computer-use"; /** File ID for a completed recording (shown after tool completes). */ recordingFileId?: string; /** File ID for the JPEG thumbnail of a completed recording. */ thumbnailFileId?: string; }> = ({ - toolName, + descriptor, title, chatId, subagentStatus, @@ -193,7 +179,6 @@ export const SubagentTool: React.FC<{ isError, isTimeout = false, showDesktopPreview, - variant = "default", recordingFileId, thumbnailFileId, }) => { @@ -222,15 +207,14 @@ export const SubagentTool: React.FC<{ toolStatus={toolStatus} isError={isError} isTimeout={isTimeout} - variant={variant} + iconKind={descriptor.iconKind} showDesktopPreview={showDesktopPreview} />{" "} {getSubagentLabel( showDesktopPreview, toolStatus, - variant, - toolName, + descriptor, title, isTimeout, )} diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index efae4a584f..63df4c856d 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -355,6 +355,241 @@ export const SubagentMessageRequestMetadata: Story = { }, }; +export const SpawnSubagentGeneralRunning: Story = { + args: { + name: "spawn_agent", + status: "running", + args: { + type: "general", + title: "Workspace diagnostics", + prompt: "Collect logs and summarize why startup failed.", + }, + result: { + chat_id: "spawn-general-child", + type: "general", + status: "pending", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("button", { name: /Spawning Workspace diagnostics/ }), + ).toBeInTheDocument(); + }, +}; + +export const SpawnSubagentGeneralCompleted: Story = { + args: { + name: "spawn_agent", + status: "completed", + args: { + type: "general", + title: "Workspace diagnostics", + prompt: "Collect logs and summarize why startup failed.", + }, + result: { + chat_id: "spawn-general-child", + type: "general", + title: "Workspace diagnostics", + status: "completed", + duration_ms: 3200, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("button", { name: /Spawned Workspace diagnostics/ }), + ).toBeInTheDocument(); + expect(canvas.getByText("Worked for 3s")).toBeInTheDocument(); + }, +}; + +export const SpawnSubagentExploreRunning: Story = { + args: { + name: "spawn_agent", + status: "running", + args: { + type: "explore", + prompt: "Read the repository and summarize the auth flow.", + }, + result: { + chat_id: "spawn-explore-child", + type: "explore", + status: "pending", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("button", { name: /Spawning Explore agent/ }), + ).toBeInTheDocument(); + }, +}; + +export const SpawnSubagentExploreCompleted: Story = { + args: { + name: "spawn_agent", + status: "completed", + args: { + type: "explore", + prompt: "Read the repository and summarize the auth flow.", + }, + result: { + chat_id: "spawn-explore-child", + type: "explore", + status: "completed", + duration_ms: 4100, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("button", { name: /Spawned Explore agent/ }), + ).toBeInTheDocument(); + expect(canvas.getByText("Worked for 4s")).toBeInTheDocument(); + }, +}; + +export const SpawnSubagentComputerUseRunning: Story = { + args: { + name: "spawn_agent", + status: "running", + args: { + type: "computer_use", + title: "Visual regression check", + prompt: + "Open the browser and check for visual regressions on the dashboard page.", + }, + result: { + chat_id: "spawn-desktop-child", + type: "computer_use", + status: "pending", + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText(/Using the computer/)).toBeInTheDocument(); + expect(canvasElement.querySelector(".lucide-monitor")).not.toBeNull(); + await waitFor(() => { + expect( + canvas.getByRole("button", { name: "Open desktop tab" }), + ).toBeInTheDocument(); + }); + }, +}; + +export const SpawnSubagentComputerUseCompleted: Story = { + args: { + name: "spawn_agent", + status: "completed", + args: { + type: "computer_use", + title: "Visual regression check", + prompt: + "Open the browser and check for visual regressions on the dashboard page.", + }, + result: { + chat_id: "spawn-desktop-child", + type: "computer_use", + title: "Visual regression check", + status: "completed", + duration_ms: "12400", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("button", { name: /Spawned Visual regression check/ }), + ).toBeInTheDocument(); + expect(canvas.getByText("Worked for 12s")).toBeInTheDocument(); + }, +}; + +export const WaitAgentExploreStreamingFromHistory: Story = { + args: { + name: "wait_agent", + status: "running", + args: { chat_id: "explore-child" }, + result: { chat_id: "explore-child", status: "pending" }, + subagentVariants: new Map([["explore-child", "explore"]]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("button", { name: /Waiting for Explore agent/ }), + ).toBeInTheDocument(); + expect(canvas.queryByText("Waiting for sub-agent…")).toBeNull(); + }, +}; + +export const MessageAgentExploreStreamingFromResult: Story = { + args: { + name: "message_agent", + status: "running", + args: { chat_id: "message-child", message: "continue" }, + result: { + chat_id: "message-child", + type: "explore", + status: "pending", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("button", { name: /Messaging Explore agent/ }), + ).toBeInTheDocument(); + expect(canvas.queryByText("Messaging sub-agent…")).toBeNull(); + }, +}; + +export const CloseAgentRunningWithoutChatId: Story = { + args: { + name: "close_agent", + status: "running", + args: {}, + result: { status: "running" }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect(canvasElement.textContent?.trim()).toBe(""); + }); + expect(canvas.queryByRole("button")).toBeNull(); + expect(canvas.queryByRole("link", { name: "View agent" })).toBeNull(); + }, +}; + +export const CloseAgentExploreCompleted: Story = { + args: { + name: "close_agent", + status: "completed", + args: { chat_id: "close-child" }, + result: { + chat_id: "close-child", + type: "explore", + status: "completed", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("button", { name: /Terminated Explore agent/ }), + ).toBeInTheDocument(); + }, +}; + // --------------------------------------------------------------------------- // ListTemplates stories // --------------------------------------------------------------------------- @@ -1345,8 +1580,8 @@ export const SpawnComputerUseAgentRunning: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByText(/Spawning/)).toBeInTheDocument(); - expect(canvasElement.querySelector(".animate-spin")).not.toBeNull(); + expect(canvas.getByText(/Using the computer/)).toBeInTheDocument(); + expect(canvasElement.querySelector(".lucide-monitor")).not.toBeNull(); }, }; @@ -1408,7 +1643,7 @@ export const WaitAgentComputerUseRunning: Story = { chat_id: "desktop-child-1", status: "pending", }, - computerUseSubagentIds: new Set(["desktop-child-1"]), + subagentVariants: new Map([["desktop-child-1", "computer_use"]]), }, decorators: [ (Story) => ( @@ -1449,7 +1684,7 @@ export const WaitAgentComputerUseCompletedNoRecording: Story = { status: "waiting", report: "Configured the dev environment.", }, - computerUseSubagentIds: new Set(["desktop-child-1"]), + subagentVariants: new Map([["desktop-child-1", "computer_use"]]), }, decorators: [ (Story) => ( @@ -1484,7 +1719,7 @@ export const WaitAgentComputerUseTimedOutNoRecording: Story = { status: "pending", error: "timed out waiting for agent", }, - computerUseSubagentIds: new Set(["desktop-child-1"]), + subagentVariants: new Map([["desktop-child-1", "computer_use"]]), }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index b7ce17794e..278f88ef60 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -31,6 +31,13 @@ import { ReadSkillTool } from "./ReadSkillTool"; import { ReadTemplateTool } from "./ReadTemplateTool"; import { StartWorkspaceTool } from "./StartWorkspaceTool"; import { SubagentTool } from "./SubagentTool"; +import { + getProvidedSubagentTitle, + getSubagentChatId, + getSubagentDescriptor, + isSubagentToolName, + type SubagentVariant, +} from "./subagentDescriptor"; import { ToolCollapsible } from "./ToolCollapsible"; import { ToolIcon } from "./ToolIcon"; import { ToolLabel } from "./ToolLabel"; @@ -66,10 +73,10 @@ interface ToolProps extends Omit, "children"> { result?: unknown; isError?: boolean; killedBySignal?: "kill" | "terminate"; - /** Maps sub-agent chat IDs to their titles, built from spawn tool results. */ + /** Maps sub-agent chat IDs to their titles, built from transcript metadata. */ subagentTitles?: Map; - /** Set of chat IDs spawned by `spawn_computer_use_agent`. */ - computerUseSubagentIds?: Set; + /** Maps sub-agent chat IDs to their normalized variants. */ + subagentVariants?: Map; /** When false, suppresses inline VNC previews while still * allowing the MonitorIcon variant to render. */ showDesktopPreviews?: boolean; @@ -98,7 +105,7 @@ type ToolRendererProps = { isError: boolean; killedBySignal?: "kill" | "terminate"; subagentTitles?: Map; - computerUseSubagentIds?: Set; + subagentVariants?: Map; showDesktopPreviews?: boolean; subagentStatusOverrides?: Map; onImplementPlan?: () => Promise | void; @@ -443,22 +450,34 @@ const SubagentRenderer: FC = ({ result, isError, subagentTitles, - computerUseSubagentIds, + subagentVariants, showDesktopPreviews = true, subagentStatusOverrides, }) => { const parsedArgs = parseArgs(args); const rec = asRecord(result); - // wait_agent and message_agent have chat_id in args, so - // check both result and args. - const chatId = - (rec ? asString(rec.chat_id) : "") || - (parsedArgs ? asString(parsedArgs.chat_id) : ""); + const chatId = getSubagentChatId({ + args: parsedArgs ?? args, + result: rec ?? result, + }); + const inferredVariant = chatId ? subagentVariants?.get(chatId) : undefined; + const descriptor = getSubagentDescriptor({ + name, + args: parsedArgs ?? args, + result: rec ?? result, + inferredVariant, + }); + if (!descriptor) { + return null; + } + const resultSubagentStatus = rec ? asString(rec.status || rec.subagent_status) : ""; - const streamSubagentStatus = - (chatId && subagentStatusOverrides?.get(chatId)) || ""; + let streamSubagentStatus = ""; + if (chatId) { + streamSubagentStatus = subagentStatusOverrides?.get(chatId) || ""; + } const subagentStatus = streamSubagentStatus || resultSubagentStatus; const durationMs = rec ? asNumber(rec.duration_ms, { parseString: true }) @@ -468,53 +487,66 @@ const SubagentRenderer: FC = ({ const thumbnailFileId = rec ? asString(rec.thumbnail_file_id) : ""; const prompt = parsedArgs ? asString(parsedArgs.prompt) : ""; const subagentMessage = parsedArgs ? asString(parsedArgs.message) : ""; - const title = - (rec ? asString(rec.title) : "") || - (parsedArgs ? asString(parsedArgs.title) : "") || - (chatId && subagentTitles?.get(chatId)) || - (name === "spawn_computer_use_agent" - ? "Computer use sub-agent" - : name === "spawn_explore_agent" - ? "Explore agent" - : "Sub-agent"); + const rawTitle = getProvidedSubagentTitle({ + args: parsedArgs ?? args, + result: rec ?? result, + }); + let title = + descriptor.fallbackTitle.charAt(0).toUpperCase() + + descriptor.fallbackTitle.slice(1); + if (chatId) { + const mappedTitle = subagentTitles?.get(chatId); + if (mappedTitle) { + title = mappedTitle; + } + } + if (rawTitle) { + title = rawTitle; + } const subagentCompleted = isSubagentSuccessStatus(subagentStatus); const subagentToolStatus = mapSubagentStatusToToolStatus( subagentStatus, status, ); - const subagentIsError = - subagentToolStatus === "error" || - ((status === "error" || isError) && !subagentCompleted); + let subagentIsError = subagentToolStatus === "error"; + if (!subagentIsError) { + const toolFailed = status === "error" || isError; + if (toolFailed && !subagentCompleted) { + subagentIsError = true; + } + } // Detect timeout from the result. A timed-out wait_agent // typically returns an error string or an object with an // error field containing "timed out". const resultStr = typeof result === "string" ? result : ""; const errorStr = rec ? asString(rec.error) : ""; - const isTimeout = - subagentIsError && - (resultStr.toLowerCase().includes("timed out") || - errorStr.toLowerCase().includes("timed out")); + let isTimeout = false; + if (subagentIsError) { + const timedOutInResult = resultStr.toLowerCase().includes("timed out"); + const timedOutInError = errorStr.toLowerCase().includes("timed out"); + if (timedOutInResult || timedOutInError) { + isTimeout = true; + } + } - // Postpone rendering wait_agent / message_agent until the - // chat_id has been parsed from the streaming args. Without it - // we can't determine variant or title, which causes a brief - // flash of the generic "Waiting for Sub-agent" text. - if ( - !chatId && - status === "running" && - (name === "wait_agent" || name === "message_agent") - ) { - return null; + // Postpone rendering wait_agent, message_agent, and close_agent + // until the chat_id has been parsed from the streaming args. + // Without it we cannot determine variant or title, which causes + // a brief flash of the generic lifecycle copy. + if (!chatId && status === "running") { + if ( + descriptor.action === "wait" || + descriptor.action === "message" || + descriptor.action === "close" + ) { + return null; + } } - const variant = - name === "spawn_computer_use_agent" || computerUseSubagentIds?.has(chatId) - ? "computer-use" - : "default"; return ( = ({ isError={subagentIsError} isTimeout={isTimeout} showDesktopPreview={ - showDesktopPreviews && computerUseSubagentIds?.has(chatId) + Boolean(chatId) && + showDesktopPreviews && + descriptor.supportsDesktopAffordance } - variant={variant} recordingFileId={recordingFileId || undefined} thumbnailFileId={thumbnailFileId || undefined} /> @@ -899,12 +932,6 @@ const toolRenderers: Record> = { read_template: ReadTemplateRenderer, read_skill: ReadSkillRenderer, read_skill_file: ReadSkillFileRenderer, - spawn_agent: SubagentRenderer, - spawn_explore_agent: SubagentRenderer, - wait_agent: SubagentRenderer, - message_agent: SubagentRenderer, - close_agent: SubagentRenderer, - spawn_computer_use_agent: SubagentRenderer, chat_summarized: ChatSummarizedRenderer, ask_user_question: AskUserQuestionRenderer, propose_plan: ProposePlanRenderer, @@ -925,7 +952,7 @@ export const Tool = memo( isError = false, killedBySignal, subagentTitles, - computerUseSubagentIds, + subagentVariants, showDesktopPreviews, subagentStatusOverrides, mcpServerConfigId, @@ -939,7 +966,9 @@ export const Tool = memo( ref, ...props }: ToolProps) => { - const Renderer = toolRenderers[name] ?? GenericToolRenderer; + const Renderer = isSubagentToolName(name) + ? SubagentRenderer + : (toolRenderers[name] ?? GenericToolRenderer); return (
= ({ name, iconUrl, isRunning, serverName }) => { + subagentIconKind?: SubagentIconKind; +}> = ({ name, iconUrl, isRunning, serverName, subagentIconKind }) => { const [imgError, setImgError] = useState(false); const color = "text-content-secondary"; const base = cn("h-4 w-4 shrink-0", color, isRunning && "grayscale"); @@ -67,6 +72,20 @@ export const ToolIcon: React.FC<{ return img; } + if (isSubagentToolName(name)) { + // This name-based fallback only exists for legacy callers that do + // not pass a descriptor. The descriptor path should provide + // subagentIconKind for new subagent types instead of extending it. + const iconKind = + subagentIconKind || + (name === "spawn_computer_use_agent" ? "monitor" : "bot"); + return iconKind === "monitor" ? ( + + ) : ( + + ); + } + switch (name) { case "execute": case "process_output": @@ -82,17 +101,11 @@ export const ToolIcon: React.FC<{ return ; case "create_workspace": return ; - case "spawn_agent": - case "spawn_explore_agent": - case "wait_agent": - case "message_agent": - case "close_agent": case "chat_summarized": return ; case "propose_plan": return ; case "computer": - case "spawn_computer_use_agent": return ; case "read_skill": case "read_skill_file": diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolLabel.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolLabel.tsx index 39deceae2e..9194ab064b 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolLabel.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolLabel.tsx @@ -1,6 +1,55 @@ import type React from "react"; +import { + getProvidedSubagentTitle, + getSubagentDescriptor, +} from "./subagentDescriptor"; import { asRecord, asString, humanizeMCPToolName, parseArgs } from "./utils"; +const renderSubagentLabel = ( + name: string, + args: unknown, + result: unknown, +): React.ReactNode | null => { + const descriptor = getSubagentDescriptor({ name, args, result }); + if (!descriptor) { + return null; + } + + const providedTitle = getProvidedSubagentTitle({ args, result }); + const fallbackTitle = descriptor.fallbackTitle; + const text = (() => { + switch (descriptor.action) { + case "spawn": + if (providedTitle) { + return `Spawning ${providedTitle}`; + } + if (descriptor.variant === "explore") { + return "Spawning Explore agent…"; + } + if (descriptor.variant === "computer_use") { + return "Spawning computer use sub-agent…"; + } + return `Spawning ${fallbackTitle}…`; + case "wait": + return providedTitle + ? `Waiting for ${providedTitle}` + : `Waiting for ${fallbackTitle}…`; + case "message": + return providedTitle + ? `Messaging ${providedTitle}` + : `Messaging ${fallbackTitle}…`; + case "close": + return providedTitle + ? `Terminating ${providedTitle}` + : `Terminating ${fallbackTitle}`; + } + })(); + + return ( + {text} + ); +}; + export const ToolLabel: React.FC<{ name: string; args: unknown; @@ -9,6 +58,14 @@ export const ToolLabel: React.FC<{ }> = ({ name, args, result, mcpSlug }) => { const parsed = parseArgs(args); const parsedResult = asRecord(result); + const subagentLabel = renderSubagentLabel( + name, + parsed ?? args, + parsedResult ?? result, + ); + if (subagentLabel) { + return subagentLabel; + } switch (name) { case "execute": { @@ -156,52 +213,6 @@ export const ToolLabel: React.FC<{ ); } - case "spawn_explore_agent": { - const spawnTitle = - (parsedResult ? asString(parsedResult.title) : "") || - (parsed ? asString(parsed.title) : ""); - return ( - - {spawnTitle ? `Spawning ${spawnTitle}` : "Spawning Explore agent…"} - - ); - } - case "spawn_agent": { - const spawnTitle = - (parsedResult ? asString(parsedResult.title) : "") || - (parsed ? asString(parsed.title) : ""); - return ( - - {spawnTitle ? `Spawning ${spawnTitle}` : "Spawning sub-agent…"} - - ); - } - case "wait_agent": { - const awaitTitle = - (parsedResult ? asString(parsedResult.title) : "") || - (parsed ? asString(parsed.title) : ""); - return ( - - {awaitTitle ? `Waiting for ${awaitTitle}` : "Waiting for sub-agent…"} - - ); - } - case "message_agent": { - const msgTitle = - (parsedResult ? asString(parsedResult.title) : "") || - (parsed ? asString(parsed.title) : ""); - return ( - - {msgTitle ? `Messaging ${msgTitle}` : "Messaging sub-agent…"} - - ); - } - case "close_agent": - return ( - - Terminating sub-agent - - ); case "chat_summarized": return ( diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/subagentDescriptor.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/subagentDescriptor.ts new file mode 100644 index 0000000000..fe75680818 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/subagentDescriptor.ts @@ -0,0 +1,151 @@ +import { asString } from "../runtimeTypeUtils"; +import { parseArgs } from "./utils"; + +export type SubagentAction = "spawn" | "wait" | "message" | "close"; +export type SubagentVariant = "general" | "explore" | "computer_use"; +export type SubagentIconKind = "bot" | "monitor"; + +export type SubagentDescriptor = { + action: SubagentAction; + variant: SubagentVariant; + iconKind: SubagentIconKind; + title: string; + fallbackTitle: string; + supportsDesktopAffordance: boolean; +}; + +const subagentCatalog: Record< + SubagentVariant, + { + fallbackTitle: string; + iconKind: SubagentIconKind; + supportsDesktopAffordance: boolean; + } +> = { + general: { + fallbackTitle: "sub-agent", + iconKind: "bot", + supportsDesktopAffordance: false, + }, + explore: { + fallbackTitle: "Explore agent", + iconKind: "bot", + supportsDesktopAffordance: false, + }, + computer_use: { + fallbackTitle: "Computer use sub-agent", + iconKind: "monitor", + supportsDesktopAffordance: true, + }, +}; + +const actionByToolName: Record = { + spawn_agent: "spawn", + spawn_explore_agent: "spawn", + spawn_computer_use_agent: "spawn", + // Legacy persisted tool name from the pre-rename unified contract. + spawn_subagent: "spawn", + wait_agent: "wait", + message_agent: "message", + close_agent: "close", +}; + +const variantBySpawnToolName: Record = { + spawn_explore_agent: "explore", + spawn_computer_use_agent: "computer_use", +}; + +const normalizeSubagentVariant = ( + value: unknown, +): SubagentVariant | undefined => { + switch (asString(value).trim().toLowerCase()) { + case "general": + return "general"; + case "explore": + return "explore"; + case "computer_use": + return "computer_use"; + default: + return undefined; + } +}; + +const getSubagentAction = (name: string): SubagentAction | undefined => + actionByToolName[name]; + +const getVariantFromName = (name: string): SubagentVariant | undefined => + variantBySpawnToolName[name as keyof typeof variantBySpawnToolName]; + +export const isSubagentToolName = (name: string): boolean => + getSubagentAction(name) !== undefined; + +export const getSubagentChatId = ({ + args, + result, +}: { + args?: unknown; + result?: unknown; +}): string => { + const resultRecord = parseArgs(result); + const argsRecord = parseArgs(args); + return ( + asString(resultRecord?.chat_id).trim() || + asString(argsRecord?.chat_id).trim() + ); +}; + +export const getProvidedSubagentTitle = ({ + args, + result, +}: { + args?: unknown; + result?: unknown; +}): string => { + const resultRecord = parseArgs(result); + const argsRecord = parseArgs(args); + return ( + asString(resultRecord?.title).trim() || asString(argsRecord?.title).trim() + ); +}; + +export const getSubagentDescriptor = ({ + name, + args, + result, + inferredVariant, +}: { + name: string; + args?: unknown; + result?: unknown; + inferredVariant?: SubagentVariant; +}): SubagentDescriptor | null => { + const action = getSubagentAction(name); + if (!action) { + return null; + } + + const resultRecord = parseArgs(result); + const argsRecord = parseArgs(args); + const variant = + normalizeSubagentVariant(resultRecord?.type) ?? + normalizeSubagentVariant(argsRecord?.type) ?? + // Legacy persisted payloads used subagent_type. + normalizeSubagentVariant(resultRecord?.subagent_type) ?? + normalizeSubagentVariant(argsRecord?.subagent_type) ?? + getVariantFromName(name) ?? + inferredVariant ?? + "general"; + const catalogEntry = subagentCatalog[variant]; + const title = + getProvidedSubagentTitle({ args: argsRecord, result: resultRecord }) || + catalogEntry.fallbackTitle; + + return { + action, + variant, + iconKind: catalogEntry.iconKind, + title, + fallbackTitle: catalogEntry.fallbackTitle, + supportsDesktopAffordance: catalogEntry.supportsDesktopAffordance, + }; +}; diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index ae57033de3..8ea162041f 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -25,8 +25,7 @@ import { } from "./ChatConversation/chatStore"; import { LiveStreamTail } from "./ChatConversation/LiveStreamTail"; import { - buildComputerUseSubagentIds, - buildSubagentTitles, + buildSubagentMaps, getEditableUserMessagePayload, parseMessagesWithMergedTools, } from "./ChatConversation/messageParsing"; @@ -87,8 +86,8 @@ export const ChatPageTimeline: FC = ({ }) .filter(isChatMessage); const parsedMessages = parseMessagesWithMergedTools(messages); - const subagentTitles = buildSubagentTitles(parsedMessages); - const computerUseSubagentIds = buildComputerUseSubagentIds(parsedMessages); + const { titles: subagentTitles, variants: subagentVariants } = + buildSubagentMaps(parsedMessages); const onRenderProfiler = useOnRenderProfiler(); return ( @@ -108,6 +107,7 @@ export const ChatPageTimeline: FC = ({ = ({ isChatCompleted={isChatCompleted} urlTransform={urlTransform} mcpServers={mcpServers} - computerUseSubagentIds={computerUseSubagentIds} showDesktopPreviews={false} /> = ({ startingResetKey={chatID} isTranscriptEmpty={parsedMessages.length === 0} subagentTitles={subagentTitles} - computerUseSubagentIds={computerUseSubagentIds} + subagentVariants={subagentVariants} urlTransform={urlTransform} mcpServers={mcpServers} /> diff --git a/site/src/pages/AgentsPage/components/ExploreModelOverrideSettings.tsx b/site/src/pages/AgentsPage/components/ExploreModelOverrideSettings.tsx index bdfd9197b1..6e31f52f32 100644 --- a/site/src/pages/AgentsPage/components/ExploreModelOverrideSettings.tsx +++ b/site/src/pages/AgentsPage/components/ExploreModelOverrideSettings.tsx @@ -100,7 +100,8 @@ export const ExploreModelOverrideSettings: FC<

Optional deployment-wide model override for read-only Explore - subagents spawned with spawn_explore_agent. + subagents spawned with spawn_agent using + type=explore.

)}