Files
coder/coderd/chatd/subagent.go
T
Kyle Carberry bb59477648 feat(db): add created_by column to chat_messages table (#22940)
Adds a `created_by` column (nullable UUID) to the `chat_messages` table
to track which user created each message. Only user-sent messages
populate this field; assistant, tool, system, and summary messages leave
it null.

The column is threaded through the full stack: SQL migration, query
updates, generated Go/TypeScript types, db2sdk conversion, chatd
(including subagent paths), and API handlers. All API handlers that
insert user messages now pass the authenticated user's ID as
`created_by`.

No foreign key constraint was added, matching the existing pattern used
by `chat_model_configs.created_by`.
2026-03-11 10:00:38 -04:00

547 lines
14 KiB
Go

package chatd
import (
"context"
"encoding/json"
"sort"
"strings"
"time"
"charm.land/fantasy"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/database"
)
var ErrSubagentNotDescendant = xerrors.New("target chat is not a descendant of current chat")
const (
subagentAwaitPollInterval = 200 * time.Millisecond
defaultSubagentWaitTimeout = 5 * time.Minute
)
type spawnAgentArgs struct {
Prompt string `json:"prompt"`
Title string `json:"title,omitempty"`
}
type waitAgentArgs struct {
ChatID string `json:"chat_id"`
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
}
type messageAgentArgs struct {
ChatID string `json:"chat_id"`
Message string `json:"message"`
Interrupt bool `json:"interrupt,omitempty"`
}
type closeAgentArgs struct {
ChatID string `json:"chat_id"`
}
func (p *Server) subagentTools(currentChat func() database.Chat) []fantasy.AgentTool {
return []fantasy.AgentTool{
fantasy.NewAgentTool(
"spawn_agent",
"Spawn a delegated child agent to work on a clearly scoped, "+
"independent task in parallel. Use this when the task is "+
"self-contained and would benefit from a separate agent "+
"(e.g. fixing a specific bug, writing a single module, "+
"running a migration). Do NOT use for simple or quick "+
"operations you can handle directly with execute, "+
"read_file, or write_file - for example, reading a group "+
"of files and outputting them verbatim does not need a "+
"subagent. Reserve subagents for tasks that require "+
"intellectual work such as code analysis, writing new "+
"code, or complex refactoring. Be careful when running "+
"parallel subagents: if two 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.",
func(ctx context.Context, args spawnAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
if currentChat == nil {
return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil
}
parent := currentChat()
if parent.ParentChatID.Valid {
return fantasy.NewTextErrorResponse("delegated chats cannot create child subagents"), nil
}
parent, err := p.db.GetChatByID(ctx, parent.ID)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
childChat, err := p.createChildSubagentChat(
ctx,
parent,
args.Prompt,
args.Title,
)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
return toolJSONResponse(map[string]any{
"chat_id": childChat.ID.String(),
"title": childChat.Title,
"status": string(childChat.Status),
}), nil
},
),
fantasy.NewAgentTool(
"wait_agent",
"Wait until a spawned child agent finishes its task. "+
"Returns the agent's final response and status. "+
"Call this after spawn_agent to collect the result "+
"before continuing your own work.",
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()
targetChat, report, err := p.awaitSubagentCompletion(
ctx,
parent.ID,
targetChatID,
timeout,
)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
return toolJSONResponse(map[string]any{
"chat_id": targetChatID.String(),
"title": targetChat.Title,
"report": report,
"status": string(targetChat.Status),
}), 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.",
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()
busyBehavior := SendMessageBusyBehaviorQueue
if args.Interrupt {
busyBehavior = SendMessageBusyBehaviorInterrupt
}
targetChat, err := p.sendSubagentMessage(
ctx,
parent.ID,
targetChatID,
args.Message,
busyBehavior,
)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
return toolJSONResponse(map[string]any{
"chat_id": targetChatID.String(),
"title": targetChat.Title,
"status": string(targetChat.Status),
"interrupted": args.Interrupt,
}), 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) {
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()
targetChat, err := p.closeSubagent(
ctx,
parent.ID,
targetChatID,
)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
return toolJSONResponse(map[string]any{
"chat_id": targetChatID.String(),
"title": targetChat.Title,
"terminated": true,
"status": string(targetChat.Status),
}), 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
}
func (p *Server) createChildSubagentChat(
ctx context.Context,
parent database.Chat,
prompt string,
title string,
) (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)
if title == "" {
title = subagentFallbackChatTitle(prompt)
}
rootChatID := parent.ID
if parent.RootChatID.Valid {
rootChatID = parent.RootChatID.UUID
}
if parent.LastModelConfigID == uuid.Nil {
return database.Chat{}, xerrors.New("parent chat model config id is required")
}
child, err := p.CreateChat(ctx, CreateOptions{
OwnerID: parent.OwnerID,
WorkspaceID: parent.WorkspaceID,
ParentChatID: uuid.NullUUID{
UUID: parent.ID,
Valid: true,
},
RootChatID: uuid.NullUUID{
UUID: rootChatID,
Valid: true,
},
ModelConfigID: parent.LastModelConfigID,
Title: title,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: prompt}},
})
if err != nil {
return database.Chat{}, xerrors.Errorf("create child chat: %w", err)
}
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: []fantasy.Content{fantasy.TextContent{Text: 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
}
if timeout <= 0 {
timeout = defaultSubagentWaitTimeout
}
timer := time.NewTimer(timeout)
defer timer.Stop()
ticker := time.NewTicker(subagentAwaitPollInterval)
defer ticker.Stop()
for {
targetChat, report, done, checkErr := p.checkSubagentCompletion(ctx, targetChatID)
if checkErr != nil {
return database.Chat{}, "", checkErr
}
if done {
if targetChat.Status == database.ChatStatusError {
reason := strings.TrimSpace(report)
if reason == "" {
reason = "agent reached error status"
}
return database.Chat{}, "", xerrors.New(reason)
}
return targetChat, report, nil
}
select {
case <-ticker.C:
case <-timer.C:
return database.Chat{}, "", xerrors.New("timed out waiting for delegated subagent completion")
case <-ctx.Done():
return database.Chat{}, "", ctx.Err()
}
}
}
func (p *Server) closeSubagent(
ctx context.Context,
parentChatID uuid.UUID,
targetChatID uuid.UUID,
) (database.Chat, error) {
isDescendant, err := isSubagentDescendant(ctx, p.db, parentChatID, targetChatID)
if err != nil {
return database.Chat{}, err
}
if !isDescendant {
return database.Chat{}, ErrSubagentNotDescendant
}
targetChat, err := p.db.GetChatByID(ctx, targetChatID)
if err != nil {
return database.Chat{}, xerrors.Errorf("get target chat: %w", err)
}
if targetChat.Status == database.ChatStatusWaiting {
return targetChat, nil
}
updatedChat := p.InterruptChat(ctx, targetChat)
if updatedChat.Status != database.ChatStatusWaiting {
return database.Chat{}, xerrors.New("set target chat waiting")
}
return updatedChat, 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)
}
if chat.Status == database.ChatStatusPending || chat.Status == database.ChatStatusRunning {
return database.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)
}
sort.Slice(messages, func(i, j int) bool {
if messages[i].CreatedAt.Equal(messages[j].CreatedAt) {
return messages[i].ID < messages[j].ID
}
return messages[i].CreatedAt.Before(messages[j].CreatedAt)
})
for i := len(messages) - 1; i >= 0; i-- {
message := messages[i]
if message.Role != string(fantasy.MessageRoleAssistant) ||
message.Visibility == database.ChatMessageVisibilityModel {
continue
}
content, parseErr := chatprompt.ParseContent(message.Role, message.Content)
if parseErr != nil {
continue
}
text := strings.TrimSpace(contentBlocksToText(content))
if text == "" {
continue
}
return text, nil
}
return "", nil
}
func isSubagentDescendant(
ctx context.Context,
store database.Store,
ancestorChatID uuid.UUID,
targetChatID uuid.UUID,
) (bool, error) {
if ancestorChatID == targetChatID {
return false, nil
}
descendants, err := listSubagentDescendants(ctx, store, ancestorChatID)
if err != nil {
return false, err
}
for _, descendant := range descendants {
if descendant.ID == targetChatID {
return true, nil
}
}
return false, nil
}
func listSubagentDescendants(
ctx context.Context,
store database.Store,
chatID uuid.UUID,
) ([]database.Chat, error) {
queue := []uuid.UUID{chatID}
visited := map[uuid.UUID]struct{}{chatID: {}}
out := make([]database.Chat, 0)
for len(queue) > 0 {
parentChatID := queue[0]
queue = queue[1:]
children, err := store.ListChildChatsByParentID(ctx, parentChatID)
if err != nil {
return nil, xerrors.Errorf("list child chats for %s: %w", parentChatID, err)
}
for _, child := range children {
if _, ok := visited[child.ID]; ok {
continue
}
visited[child.ID] = struct{}{}
out = append(out, child)
queue = append(queue, child.ID)
}
}
return out, nil
}
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))
}