mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
refactor: unify subagent spawn behind spawn_subagent (#24535)
Unify the three subagent spawn tools (`spawn_agent`, `spawn_explore_agent`, `spawn_computer_use_agent`) behind a single `spawn_subagent` tool keyed by a `subagent_type` discriminant (`general`, `explore`, `computer_use`). Mirrors the single-entry-point pattern already used by `task` in mux while keeping `wait_agent`, `message_agent`, and `close_agent` as separate lifecycle tools. A new backend subagent definition catalog (`coderd/x/chatd/subagent_catalog.go`) is the source of truth for tool description, prompt guidance, availability rules (plan mode, desktop/Anthropic gating), and child-chat option building. `spawn_subagent` advertises only the types available in the current context and validates `subagent_type` server-side; context inheritance still flows through the existing `createChildSubagentChatWithOptions` path. `wait_agent`, `message_agent`, and `close_agent` responses now include a server-derived `subagent_type` so the UI stops inferring lifecycle state from tool names. The frontend gets a shared normalization helper (`site/src/pages/AgentsPage/components/ChatElements/tools/subagentDescriptor.ts`) that maps either legacy tool names or new `spawn_subagent` args into a common descriptor (action, variant, icon, fallback copy). Legacy transcripts still render identically; `Tool.tsx`, `SubagentTool.tsx`, `ToolLabel.tsx`, `ToolIcon.tsx`, and `messageParsing.ts` now key off the descriptor instead of hard-coded names. Existing UI copy is preserved (`Spawning Explore agent...`, `Using the computer...`, computer-use monitor icon and Open Desktop affordance). > This PR was opened by Mux working on Mike's behalf.
This commit is contained in:
+20
-22
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{},
|
||||
))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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++ {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <plan-file-path> block below when it is
|
||||
available.
|
||||
@@ -101,22 +101,26 @@ When the <plan-file-path> block below is present, use that exact path.
|
||||
` + defaultSystemPromptPlanPathBlockPlaceholder + `
|
||||
</planning>`
|
||||
|
||||
// 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 <plan-file-path> 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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
+124
-189
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
) + "."
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user