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")
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user