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.
This commit is contained in:
Mathias Fredriksson
2026-06-26 13:41:43 +03:00
committed by GitHub
parent 637a801a41
commit 59fcc9c0ad
26 changed files with 1183 additions and 155 deletions
+4 -2
View File
@@ -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,
+3 -1
View File
@@ -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",
+2 -2
View File
@@ -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,
+25 -7
View File
@@ -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 {
@@ -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")
}
+4 -2
View File
@@ -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
+1
View File
@@ -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,
@@ -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, "<subagent-orchestration>")
require.Contains(t, DefaultSystemPrompt, "</subagent-orchestration>")
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()
+11 -1
View File
@@ -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 = `<subagent-orchestration>
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.
</subagent-orchestration>`
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 <plan-file-path> block below is present, use that exact path.
` + defaultSystemPromptPlanPathBlockPlaceholder + `
</planning>`
</planning>
` + subagentOrchestrationPromptBlock
var planningOverlayPrompt = `You are in Plan Mode.
Every response must work toward producing a plan.
+6 -2
View File
@@ -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
+251 -55
View File
@@ -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)
+7 -2
View File
@@ -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. " +
+342 -7
View File
@@ -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")
})
}
+2 -1
View File
@@ -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
+26 -25
View File
@@ -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.
@@ -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",
},
},
],
@@ -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,
});
});
});
@@ -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 (
<ToolCall.Root
className="w-full"
status={status}
isError={isError}
errorMessage={errorMessage || "Failed to list agents"}
hasContent={hasContent}
>
<ToolCall.Header iconName="list_agents" label={label} />
<ToolCall.Content>
<div className="mt-1.5">
{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 = (
<span>
{title} ({type}, {chatStatus})
</span>
);
if (!agentChatPath) {
return (
<div key={index} className="text-[13px] text-content-secondary">
{row}
</div>
);
}
return (
<div key={chatId || index} className="flex items-center gap-1.5">
<Link
to={{
pathname: agentChatPath,
search: location.search,
}}
className="flex items-center gap-1.5 text-[13px] text-content-secondary opacity-50 transition-opacity hover:opacity-100"
>
{row}
<ExternalLinkIcon className="size-3 shrink-0" />
</Link>
</div>
);
})}
</div>
</ToolCall.Content>
</ToolCall.Root>
);
};
@@ -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 ",
},
};
@@ -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",
@@ -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<ToolRendererProps> = ({
}
// 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<ToolRendererProps> = ({
);
};
const ListAgentsRenderer: FC<ToolRendererProps> = ({
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 (
<ListAgentsTool
agents={agents}
total={total}
status={status}
isError={isError}
errorMessage={
rec
? asString(rec.error || rec.message)
: typeof result === "string" && isError
? result
: undefined
}
/>
);
};
const ReadTemplateRenderer: FC<ToolRendererProps> = ({
status,
result,
@@ -1033,6 +1064,7 @@ const toolRenderers: Record<string, FC<ToolRendererProps>> = {
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;
@@ -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}`;
}
})();
@@ -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<string, SubagentAction> = {
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<string, SubagentVariant> = {
@@ -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",
@@ -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 }));
};
@@ -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");
});