mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
d1a471e29e
> Mux working on behalf of Mike. ## Summary - retune chatd subagent guidance to prefer `general` for substantial delegated work, including read-only synthesis and planning support - narrow `explore` guidance to repository-local code lookup and bounded tracing - add regression tests for planning, spawn tool, and Plan Mode guidance text ## Tests - `go test ./coderd/x/chatd -run 'Test(DefaultSystemPromptPlanningGuidance_SteersSubagentSelection|SpawnAgent_DescriptionSteersGeneralForSubstantialResearch|SpawnAgent_PlanModeDescriptionOmitsComputerUse|PlanningOverlaySubagentGuidance_UsesPlanModeSafeDescriptions|ExploreSubagentIsReadOnly)$'` - `make lint` - `make test TEST_PACKAGES=./coderd/x/chatd RUN=Guidance && make test TEST_PACKAGES=./coderd/x/chatd RUN=Description` - pre-commit hook during `git commit`
358 lines
12 KiB
Go
358 lines
12 KiB
Go
package chatd
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
|
|
"charm.land/fantasy"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
const (
|
|
spawnAgentToolName = "spawn_agent"
|
|
|
|
subagentTypeGeneral = "general"
|
|
subagentTypeExplore = "explore"
|
|
subagentTypeComputerUse = "computer_use"
|
|
|
|
defaultSystemPromptPlanningGuidance = "1. Use " + spawnAgentToolName +
|
|
" and wait_agent when delegation helps gather context. Prefer type=\"" +
|
|
subagentTypeGeneral +
|
|
"\" for substantial delegated research, analysis, reasoning, review, " +
|
|
"planning support, or implementation. Use type=\"" + subagentTypeGeneral +
|
|
"\" even for read-only work when the task is open-ended, multi-step, " +
|
|
"parallel, requires synthesis, or may later need edits. When planning, " +
|
|
"type=\"" + subagentTypeGeneral +
|
|
"\" remains non-mutating until implementation is approved. Use type=\"" +
|
|
subagentTypeExplore +
|
|
"\" only for narrow repository-local read-only code discovery or code " +
|
|
"tracing, such as locating files, callsites, or a bounded existing flow. " +
|
|
"Do not use type=\"" + subagentTypeExplore +
|
|
"\" for generic research, broad architecture analysis, planning synthesis, " +
|
|
"external or web research, parallel research, or tasks that may need edits."
|
|
)
|
|
|
|
type spawnAgentArgs struct {
|
|
Type string `json:"type"`
|
|
Prompt string `json:"prompt"`
|
|
Title string `json:"title,omitempty"`
|
|
}
|
|
|
|
type subagentDefinition struct {
|
|
id string
|
|
description string
|
|
unavailableReason func(context.Context, *Server, database.Chat) string
|
|
buildOptions func(context.Context, *Server, database.Chat, database.Chat, uuid.UUID, string) (childSubagentChatOptions, error)
|
|
}
|
|
|
|
func allSubagentDefinitions() []subagentDefinition {
|
|
return []subagentDefinition{
|
|
{
|
|
id: subagentTypeGeneral,
|
|
description: "substantial delegated research, analysis, reasoning, review, planning support, and implementation",
|
|
buildOptions: func(ctx context.Context, p *Server, parent database.Chat, _ database.Chat, _ uuid.UUID, _ string) (childSubagentChatOptions, error) {
|
|
modelConfigID, err := p.resolveSubagentModelConfigID(
|
|
ctx,
|
|
parent.OwnerID,
|
|
codersdk.ChatModelOverrideContextGeneral,
|
|
)
|
|
if err != nil {
|
|
return childSubagentChatOptions{}, err
|
|
}
|
|
options := childSubagentChatOptions{}
|
|
if modelConfigID != uuid.Nil {
|
|
options.modelConfigIDOverride = &modelConfigID
|
|
}
|
|
return options, nil
|
|
},
|
|
},
|
|
{
|
|
id: subagentTypeExplore,
|
|
description: "narrow repository-local read-only code discovery and code tracing",
|
|
buildOptions: func(ctx context.Context, p *Server, _ database.Chat, turnParent database.Chat, currentModelConfigID uuid.UUID, _ string) (childSubagentChatOptions, error) {
|
|
modelConfigID, err := p.resolveSubagentModelConfigID(
|
|
ctx,
|
|
turnParent.OwnerID,
|
|
codersdk.ChatModelOverrideContextExplore,
|
|
)
|
|
if err != nil {
|
|
return childSubagentChatOptions{}, err
|
|
}
|
|
if modelConfigID == uuid.Nil {
|
|
modelConfigID = currentModelConfigID
|
|
}
|
|
inheritedMCPServerIDs, err := p.resolveExploreToolSnapshot(
|
|
ctx,
|
|
turnParent,
|
|
)
|
|
if err != nil {
|
|
return childSubagentChatOptions{}, err
|
|
}
|
|
// Clearing plan mode changes only the Explore model behavior.
|
|
// The inherited tool snapshot still comes from the parent turn.
|
|
clearPlanMode := database.NullChatPlanMode{}
|
|
return childSubagentChatOptions{
|
|
chatMode: database.NullChatMode{
|
|
ChatMode: database.ChatModeExplore,
|
|
Valid: true,
|
|
},
|
|
modelConfigIDOverride: &modelConfigID,
|
|
planModeOverride: &clearPlanMode,
|
|
inheritedMCPServerIDs: inheritedMCPServerIDs,
|
|
}, nil
|
|
},
|
|
},
|
|
{
|
|
id: subagentTypeComputerUse,
|
|
description: "desktop GUI interaction, screenshots, and browser or app automation",
|
|
unavailableReason: func(ctx context.Context, p *Server, currentChat database.Chat) string {
|
|
if currentChat.PlanMode.Valid && currentChat.PlanMode.ChatPlanMode == database.ChatPlanModePlan {
|
|
return `type "computer_use" is unavailable in plan mode`
|
|
}
|
|
if !p.isDesktopEnabled(ctx) {
|
|
return `type "computer_use" is unavailable because desktop access is not enabled`
|
|
}
|
|
_, _, _, err := p.computerUseProviderAndModelFromConfig(ctx)
|
|
if err != nil {
|
|
p.logger.Warn(ctx, "computer-use provider config is unavailable",
|
|
slog.F("chat_id", currentChat.ID),
|
|
slog.Error(err),
|
|
)
|
|
return `type "computer_use" is unavailable because its provider configuration could not be loaded`
|
|
}
|
|
return ""
|
|
},
|
|
buildOptions: func(ctx context.Context, p *Server, _ database.Chat, _ database.Chat, _ uuid.UUID, prompt string) (childSubagentChatOptions, error) {
|
|
provider, _, _, err := p.computerUseProviderAndModelFromConfig(ctx)
|
|
if err != nil {
|
|
return childSubagentChatOptions{}, err
|
|
}
|
|
configured, err := p.providerConfigured(ctx, provider)
|
|
if err != nil {
|
|
return childSubagentChatOptions{}, err
|
|
}
|
|
if !configured {
|
|
return childSubagentChatOptions{}, xerrors.Errorf(
|
|
`API key for computer-use provider %q is not configured`,
|
|
provider,
|
|
)
|
|
}
|
|
return childSubagentChatOptions{
|
|
chatMode: database.NullChatMode{
|
|
ChatMode: database.ChatModeComputerUse,
|
|
Valid: true,
|
|
},
|
|
systemPrompt: computerUseSubagentSystemPrompt + "\n\n" + strings.TrimSpace(prompt),
|
|
}, nil
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func subagentDefinitionsByID(ids ...string) []subagentDefinition {
|
|
defs := make([]subagentDefinition, 0, len(ids))
|
|
for _, id := range ids {
|
|
if def, ok := lookupSubagentDefinition(id); ok {
|
|
defs = append(defs, def)
|
|
}
|
|
}
|
|
return defs
|
|
}
|
|
|
|
func lookupSubagentDefinition(id string) (subagentDefinition, bool) {
|
|
for _, def := range allSubagentDefinitions() {
|
|
if def.id == id {
|
|
return def, true
|
|
}
|
|
}
|
|
return subagentDefinition{}, false
|
|
}
|
|
|
|
func availableSubagentDefinitions(
|
|
ctx context.Context,
|
|
p *Server,
|
|
currentChat database.Chat,
|
|
) []subagentDefinition {
|
|
defs := allSubagentDefinitions()
|
|
available := make([]subagentDefinition, 0, len(defs))
|
|
for _, def := range defs {
|
|
if def.unavailableReasonText(ctx, p, currentChat) == "" {
|
|
available = append(available, def)
|
|
}
|
|
}
|
|
return available
|
|
}
|
|
|
|
func availableSubagentTypeIDs(
|
|
ctx context.Context,
|
|
p *Server,
|
|
currentChat database.Chat,
|
|
) []string {
|
|
defs := availableSubagentDefinitions(ctx, p, currentChat)
|
|
ids := make([]string, 0, len(defs))
|
|
for _, def := range defs {
|
|
ids = append(ids, def.id)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func (d subagentDefinition) unavailableReasonText(
|
|
ctx context.Context,
|
|
p *Server,
|
|
currentChat database.Chat,
|
|
) string {
|
|
if d.unavailableReason == nil {
|
|
return ""
|
|
}
|
|
return d.unavailableReason(ctx, p, currentChat)
|
|
}
|
|
|
|
func resolveSubagentDefinition(
|
|
ctx context.Context,
|
|
p *Server,
|
|
currentChat database.Chat,
|
|
rawSubagentType string,
|
|
) (subagentDefinition, error) {
|
|
subagentType := strings.TrimSpace(rawSubagentType)
|
|
def, ok := lookupSubagentDefinition(subagentType)
|
|
if !ok {
|
|
return subagentDefinition{}, xerrors.Errorf(
|
|
"type must be one of: %s",
|
|
strings.Join(availableSubagentTypeIDs(ctx, p, currentChat), ", "),
|
|
)
|
|
}
|
|
if reason := def.unavailableReasonText(ctx, p, currentChat); reason != "" {
|
|
return subagentDefinition{}, xerrors.New(reason)
|
|
}
|
|
return def, nil
|
|
}
|
|
|
|
func validateSubagentSpawnParent(currentChat database.Chat) error {
|
|
if currentChat.ParentChatID.Valid {
|
|
return xerrors.New("delegated chats cannot create child subagents")
|
|
}
|
|
if isExploreSubagentMode(currentChat.Mode) {
|
|
return xerrors.New("explore chats cannot create child subagents")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func subagentTypeFromChat(chat database.Chat) string {
|
|
if !chat.Mode.Valid {
|
|
return subagentTypeGeneral
|
|
}
|
|
switch chat.Mode.ChatMode {
|
|
case database.ChatModeExplore:
|
|
return subagentTypeExplore
|
|
case database.ChatModeComputerUse:
|
|
return subagentTypeComputerUse
|
|
default:
|
|
return subagentTypeGeneral
|
|
}
|
|
}
|
|
|
|
func withSubagentType(result map[string]any, chat database.Chat) map[string]any {
|
|
if result == nil {
|
|
result = map[string]any{}
|
|
}
|
|
result["type"] = subagentTypeFromChat(chat)
|
|
return result
|
|
}
|
|
|
|
func subagentErrorResponse(err error, chat *database.Chat) fantasy.ToolResponse {
|
|
if chat == nil {
|
|
return fantasy.NewTextErrorResponse(err.Error())
|
|
}
|
|
return toolJSONErrorResponse(withSubagentType(map[string]any{
|
|
"error": err.Error(),
|
|
}, *chat))
|
|
}
|
|
|
|
func buildSpawnAgentDescription(
|
|
ctx context.Context,
|
|
p *Server,
|
|
currentChat database.Chat,
|
|
) string {
|
|
availableDefs := availableSubagentDefinitions(ctx, p, currentChat)
|
|
description := "Spawn a delegated child subagent to work on a clearly scoped, " +
|
|
"independent task in parallel. Use the type field to choose " +
|
|
"the right specialist. Available type values: " +
|
|
formatSubagentDefinitions(availableDefs) + ". Do not use this for " +
|
|
"simple or quick operations you can handle directly with execute, " +
|
|
"read_file, or write_file. Prefer type=\"" + subagentTypeGeneral +
|
|
"\" for substantial delegated research, analysis, reasoning, review, " +
|
|
"planning support, or implementation, even when the child should only " +
|
|
"report findings. When using type=\"" + subagentTypeGeneral +
|
|
"\" for read-only work, explicitly instruct the child not to modify " +
|
|
"files and to return findings. Use type=\"" + subagentTypeExplore +
|
|
"\" only for narrow repository-local read-only code discovery or code " +
|
|
"tracing, such as locating files, callsites, or a bounded existing flow. " +
|
|
"Do not use type=\"" + subagentTypeExplore +
|
|
"\" for generic research, broad architecture analysis, planning " +
|
|
"synthesis, external or web research, parallel research, or tasks that " +
|
|
"may need edits. 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."
|
|
if currentChat.PlanMode.Valid && currentChat.PlanMode.ChatPlanMode == database.ChatPlanModePlan {
|
|
description += " During plan mode, type=\"" + subagentTypeGeneral +
|
|
"\" is for non-mutating substantial investigation and planning support, " +
|
|
"and type=\"" + subagentTypeExplore +
|
|
"\" is for narrow repository-local lookup or tracing. Both may use " +
|
|
"shell commands for exploration and inspection, but only type=\"" +
|
|
subagentTypeGeneral +
|
|
"\" should be used for cloning repositories or non-local investigation. " +
|
|
"They must not implement changes or intentionally modify workspace files."
|
|
}
|
|
return description
|
|
}
|
|
|
|
func formatSubagentDefinitions(defs []subagentDefinition) string {
|
|
return formatSubagentDefinitionsWithDescriptionOverrides(defs, nil)
|
|
}
|
|
|
|
func formatSubagentDefinitionsWithDescriptionOverrides(
|
|
defs []subagentDefinition,
|
|
descriptionOverrides map[string]string,
|
|
) string {
|
|
parts := make([]string, 0, len(defs))
|
|
for _, def := range defs {
|
|
description := def.description
|
|
if override, ok := descriptionOverrides[def.id]; ok {
|
|
description = override
|
|
}
|
|
parts = append(parts, def.id+" ("+description+")")
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func planningOverlaySubagentGuidance() string {
|
|
planModeDescriptions := map[string]string{
|
|
subagentTypeGeneral: "non-mutating substantial investigation, analysis, and planning support",
|
|
subagentTypeExplore: "narrow repository-local codebase lookup and code tracing",
|
|
}
|
|
|
|
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, " +
|
|
spawnAgentToolName + " delegation is for investigation and planning " +
|
|
"support, not code writing or implementation. Use type=\"" + subagentTypeGeneral +
|
|
"\" for substantial investigation, reasoning, and planning support. " +
|
|
"Use type=\"" + subagentTypeExplore +
|
|
"\" only for narrow repository-local lookup or tracing. Allowed type " +
|
|
"values in Plan Mode: " +
|
|
formatSubagentDefinitionsWithDescriptionOverrides(
|
|
subagentDefinitionsByID(
|
|
subagentTypeGeneral,
|
|
subagentTypeExplore,
|
|
),
|
|
planModeDescriptions,
|
|
) + "."
|
|
}
|