Files
coder/coderd/x/chatd/subagent.go
T
Michael Suchacz c17bed25e0 feat: wire chat lifecycle hooks into chatd (#27429)
Wires chat lifecycle hooks into chatd, gated by the
`agent-lifecycle-hooks` experiment. Part of the lifecycle hooks stack
(#27401, #27428, #27430). See `docs/admin/setup/chat-lifecycle-hooks.md`
for the consumer-facing contract.

## Summary

When a hook URL is configured, chatd dispatches `session_start`,
`user_prompt_submit`, `pre_tool_use`, `post_tool_use`, `pre_compact`,
`post_compact`, and `stop` events to the consumer and applies its
responses.

## Design

- **Stateless**: Coder stores no hook dispatch or decision state.
Delivery is at least once; consumers deduplicate on stable payload
identifiers (chat ID, event type, tool-use ID) and answer duplicates
with the same decision.
- **Admission-time prompt effects**: `user_prompt_submit` dispatches
exactly once per submission (create, send, queue, edit, subagent spawn)
and folds its effects into the stored prompt as typed message parts:
original-or-overridden user parts, then model-only `hook-context`, then
a user-visible `hook-notice`. Hook context is stripped from every
client-facing conversion; hook notices are excluded from model prompts.
The server rejects hook parts in client-submitted content.
- **Tool gating**: `pre_tool_use` allow can override tool input; deny
becomes a synthetic denied tool result, with any returned model context
persisted as a model-only transcript row so it never reaches clients.
The denial text identifies an external policy (the deployment's
lifecycle hook) as the source and marks the decision as persistent, so
the model explains the denial instead of retrying it or misreporting it
as an infrastructure failure.
- **Fail closed**: a dispatch failure rejects the triggering request or
moves the chat to the error state in the same transaction as the
affected step, so a runnable state is never published with unapproved
content.
- **Admission before persistence**: `pre_tool_use` is dispatched for the
calls the model produced, before the assistant message is stored. See
"Staged tool admission" below.
- **Fresh dispatch per tool call**: every non-provider-executed tool
call is decided by its own `pre_tool_use` dispatch; Coder never reuses
an earlier decision on the consumer's behalf. Retries re-dispatch the
same logical event.

## Structure

All hook dispatch flows through one seam: entry points build a
`chathooks.Chat` (chat identity) and a `chathooks.Message` (event
details) and call `Trigger.Trigger`, the only component that talks to
the dispatcher. The integration lives in the `coderd/x/chatd/chathooks`
subpackage, split by responsibility:

- `trigger.go`: the trigger seam; builds the wire envelope per event,
normalizes deny into a typed error, and holds the package's single
enabled-check.
- `effects.go`: pure conversion of hook results into transcript rows and
prompt parts.
- `errors.go`: failure classification (dispatch error messages, denial
mapping, tool-result dispatch-failure scanning).
- `tooluse.go`: the tool-call gate (`pre_tool_use` preflight,
`post_tool_use` payloads, applying admitted input to the step).

Server-bound glue stays in `coderd/x/chatd/hook_server.go`: the
chat-parking dispatch error handlers, the step-commit row insertion
wrappers, and the dynamic post-tool-use state loader, which depends on
chatd validation types.

This PR adopts the `codersdk/x/agenthooks` and
`coderd/x/agenthooks/dispatch` import paths introduced at the tip of
#27401; intermediate commits still reference the pre-move paths and are
not individually buildable.

## Staged tool admission

`pre_tool_use` originally ran at tool execution time, which is after the
assistant message carrying the tool call was already committed. An
`input_override` therefore had to rewrite stored message content in
place. @hugodutka pointed out that chatd treats message content as
immutable, and that the rewrite was a shortcut rather than a
requirement.

It was also a correctness problem in its own right: the rewrite only
updated the database, so the transcript could show one input while a
different one had executed.

The hook now runs before the step is persisted:

```text
provider stream ends (tool calls complete, in memory)
  -> pre_tool_use dispatch per call
  -> ONE transaction: assistant row with admitted inputs, synthetic denials, hook rows
  -> execute
```

The step is inserted once, carrying the input the tool runs with.
`UpdateChatMessageContentByID` and `Tx.UpdateMessageContent` are deleted
from #27428, so message content stays immutable.

Two consequences, both intentional:

- **Clients converge rather than wait.** Tool-call parts still stream
live, so a rewritten call briefly shows the model's proposed input
before the committed message replaces it. The chat store already clears
stream state when an assistant message arrives, so the stored input wins
with no frontend change and no added latency before tool cards appear.
- **A call already in history was already admitted.** Execution consumes
the stored input instead of dispatching a second decision, which keeps
one dispatch and one set of hook effects per call. A consumer policy
change between admission and execution applies to later calls, not to
calls already admitted.

The per-chat debug endpoint still records the provider's original tool
input. Its purpose is to report provider behavior, and it requires an
explicit per-chat debug flag; the invariant here covers the transcript.

## Configuration

Adds `chat-hook-url`, `chat-hook-secret`, `chat-hook-timeout`, and
`chat-hook-enabled` deployment options with startup validation. The
flags are hidden from `coder server --help` while the feature is
experimental; the setup guide documents them.

## Tool input validation

Built-in tool arguments reach a consumer as raw JSON with key spelling
preserved, but the tools decode those bytes with Go, which matches
struct fields case-insensitively and keeps the last match. A policy
reading `path` could therefore authorize one value while the tool
executed another, and a lone case variant such as `{"PATH":"/secret"}`
was invisible to a policy checking for `path`.

Coder now rejects a built-in tool call whose input repeats a key or
spells a schema property with different capitalization, before the
`pre_tool_use` dispatch, so a consumer is never asked to authorize bytes
whose meaning depends on the reader. Rejected calls produce an error
result the model can retry; unambiguous calls in the same batch still
run. A consumer-authored `input_override` is rechecked after the
dispatch and fails the turn closed, because the model cannot correct it.
Dynamic and MCP inputs are excluded because the client and the workspace
agent execute those calls rather than coderd.

Two paths needed more than a schema check. Execution resolves a
deprecated tool name to its canonical tool, so validation resolves
aliases first. The `edit_files` decoder also reads `search` and
`replace`, which its schema does not advertise, so those aliases are now
matched exactly and their case variants ignored.

A hook denial now returns a structured 403 carrying `kind:
"hook_denied"`, mirroring the dispatch-failure response that already
carries its own kind. Without it a client cannot tell a policy decision
apart from a generic failure, and the chat UI titled a denial "Request
failed". Adding a kind needs no migration: `ChatErrorKind` is persisted
only inside the JSONB `chats.last_error` column, whose decoder accepts
unknown kinds.

The hook docs also correct the tool-input convergence window. A batch
dispatches sequentially before the assistant row commits, so the
original input stays visible for a span that scales with the number of
tool calls in the step rather than a single hook timeout.

> This PR was written by Mux, an AI coding agent, on Mike's behalf.
2026-07-29 11:39:12 +00:00

1805 lines
56 KiB
Go

package chatd
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
"charm.land/fantasy"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub"
coderdpubsub "github.com/coder/coder/v2/coderd/pubsub"
"github.com/coder/coder/v2/coderd/x/agenthooks/dispatch"
"github.com/coder/coder/v2/coderd/x/chatd/chathooks"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/codersdk/x/agenthooks"
)
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(
context.Context,
uuid.UUID,
) (database.ChatModelConfig, string, error)
type modelOverrideProviderKeysResolver func(
context.Context,
uuid.UUID,
uuid.UUID,
) (chatprovider.ProviderAPIKeys, error)
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
// every computer use subagent chat. It instructs the model on how to
// interact with the desktop environment via the computer tool.
const computerUseSubagentSystemPrompt = `You are a computer use agent with access to a desktop environment. You can see the screen, move the mouse, click, type, scroll, and drag.
Your primary tool is the "computer" tool which lets you interact with the desktop. After every action you take, you will receive a screenshot showing the current state of the screen. Use these screenshots to verify your actions and plan next steps.
Guidelines:
- Always start by taking a screenshot to see the current state of the desktop.
- Use wait or ordinary actions when you only need a screenshot for your own reasoning.
- Use an explicit screenshot action when you want to share a durable screenshot with the user; those screenshots are attached to the chat automatically.
- Be precise with coordinates when clicking or typing.
- Wait for UI elements to load before interacting with them.
- If an action doesn't produce the expected result, try alternative approaches.
- Report what you accomplished when done.`
type waitAgentArgs struct {
ChatID string `json:"chat_id"`
TimeoutSeconds *int `json:"timeout_seconds,omitempty" description:"Defaults to 5 minutes."`
}
type messageAgentArgs struct {
ChatID string `json:"chat_id"`
Message string `json:"message"`
Interrupt bool `json:"interrupt,omitempty"`
}
type interruptAgentArgs struct {
ChatID string `json:"chat_id"`
}
type listAgentsArgs struct {
Limit *int `json:"limit,omitempty"`
Offset *int `json:"offset,omitempty"`
}
type listSubagentModelsArgs struct{}
func subagentModelOverrideLogLabel(
overrideContext codersdk.ChatModelOverrideContext,
) string {
switch overrideContext {
case codersdk.ChatModelOverrideContextGeneral:
return "general delegated child"
case codersdk.ChatModelOverrideContextExplore:
return "explore"
default:
return string(overrideContext)
}
}
func readSubagentModelOverride(
ctx context.Context,
db database.Store,
overrideContext codersdk.ChatModelOverrideContext,
) (string, error) {
switch overrideContext {
case codersdk.ChatModelOverrideContextGeneral:
return db.GetChatGeneralModelOverride(ctx)
case codersdk.ChatModelOverrideContextExplore:
return db.GetChatExploreModelOverride(ctx)
default:
return "", xerrors.Errorf(
"unsupported subagent model override context %q",
overrideContext,
)
}
}
func personalModelOverrideContextForSubagent(
overrideContext codersdk.ChatModelOverrideContext,
) (codersdk.ChatPersonalModelOverrideContext, error) {
switch overrideContext {
case codersdk.ChatModelOverrideContextGeneral:
return codersdk.ChatPersonalModelOverrideContextGeneral, nil
case codersdk.ChatModelOverrideContextExplore:
return codersdk.ChatPersonalModelOverrideContextExplore, nil
default:
return "", xerrors.Errorf(
"unknown subagent model override context %q",
overrideContext,
)
}
}
func userCanUseProviderKeys(
providerKeys chatprovider.ProviderAPIKeys,
providerName string,
) bool {
return providerKeys.APIKey(providerName) != "" ||
(chatprovider.ProviderAllowsAmbientCredentials(providerName) &&
providerKeys.HasProvider(providerName))
}
type modelOverrideFailureMode int
const (
modelOverrideFailureModeSoft modelOverrideFailureMode = iota
modelOverrideFailureModeHard
)
func modelOverrideErrorLabel(overrideContext string) string {
return strings.ReplaceAll(overrideContext, "_", " ")
}
// resolveConfiguredModelOverride returns ok when a usable override is
// resolved. In hard failure mode, ok is also true for configured but unusable
// overrides so callers can distinguish them from unset or malformed values.
// The normalized provider name is only meaningful for a usable override.
func (p *Server) resolveConfiguredModelOverride(
ctx context.Context,
overrideContext string,
raw string,
ownerID uuid.UUID,
resolveModelConfig modelOverrideConfigResolver,
resolveProviderKeys modelOverrideProviderKeysResolver,
failureMode modelOverrideFailureMode,
) (database.ChatModelConfig, string, *string, bool, error) {
parsed, ok := parseModelOverride(raw)
if !ok {
p.logger.Info(ctx,
"invalid model override, ignoring",
slog.F("override_context", overrideContext),
slog.F("raw_model_config_id", strings.TrimSpace(raw)),
)
return database.ChatModelConfig{}, "", nil, false, nil
}
if parsed.modelConfigID == uuid.Nil {
return database.ChatModelConfig{}, "", nil, false, nil
}
modelConfig, providerName, err := resolveModelConfig(
ctx,
parsed.modelConfigID,
)
if err != nil {
if failureMode == modelOverrideFailureModeHard {
label := modelOverrideErrorLabel(overrideContext)
switch {
case errors.Is(err, sql.ErrNoRows):
return database.ChatModelConfig{}, "", parsed.reasoningEffort, true, xerrors.Errorf(
"%s model override is unavailable: %s",
label,
parsed.modelConfigID,
)
case errors.Is(err, errInvalidModelOverrideMetadata):
return database.ChatModelConfig{}, "", parsed.reasoningEffort, true, xerrors.Errorf(
"%s model override metadata is invalid for %s: %w",
label,
parsed.modelConfigID,
err,
)
default:
return database.ChatModelConfig{}, "", parsed.reasoningEffort, true, xerrors.Errorf(
"resolve %s model override %s: %w",
label,
parsed.modelConfigID,
err,
)
}
}
switch {
case errors.Is(err, sql.ErrNoRows):
p.logger.Info(ctx,
"model override is unavailable, ignoring",
slog.F("override_context", overrideContext),
slog.F("model_config_id", parsed.modelConfigID),
)
case errors.Is(err, errInvalidModelOverrideMetadata):
p.logger.Info(ctx,
"model override metadata is invalid, ignoring",
slog.F("override_context", overrideContext),
slog.F("model_config_id", parsed.modelConfigID),
slog.Error(err),
)
default:
p.logger.Warn(ctx,
"failed to resolve model override, ignoring",
slog.F("override_context", overrideContext),
slog.F("model_config_id", parsed.modelConfigID),
slog.Error(err),
)
}
return database.ChatModelConfig{}, "", nil, false, nil
}
providerKeys, err := resolveProviderKeys(ctx, ownerID, modelConfigAIProviderID(modelConfig))
if err != nil {
return database.ChatModelConfig{}, "", nil, false, xerrors.Errorf(
"resolve provider API keys: %w",
err,
)
}
if !userCanUseProviderKeys(providerKeys, providerName) {
if failureMode == modelOverrideFailureModeHard {
return database.ChatModelConfig{}, "", parsed.reasoningEffort, true, xerrors.Errorf(
"%s model override credentials are unavailable for provider %q",
modelOverrideErrorLabel(overrideContext),
providerName,
)
}
p.logger.Info(ctx,
"model override credentials are unavailable, ignoring",
slog.F("override_context", overrideContext),
slog.F("model_config_id", parsed.modelConfigID),
slog.F("provider", providerName),
)
return database.ChatModelConfig{}, "", nil, false, nil
}
return modelConfig, providerName, parsed.reasoningEffort, true, nil
}
func (p *Server) resolvePersonalSubagentModelConfigID(
ctx context.Context,
ownerID uuid.UUID,
overrideContext codersdk.ChatModelOverrideContext,
) (uuid.UUID, *string, bool, error) {
personalContext, err := personalModelOverrideContextForSubagent(overrideContext)
if err != nil {
return uuid.Nil, nil, false, err
}
raw, err := p.db.GetUserChatPersonalModelOverride(
ctx,
database.GetUserChatPersonalModelOverrideParams{
UserID: ownerID,
Key: ChatPersonalModelOverrideKey(personalContext),
},
)
if err != nil {
if !xerrors.Is(err, sql.ErrNoRows) {
return uuid.Nil, nil, false, xerrors.Errorf(
"get %s personal model override: %w",
subagentModelOverrideLogLabel(overrideContext),
err,
)
}
raw = ""
}
parsed := ParseChatPersonalModelOverride(
raw,
codersdk.ChatPersonalModelOverrideModeDeploymentDefault,
)
if parsed.Malformed {
p.logger.Debug(ctx,
"personal model override is malformed, using deployment default",
slog.F("override_context", overrideContext),
slog.F("owner_id", ownerID),
slog.F("raw_model_config_id", strings.TrimSpace(raw)),
)
}
switch parsed.Mode {
case codersdk.ChatPersonalModelOverrideModeChatDefault:
return uuid.Nil, nil, true, nil
case codersdk.ChatPersonalModelOverrideModeDeploymentDefault:
case codersdk.ChatPersonalModelOverrideModeModel:
modelConfig, ok, err := p.resolvePersonalModelOverride(
ctx,
overrideContext,
ownerID,
parsed.ModelConfigID,
)
if err != nil {
return uuid.Nil, nil, false, err
}
if ok {
return modelConfig.ID, parsed.ReasoningEffort, true, nil
}
default:
p.logger.Warn(ctx,
"unsupported personal model override mode, using deployment default",
slog.F("override_context", overrideContext),
slog.F("owner_id", ownerID),
slog.F("mode", parsed.Mode),
)
}
return uuid.Nil, nil, false, nil
}
func (p *Server) resolvePersonalModelOverride(
ctx context.Context,
overrideContext codersdk.ChatModelOverrideContext,
ownerID uuid.UUID,
modelConfigID uuid.UUID,
) (database.ChatModelConfig, bool, error) {
modelConfig, providerName, err := p.resolveModelConfigAndNormalizedProvider(
ctx,
modelConfigID,
)
if err != nil {
switch {
case xerrors.Is(err, sql.ErrNoRows):
p.logger.Debug(ctx,
"personal model override is unavailable, using deployment default",
slog.F("override_context", overrideContext),
slog.F("owner_id", ownerID),
slog.F("model_config_id", modelConfigID),
)
case errors.Is(err, errInvalidModelOverrideMetadata):
p.logger.Debug(ctx,
"personal model override metadata is invalid, using deployment default",
slog.F("override_context", overrideContext),
slog.F("owner_id", ownerID),
slog.F("model_config_id", modelConfigID),
slog.Error(err),
)
default:
p.logger.Warn(ctx,
"failed to resolve personal model override, using deployment default",
slog.F("override_context", overrideContext),
slog.F("owner_id", ownerID),
slog.F("model_config_id", modelConfigID),
slog.Error(err),
)
}
return database.ChatModelConfig{}, false, nil
}
providerKeys, err := p.resolveUserProviderAPIKeys(ctx, ownerID, modelConfigAIProviderID(modelConfig))
if err != nil {
return database.ChatModelConfig{}, false, xerrors.Errorf(
"resolve provider API keys: %w",
err,
)
}
if !userCanUseProviderKeys(providerKeys, providerName) {
p.logger.Debug(ctx,
"personal model override credentials are unavailable, using deployment default",
slog.F("override_context", overrideContext),
slog.F("owner_id", ownerID),
slog.F("model_config_id", modelConfigID),
slog.F("provider", providerName),
)
return database.ChatModelConfig{}, false, nil
}
return modelConfig, true, nil
}
func withResolvedReasoningEffort(
modelConfig database.ChatModelConfig,
reasoningEffort *string,
) database.ChatModelConfig {
if reasoningEffort == nil {
return modelConfig
}
callConfig := codersdk.ChatModelCallConfig{}
if len(modelConfig.Options) > 0 {
if err := json.Unmarshal(modelConfig.Options, &callConfig); err != nil {
return modelConfig
}
}
resolvedEffort := chatprovider.ResolveReasoningEffort(
reasoningEffort,
callConfig.ReasoningEffort,
)
if resolvedEffort == nil {
return modelConfig
}
callConfig.ReasoningEffort = &codersdk.ChatModelReasoningEffortConfig{
Default: resolvedEffort,
Max: resolvedEffort,
}
options, err := json.Marshal(callConfig)
if err != nil {
return modelConfig
}
modelConfig.Options = options
return modelConfig
}
func (p *Server) resolveSubagentModelConfigID(
ctx context.Context,
ownerID uuid.UUID,
overrideContext codersdk.ChatModelOverrideContext,
) (uuid.UUID, *string, error) {
//nolint:gocritic // Chatd needs its scoped config and user-data access here.
chatdCtx := dbauthz.AsChatd(ctx)
personalOverridesEnabled, err := p.db.GetChatPersonalModelOverridesEnabled(chatdCtx)
if err != nil {
return uuid.Nil, nil, xerrors.Errorf(
"get chat personal model overrides enabled: %w",
err,
)
}
if personalOverridesEnabled {
modelConfigID, reasoningEffort, resolved, err := p.resolvePersonalSubagentModelConfigID(
chatdCtx,
ownerID,
overrideContext,
)
if err != nil {
return uuid.Nil, nil, err
}
if resolved {
return modelConfigID, reasoningEffort, nil
}
}
raw, err := readSubagentModelOverride(chatdCtx, p.db, overrideContext)
if err != nil {
return uuid.Nil, nil, xerrors.Errorf(
"get %s model override: %w",
subagentModelOverrideLogLabel(overrideContext),
err,
)
}
modelConfig, _, reasoningEffort, ok, err := p.resolveConfiguredModelOverride(
chatdCtx,
string(overrideContext),
raw,
ownerID,
p.resolveModelConfigAndNormalizedProvider,
p.resolveUserProviderAPIKeys,
modelOverrideFailureModeSoft,
)
if err != nil {
return uuid.Nil, nil, err
}
if !ok {
return uuid.Nil, nil, nil
}
return modelConfig.ID, reasoningEffort, nil
}
func modelConfigAIProviderID(modelConfig database.ChatModelConfig) uuid.UUID {
if !modelConfig.AIProviderID.Valid {
return uuid.Nil
}
return modelConfig.AIProviderID.UUID
}
func (p *Server) resolveModelConfigAndNormalizedProvider(
ctx context.Context,
modelConfigID uuid.UUID,
) (database.ChatModelConfig, string, error) {
if modelConfigID == uuid.Nil {
return database.ChatModelConfig{}, "", sql.ErrNoRows
}
modelConfig, err := p.configCache.ModelConfigByID(ctx, modelConfigID)
if err != nil {
return database.ChatModelConfig{}, "", err
}
if !modelConfig.Enabled {
return database.ChatModelConfig{}, "", sql.ErrNoRows
}
if modelConfig.AIProviderID.Valid {
provider, err := p.db.GetAIProviderByID(ctx, modelConfig.AIProviderID.UUID)
if err != nil {
return database.ChatModelConfig{}, "", err
}
if !provider.Enabled {
return database.ChatModelConfig{}, "", sql.ErrNoRows
}
providerName := chatprovider.NormalizeProvider(string(provider.Type))
if providerName == "" {
return database.ChatModelConfig{}, "", errInvalidModelOverrideMetadata
}
if _, _, err := chatprovider.ResolveModelWithProviderHint(modelConfig.Model, providerName); err != nil {
return database.ChatModelConfig{}, "", errInvalidModelOverrideMetadata
}
return modelConfig, providerName, nil
}
// Active configs carry a provider FK; resolved above. Missing FK means no usable config.
return database.ChatModelConfig{}, "", sql.ErrNoRows
}
func (p *Server) resolveExplicitSpawnOverrides(
ctx context.Context,
ownerID uuid.UUID,
args spawnAgentArgs,
) (*uuid.UUID, *string, error) {
var explicitModelConfigID *uuid.UUID
if raw := strings.TrimSpace(args.ModelConfigID); raw != "" {
modelConfigID, err := uuid.Parse(raw)
if err != nil {
return nil, nil, xerrors.New(
"invalid model_config_id: must be a valid UUID; use " +
listSubagentModelsToolName + " to see available models",
)
}
//nolint:gocritic // Chatd needs its scoped config and user-data access here.
chatdCtx := dbauthz.AsChatd(ctx)
modelConfig, providerName, err := p.resolveModelConfigAndNormalizedProvider(
chatdCtx,
modelConfigID,
)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, nil, xerrors.New(
"model_config_id not found or is disabled; use " +
listSubagentModelsToolName + " to see available models",
)
case errors.Is(err, errInvalidModelOverrideMetadata):
return nil, nil, xerrors.Errorf(
"model_config_id metadata is invalid: %w",
err,
)
default:
p.logger.Warn(ctx, "failed to resolve spawn_agent model_config_id",
slog.F("model_config_id", modelConfigID),
slog.Error(err),
)
return nil, nil, xerrors.New("internal error looking up model config")
}
}
providerKeys, err := p.resolveUserProviderAPIKeys(
chatdCtx,
ownerID,
modelConfigAIProviderID(modelConfig),
)
if err != nil {
p.logger.Warn(ctx, "failed to resolve provider API keys for spawn_agent model_config_id",
slog.F("model_config_id", modelConfigID),
slog.Error(err),
)
return nil, nil, xerrors.New("internal error looking up model config")
}
if !userCanUseProviderKeys(providerKeys, providerName) {
return nil, nil, xerrors.Errorf(
"model_config_id credentials are unavailable for provider %q",
providerName,
)
}
explicitModelConfigID = &modelConfig.ID
}
var explicitReasoningEffort *string
if raw := strings.TrimSpace(args.ReasoningEffort); raw != "" {
effort := strings.ToLower(raw)
if !chatprovider.IsValidReasoningEffort(effort) {
return nil, nil, xerrors.Errorf(
"invalid reasoning_effort: must be one of %s",
strings.Join(codersdk.ChatModelReasoningEffortValues(), ", "),
)
}
explicitReasoningEffort = &effort
}
return explicitModelConfigID, explicitReasoningEffort, nil
}
func (p *Server) listSpawnableModelConfigs(
ctx context.Context,
ownerID uuid.UUID,
) ([]map[string]any, error) {
//nolint:gocritic // Chatd needs its scoped config and user-data access here.
chatdCtx := dbauthz.AsChatd(ctx)
rows, err := p.db.GetEnabledChatModelConfigs(chatdCtx)
if err != nil {
return nil, xerrors.Errorf("get enabled chat model configs: %w", err)
}
models := make([]map[string]any, 0, len(rows))
providerKeysByID := make(map[uuid.UUID]chatprovider.ProviderAPIKeys)
for _, row := range rows {
providerName := chatprovider.NormalizeProvider(row.Provider)
if providerName == "" {
continue
}
if _, _, err := chatprovider.ResolveModelWithProviderHint(
row.ChatModelConfig.Model,
providerName,
); err != nil {
continue
}
providerID := modelConfigAIProviderID(row.ChatModelConfig)
providerKeys, ok := providerKeysByID[providerID]
if !ok {
providerKeys, err = p.resolveUserProviderAPIKeys(
chatdCtx,
ownerID,
providerID,
)
if err != nil {
return nil, xerrors.Errorf("resolve provider API keys: %w", err)
}
providerKeysByID[providerID] = providerKeys
}
if !userCanUseProviderKeys(providerKeys, providerName) {
continue
}
entry := map[string]any{
"model_config_id": row.ChatModelConfig.ID.String(),
"display_name": row.ChatModelConfig.DisplayName,
"model": row.ChatModelConfig.Model,
"provider": providerName,
"context_limit": row.ChatModelConfig.ContextLimit,
"is_default": row.ChatModelConfig.IsDefault,
}
callConfig := codersdk.ChatModelCallConfig{}
if len(row.ChatModelConfig.Options) > 0 {
if err := json.Unmarshal(row.ChatModelConfig.Options, &callConfig); err == nil {
if efforts := chatprovider.SelectableReasoningEfforts(callConfig.ReasoningEffort); len(efforts) > 0 {
entry["reasoning_efforts"] = efforts
}
}
}
models = append(models, entry)
}
return models, nil
}
func (p *Server) subagentTools(
ctx context.Context,
currentChat func() database.Chat,
currentModelConfigID uuid.UUID,
) []fantasy.AgentTool {
currentChatSnapshot := database.Chat{}
if currentChat != nil {
currentChatSnapshot = currentChat()
}
spawnAgentDescription := buildSpawnAgentDescription(
ctx,
p,
currentChatSnapshot,
)
return []fantasy.AgentTool{
fantasy.NewAgentTool(
spawnAgentToolName,
spawnAgentDescription,
func(ctx context.Context, args spawnAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
if currentChat == nil {
return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil
}
parent, err := p.loadSubagentSpawnParentChat(ctx, currentChat)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
definition, err := resolveSubagentDefinition(
ctx,
p,
parent,
args.Type,
)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
if definition.id == subagentTypeComputerUse &&
(strings.TrimSpace(args.ModelConfigID) != "" ||
strings.TrimSpace(args.ReasoningEffort) != "") {
return fantasy.NewTextErrorResponse(
`model_config_id and reasoning_effort are not supported for type "` +
subagentTypeComputerUse + `"`,
), nil
}
explicitModelConfigID, explicitReasoningEffort, err := p.resolveExplicitSpawnOverrides(
ctx,
parent.OwnerID,
args,
)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
turnParent := currentChatSnapshot
if turnParent.ID == uuid.Nil {
turnParent = parent
}
options, err := definition.buildOptions(
ctx,
p,
parent,
turnParent,
currentModelConfigID,
explicitModelConfigID,
args.Prompt,
)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
if explicitReasoningEffort != nil {
options.reasoningEffortOverride = explicitReasoningEffort
}
childChat, err := p.createChildSubagentChatWithOptions(
ctx,
parent,
args.Prompt,
args.Title,
options,
)
if err != nil {
// A failed hook dispatch must fail closed instead of
// degrading into a tool error the model can ignore.
if _, ok := errors.AsType[*dispatch.Error](err); ok {
return fantasy.ToolResponse{}, err
}
// chathooks.UserPromptDeniedError.Error() carries the user-facing
// denial message, so the model can adjust its prompt.
return fantasy.NewTextErrorResponse(err.Error()), nil
}
return toolJSONResponse(withSubagentType(map[string]any{
"chat_id": childChat.ID.String(),
"title": childChat.Title,
"status": string(childChat.Status),
}, childChat)), nil
},
),
fantasy.NewAgentTool(
listSubagentModelsToolName,
"List the enabled model configurations available for "+
spawnAgentToolName+"'s model_config_id argument. Only models "+
"usable with the chat owner's credentials are returned. Each "+
"entry includes model_config_id, display_name, model, "+
"provider, context_limit, is_default, and reasoning_efforts "+
"(the values accepted by "+spawnAgentToolName+"'s "+
"reasoning_effort for that model).",
func(ctx context.Context, _ listSubagentModelsArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
if currentChat == nil {
return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil
}
parent, err := p.loadSubagentSpawnParentChat(ctx, currentChat)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
models, err := p.listSpawnableModelConfigs(ctx, parent.OwnerID)
if err != nil {
p.logger.Warn(ctx, "failed to list spawnable model configs",
slog.F("chat_id", parent.ID),
slog.Error(err),
)
return fantasy.NewTextErrorResponse("internal error listing model configs"), nil
}
return toolJSONResponse(map[string]any{
"models": models,
}), nil
},
),
fantasy.NewAgentTool(
"wait_agent",
"Wait for a spawned child agent to finish and return its response "+
"and status. Returns immediately when the agent finishes, even if "+
"a longer timeout is set. A timeout does not stop the agent; 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
}
targetChatID, err := parseSubagentToolChatID(args.ChatID)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
timeout := defaultSubagentWaitTimeout
if args.TimeoutSeconds != nil {
timeout = time.Duration(*args.TimeoutSeconds) * time.Second
}
parent := currentChat()
var targetChatInfo *database.Chat
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 recording",
slog.F("chat_id", targetChatID),
slog.Error(lookupErr),
)
}
// Authorize: the target chat must be a descendant
// of the current (parent) chat.
isDescendant, descErr := isSubagentDescendant(ctx, p.db, parent.ID, targetChatID)
if descErr != nil {
return subagentErrorResponse(
xerrors.New(fmt.Sprintf("failed to verify subagent relationship: %v", descErr)),
targetChatInfo,
), nil
}
if !isDescendant {
return subagentErrorResponse(
ErrSubagentNotDescendant,
targetChatInfo,
), nil
}
// Check if the target is a computer_use subagent
// and start a desktop recording. Failures are
// best-effort warnings. Recording never blocks
// the wait_agent flow.
var recordingID string
var agentConn workspacesdk.AgentConn
isComputerUseChat := targetChatInfo != nil &&
targetChatInfo.Mode.Valid &&
targetChatInfo.Mode.ChatMode == database.ChatModeComputerUse &&
targetChatInfo.AgentID.Valid
canRecord := isComputerUseChat && p.agentConnFn != nil
if canRecord {
conn, closeFn, connErr := p.agentConnFn(ctx, targetChatInfo.AgentID.UUID)
if connErr == nil {
agentConn = conn
defer closeFn()
recordingID = targetChatID.String()
startErr := conn.StartDesktopRecording(ctx,
workspacesdk.StartDesktopRecordingRequest{RecordingID: recordingID})
if startErr != nil {
p.logger.Warn(ctx, "failed to start desktop recording",
slog.Error(startErr))
recordingID = ""
}
} else {
p.logger.Warn(ctx, "failed to get agent conn for recording",
slog.Error(connErr))
}
}
targetChat, report, awaitErr := p.awaitSubagentCompletion(
ctx, parent.ID, targetChatID, timeout,
)
// 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
decoded, lastError := subagentLastError(errChat.LastError)
if lastError == "" {
lastError = errStatus.reason
}
payload := map[string]any{
"chat_id": errChat.ID.String(),
"title": errChat.Title,
"status": string(errChat.Status),
"last_error": lastError,
"report": errStatus.report,
}
if decoded != nil {
kind := decoded.Kind
if kind == "" {
kind = codersdk.ChatErrorKindGeneric
}
payload["last_error_kind"] = string(kind)
payload["last_error_retryable"] = decoded.Retryable
if decoded.Detail != "" {
payload["last_error_detail"] = decoded.Detail
}
}
return toolJSONResponse(withSubagentType(payload, errChat)), nil
}
return subagentErrorResponse(awaitErr, targetChatInfo), nil
}
// Only stop and store the recording on success.
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. 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
}
targetChatID, err := parseSubagentToolChatID(args.ChatID)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
parent := currentChat()
var targetChatInfo *database.Chat
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 message",
slog.F("chat_id", targetChatID),
slog.Error(lookupErr),
)
}
busyBehavior := SendMessageBusyBehaviorQueue
if args.Interrupt {
busyBehavior = SendMessageBusyBehaviorInterrupt
}
targetChat, err := p.sendSubagentMessage(
ctx,
parent.ID,
targetChatID,
args.Message,
busyBehavior,
)
if err != nil {
return subagentErrorResponse(err, targetChatInfo), nil
}
interrupted := false
if args.Interrupt && targetChatInfo != nil {
interrupted = targetChatInfo.Status == database.ChatStatusRunning
}
return toolJSONResponse(withSubagentType(map[string]any{
"chat_id": targetChat.ID.String(),
"title": targetChat.Title,
"status": string(targetChat.Status),
"interrupted": interrupted,
}, targetChat)), nil
},
),
fantasy.NewAgentTool(
"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
}
targetChatID, err := parseSubagentToolChatID(args.ChatID)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
parent := currentChat()
var targetChatInfo *database.Chat
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 interrupt",
slog.F("chat_id", targetChatID),
slog.Error(lookupErr),
)
}
targetChat, interrupted, err := p.interruptSubagent(
ctx,
parent.ID,
targetChatID,
)
if err != nil {
return subagentErrorResponse(err, targetChatInfo), nil
}
return toolJSONResponse(withSubagentType(map[string]any{
"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: running = working, "+
"interrupting = transient, waiting = 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
},
),
}
}
func (p *Server) loadSubagentSpawnParentChat(
ctx context.Context,
currentChat func() database.Chat,
) (database.Chat, error) {
parent := currentChat()
if err := validateSubagentSpawnParent(parent); err != nil {
return database.Chat{}, err
}
reloadedParent, err := p.db.GetChatByID(ctx, parent.ID)
if err != nil {
p.logger.Warn(ctx, "failed to load parent chat for spawn_agent",
slog.F("chat_id", parent.ID),
slog.Error(err),
)
return database.Chat{}, xerrors.New("failed to load parent chat")
}
parent = reloadedParent
if err := validateSubagentSpawnParent(parent); err != nil {
return database.Chat{}, err
}
return parent, nil
}
func parseSubagentToolChatID(raw string) (uuid.UUID, error) {
chatID, err := uuid.Parse(strings.TrimSpace(raw))
if err != nil {
return uuid.Nil, xerrors.New("chat_id must be a valid UUID")
}
return chatID, nil
}
// childSubagentChatOptions carries per-child overrides for subagent chat
// creation. modelConfigIDOverride, reasoningEffortOverride, and
// planModeOverride apply to any subagent. inheritedMCPServerIDs is an
// Explore-only snapshot of the spawning parent turn's effective external MCP
// entitlement. resolveExploreToolSnapshot computes and persists it on the
// child chat. Non-Explore children ignore this field.
type childSubagentChatOptions struct {
chatMode database.NullChatMode
systemPrompt string
modelConfigIDOverride *uuid.UUID
reasoningEffortOverride *string
planModeOverride *database.NullChatPlanMode
inheritedMCPServerIDs []uuid.UUID
}
// resolveExploreToolSnapshot computes the child chat's inherited MCP
// server snapshot from the spawning parent turn.
//
// The MCP set is filtered in two stages. First,
// filterExternalMCPConfigsForTurn applies the parent turn's plan-mode
// policy to the parent's MCP configs, producing visibleConfigs. Second,
// if the parent is itself an Explore child, the visible set is narrowed to
// the parent's persisted MCPServerIDs so an Explore chain cannot
// re-escalate beyond the original grant. Non-Explore parents pass
// through the second stage unchanged.
func (p *Server) resolveExploreToolSnapshot(
ctx context.Context,
parent database.Chat,
) ([]uuid.UUID, error) {
inheritedMCPServerIDs := []uuid.UUID{}
if len(parent.MCPServerIDs) > 0 {
configs, err := p.db.GetMCPServerConfigsByIDs(ctx, parent.MCPServerIDs)
if err != nil {
return nil, xerrors.Errorf("get parent MCP server configs for chat %s: %w", parent.ID, err)
}
visibleConfigs, _ := filterExternalMCPConfigsForTurn(
configs,
parent.PlanMode,
parent.ParentChatID,
)
// Empty means the parent is not Explore, so all plan-filtered
// configs remain eligible. Populated means the parent is
// Explore, so only its persisted snapshot can pass.
allowedParentIDs := map[uuid.UUID]struct{}{}
if isExploreSubagentMode(parent.Mode) {
for _, id := range parent.MCPServerIDs {
allowedParentIDs[id] = struct{}{}
}
}
for _, cfg := range visibleConfigs {
if len(allowedParentIDs) > 0 {
if _, ok := allowedParentIDs[cfg.ID]; !ok {
continue
}
}
inheritedMCPServerIDs = append(inheritedMCPServerIDs, cfg.ID)
}
}
return inheritedMCPServerIDs, nil
}
func (p *Server) createChildSubagentChatWithOptions(
ctx context.Context,
parent database.Chat,
prompt string,
title string,
opts childSubagentChatOptions,
) (database.Chat, error) {
if parent.ParentChatID.Valid {
return database.Chat{}, xerrors.New("delegated chats cannot create child subagents")
}
prompt = strings.TrimSpace(prompt)
if prompt == "" {
return database.Chat{}, xerrors.New("prompt is required")
}
title = strings.TrimSpace(title)
rootChatID := parent.ID
if parent.RootChatID.Valid {
rootChatID = parent.RootChatID.UUID
}
modelConfigID := parent.LastModelConfigID
if opts.modelConfigIDOverride != nil {
modelConfigID = *opts.modelConfigIDOverride
}
if modelConfigID == uuid.Nil {
return database.Chat{}, xerrors.New("model config is required")
}
childPlanMode := parent.PlanMode
if opts.planModeOverride != nil {
childPlanMode = *opts.planModeOverride
}
mcpServerIDs := parent.MCPServerIDs
if isExploreSubagentMode(opts.chatMode) {
mcpServerIDs = slices.Clone(opts.inheritedMCPServerIDs)
}
if mcpServerIDs == nil {
mcpServerIDs = []uuid.UUID{}
}
labelsJSON, err := json.Marshal(database.StringMap{})
if err != nil {
return database.Chat{}, xerrors.Errorf("marshal labels: %w", err)
}
childSystemPrompt := SanitizePromptText(opts.systemPrompt)
// Resolve the deployment prompt before opening the transaction so
// 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
}
// Review before persistence so spawned chats cannot bypass prompt policy.
childChatID := uuid.New()
var promptResult *chathooks.Result
if p.hooks.Enabled() {
mintedTurnID := uuid.New()
promptMessage, err := chathooks.UserPromptMessage([]codersdk.ChatMessagePart{codersdk.ChatMessageText(prompt)})
if err != nil {
return database.Chat{}, err
}
promptResult, err = p.hooks.Trigger(ctx, chathooks.Chat{
ID: childChatID,
OwnerID: parent.OwnerID,
WorkspaceID: parent.WorkspaceID,
ParentChatID: uuid.NullUUID{UUID: parent.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: rootChatID, Valid: true},
TurnID: &mintedTurnID,
}, promptMessage, agenthooks.EventUserPromptSubmit)
if err != nil {
return database.Chat{}, chathooks.UserPromptDenial(err)
}
override, overridden, overrideErr := chathooks.UserPromptOverride(promptResult)
if overrideErr != nil {
return database.Chat{}, overrideErr
}
if overridden {
// The overridden prompt also feeds the fallback title below.
prompt = override
}
}
if title == "" {
title = subagentFallbackChatTitle(prompt)
}
workspaceAwareness := workspaceDetachedNoCreateAwareness
if parent.WorkspaceID.Valid {
workspaceAwareness = workspaceAttachedAwareness
}
workspaceAwarenessContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
codersdk.ChatMessageText(workspaceAwareness),
})
if err != nil {
return database.Chat{}, xerrors.Errorf("marshal workspace awareness: %w", err)
}
childUserParts := []codersdk.ChatMessagePart{codersdk.ChatMessageText(prompt)}
childUserParts = append(childUserParts, chathooks.UserPromptParts(promptResult)...)
userContent, err := chatprompt.MarshalParts(childUserParts)
if err != nil {
return database.Chat{}, xerrors.Errorf("marshal initial user content: %w", err)
}
initialMessages := make([]chatstate.Message, 0, 4)
if deploymentPrompt != "" {
deploymentContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
codersdk.ChatMessageText(deploymentPrompt),
})
if err != nil {
return database.Chat{}, xerrors.Errorf("marshal deployment system prompt: %w", err)
}
initialMessages = append(initialMessages, systemMessage(deploymentContent, modelConfigID))
}
if childSystemPrompt != "" {
childSystemPromptContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
codersdk.ChatMessageText(childSystemPrompt),
})
if err != nil {
return database.Chat{}, xerrors.Errorf("marshal child system prompt: %w", err)
}
initialMessages = append(initialMessages, systemMessage(childSystemPromptContent, modelConfigID))
}
initialMessages = append(initialMessages, systemMessage(workspaceAwarenessContent, modelConfigID))
// The child shares the parent's workspace and agent, so it inherits
// workspace context the same way a top-level chat does: pinned from the
// agent's latest snapshot (see hydrateChatContextOnCreate below). The
// parent's context is not copied into child history.
initialMessages = append(initialMessages, userMessage(userContent, modelConfigID, parent.OwnerID, opts.reasoningEffortOverride))
publisher := p.pubsub
if publisher == nil {
publisher = dbpubsub.NewInMemory()
}
result, err := chatstate.CreateChatWithID(ctx, p.db, publisher, childChatID, chatstate.CreateChatInput{
OrganizationID: parent.OrganizationID,
OwnerID: parent.OwnerID,
WorkspaceID: parent.WorkspaceID,
BuildID: parent.BuildID,
AgentID: parent.AgentID,
ParentChatID: uuid.NullUUID{UUID: parent.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: rootChatID, Valid: true},
LastModelConfigID: modelConfigID,
Title: title,
Mode: opts.chatMode,
PlanMode: childPlanMode,
MCPServerIDs: mcpServerIDs,
Labels: pqtype.NullRawMessage{
RawMessage: labelsJSON,
Valid: true,
},
DynamicTools: pqtype.NullRawMessage{},
ClientType: parent.ClientType,
InitialMessages: initialMessages,
})
if err != nil {
return database.Chat{}, xerrors.Errorf("create child chat: %w", err)
}
child := result.Chat
// Pin the child to its agent's latest context snapshot, mirroring the
// top-level create path. The child shares the parent's workspace agent,
// so this reproduces the parent's workspace context without copying it
// through chat history.
p.hydrateChatContextOnCreate(ctx, child)
p.publishChatPubsubEvent(child, codersdk.ChatWatchEventKindCreated, nil)
return child, nil
}
func (p *Server) sendSubagentMessage(
ctx context.Context,
parentChatID uuid.UUID,
targetChatID uuid.UUID,
message string,
busyBehavior SendMessageBusyBehavior,
) (database.Chat, error) {
message = strings.TrimSpace(message)
if message == "" {
return database.Chat{}, xerrors.New("message is required")
}
isDescendant, err := isSubagentDescendant(ctx, p.db, parentChatID, targetChatID)
if err != nil {
return database.Chat{}, err
}
if !isDescendant {
return database.Chat{}, ErrSubagentNotDescendant
}
// Look up the target chat to get the owner for CreatedBy.
targetChat, err := p.db.GetChatByID(ctx, targetChatID)
if err != nil {
return database.Chat{}, xerrors.Errorf("get target chat: %w", err)
}
sendResult, err := p.SendMessage(ctx, SendMessageOptions{
ChatID: targetChatID,
CreatedBy: targetChat.OwnerID,
Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText(message)},
BusyBehavior: busyBehavior,
})
if err != nil {
return database.Chat{}, err
}
return sendResult.Chat, nil
}
func (p *Server) awaitSubagentCompletion(
ctx context.Context,
parentChatID uuid.UUID,
targetChatID uuid.UUID,
timeout time.Duration,
) (database.Chat, string, error) {
isDescendant, err := isSubagentDescendant(ctx, p.db, parentChatID, targetChatID)
if err != nil {
return database.Chat{}, "", err
}
if !isDescendant {
return database.Chat{}, "", ErrSubagentNotDescendant
}
// Check immediately before entering the poll loop.
targetChat, report, done, checkErr := p.checkSubagentCompletion(ctx, targetChatID)
if checkErr != nil {
return database.Chat{}, "", checkErr
}
if done {
return handleSubagentDone(targetChat, report)
}
if timeout <= 0 {
timeout = defaultSubagentWaitTimeout
}
timer := p.clock.NewTimer(timeout, "chatd", "subagent_await")
defer timer.Stop()
// Subscribe for fast status notifications and use a less
// aggressive fallback poll. If subscription fails, fall back to
// the original 200ms polling.
pollInterval := subagentAwaitFallbackPoll
ch := make(chan struct{}, 1)
notifyCh := (<-chan struct{})(ch)
cancel, subErr := p.pubsub.SubscribeWithErr(
coderdpubsub.ChatStateUpdateChannel(targetChatID),
func(_ context.Context, _ []byte, _ error) {
// Non-blocking send so we never stall the
// pubsub dispatch goroutine.
select {
case ch <- struct{}{}:
default:
}
},
)
if subErr == nil {
defer cancel()
} else {
// Subscription failed; fall back to fast polling.
pollInterval = subagentAwaitPollInterval
notifyCh = nil
}
ticker := p.clock.NewTicker(pollInterval, "chatd", "subagent_poll")
defer ticker.Stop()
for {
select {
case <-notifyCh:
case <-ticker.C:
case <-timer.C:
return database.Chat{}, "", ErrSubagentWaitTimeout
case <-ctx.Done():
return database.Chat{}, "", ctx.Err()
}
targetChat, report, done, checkErr = p.checkSubagentCompletion(ctx, targetChatID)
if checkErr != nil {
return database.Chat{}, "", checkErr
}
if done {
return handleSubagentDone(targetChat, report)
}
}
}
// handleSubagentDone translates a completed subagent check into the
// 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,
) (database.Chat, string, error) {
if chat.Status == database.ChatStatusError {
reason := strings.TrimSpace(report)
if reason == "" {
reason = "agent reached error status"
}
return database.Chat{}, "", &subagentStatusError{
chat: chat,
report: report,
reason: reason,
}
}
return chat, report, nil
}
// subagentGenericErrorMessage matches the normalized fallback that
// chaterror and db2sdk emit for unclassifiable failures. It carries no
// actionable information, so a provider detail replaces it entirely.
const subagentGenericErrorMessage = "The chat request failed unexpectedly."
// subagentLastError decodes a chat's last_error payload and builds the
// message surfaced to the parent model, preferring the actionable
// provider detail. The content mirrors what the chat UI renders from
// the same payload. An unrecognized payload yields an empty message so
// the caller falls back to its own status reason instead of exposing
// raw stored bytes.
func subagentLastError(raw pqtype.NullRawMessage) (*codersdk.ChatError, string) {
if !raw.Valid {
return nil, ""
}
var payload codersdk.ChatError
if err := json.Unmarshal(raw.RawMessage, &payload); err != nil {
return nil, ""
}
switch {
case payload.Message == "" && payload.Detail == "":
return nil, ""
case payload.Detail == "":
return &payload, payload.Message
case payload.Message == "" || payload.Message == subagentGenericErrorMessage:
return &payload, payload.Detail
default:
return &payload, payload.Message + " (" + payload.Detail + ")"
}
}
// 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, bool, error) {
isDescendant, err := isSubagentDescendant(ctx, p.db, parentChatID, targetChatID)
if err != nil {
return database.Chat{}, false, err
}
if !isDescendant {
return database.Chat{}, false, ErrSubagentNotDescendant
}
targetChat, err := p.db.GetChatByID(ctx, targetChatID)
if err != nil {
return database.Chat{}, false, xerrors.Errorf("get target chat: %w", err)
}
if targetChat.Status == database.ChatStatusWaiting {
return targetChat, false, nil
}
updatedChat, err := p.InterruptChat(ctx, targetChat)
if err != nil {
// Idle / archived chats no longer satisfy the
// chatstate.Interrupt precondition. Surface the error
// so the caller can decide whether the parent expected
// the subagent to already be waiting.
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, true, nil
}
func (p *Server) checkSubagentCompletion(
ctx context.Context,
chatID uuid.UUID,
) (database.Chat, string, bool, error) {
chat, err := p.db.GetChatByID(ctx, chatID)
if err != nil {
return database.Chat{}, "", false, xerrors.Errorf("get chat: %w", err)
}
// 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.ChatStatusRunning ||
chat.Status == database.ChatStatusInterrupting {
return chat, "", false, nil
}
report, err := latestSubagentAssistantMessage(ctx, p.db, chatID)
if err != nil {
return database.Chat{}, "", false, err
}
return chat, report, true, nil
}
func latestSubagentAssistantMessage(
ctx context.Context,
store database.Store,
chatID uuid.UUID,
) (string, error) {
messages, err := store.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{
ChatID: chatID,
AfterID: 0,
})
if err != nil {
return "", xerrors.Errorf("get chat messages: %w", err)
}
for i := len(messages) - 1; i >= 0; i-- {
message := messages[i]
if message.Role != database.ChatMessageRoleAssistant ||
message.Visibility == database.ChatMessageVisibilityModel {
continue
}
content, parseErr := chatprompt.ParseContent(message)
if parseErr != nil {
continue
}
text := strings.TrimSpace(contentBlocksToText(content))
if text == "" {
continue
}
return text, nil
}
return "", nil
}
// isSubagentDescendant reports whether targetChatID is a descendant
// of ancestorChatID by walking up the parent chain from the target.
// This is O(depth) DB queries instead of O(nodes) BFS.
func isSubagentDescendant(
ctx context.Context,
store database.Store,
ancestorChatID uuid.UUID,
targetChatID uuid.UUID,
) (bool, error) {
if ancestorChatID == targetChatID {
return false, nil
}
currentID := targetChatID
visited := map[uuid.UUID]struct{}{} // cycle protection
for {
if _, seen := visited[currentID]; seen {
return false, nil
}
visited[currentID] = struct{}{}
chat, err := store.GetChatByID(ctx, currentID)
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
return false, nil // chain broken; not a confirmed descendant
}
return false, xerrors.Errorf("get chat %s: %w", currentID, err)
}
if !chat.ParentChatID.Valid {
return false, nil // reached root without finding ancestor
}
if chat.ParentChatID.UUID == ancestorChatID {
return true, nil
}
currentID = chat.ParentChatID.UUID
}
}
func subagentFallbackChatTitle(message string) string {
const maxWords = 6
const maxRunes = 80
words := strings.Fields(message)
if len(words) == 0 {
return "New Chat"
}
truncated := false
if len(words) > maxWords {
words = words[:maxWords]
truncated = true
}
title := strings.Join(words, " ")
if truncated {
title += "..."
}
return subagentTruncateRunes(title, maxRunes)
}
func subagentTruncateRunes(value string, maxRunes int) string {
if maxRunes <= 0 {
return ""
}
runes := []rune(value)
if len(runes) <= maxRunes {
return value
}
return string(runes[:maxRunes])
}
func toolJSONResponse(result map[string]any) fantasy.ToolResponse {
data, err := json.Marshal(result)
if err != nil {
return fantasy.NewTextResponse("{}")
}
return fantasy.NewTextResponse(string(data))
}
func toolJSONErrorResponse(result map[string]any) fantasy.ToolResponse {
resp := toolJSONResponse(result)
resp.IsError = true
return resp
}