From 59fcc9c0add664d5c0a92c32eabf7c75c1fd21e9 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 26 Jun 2026 13:41:43 +0300 Subject: [PATCH] feat: improve sub-agent orchestration tools (#26673) Tool errors caused orchestrators to abandon spawned agents. Bare error responses and the close_agent name framed delegation as one-shot: one transient failure or timeout ended the work, and the orchestrator had no way to recover or reuse agents. Renames close_agent to interrupt_agent with a hidden backward-compatible alias. wait_agent and message_agent return structured payloads instead of bare errors, so the orchestrator can retry after a timeout, recover from an error status, or redirect an idle agent. Adds list_agents so orchestrators can rediscover spawned agents. Adds root-only orchestration guidance for error recovery. --- coderd/x/chatd/chatd.go | 6 +- coderd/x/chatd/chatd_internal_test.go | 4 +- coderd/x/chatd/chatd_test.go | 4 +- coderd/x/chatd/chatloop/chatloop.go | 32 +- .../chatloop/chatloop_run_internal_test.go | 88 +++++ coderd/x/chatd/chatprompt/chatprompt.go | 6 +- coderd/x/chatd/generation.go | 1 + coderd/x/chatd/instruction_internal_test.go | 9 + coderd/x/chatd/prompt.go | 12 +- coderd/x/chatd/recording_internal_test.go | 8 +- coderd/x/chatd/subagent.go | 306 ++++++++++++--- coderd/x/chatd/subagent_catalog.go | 9 +- coderd/x/chatd/subagent_internal_test.go | 349 +++++++++++++++++- docs/ai-coder/agents/architecture.md | 3 +- docs/ai-coder/agents/index.md | 51 +-- .../AgentsPage/AgentChatPage.stories.tsx | 20 +- .../ChatConversation/messageParsing.test.ts | 62 +++- .../ChatElements/tools/ListAgentsTool.tsx | 87 +++++ .../ChatElements/tools/SubagentTool.tsx | 16 +- .../ChatElements/tools/Tool.stories.tsx | 151 +++++++- .../components/ChatElements/tools/Tool.tsx | 45 ++- .../ChatElements/tools/ToolLabel.tsx | 10 +- .../ChatElements/tools/subagentDescriptor.ts | 16 +- .../ChatElements/tools/toolVisibility.test.ts | 26 +- .../ChatElements/tools/toolVisibility.ts | 6 +- .../ChatElements/tools/utils.test.ts | 11 + 26 files changed, 1183 insertions(+), 155 deletions(-) create mode 100644 site/src/pages/AgentsPage/components/ChatElements/tools/ListAgentsTool.tsx diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index b82a22289e..d2db52f031 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -3624,9 +3624,9 @@ func builtinPlanToolAllowed(name string, isRootChat bool) bool { return true case "write_file", "edit_files", "list_templates", "read_template", "create_workspace", "start_workspace", "stop_workspace", "propose_plan", "spawn_agent", - "spawn_explore_agent", "wait_agent", "ask_user_question", "attach_file": + "spawn_explore_agent", "wait_agent", "list_agents", "ask_user_question", "attach_file": return isRootChat - case "process_list", "process_signal", "message_agent", "close_agent", + case "process_list", "process_signal", "message_agent", "interrupt_agent", "close_agent", "spawn_computer_use_agent": return false default: @@ -3708,7 +3708,9 @@ func allowedExploreToolNames(allTools []fantasy.AgentTool) []string { "spawn_agent": false, "wait_agent": false, "message_agent": false, + "interrupt_agent": false, "close_agent": false, + "list_agents": false, "read_skill": true, "read_skill_file": true, "ask_user_question": false, diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index bafde28c61..3c15609a3c 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -428,7 +428,8 @@ func TestActiveToolNamesForTurn(t *testing.T) { "spawn_agent", "wait_agent", "message_agent", - "close_agent", + "interrupt_agent", + "list_agents", "read_skill", "read_skill_file", "ask_user_question", @@ -448,6 +449,7 @@ func TestActiveToolNamesForTurn(t *testing.T) { "propose_plan", "spawn_agent", "wait_agent", + "list_agents", "read_skill", "read_skill_file", "ask_user_question", diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index 125db2b485..acf1609ecc 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -387,7 +387,7 @@ func TestSubagentChatExcludesWorkspaceProvisioningTools(t *testing.T) { "list_templates", "read_template", "create_workspace", "start_workspace", "stop_workspace", } - subagentTools := []string{"spawn_agent", "wait_agent", "message_agent", "close_agent"} + subagentTools := []string{"spawn_agent", "wait_agent", "message_agent", "interrupt_agent", "list_agents"} // Identify root and subagent calls. Root chat calls include // spawn_agent; the subagent call does not. Because the root chat @@ -9537,7 +9537,7 @@ func TestComputerUseSubagentToolsAndModel(t *testing.T) { // 5. Verify subagent tools are NOT present. subagentTools := []string{ "spawn_agent", - "wait_agent", "message_agent", "close_agent", + "wait_agent", "message_agent", "interrupt_agent", "list_agents", } for _, tool := range subagentTools { require.NotContains(t, childTools, tool, diff --git a/coderd/x/chatd/chatloop/chatloop.go b/coderd/x/chatd/chatloop/chatloop.go index 90f6be61af..0560cfd21e 100644 --- a/coderd/x/chatd/chatloop/chatloop.go +++ b/coderd/x/chatd/chatloop/chatloop.go @@ -260,6 +260,11 @@ type ExecuteLocalToolsOptions struct { // case a default budget applies. ContextLimit int64 + // ToolNameAliases maps a non-advertised tool name to the canonical + // tool it dispatches to. Used for backward compatibility when a tool + // is renamed but old chat histories still reference the old name. + ToolNameAliases map[string]string + PublishMessagePart func(codersdk.ChatMessageRole, codersdk.ChatMessagePart) Logger slog.Logger Metrics *Metrics @@ -540,6 +545,7 @@ func ExecuteLocalTools(ctx context.Context, opts ExecuteLocalToolsOptions) (Tool modelName, opts.BuiltinToolNames, maxResultBytes, + opts.ToolNameAliases, func(tr fantasy.ToolResultContent, completedAt time.Time) { recordToolResultTimestamp(&result, tr.ToolCallID, completedAt) publishToolAttachments(ctx, opts.Logger, tr, completedAt, publishMessagePart) @@ -1006,6 +1012,7 @@ func executeTools( provider, model string, builtinToolNames map[string]bool, maxResultBytes int, + toolNameAliases map[string]string, onResult func(fantasy.ToolResultContent, time.Time), ) []fantasy.ToolResultContent { if len(toolCalls) == 0 { @@ -1085,6 +1092,7 @@ func executeTools( providerRunnerNames, resultProviderMetadata, maxResultBytes, + toolNameAliases, ) }() } @@ -1205,6 +1213,7 @@ func executeSingleTool( providerRunnerNames map[string]struct{}, resultProviderMetadata map[string]func(fantasy.ToolResponse) fantasy.ProviderMetadata, maxResultBytes int, + toolNameAliases map[string]string, ) fantasy.ToolResultContent { result := fantasy.ToolResultContent{ ToolCallID: tc.ToolCallID, @@ -1224,31 +1233,40 @@ func executeSingleTool( } }() - _, isProviderRunner := providerRunnerNames[tc.ToolName] - if !isProviderRunner && !isToolActive(tc.ToolName, activeTools) { + // Resolve backward-compatible tool aliases (for example a renamed + // tool whose old name still appears in chat history) to the canonical + // tool before the active-tool and dispatch lookups. + resolvedName := tc.ToolName + if alias, ok := toolNameAliases[tc.ToolName]; ok { + resolvedName = alias + } + + _, isProviderRunner := providerRunnerNames[resolvedName] + if !isProviderRunner && !isToolActive(resolvedName, activeTools) { result.Result = fantasy.ToolResultOutputContentError{ - Error: xerrors.New("Tool not active in this turn: " + tc.ToolName), + Error: xerrors.New("Tool not active in this turn: " + resolvedName), } return result } - tool, exists := toolMap[tc.ToolName] + tool, exists := toolMap[resolvedName] if !exists { result.Result = fantasy.ToolResultOutputContentError{ - Error: xerrors.New("Tool not found: " + tc.ToolName), + Error: xerrors.New("Tool not found: " + resolvedName), } return result } logger.Debug(ctx, "tool execution", slog.F("tool_name", tc.ToolName), + slog.F("resolved_tool_name", resolvedName), slog.F("tool_call_id", tc.ToolCallID), - slog.F("builtin", builtinToolNames[tc.ToolName]), + slog.F("builtin", builtinToolNames[resolvedName]), slog.F("is_provider_runner", isProviderRunner), ) resp, err := tool.Run(ctx, fantasy.ToolCall{ ID: tc.ToolCallID, - Name: tc.ToolName, + Name: resolvedName, Input: tc.Input, }) if err != nil { diff --git a/coderd/x/chatd/chatloop/chatloop_run_internal_test.go b/coderd/x/chatd/chatloop/chatloop_run_internal_test.go index 02e3c1f25d..71af2ca7f1 100644 --- a/coderd/x/chatd/chatloop/chatloop_run_internal_test.go +++ b/coderd/x/chatd/chatloop/chatloop_run_internal_test.go @@ -915,6 +915,7 @@ func TestExecuteSingleTool_MediaBase64Encoding(t *testing.T) { map[string]struct{}{}, nil, defaultToolResultBytes, + nil, ) media, ok := result.Result.(fantasy.ToolResultOutputContentMedia) @@ -963,6 +964,7 @@ func TestExecuteSingleTool_MediaBase64Encoding(t *testing.T) { map[string]struct{}{}, nil, defaultToolResultBytes, + nil, ) media, ok := result.Result.(fantasy.ToolResultOutputContentMedia) @@ -1006,6 +1008,7 @@ func TestExecuteSingleTool_MediaBase64Encoding(t *testing.T) { map[string]struct{}{}, nil, defaultToolResultBytes, + nil, ) textOutput, ok := result.Result.(fantasy.ToolResultOutputContentText) @@ -1015,3 +1018,88 @@ func TestExecuteSingleTool_MediaBase64Encoding(t *testing.T) { require.Contains(t, textOutput.Text, "world") }) } + +func TestExecuteSingleTool_ResolvesToolNameAlias(t *testing.T) { + t.Parallel() + + metrics := NewMetrics(prometheus.NewRegistry()) + logger := slog.Make() + + var gotName string + tool := fantasy.NewAgentTool( + "interrupt_agent", + "interrupts an agent", + func(_ context.Context, _ struct{}, call fantasy.ToolCall) (fantasy.ToolResponse, error) { + gotName = call.Name + return fantasy.ToolResponse{Content: `{"interrupted":true}`}, nil + }, + ) + toolMap := map[string]fantasy.AgentTool{"interrupt_agent": tool} + + // The model emits the deprecated name from old history; only the + // canonical name is advertised/active. + tc := fantasy.ToolCallContent{ + ToolCallID: "call-alias", + ToolName: "close_agent", + Input: "{}", + } + + result := executeSingleTool( + context.Background(), + toolMap, + tc, + metrics, + logger, + "fake", "fake-model", + map[string]bool{}, + []string{"interrupt_agent"}, + map[string]struct{}{}, + nil, + defaultToolResultBytes, + map[string]string{"close_agent": "interrupt_agent"}, + ) + + textOutput, ok := result.Result.(fantasy.ToolResultOutputContentText) + require.True(t, ok, "expected text output, got %T", result.Result) + require.Contains(t, textOutput.Text, "interrupted") + // The handler receives the resolved canonical name. + require.Equal(t, "interrupt_agent", gotName) + // The persisted result keeps the original alias so existing history + // renders consistently. + require.Equal(t, "close_agent", result.ToolName) +} + +func TestExecuteSingleTool_UnknownAliasFallsThrough(t *testing.T) { + t.Parallel() + + metrics := NewMetrics(prometheus.NewRegistry()) + logger := slog.Make() + + tc := fantasy.ToolCallContent{ + ToolCallID: "call-missing", + ToolName: "close_agent", + Input: "{}", + } + + // No alias provided: the deprecated name is neither active nor in the + // tool map, so it surfaces a clear not-active error and the model can + // self-correct to the advertised name. + result := executeSingleTool( + context.Background(), + map[string]fantasy.AgentTool{}, + tc, + metrics, + logger, + "fake", "fake-model", + map[string]bool{}, + []string{"interrupt_agent"}, + map[string]struct{}{}, + nil, + defaultToolResultBytes, + nil, + ) + + errOutput, ok := result.Result.(fantasy.ToolResultOutputContentError) + require.True(t, ok, "expected error output, got %T", result.Result) + require.Contains(t, errOutput.Error.Error(), "close_agent") +} diff --git a/coderd/x/chatd/chatprompt/chatprompt.go b/coderd/x/chatd/chatprompt/chatprompt.go index 2e955ae47d..29028e6d25 100644 --- a/coderd/x/chatd/chatprompt/chatprompt.go +++ b/coderd/x/chatd/chatprompt/chatprompt.go @@ -902,10 +902,12 @@ func matchingAttachmentForMedia( return chattool.AttachmentMetadata{}, false } -// Keep in sync with coderd/x/chatd/subagent.go. +// isSubagentLifecycleToolName lists subagent tools whose error results +// may carry structured JSON. Keep in sync with coderd/x/chatd/subagent.go. +// See subagentToolNameAliases for the full alias map. func isSubagentLifecycleToolName(name string) bool { switch name { - case "spawn_agent", "wait_agent", "message_agent", "close_agent": + case "spawn_agent", "wait_agent", "message_agent", "interrupt_agent", "close_agent": return true default: return false diff --git a/coderd/x/chatd/generation.go b/coderd/x/chatd/generation.go index e22c797895..84f7efbbdc 100644 --- a/coderd/x/chatd/generation.go +++ b/coderd/x/chatd/generation.go @@ -670,6 +670,7 @@ func (s *taskStarter) executeLocalTools( ModelProvider: provider, ModelName: modelName, ContextLimit: prepared.ContextLimitFallback, + ToolNameAliases: subagentToolNameAliases, PublishMessagePart: publish, Logger: s.opts.Logger, Metrics: s.server.metrics, diff --git a/coderd/x/chatd/instruction_internal_test.go b/coderd/x/chatd/instruction_internal_test.go index 13717f0347..fd0d72aaaf 100644 --- a/coderd/x/chatd/instruction_internal_test.go +++ b/coderd/x/chatd/instruction_internal_test.go @@ -132,6 +132,15 @@ func TestDefaultSystemPromptContainsVersionControlSafety(t *testing.T) { require.Contains(t, DefaultSystemPrompt, "Never treat the original request as confirmation") } +func TestDefaultSystemPromptContainsSubagentOrchestration(t *testing.T) { + t.Parallel() + + require.Contains(t, DefaultSystemPrompt, "") + require.Contains(t, DefaultSystemPrompt, "") + require.Contains(t, DefaultSystemPrompt, "An error status is often recoverable") + require.Contains(t, DefaultSystemPrompt, "call list_agents to recover them") +} + func TestWorkspaceAwarenessDelaysWorkspaceCreation(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/prompt.go b/coderd/x/chatd/prompt.go index 76b6940448..288f789256 100644 --- a/coderd/x/chatd/prompt.go +++ b/coderd/x/chatd/prompt.go @@ -4,6 +4,14 @@ import "github.com/coder/coder/v2/coderd/x/chatd/chattool" const defaultSystemPromptPlanPathBlockPlaceholder = "{{CODER_CHAT_PLAN_FILE_PATH_BLOCK}}" +// subagentOrchestrationPromptBlock is the root-only orchestration guidance. +// Delegated child chats cannot call list_agents or message_agent, so this +// block is stripped from their system prompt at creation time. +const subagentOrchestrationPromptBlock = ` +An error status is often recoverable. Resume the agent with message_agent to retry; treat only genuine, repeating failures as terminal. +If you lose track of your spawned agents, call list_agents to recover them before finishing. +` + const workspaceAttachedAwareness = "This chat is attached to a workspace. You can use workspace tools like execute, read_file, write_file, etc." const workspaceDetachedAwarenessBase = `No workspace is attached to this chat yet. @@ -131,7 +139,9 @@ Once a workspace is available: Write the file first, then present it. All file paths must be absolute. When the block below is present, use that exact path. ` + defaultSystemPromptPlanPathBlockPlaceholder + ` -` + + +` + subagentOrchestrationPromptBlock var planningOverlayPrompt = `You are in Plan Mode. Every response must work toward producing a plan. diff --git a/coderd/x/chatd/recording_internal_test.go b/coderd/x/chatd/recording_internal_test.go index 23d4f2ff2a..8cf2b37d20 100644 --- a/coderd/x/chatd/recording_internal_test.go +++ b/coderd/x/chatd/recording_internal_test.go @@ -590,8 +590,12 @@ func TestWaitAgentTimeoutLeavesRecordingRunning(t *testing.T) { result := testutil.RequireReceive(ctx, t, resultCh) require.NoError(t, result.err) - assert.True(t, result.resp.IsError, "expected error response on timeout") - assert.Contains(t, result.resp.Content, "timed out") + // On timeout the agent is still working, so wait_agent now + // returns a non-error payload rather than a tool error. The + // recording is intentionally left running: the gomock controller + // fails the test if StopDesktopRecording is called. + require.False(t, result.resp.IsError, "timeout must return a non-error payload, not an error") + assert.Contains(t, result.resp.Content, `"timed_out":true`) } // TestStopAndStoreRecording_Oversized verifies that when the diff --git a/coderd/x/chatd/subagent.go b/coderd/x/chatd/subagent.go index 6a7b6bc916..cbfd335150 100644 --- a/coderd/x/chatd/subagent.go +++ b/coderd/x/chatd/subagent.go @@ -31,6 +31,30 @@ import ( var ErrSubagentNotDescendant = xerrors.New("target chat is not a descendant of current chat") +// ErrSubagentWaitTimeout is returned by awaitSubagentCompletion when the +// wait deadline elapses before the subagent reaches a terminal status. The +// agent is still working and the wait can be retried. +var ErrSubagentWaitTimeout = xerrors.New("timed out waiting for delegated subagent completion") + +// subagentToolNameAliases maps deprecated subagent tool names to their +// current names so historical close_agent calls in chat history still +// dispatch to interrupt_agent without advertising the old name in the +// tool list. +var subagentToolNameAliases = map[string]string{ + "close_agent": "interrupt_agent", +} + +// subagentStatusError wraps a subagent that reached error status. It +// carries the chat and report so callers can surface a structured, +// recoverable-aware payload instead of a bare tool error. +type subagentStatusError struct { + chat database.Chat + report string + reason string +} + +func (e *subagentStatusError) Error() string { return e.reason } + var errInvalidModelOverrideMetadata = xerrors.New("invalid model override metadata") type modelOverrideConfigResolver func( @@ -48,6 +72,10 @@ const ( subagentAwaitPollInterval = 200 * time.Millisecond subagentAwaitFallbackPoll = 5 * time.Second defaultSubagentWaitTimeout = 5 * time.Minute + + defaultListAgentsLimit = 10 + maxListAgentsLimit = 50 + subagentRecordingStopTimeout = 90 * time.Second ) // computerUseSubagentSystemPrompt is the system prompt prepended to @@ -77,10 +105,15 @@ type messageAgentArgs struct { Interrupt bool `json:"interrupt,omitempty"` } -type closeAgentArgs struct { +type interruptAgentArgs struct { ChatID string `json:"chat_id"` } +type listAgentsArgs struct { + Limit *int `json:"limit,omitempty"` + Offset *int `json:"offset,omitempty"` +} + func (p *Server) isDesktopEnabled(ctx context.Context) bool { enabled, err := p.db.GetChatDesktopEnabled(ctx) if err != nil { @@ -609,9 +642,9 @@ func (p *Server) subagentTools( fantasy.NewAgentTool( "wait_agent", "Wait until a spawned child agent finishes its task. "+ - "Returns the agent's final response and status. "+ - "Call this after "+spawnAgentToolName+" to collect the "+ - "result before continuing your own work.", + "Returns the agent's response and status. A timeout is not "+ + "a failure: the agent is still running. Call wait_agent again "+ + "or use list_agents to check its status.", func(ctx context.Context, args waitAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { if currentChat == nil { return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil @@ -694,41 +727,63 @@ func (p *Server) subagentTools( // On timeout or error, leave the recording running on // the agent so the next wait_agent call continues it. if awaitErr != nil { + if xerrors.Is(awaitErr, ErrSubagentWaitTimeout) { + // The agent may have completed in the gap between + // the last poll and the timer firing. Re-check + // completion with a fresh DB read to avoid acting + // on a stale status (TOCTOU). + checkedChat, checkedReport, done, checkErr := p.checkSubagentCompletion(ctx, targetChatID) + if checkErr != nil { + return subagentErrorResponse(checkErr, targetChatInfo), nil + } + if !done { + return toolJSONResponse(withSubagentType(map[string]any{ + "chat_id": targetChatID.String(), + "title": checkedChat.Title, + "status": string(checkedChat.Status), + "timed_out": true, + }, checkedChat)), nil + } + // The agent completed in the gap. Classify through + // the same handler as the normal poll path. If the + // agent errored, handleSubagentDone returns a + // subagentStatusError that the error-status block + // below catches. + targetChat, report, awaitErr = handleSubagentDone(checkedChat, checkedReport) + if awaitErr == nil { + return p.waitAgentSuccessResponse(ctx, recordingID, agentConn, parent, targetChat, report), nil + } + } + if errStatus, ok := errors.AsType[*subagentStatusError](awaitErr); ok { + errChat := errStatus.chat + lastError := subagentLastErrorMessage(errChat.LastError) + if lastError == "" { + lastError = errStatus.reason + } + return toolJSONResponse(withSubagentType(map[string]any{ + "chat_id": errChat.ID.String(), + "title": errChat.Title, + "status": string(errChat.Status), + "last_error": lastError, + "report": errStatus.report, + }, errChat)), 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 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 := 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 - } - if recResult.thumbnailFileID != "" { - resp["thumbnail_file_id"] = recResult.thumbnailFileID - } - return toolJSONResponse(resp), nil + return p.waitAgentSuccessResponse(ctx, recordingID, agentConn, parent, targetChat, report), nil }, ), fantasy.NewAgentTool( "message_agent", "Send a follow-up message to a previously spawned child "+ - "agent. Use this to provide additional instructions, "+ - "corrections, or context to a running or completed "+ - "agent. After sending, use wait_agent to collect the "+ - "updated response.", + "agent. If the agent is idle, it resumes work on the "+ + "message. If the agent is busy, the message is queued and "+ + "processed after current work. Set interrupt to true to "+ + "stop the agent's current work; the message is queued and "+ + "processed next, after any already-queued messages. "+ + "After sending, use wait_agent to retrieve the response.", func(ctx context.Context, args messageAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { if currentChat == nil { return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil @@ -764,20 +819,26 @@ func (p *Server) subagentTools( return subagentErrorResponse(err, targetChatInfo), nil } + interrupted := false + if args.Interrupt && targetChatInfo != nil { + interrupted = targetChatInfo.Status == database.ChatStatusRunning || + targetChatInfo.Status == database.ChatStatusPending + } return toolJSONResponse(withSubagentType(map[string]any{ "chat_id": targetChat.ID.String(), "title": targetChat.Title, "status": string(targetChat.Status), - "interrupted": args.Interrupt, + "interrupted": interrupted, }, targetChat)), nil }, ), fantasy.NewAgentTool( - "close_agent", - "Immediately stop a spawned child agent. Use this to "+ - "cancel a subagent that is stuck, no longer needed, "+ - "or working on the wrong approach.", - func(ctx context.Context, args closeAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { + "interrupt_agent", + "Interrupt a spawned child agent's current work. The "+ + "status may briefly read interrupting before transitioning "+ + "to waiting, or running if there are queued messages. "+ + "Resume with message_agent or leave it idle.", + func(ctx context.Context, args interruptAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { if currentChat == nil { return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil } @@ -792,12 +853,12 @@ func (p *Server) subagentTools( 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", + p.logger.Warn(ctx, "unexpected error looking up chat for interrupt", slog.F("chat_id", targetChatID), slog.Error(lookupErr), ) } - targetChat, err := p.closeSubagent( + targetChat, interrupted, err := p.interruptSubagent( ctx, parent.ID, targetChatID, @@ -807,13 +868,85 @@ func (p *Server) subagentTools( } return toolJSONResponse(withSubagentType(map[string]any{ - "chat_id": targetChat.ID.String(), - "title": targetChat.Title, - "terminated": true, - "status": string(targetChat.Status), + "chat_id": targetChat.ID.String(), + "title": targetChat.Title, + "interrupted": interrupted, + "status": string(targetChat.Status), }, targetChat)), nil }, ), + fantasy.NewAgentTool( + "list_agents", + "List the child agents spawned by this chat, most recently "+ + "active first. Returns up to `limit` agents (default 10) "+ + "with `total` and `has_more`; use `offset` to page. The "+ + "sort order is best-effort: an agent's position may shift "+ + "if its updated_at changes between calls. Each "+ + "agent has chat_id, title, type, status, created_at, "+ + "updated_at. Status: pending/running = working, "+ + "interrupting = transient, waiting/completed = idle, "+ + "error = stopped on error.", + func(ctx context.Context, args listAgentsArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { + if currentChat == nil { + return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil + } + + limit := defaultListAgentsLimit + if args.Limit != nil { + limit = min(max(*args.Limit, 1), maxListAgentsLimit) + } + offset := 0 + if args.Offset != nil && *args.Offset > 0 { + offset = *args.Offset + } + + parent := currentChat() + if parent.ParentChatID.Valid { + return fantasy.NewTextErrorResponse("list_agents is only available on root chats"), nil + } + rows, err := p.db.GetChildChatsByParentIDs(ctx, database.GetChildChatsByParentIDsParams{ + ParentIds: []uuid.UUID{parent.ID}, + // Exclude archived children by default. Do not pass an + // invalid NullBool, which would include archived rows. + Archived: sql.NullBool{Bool: false, Valid: true}, + }) + if err != nil { + return fantasy.NewTextErrorResponse(xerrors.Errorf("list child agents: %w", err).Error()), nil + } + + slices.SortStableFunc(rows, func(a, b database.GetChildChatsByParentIDsRow) int { + if c := b.Chat.UpdatedAt.Compare(a.Chat.UpdatedAt); c != 0 { + return c + } + return strings.Compare(b.Chat.ID.String(), a.Chat.ID.String()) + }) + + total := len(rows) + start := min(offset, total) + end := min(start+limit, total) + page := rows[start:end] + + agents := make([]map[string]any, 0, len(page)) + for _, row := range page { + child := row.Chat + agents = append(agents, withSubagentType(map[string]any{ + "chat_id": child.ID.String(), + "title": child.Title, + "status": string(child.Status), + "created_at": child.CreatedAt.Format(time.RFC3339), + "updated_at": child.UpdatedAt.Format(time.RFC3339), + }, child)) + } + + return toolJSONResponse(map[string]any{ + "agents": agents, + "total": total, + "returned": len(agents), + "offset": offset, + "has_more": end < total, + }), nil + }, + ), } } @@ -988,6 +1121,9 @@ func (p *Server) createChildSubagentChatWithOptions( // child chat creation does not hold one DB connection while waiting // for another pool checkout. deploymentPrompt := p.resolveDeploymentSystemPrompt(ctx) + // Delegated chats cannot call list_agents or message_agent, so + // strip the root-only orchestration guidance from their prompt. + deploymentPrompt = strings.Replace(deploymentPrompt, subagentOrchestrationPromptBlock, "", 1) if limitErr := p.checkUsageLimit(ctx, p.db, parent.OwnerID, uuid.NullUUID{UUID: parent.OrganizationID, Valid: true}); limitErr != nil { return database.Chat{}, limitErr @@ -1183,7 +1319,7 @@ func (p *Server) awaitSubagentCompletion( case <-notifyCh: case <-ticker.C: case <-timer.C: - return database.Chat{}, "", xerrors.New("timed out waiting for delegated subagent completion") + return database.Chat{}, "", ErrSubagentWaitTimeout case <-ctx.Done(): return database.Chat{}, "", ctx.Err() } @@ -1199,7 +1335,9 @@ func (p *Server) awaitSubagentCompletion( } // handleSubagentDone translates a completed subagent check into the -// appropriate return value, surfacing error-status chats as errors. +// appropriate return value. An error-status chat is returned as a typed +// subagentStatusError that carries the chat and report so the +// wait_agent handler can surface a structured, recoverable-aware payload. func handleSubagentDone( chat database.Chat, report string, @@ -1209,31 +1347,83 @@ func handleSubagentDone( if reason == "" { reason = "agent reached error status" } - return database.Chat{}, "", xerrors.New(reason) + return database.Chat{}, "", &subagentStatusError{ + chat: chat, + report: report, + reason: reason, + } } return chat, report, nil } -func (p *Server) closeSubagent( +// subagentLastErrorMessage extracts the normalized, user-facing message +// from a chat's last_error payload, falling back to the raw JSON when the +// payload is not a recognized ChatError. +func subagentLastErrorMessage(raw pqtype.NullRawMessage) string { + if !raw.Valid { + return "" + } + var payload codersdk.ChatError + if err := json.Unmarshal(raw.RawMessage, &payload); err == nil && payload.Message != "" { + return payload.Message + } + return string(raw.RawMessage) +} + +// waitAgentSuccessResponse stops and stores the recording (if active) and +// builds the normal completion payload for a wait_agent call. +func (p *Server) waitAgentSuccessResponse( + ctx context.Context, + recordingID string, + agentConn workspacesdk.AgentConn, + parent database.Chat, + targetChat database.Chat, + report string, +) fantasy.ToolResponse { + var recResult recordingResult + if recordingID != "" && agentConn != nil { + // Use a fresh context for cleanup so a canceled + // parent context does not prevent recording storage. + stopCtx, stopCancel := context.WithTimeout(context.WithoutCancel(ctx), subagentRecordingStopTimeout) + defer stopCancel() + recResult = p.stopAndStoreRecording(stopCtx, agentConn, + recordingID, parent.ID, parent.OwnerID, parent.WorkspaceID) + } + 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 + } + if recResult.thumbnailFileID != "" { + resp["thumbnail_file_id"] = recResult.thumbnailFileID + } + return toolJSONResponse(resp) +} + +func (p *Server) interruptSubagent( ctx context.Context, parentChatID uuid.UUID, targetChatID uuid.UUID, -) (database.Chat, error) { +) (database.Chat, bool, error) { isDescendant, err := isSubagentDescendant(ctx, p.db, parentChatID, targetChatID) if err != nil { - return database.Chat{}, err + return database.Chat{}, false, err } if !isDescendant { - return database.Chat{}, ErrSubagentNotDescendant + return database.Chat{}, false, ErrSubagentNotDescendant } targetChat, err := p.db.GetChatByID(ctx, targetChatID) if err != nil { - return database.Chat{}, xerrors.Errorf("get target chat: %w", err) + return database.Chat{}, false, xerrors.Errorf("get target chat: %w", err) } if targetChat.Status == database.ChatStatusWaiting { - return targetChat, nil + return targetChat, false, nil } updatedChat, err := p.InterruptChat(ctx, targetChat) @@ -1242,13 +1432,13 @@ func (p *Server) closeSubagent( // chatstate.Interrupt precondition. Surface the error // so the caller can decide whether the parent expected // the subagent to already be waiting. - return database.Chat{}, xerrors.Errorf("interrupt subagent chat: %w", err) + return database.Chat{}, false, xerrors.Errorf("interrupt subagent chat: %w", err) } // chatstate.Interrupt lands active runs in `interrupting` // and requires-action chats in `running`. Workers finalize // the transition; accept either non-active status as long as // the transition committed. - return updatedChat, nil + return updatedChat, true, nil } func (p *Server) checkSubagentCompletion( @@ -1260,8 +1450,14 @@ func (p *Server) checkSubagentCompletion( return database.Chat{}, "", false, xerrors.Errorf("get chat: %w", err) } - if chat.Status == database.ChatStatusPending || chat.Status == database.ChatStatusRunning { - return database.Chat{}, "", false, nil + // interrupting is transient: the worker transitions it to + // waiting (no queued messages) or running (queued messages). + // Treat it as not-done so the agent settles before + // classification, avoiding stale partial output. + if chat.Status == database.ChatStatusPending || + chat.Status == database.ChatStatusRunning || + chat.Status == database.ChatStatusInterrupting { + return chat, "", false, nil } report, err := latestSubagentAssistantMessage(ctx, p.db, chatID) diff --git a/coderd/x/chatd/subagent_catalog.go b/coderd/x/chatd/subagent_catalog.go index e567631271..dd704ac73d 100644 --- a/coderd/x/chatd/subagent_catalog.go +++ b/coderd/x/chatd/subagent_catalog.go @@ -299,7 +299,12 @@ func buildSpawnAgentDescription( "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." + "After spawning, use wait_agent to retrieve the result. Agents persist " + + "after completion; reuse an agent via message_agent for follow-up work " + + "when it already has relevant context. Spawned agents are your " + + "responsibility: do not abandon one in a working state (pending or " + + "running); retrieve its result, redirect it with message_agent, or stop " + + "it with interrupt_agent." if currentChat.PlanMode.Valid && currentChat.PlanMode.ChatPlanMode == database.ChatPlanModePlan { description += " During plan mode, type=\"" + subagentTypeGeneral + "\" is for non-mutating substantial investigation and planning support, " + @@ -340,7 +345,7 @@ func planningOverlaySubagentGuidance() string { 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, " + + "Workspace MCP tools are not available in root plan mode, and side-effecting built-in tools such as process_list, process_signal, message_agent, interrupt_agent, and computer-use actions remain unavailable. In Plan Mode, " + spawnAgentToolName + " delegation is for investigation and planning " + "support, not code writing or implementation. Use type=\"" + subagentTypeGeneral + "\" for substantial investigation, reasoning, and planning support. " + diff --git a/coderd/x/chatd/subagent_internal_test.go b/coderd/x/chatd/subagent_internal_test.go index bb02bf66cd..1d8f544829 100644 --- a/coderd/x/chatd/subagent_internal_test.go +++ b/coderd/x/chatd/subagent_internal_test.go @@ -2665,16 +2665,16 @@ func TestSubagentLifecycleToolsIncludePersistedSubagentTypeAcrossVariants(t *tes require.Equal(t, tt.variant, messageResult["type"]) setChatStatus(ctx, t, db, childID, database.ChatStatusRunning, "") - closeResult := requireToolResponseMap(t, runSubagentTool( + interruptResult := requireToolResponseMap(t, runSubagentTool( ctx, t, server, parentChat, parentChat.LastModelConfigID, - "close_agent", - closeAgentArgs{ChatID: childID.String()}, + "interrupt_agent", + interruptAgentArgs{ChatID: childID.String()}, ), false) - require.Equal(t, tt.variant, closeResult["type"]) + require.Equal(t, tt.variant, interruptResult["type"]) }) } } @@ -2719,9 +2719,9 @@ func TestSubagentLifecycleToolErrorsIncludePersistedSubagentType(t *testing.T) { wantError: ErrSubagentNotDescendant.Error(), }, { - name: "CloseAgent", - toolName: "close_agent", - args: closeAgentArgs{ChatID: child.ID.String()}, + name: "InterruptAgent", + toolName: "interrupt_agent", + args: interruptAgentArgs{ChatID: child.ID.String()}, wantError: ErrSubagentNotDescendant.Error(), }, } @@ -3700,3 +3700,338 @@ func TestAwaitSubagentCompletion(t *testing.T) { assert.Equal(t, "zero timeout ok", report) }) } + +func TestWaitAgentTimeoutReturnsInformationalPayload(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + mClock := quartz.NewMock(t) + server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}, withInternalTestServerClock(mClock)) + ctx := chatdTestContext(t) + user, org, model := seedInternalChatDeps(t, db) + parent, child := createParentChildChats(ctx, t, server, user, org, model) + + WaitUntilIdleForTest(server) + setChatStatus(ctx, t, db, child.ID, database.ChatStatusRunning, "") + + timerTrap := mClock.Trap().NewTimer("chatd", "subagent_await") + + type toolResult struct { + resp fantasy.ToolResponse + } + resultCh := make(chan toolResult, 1) + oneSecond := 1 + go func() { + resp := runSubagentTool( + ctx, + t, + server, + parent, + parent.LastModelConfigID, + "wait_agent", + waitAgentArgs{ChatID: child.ID.String(), TimeoutSeconds: &oneSecond}, + ) + resultCh <- toolResult{resp: resp} + }() + + // Wait for the timer to be created, then advance past it. + timerTrap.MustWait(ctx).MustRelease(ctx) + timerTrap.Close() + mClock.Advance(time.Second).MustWait(ctx) + + result := testutil.RequireReceive(ctx, t, resultCh) + m := requireToolResponseMap(t, result.resp, false) + + require.Equal(t, true, m["timed_out"]) + require.Equal(t, child.ID.String(), m["chat_id"]) + require.Equal(t, string(database.ChatStatusRunning), m["status"]) + require.Equal(t, subagentTypeGeneral, m["type"]) +} + +func TestWaitAgentErrorStatusReturnsStructuredPayload(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}) + ctx := chatdTestContext(t) + user, org, model := seedInternalChatDeps(t, db) + parent, child := createParentChildChats(ctx, t, server, user, org, model) + + // An errored, non-archived agent is often recoverable. wait_agent + // must surface a structured payload (status, last_error, report) + // rather than a bare tool error. + WaitUntilIdleForTest(server) + setChatStatus(ctx, t, db, child.ID, database.ChatStatusError, "provider overloaded") + insertAssistantMessage(t, db, child.ID, model.ID, "partial progress") + + result := requireToolResponseMap(t, runSubagentTool( + ctx, + t, + server, + parent, + parent.LastModelConfigID, + "wait_agent", + waitAgentArgs{ChatID: child.ID.String()}, + ), false) + + require.Equal(t, string(database.ChatStatusError), result["status"]) + require.Equal(t, child.ID.String(), result["chat_id"]) + require.Equal(t, "provider overloaded", result["last_error"]) + require.Equal(t, "partial progress", result["report"]) + require.Equal(t, subagentTypeGeneral, result["type"]) + require.NotContains(t, result, "timed_out") +} + +func TestWaitAgentTimeoutGapCompletesWithError(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + mClock := quartz.NewMock(t) + server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}, withInternalTestServerClock(mClock)) + ctx := chatdTestContext(t) + user, org, model := seedInternalChatDeps(t, db) + parent, child := createParentChildChats(ctx, t, server, user, org, model) + + WaitUntilIdleForTest(server) + setChatStatus(ctx, t, db, child.ID, database.ChatStatusRunning, "") + + timerTrap := mClock.Trap().NewTimer("chatd", "subagent_await") + + type toolResult struct { + resp fantasy.ToolResponse + } + resultCh := make(chan toolResult, 1) + oneSecond := 1 + go func() { + resp := runSubagentTool( + ctx, + t, + server, + parent, + parent.LastModelConfigID, + "wait_agent", + waitAgentArgs{ChatID: child.ID.String(), TimeoutSeconds: &oneSecond}, + ) + resultCh <- toolResult{resp: resp} + }() + + // Wait for the timer to be created, then advance past it. + timerTrap.MustWait(ctx).MustRelease(ctx) + timerTrap.Close() + + // Flip the child to error before the timer fires so the + // timeout-gap branch (checkSubagentCompletion after timeout) + // classifies it through handleSubagentDone. + setChatStatus(ctx, t, db, child.ID, database.ChatStatusError, "provider overloaded") + insertAssistantMessage(t, db, child.ID, model.ID, "partial progress") + + mClock.Advance(time.Second).MustWait(ctx) + + result := testutil.RequireReceive(ctx, t, resultCh) + m := requireToolResponseMap(t, result.resp, false) + + require.Equal(t, string(database.ChatStatusError), m["status"]) + require.Equal(t, "provider overloaded", m["last_error"]) + require.Equal(t, "partial progress", m["report"]) + require.Equal(t, child.ID.String(), m["chat_id"]) + require.Equal(t, subagentTypeGeneral, m["type"]) + require.NotContains(t, m, "timed_out") +} + +func listAgentsChatIDs(t *testing.T, result map[string]any) []string { + t.Helper() + agents, ok := result["agents"].([]any) + require.True(t, ok, "agents must be an array") + ids := make([]string, 0, len(agents)) + for _, raw := range agents { + agent, ok := raw.(map[string]any) + require.True(t, ok, "each agent must be an object") + id, ok := agent["chat_id"].(string) + require.True(t, ok, "each agent must have a chat_id") + ids = append(ids, id) + } + return ids +} + +func TestListAgents(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}) + user, org, model := seedInternalChatDeps(t, db) + + // Helpers take the running subtest's t and ctx so a failed require + // fires on the correct goroutine. + newParent := func(t *testing.T, ctx context.Context, title string) database.Chat { + t.Helper() + parent, err := server.CreateChat(ctx, CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + APIKeyID: testAPIKeyID(t, db, user.ID), + Title: title, + ModelConfigID: model.ID, + InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")}, + }) + require.NoError(t, err) + return parent + } + newChild := func(t *testing.T, ctx context.Context, parent database.Chat, title string, mode database.NullChatMode) database.Chat { + t.Helper() + child, err := server.CreateChat(ctx, CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + APIKeyID: testAPIKeyID(t, db, user.ID), + ParentChatID: uuid.NullUUID{UUID: parent.ID, Valid: true}, + RootChatID: uuid.NullUUID{UUID: parent.ID, Valid: true}, + Title: title, + ModelConfigID: model.ID, + ChatMode: mode, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("do work"), + }, + }) + require.NoError(t, err) + return child + } + + t.Run("Empty", func(t *testing.T) { + t.Parallel() + ctx := chatdTestContext(t) + parent := newParent(t, ctx, "list-agents-empty") + + result := requireToolResponseMap(t, runSubagentTool( + ctx, t, server, parent, parent.LastModelConfigID, + "list_agents", listAgentsArgs{}, + ), false) + + require.Equal(t, float64(0), result["total"]) + require.Equal(t, float64(0), result["returned"]) + require.Equal(t, false, result["has_more"]) + require.Empty(t, listAgentsChatIDs(t, result)) + }) + + t.Run("ReturnsChildren", func(t *testing.T) { + t.Parallel() + ctx := chatdTestContext(t) + parent := newParent(t, ctx, "list-agents-children") + generalChild := newChild(t, ctx, parent, "general-child", database.NullChatMode{}) + exploreChild := newChild(t, ctx, parent, "explore-child", database.NullChatMode{ + ChatMode: database.ChatModeExplore, + Valid: true, + }) + + result := requireToolResponseMap(t, runSubagentTool( + ctx, t, server, parent, parent.LastModelConfigID, + "list_agents", listAgentsArgs{}, + ), false) + + require.Equal(t, float64(2), result["total"]) + require.Equal(t, float64(2), result["returned"]) + require.Equal(t, false, result["has_more"]) + ids := listAgentsChatIDs(t, result) + require.Contains(t, ids, generalChild.ID.String()) + require.Contains(t, ids, exploreChild.ID.String()) + + agents, ok := result["agents"].([]any) + require.True(t, ok) + typesByID := map[string]string{} + for _, raw := range agents { + agent := raw.(map[string]any) + typesByID[agent["chat_id"].(string)] = agent["type"].(string) + require.NotEmpty(t, agent["created_at"]) + require.NotEmpty(t, agent["updated_at"]) + } + require.Equal(t, subagentTypeGeneral, typesByID[generalChild.ID.String()]) + require.Equal(t, subagentTypeExplore, typesByID[exploreChild.ID.String()]) + }) + + t.Run("Pagination", func(t *testing.T) { + t.Parallel() + ctx := chatdTestContext(t) + parent := newParent(t, ctx, "list-agents-pagination") + newChild(t, ctx, parent, "child-a", database.NullChatMode{}) + newChild(t, ctx, parent, "child-b", database.NullChatMode{}) + newChild(t, ctx, parent, "child-c", database.NullChatMode{}) + + limit := 2 + first := requireToolResponseMap(t, runSubagentTool( + ctx, t, server, parent, parent.LastModelConfigID, + "list_agents", listAgentsArgs{Limit: &limit}, + ), false) + require.Equal(t, float64(3), first["total"]) + require.Equal(t, float64(2), first["returned"]) + require.Equal(t, true, first["has_more"]) + firstIDs := listAgentsChatIDs(t, first) + require.Len(t, firstIDs, 2) + + offset := 2 + second := requireToolResponseMap(t, runSubagentTool( + ctx, t, server, parent, parent.LastModelConfigID, + "list_agents", listAgentsArgs{Limit: &limit, Offset: &offset}, + ), false) + require.Equal(t, float64(3), second["total"]) + require.Equal(t, float64(1), second["returned"]) + require.Equal(t, false, second["has_more"]) + secondIDs := listAgentsChatIDs(t, second) + require.Len(t, secondIDs, 1) + require.NotContains(t, firstIDs, secondIDs[0]) + }) + + t.Run("OrderByUpdatedAtDesc", func(t *testing.T) { + t.Parallel() + ctx := chatdTestContext(t) + parent := newParent(t, ctx, "list-agents-order") + older := newChild(t, ctx, parent, "older-child", database.NullChatMode{}) + newChild(t, ctx, parent, "newer-child", database.NullChatMode{}) + + // Touch the older child so its updated_at advances past the + // newer one; it must then sort first. + setChatStatus(ctx, t, db, older.ID, database.ChatStatusWaiting, "") + + result := requireToolResponseMap(t, runSubagentTool( + ctx, t, server, parent, parent.LastModelConfigID, + "list_agents", listAgentsArgs{}, + ), false) + ids := listAgentsChatIDs(t, result) + require.Len(t, ids, 2) + require.Equal(t, older.ID.String(), ids[0]) + }) + + t.Run("ExcludesArchived", func(t *testing.T) { + t.Parallel() + ctx := chatdTestContext(t) + parent := newParent(t, ctx, "list-agents-archived") + archivedChild := newChild(t, ctx, parent, "archived-child", database.NullChatMode{}) + + WaitUntilIdleForTest(server) + // SetArchived is only allowed from a waiting/error state, so + // settle the family into waiting first. Archiving then marks + // the children archived; they must be excluded from + // list_agents by default. + setChatStatus(ctx, t, db, parent.ID, database.ChatStatusWaiting, "") + setChatStatus(ctx, t, db, archivedChild.ID, database.ChatStatusWaiting, "") + require.NoError(t, server.ArchiveChat(ctx, parent)) + + result := requireToolResponseMap(t, runSubagentTool( + ctx, t, server, parent, parent.LastModelConfigID, + "list_agents", listAgentsArgs{}, + ), false) + require.Equal(t, float64(0), result["total"]) + require.Empty(t, listAgentsChatIDs(t, result)) + }) + + t.Run("DelegatedChatRejected", func(t *testing.T) { + t.Parallel() + ctx := chatdTestContext(t) + parent := newParent(t, ctx, "list-agents-delegated") + child := newChild(t, ctx, parent, "delegated-caller", database.NullChatMode{}) + + resp := runSubagentTool( + ctx, t, server, child, child.LastModelConfigID, + "list_agents", listAgentsArgs{}, + ) + require.True(t, resp.IsError, "list_agents on a delegated chat must return an error") + msg := resp.Content + require.Contains(t, msg, "only available on root chats") + }) +} diff --git a/docs/ai-coder/agents/architecture.md b/docs/ai-coder/agents/architecture.md index 9d8c8e6ecf..1360faf0af 100644 --- a/docs/ai-coder/agents/architecture.md +++ b/docs/ai-coder/agents/architecture.md @@ -178,8 +178,9 @@ parallel. | `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. | +| `interrupt_agent` | Halts a sub-agent's current turn; it transitions to waiting or running if there are queued messages. | | `spawn_agent` (`type=computer_use`) | Spawns a sub-agent with desktop interaction capabilities (screenshot, mouse, keyboard). Requires an administrator-configured computer-use provider (Anthropic or OpenAI) and the [virtual desktop experiment](./platform-controls/experiments.md#virtual-desktop) to be enabled. | +| `list_agents` | Lists spawned child agents, most recently active first. | ### Provider tools diff --git a/docs/ai-coder/agents/index.md b/docs/ai-coder/agents/index.md index 886e7c7835..1e464a63b4 100644 --- a/docs/ai-coder/agents/index.md +++ b/docs/ai-coder/agents/index.md @@ -229,30 +229,31 @@ 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 | -| `attach_file` | Attach a workspace file to the chat as a durable downloadable attachment | -| `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) | +| 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 | +| `attach_file` | Attach a workspace file to the chat as a durable downloadable attachment | +| `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 | +| `interrupt_agent` | Halt a sub-agent's current turn; it transitions to waiting or running if there are queued messages | +| `spawn_agent` (`type=computer_use`) | Spawn a sub-agent with desktop interaction (screenshot, mouse, keyboard) | +| `list_agents` | List spawned child agents, most recently active first | +| `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 @@ -260,7 +261,7 @@ 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`) +`wait_agent`, `message_agent`, `interrupt_agent`, `list_agents`) are only available to root chats. Sub-agents do not have access to these tools and cannot create workspaces or spawn further sub-agents. diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 86bf90598c..ac6a4c6c07 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -755,21 +755,22 @@ const EVERY_TOOL_ASSISTANT_TURN = { }, }, - // close_agent -- terminate a subagent + // interrupt_agent: interrupt a subagent { type: "tool-call", - tool_call_id: "every-close-agent", - tool_name: "close_agent", + tool_call_id: "every-interrupt-agent", + tool_name: "interrupt_agent", args: { chat_id: "every-explore-child" }, }, { type: "tool-result", - tool_call_id: "every-close-agent", - tool_name: "close_agent", + tool_call_id: "every-interrupt-agent", + tool_name: "interrupt_agent", result: { chat_id: "every-explore-child", type: "explore", status: "completed", + interrupted: true, }, }, @@ -1693,18 +1694,19 @@ export const WithMixedSubagentTranscript: Story = { }, { type: "tool-call", - tool_call_id: "legacy-close", - tool_name: "close_agent", + tool_call_id: "legacy-interrupt", + tool_name: "interrupt_agent", args: { chat_id: "legacy-child" }, }, { type: "tool-result", - tool_call_id: "legacy-close", - tool_name: "close_agent", + tool_call_id: "legacy-interrupt", + tool_name: "interrupt_agent", result: { chat_id: "legacy-child", type: "general", status: "completed", + interrupted: "true", }, }, ], diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts index 5e0a259fb4..7d3fa92bd8 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts @@ -960,7 +960,7 @@ describe("subagent transcript parsing", () => { expect(variants.get("unified-child")).toBe("explore"); }); - it("includes close_agent in the shared subagent parsing path", () => { + it("includes close_agent (legacy alias) in the shared subagent parsing path", () => { const { variants } = parseSubagents([ msg(1, [ toolCall("close-tool", "close_agent", { chat_id: "closing-child" }), @@ -975,6 +975,24 @@ describe("subagent transcript parsing", () => { expect(variants.get("closing-child")).toBe("explore"); }); + it("includes interrupt_agent in the shared subagent parsing path", () => { + const { variants } = parseSubagents([ + msg(1, [ + toolCall("interrupt-tool", "interrupt_agent", { + chat_id: "interrupting-child", + }), + toolResult("interrupt-tool", "interrupt_agent", { + chat_id: "interrupting-child", + type: "explore", + status: "completed", + interrupted: true, + }), + ]), + ]); + + expect(variants.get("interrupting-child")).toBe("explore"); + }); + it("tracks computer-use variants for legacy and spawn_agent tools", () => { const { variants } = parseSubagents([ msg(1, [ @@ -1022,8 +1040,10 @@ describe("subagent transcript parsing", () => { }), ]), msg(3, [ - toolCall("close-tool", "close_agent", { chat_id: "close-child" }), - toolResult("close-tool", "close_agent", { + toolCall("interrupt-tool", "interrupt_agent", { + chat_id: "close-child", + }), + toolResult("interrupt-tool", "interrupt_agent", { chat_id: "close-child", type: "general", status: "completed", @@ -1068,8 +1088,10 @@ describe("subagent transcript parsing", () => { }), ]), msg(4, [ - toolCall("close-tool", "close_agent", { chat_id: "history-child" }), - toolResult("close-tool", "close_agent", { + toolCall("interrupt-tool", "interrupt_agent", { + chat_id: "history-child", + }), + toolResult("interrupt-tool", "interrupt_agent", { chat_id: "history-child", status: "completed", }), @@ -1086,7 +1108,8 @@ describe("getSubagentDescriptor", () => { const lifecycleTools = [ { name: "wait_agent", action: "wait" }, { name: "message_agent", action: "message" }, - { name: "close_agent", action: "close" }, + { name: "close_agent", action: "interrupt" }, + { name: "interrupt_agent", action: "interrupt" }, ] as const; for (const tool of lifecycleTools) { @@ -1111,6 +1134,7 @@ describe("getSubagentDescriptor", () => { "wait_agent", "message_agent", "close_agent", + "interrupt_agent", ] as const; for (const name of lifecycleToolNames) { @@ -1127,4 +1151,30 @@ describe("getSubagentDescriptor", () => { }); } }); + + it("renders list_agents with a fixed generic affordance", () => { + const descriptor = getSubagentDescriptor({ + name: "list_agents", + args: {}, + result: { + agents: [ + { chat_id: "agent-1", type: "explore", status: "completed" }, + { chat_id: "agent-2", type: "computer_use", status: "running" }, + ], + total: 2, + returned: 2, + offset: 0, + has_more: false, + }, + }); + + // The list result has no single top-level type, so the descriptor + // must not derive a variant from per-agent types. + expect(descriptor).toMatchObject({ + action: "list", + variant: "general", + iconKind: "bot", + supportsDesktopAffordance: false, + }); + }); }); diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ListAgentsTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ListAgentsTool.tsx new file mode 100644 index 0000000000..05ae1e431c --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ListAgentsTool.tsx @@ -0,0 +1,87 @@ +import { ExternalLinkIcon } from "lucide-react"; +import type React from "react"; +import { Link, useLocation } from "react-router"; +import { safeBuildAgentChatPath } from "../../../utils/navigation"; +import { ToolCall } from "./ToolCall"; +import { asRecord, asString, type ToolStatus } from "./utils"; + +/** + * Collapsed-by-default rendering for `list_agents` tool calls. Shows + * "Listed N of M agents" with a chevron; expanding reveals the agent + * list with links to each agent's chat. + */ +export const ListAgentsTool: React.FC<{ + agents: unknown[]; + total: number; + status: ToolStatus; + isError: boolean; + errorMessage?: string; +}> = ({ agents, total, status, isError, errorMessage }) => { + const location = useLocation(); + const hasContent = agents.length > 0; + const isRunning = status === "running"; + + const label = isRunning + ? "Listing agents" + : hasContent + ? `Listed ${agents.length} of ${total} agents` + : "Listed 0 agents"; + + return ( + + + +
+ {agents.map((agent, index) => { + const rec = asRecord(agent); + if (!rec) { + return null; + } + const title = asString(rec.title) || "untitled"; + const chatStatus = asString(rec.status) || "unknown"; + const type = asString(rec.type) || "general"; + const chatId = asString(rec.chat_id); + const agentChatPath = chatId + ? safeBuildAgentChatPath({ chatId }) + : null; + + const row = ( + + {title} ({type}, {chatStatus}) + + ); + + if (!agentChatPath) { + return ( +
+ {row} +
+ ); + } + + return ( +
+ + {row} + + +
+ ); + })} +
+
+
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx index c64f39fb56..9fc4aaa26f 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx @@ -41,11 +41,17 @@ const SUBAGENT_VERBS: Record< error: "Failed to message ", timeout: "Timed out messaging ", }, - close: { - completed: "Terminated ", - running: "Terminating ", - error: "Failed to terminate ", - timeout: "Timed out terminating ", + interrupt: { + completed: "Interrupted ", + running: "Interrupting ", + error: "Failed to interrupt ", + timeout: "Timed out interrupting ", + }, + list: { + completed: "Listed ", + running: "Listing ", + error: "Failed to list ", + timeout: "Timed out listing ", }, }; 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 32c5a5853f..eb5321c939 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -224,7 +224,7 @@ const allToolShowcaseItems: ToolShowcaseItem[] = [ result: { chat_id: "bot-child", status: "completed" }, }, { - name: "close_agent", + name: "interrupt_agent", args: { chat_id: "bot-child" }, result: { chat_id: "bot-child", status: "completed" }, }, @@ -1015,9 +1015,9 @@ export const MessageAgentExploreStreamingFromResult: Story = { }, }; -export const CloseAgentRunningWithoutChatId: Story = { +export const InterruptAgentRunningWithoutChatId: Story = { args: { - name: "close_agent", + name: "interrupt_agent", status: "running", args: {}, result: { status: "running" }, @@ -1033,25 +1033,127 @@ export const CloseAgentRunningWithoutChatId: Story = { }, }; -export const CloseAgentExploreCompleted: Story = { +// interrupt_agent is the post-rename name for close_agent. The response +// carries `interrupted: true`. +export const InterruptAgentExploreCompleted: Story = { args: { - name: "close_agent", + name: "interrupt_agent", status: "completed", - args: { chat_id: "close-child" }, + args: { chat_id: "interrupt-child" }, result: { - chat_id: "close-child", + chat_id: "interrupt-child", type: "explore", status: "completed", + interrupted: true, }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); expect( - canvas.getByRole("button", { name: /Terminated Explore agent/ }), + canvas.getByRole("button", { name: /Interrupted Explore agent/ }), ).toBeInTheDocument(); }, }; +// list_agents renders through ListAgentsTool, showing a count in the +// header and an expandable list of agents with links. +export const ListAgentsCompleted: Story = { + args: { + name: "list_agents", + status: "completed", + args: {}, + result: { + agents: [ + { + chat_id: "agent-1", + title: "Repository review", + type: "general", + status: "completed", + created_at: "2026-04-21T00:00:00.000Z", + updated_at: "2026-04-21T00:05:00.000Z", + }, + { + chat_id: "agent-2", + title: "Inspect repository", + type: "explore", + status: "running", + created_at: "2026-04-21T00:01:00.000Z", + updated_at: "2026-04-21T00:06:00.000Z", + }, + { + chat_id: "agent-3", + title: "Drive the desktop", + type: "computer_use", + status: "pending", + created_at: "2026-04-21T00:02:00.000Z", + updated_at: "2026-04-21T00:07:00.000Z", + }, + ], + total: 3, + returned: 3, + offset: 0, + has_more: false, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const header = canvas.getByRole("button", { name: /Listed 3 of 3 agents/ }); + expect(header).toBeInTheDocument(); + // Expand to verify agent rows and links render. + await userEvent.click(header); + expect( + canvas.getByText("Repository review (general, completed)"), + ).toBeInTheDocument(); + expect( + canvas.getByText("Inspect repository (explore, running)"), + ).toBeInTheDocument(); + }, +}; + +export const ListAgentsRunning: Story = { + args: { + name: "list_agents", + status: "running", + args: {}, + result: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Listing agents")).toBeInTheDocument(); + }, +}; + +export const ListAgentsEmpty: Story = { + args: { + name: "list_agents", + status: "completed", + args: {}, + result: { + agents: [], + total: 0, + has_more: false, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Listed 0 agents")).toBeInTheDocument(); + }, +}; + +export const ListAgentsError: Story = { + args: { + name: "list_agents", + status: "error", + isError: true, + args: {}, + result: "list_agents is only available on root chats", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Listed 0 agents")).toBeInTheDocument(); + }, +}; + // --------------------------------------------------------------------------- // ListTemplates stories // --------------------------------------------------------------------------- @@ -1161,17 +1263,17 @@ export const ChatSummarized: Story = { }; // --------------------------------------------------------------------------- -// SubagentTerminate stories +// SubagentInterrupt stories // --------------------------------------------------------------------------- -export const SubagentTerminate: Story = { +export const SubagentInterrupt: Story = { args: { - name: "close_agent", + name: "interrupt_agent", args: undefined, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByText(/Terminated/)).toBeInTheDocument(); + expect(canvas.getByText(/Interrupted/)).toBeInTheDocument(); expect(canvas.getByText("Sub-agent")).toBeInTheDocument(); }, }; @@ -2169,6 +2271,31 @@ export const SubagentWaitTimedOutTitleFromMap: Story = { }, }; +export const SubagentWaitTimedOutStructured: Story = { + args: { + name: "wait_agent", + status: "completed", + isError: false, + args: { chat_id: "timed-out-child" }, + result: { + chat_id: "timed-out-child", + title: "Fix login bug", + status: "running", + timed_out: true, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Should show clock icon for timeout. + expect(canvasElement.querySelector(".lucide-clock")).not.toBeNull(); + // Should NOT show red alert icon. + expect(canvasElement.querySelector(".lucide-circle-alert")).toBeNull(); + // Should show timeout verb. + expect(canvas.getByText(/Timed out waiting for/)).toBeInTheDocument(); + expect(canvas.getByText("Fix login bug")).toBeInTheDocument(); + }, +}; + export const SubagentSpawnError: Story = { args: { name: "spawn_agent", diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index 619a4b1929..a1bfcc2207 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -19,6 +19,7 @@ import { ExecuteTool as ExecuteToolComponent, WaitForExternalAuthTool, } from "./ExecuteTool"; +import { ListAgentsTool } from "./ListAgentsTool"; import { ListTemplatesTool } from "./ListTemplatesTool"; import { ProcessOutputTool } from "./ProcessOutputTool"; import { ProposePlanTool } from "./ProposePlanTool"; @@ -528,12 +529,14 @@ const SubagentRenderer: FC = ({ } // 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". + // returns a structured payload with timed_out: true + // (IsError=false), or an error string containing "timed out". const resultStr = typeof result === "string" ? result : ""; const errorStr = rec ? asString(rec.error) : ""; let isTimeout = false; - if (subagentIsError) { + if (rec && rec.timed_out === true) { + isTimeout = true; + } else if (subagentIsError) { const timedOutInResult = resultStr.toLowerCase().includes("timed out"); const timedOutInError = errorStr.toLowerCase().includes("timed out"); if (timedOutInResult || timedOutInError) { @@ -586,6 +589,34 @@ const ListTemplatesRenderer: FC = ({ ); }; +const ListAgentsRenderer: FC = ({ + status, + result, + isError, +}) => { + const rec = asRecord(result); + const agents = rec && Array.isArray(rec.agents) ? rec.agents : []; + const total = rec + ? (asNumber(rec.total, { parseString: true }) ?? agents.length) + : 0; + + return ( + + ); +}; + const ReadTemplateRenderer: FC = ({ status, result, @@ -1033,6 +1064,7 @@ const toolRenderers: Record> = { create_workspace: CreateWorkspaceRenderer, start_workspace: StartWorkspaceRenderer, list_templates: ListTemplatesRenderer, + list_agents: ListAgentsRenderer, read_template: ReadTemplateRenderer, read_skill: ReadSkillRenderer, read_skill_file: ReadSkillFileRenderer, @@ -1074,9 +1106,10 @@ export const Tool = memo( ref, ...props }: ToolProps) => { - const Renderer = isSubagentToolName(name) - ? SubagentRenderer - : (toolRenderers[name] ?? GenericToolRenderer); + const Renderer = + isSubagentToolName(name) && name !== "list_agents" + ? SubagentRenderer + : (toolRenderers[name] ?? GenericToolRenderer); const isShellTool = name === "execute" || name === "process_output"; if (!shouldRenderTool({ name, status, args, result })) { return null; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolLabel.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolLabel.tsx index 4e0ff7d54c..b8babecf7a 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolLabel.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolLabel.tsx @@ -39,10 +39,14 @@ const renderSubagentLabel = ( return providedTitle ? `Messaging ${providedTitle}` : `Messaging ${fallbackTitle}…`; - case "close": + case "interrupt": return providedTitle - ? `Terminating ${providedTitle}` - : `Terminating ${fallbackTitle}`; + ? `Interrupting ${providedTitle}` + : `Interrupting ${fallbackTitle}`; + case "list": + return providedTitle + ? `Listing ${providedTitle}` + : `Listing ${fallbackTitle}`; } })(); diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/subagentDescriptor.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/subagentDescriptor.ts index fe75680818..0d7503b0e6 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/subagentDescriptor.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/subagentDescriptor.ts @@ -1,7 +1,12 @@ import { asString } from "../runtimeTypeUtils"; import { parseArgs } from "./utils"; -export type SubagentAction = "spawn" | "wait" | "message" | "close"; +export type SubagentAction = + | "spawn" + | "wait" + | "message" + | "interrupt" + | "list"; export type SubagentVariant = "general" | "explore" | "computer_use"; export type SubagentIconKind = "bot" | "monitor"; @@ -47,7 +52,14 @@ const actionByToolName: Record = { spawn_subagent: "spawn", wait_agent: "wait", message_agent: "message", - close_agent: "close", + // Legacy persisted tool name kept so old chat histories still render. + close_agent: "interrupt", + interrupt_agent: "interrupt", + // list_agents is a subagent tool but renders through + // ListAgentsRenderer, not SubagentRenderer. The "list" action + // exists for isSubagentToolName classification and ToolIcon + // dispatch, not for the SubagentRenderer label machinery. + list_agents: "list", }; const variantBySpawnToolName: Record = { diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts index e6aa89b3a0..31f68826e0 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts @@ -114,7 +114,7 @@ describe("toolVisibility", () => { ).toBe(false); }); - it("hides running close_agent rows until chat_id is available", () => { + it("hides running close_agent (legacy alias) rows until chat_id is available", () => { expect( shouldRenderTool({ name: "close_agent", @@ -125,6 +125,28 @@ describe("toolVisibility", () => { ).toBe(false); }); + it("hides running interrupt_agent rows until chat_id is available", () => { + expect( + shouldRenderTool({ + name: "interrupt_agent", + status: "running", + args: {}, + result: { status: "running" }, + }), + ).toBe(false); + }); + + it("renders list_agents rows even without a chat_id", () => { + expect( + shouldRenderTool({ + name: "list_agents", + status: "running", + args: {}, + result: undefined, + }), + ).toBe(true); + }); + it("renders running lifecycle rows once args provide the chat_id", () => { expect( shouldRenderTool({ @@ -136,7 +158,7 @@ describe("toolVisibility", () => { ).toBe(true); }); - it("renders completed lifecycle rows even if chat_id is absent", () => { + it("renders completed close_agent (legacy alias) rows even if chat_id is absent", () => { expect( shouldRenderTool({ name: "close_agent", diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts index 32c17772fd..58089f02c6 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts @@ -93,13 +93,13 @@ const shouldRenderSubagentLifecycleTool = ({ if ( descriptor.action !== "wait" && descriptor.action !== "message" && - descriptor.action !== "close" + descriptor.action !== "interrupt" ) { return true; } - // Wait, message, and close rows can stream before their target chat_id - // arrives. Hiding them until that id exists avoids flashing generic + // Wait, message, and interrupt rows can stream before their target + // chat_id arrives. Hiding them until that id exists avoids flashing generic // lifecycle copy before the transcript can resolve the real title. return Boolean(getSubagentChatId({ args, result })); }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts index 44ff5c75df..609773d550 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts @@ -221,6 +221,17 @@ describe("mapSubagentStatusToToolStatus", () => { ); }); + it("treats interrupted as an unknown status, not a chat status", () => { + // The interrupt_agent rename returns an `interrupted: true` response + // boolean, which is not a chat status. Status mapping only handles + // chat status strings, so "interrupted" falls back like any unknown + // value and "terminated" keeps mapping to completed. + expect(mapSubagentStatusToToolStatus("interrupted", "running")).toBe( + "running", + ); + expect(mapSubagentStatusToToolStatus("interrupted", "error")).toBe("error"); + }); + it("maps error to error", () => { expect(mapSubagentStatusToToolStatus("error", "running")).toBe("error"); });