mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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. " +
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user