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")
})
}