mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: allow spawn_agent model and reasoning effort override (#27385)
This commit is contained in:
@@ -849,6 +849,8 @@ The generation goroutine supports:
|
||||
|
||||
Model configs may carry a `reasoning_effort` config (`{default, max}`) inside `chat_model_configs.options`. Users select a per-turn effort when sending or editing a message; the value is stored on `chat_messages.reasoning_effort` and on `chat_queued_messages.reasoning_effort` for queued messages. Queued messages carry the value through promotion, and `chats.last_reasoning_effort` tracks the most recent message that set one, mirroring `last_model_config_id`.
|
||||
|
||||
Subagent spawning is a second source of both values. `spawn_agent` accepts optional `model_config_id` and `reasoning_effort` args (discoverable via the `list_subagent_models` tool): an explicit model selection becomes the child chat's `last_model_config_id` and wins over personal and deployment subagent overrides and over parent inheritance, and an explicit effort is stored on the child's initial message and wins over effort carried by those overrides. Both are validated at spawn time (enabled config, enabled provider, usable credentials, effort on the global scale) and rejected with tool errors before the child chat is created; `computer_use` spawns reject both args because their model routing is specialized. Generation-time resolution and clamping below apply to the child unchanged.
|
||||
|
||||
During generation preparation, the effective effort is resolved as the chat's `last_reasoning_effort` if set, else the config's `default`; clamped to the config's `max` on the global scale `none < minimal < low < medium < high < xhigh < max`; and passed through to the provider. The provider verifies whether the configured value is valid for that model at runtime. If the model config has no `reasoning_effort`, any user-selected value is ignored. The resolved value is injected into the provider-native options with `chatprovider.ApplyReasoningEffort` after provider option conversion. For Anthropic, the fantasy provider converts effort into enabled budget thinking on models older than Claude 4.6, which reject adaptive thinking.
|
||||
|
||||
#### Compaction model selection
|
||||
|
||||
+25
-23
@@ -3346,7 +3346,8 @@ 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", "list_agents", "ask_user_question", "attach_file":
|
||||
"spawn_explore_agent", "wait_agent", "list_agents", "list_subagent_models",
|
||||
"ask_user_question", "attach_file":
|
||||
return isRootChat
|
||||
case "process_list", "process_signal", "message_agent", "interrupt_agent", "close_agent",
|
||||
"spawn_computer_use_agent":
|
||||
@@ -3414,28 +3415,29 @@ func activeToolNamesForTurn(
|
||||
|
||||
func allowedExploreToolNames(allTools []fantasy.AgentTool) []string {
|
||||
builtinExplorePolicy := map[string]bool{
|
||||
"read_file": true,
|
||||
"write_file": false,
|
||||
"edit_files": false,
|
||||
"execute": true,
|
||||
"process_output": true,
|
||||
"process_list": false,
|
||||
"process_signal": false,
|
||||
"list_templates": false,
|
||||
"read_template": false,
|
||||
"create_workspace": false,
|
||||
"start_workspace": false,
|
||||
"stop_workspace": false,
|
||||
"propose_plan": false,
|
||||
"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,
|
||||
"read_file": true,
|
||||
"write_file": false,
|
||||
"edit_files": false,
|
||||
"execute": true,
|
||||
"process_output": true,
|
||||
"process_list": false,
|
||||
"process_signal": false,
|
||||
"list_templates": false,
|
||||
"read_template": false,
|
||||
"create_workspace": false,
|
||||
"start_workspace": false,
|
||||
"stop_workspace": false,
|
||||
"propose_plan": false,
|
||||
"spawn_agent": false,
|
||||
"wait_agent": false,
|
||||
"message_agent": false,
|
||||
"interrupt_agent": false,
|
||||
"close_agent": false,
|
||||
"list_agents": false,
|
||||
"list_subagent_models": false,
|
||||
"read_skill": true,
|
||||
"read_skill_file": true,
|
||||
"ask_user_question": false,
|
||||
}
|
||||
|
||||
toolNames := make([]string, 0, len(allTools))
|
||||
|
||||
@@ -113,6 +113,8 @@ type listAgentsArgs struct {
|
||||
Offset *int `json:"offset,omitempty"`
|
||||
}
|
||||
|
||||
type listSubagentModelsArgs struct{}
|
||||
|
||||
func subagentModelOverrideLogLabel(
|
||||
overrideContext codersdk.ChatModelOverrideContext,
|
||||
) string {
|
||||
@@ -543,6 +545,142 @@ func (p *Server) resolveModelConfigAndNormalizedProvider(
|
||||
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,
|
||||
@@ -583,6 +721,24 @@ func (p *Server) subagentTools(
|
||||
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
|
||||
@@ -594,12 +750,17 @@ func (p *Server) subagentTools(
|
||||
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,
|
||||
@@ -618,6 +779,39 @@ func (p *Server) subagentTools(
|
||||
}, 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 "+
|
||||
|
||||
@@ -14,7 +14,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
spawnAgentToolName = "spawn_agent"
|
||||
spawnAgentToolName = "spawn_agent"
|
||||
listSubagentModelsToolName = "list_subagent_models"
|
||||
|
||||
subagentTypeGeneral = "general"
|
||||
subagentTypeExplore = "explore"
|
||||
@@ -38,16 +39,18 @@ const (
|
||||
)
|
||||
|
||||
type spawnAgentArgs struct {
|
||||
Type string `json:"type"`
|
||||
Prompt string `json:"prompt"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Prompt string `json:"prompt"`
|
||||
Title string `json:"title,omitempty"`
|
||||
ModelConfigID string `json:"model_config_id,omitempty" description:"Optional model config UUID from list_subagent_models. Runs the child on that model instead of the configured default. Not supported for type 'computer_use'."`
|
||||
ReasoningEffort string `json:"reasoning_effort,omitempty" description:"Optional reasoning effort for the child: none, minimal, low, medium, high, xhigh, or max. Clamped to the selected model's supported range. Not supported for type 'computer_use'."`
|
||||
}
|
||||
|
||||
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)
|
||||
buildOptions func(context.Context, *Server, database.Chat, database.Chat, uuid.UUID, *uuid.UUID, string) (childSubagentChatOptions, error)
|
||||
}
|
||||
|
||||
func allSubagentDefinitions() []subagentDefinition {
|
||||
@@ -55,7 +58,10 @@ func allSubagentDefinitions() []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) {
|
||||
buildOptions: func(ctx context.Context, p *Server, parent database.Chat, _ database.Chat, _ uuid.UUID, explicitModelConfigID *uuid.UUID, _ string) (childSubagentChatOptions, error) {
|
||||
if explicitModelConfigID != nil {
|
||||
return childSubagentChatOptions{modelConfigIDOverride: explicitModelConfigID}, nil
|
||||
}
|
||||
modelConfigID, reasoningEffort, err := p.resolveSubagentModelConfigID(
|
||||
ctx,
|
||||
parent.OwnerID,
|
||||
@@ -75,17 +81,24 @@ func allSubagentDefinitions() []subagentDefinition {
|
||||
{
|
||||
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, reasoningEffort, err := p.resolveSubagentModelConfigID(
|
||||
ctx,
|
||||
turnParent.OwnerID,
|
||||
codersdk.ChatModelOverrideContextExplore,
|
||||
)
|
||||
if err != nil {
|
||||
return childSubagentChatOptions{}, err
|
||||
}
|
||||
if modelConfigID == uuid.Nil {
|
||||
modelConfigID = currentModelConfigID
|
||||
buildOptions: func(ctx context.Context, p *Server, _ database.Chat, turnParent database.Chat, currentModelConfigID uuid.UUID, explicitModelConfigID *uuid.UUID, _ string) (childSubagentChatOptions, error) {
|
||||
modelConfigID := currentModelConfigID
|
||||
var reasoningEffort *string
|
||||
if explicitModelConfigID != nil {
|
||||
modelConfigID = *explicitModelConfigID
|
||||
} else {
|
||||
resolvedModelConfigID, resolvedReasoningEffort, err := p.resolveSubagentModelConfigID(
|
||||
ctx,
|
||||
turnParent.OwnerID,
|
||||
codersdk.ChatModelOverrideContextExplore,
|
||||
)
|
||||
if err != nil {
|
||||
return childSubagentChatOptions{}, err
|
||||
}
|
||||
if resolvedModelConfigID != uuid.Nil {
|
||||
modelConfigID = resolvedModelConfigID
|
||||
}
|
||||
reasoningEffort = resolvedReasoningEffort
|
||||
}
|
||||
inheritedMCPServerIDs, err := p.resolveExploreToolSnapshot(
|
||||
ctx,
|
||||
@@ -129,7 +142,7 @@ func allSubagentDefinitions() []subagentDefinition {
|
||||
}
|
||||
return ""
|
||||
},
|
||||
buildOptions: func(ctx context.Context, p *Server, currentChat database.Chat, _ database.Chat, _ uuid.UUID, prompt string) (childSubagentChatOptions, error) {
|
||||
buildOptions: func(ctx context.Context, p *Server, currentChat database.Chat, _ database.Chat, _ uuid.UUID, _ *uuid.UUID, prompt string) (childSubagentChatOptions, error) {
|
||||
provider, _, _, err := p.computerUseProviderAndModelFromConfig(ctx)
|
||||
if err != nil {
|
||||
return childSubagentChatOptions{}, err
|
||||
@@ -301,6 +314,11 @@ 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. " +
|
||||
"You may optionally set model_config_id (a model config UUID from " +
|
||||
listSubagentModelsToolName + ") to run the child on a specific model " +
|
||||
"instead of the configured default, and reasoning_effort to pin the " +
|
||||
"child's reasoning effort; both apply only to type \"" +
|
||||
subagentTypeGeneral + "\" and type \"" + subagentTypeExplore + "\". " +
|
||||
"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 " +
|
||||
|
||||
@@ -1387,6 +1387,530 @@ func TestCreateChildSubagentChat_OverrideWorksWhenParentHasNoModel(t *testing.T)
|
||||
require.Equal(t, overrideModel.ID, childChat.LastModelConfigID)
|
||||
}
|
||||
|
||||
func TestSpawnAgent_ExplicitModelConfigID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, subagentType := range []string{subagentTypeGeneral, subagentTypeExplore} {
|
||||
t.Run(subagentType, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
|
||||
|
||||
ctx := chatdTestContext(t)
|
||||
user, org, parentModel := seedInternalChatDeps(t, db)
|
||||
selectedModel := insertInternalChatModelConfig(
|
||||
t, db, "spawn-explicit-"+uuid.NewString(), true,
|
||||
)
|
||||
parentChat := createInternalParentChat(
|
||||
ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-explicit-model",
|
||||
)
|
||||
|
||||
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
|
||||
Type: subagentType,
|
||||
Prompt: "delegate work",
|
||||
ModelConfigID: selectedModel.ID.String(),
|
||||
})
|
||||
result := requireSpawnAgentResponse(t, resp)
|
||||
require.Equal(t, subagentType, result.SubagentType)
|
||||
childID, err := uuid.Parse(result.ChatID)
|
||||
require.NoError(t, err)
|
||||
|
||||
childChat, err := db.GetChatByID(ctx, childID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, selectedModel.ID, childChat.LastModelConfigID)
|
||||
require.False(t, childChat.LastReasoningEffort.Valid)
|
||||
|
||||
parentAfter, err := db.GetChatByID(ctx, parentChat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, parentModel.ID, parentAfter.LastModelConfigID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type personalOverrideErrorStore struct {
|
||||
database.Store
|
||||
}
|
||||
|
||||
func (*personalOverrideErrorStore) GetUserChatPersonalModelOverride(
|
||||
context.Context,
|
||||
database.GetUserChatPersonalModelOverrideParams,
|
||||
) (string, error) {
|
||||
return "", xerrors.New("unexpected personal override lookup")
|
||||
}
|
||||
|
||||
func TestSpawnAgent_ExplicitModelSkipsConfiguredOverrides(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, subagentType := range []string{subagentTypeGeneral, subagentTypeExplore} {
|
||||
t.Run(subagentType, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
store := &personalOverrideErrorStore{Store: db}
|
||||
server := newInternalTestServer(t, store, ps, chatprovider.ProviderAPIKeys{})
|
||||
|
||||
ctx := chatdTestContext(t)
|
||||
user, org, parentModel := seedInternalChatDeps(t, db)
|
||||
enableInternalChatPersonalModelOverrides(t, db)
|
||||
selectedModel := insertInternalChatModelConfig(
|
||||
t, db, "selected-skip-overrides-"+uuid.NewString(), true,
|
||||
)
|
||||
parentChat := createInternalParentChat(
|
||||
ctx, t, server, store, org.ID, user.ID, parentModel.ID, "parent-skip-overrides",
|
||||
)
|
||||
|
||||
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
|
||||
Type: subagentType,
|
||||
Prompt: "delegate work",
|
||||
ModelConfigID: selectedModel.ID.String(),
|
||||
})
|
||||
childChat, err := db.GetChatByID(ctx, requireSpawnAgentChildChatID(t, resp))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, selectedModel.ID, childChat.LastModelConfigID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnAgent_ExplicitModelBeatsConfiguredOverrides(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
|
||||
|
||||
ctx := chatdTestContext(t)
|
||||
user, org, parentModel := seedInternalChatDeps(t, db)
|
||||
deploymentModel := insertInternalChatModelConfig(
|
||||
t, db, "general-deployment-"+uuid.NewString(), true,
|
||||
)
|
||||
personalModel := insertInternalChatModelConfig(
|
||||
t, db, "general-personal-"+uuid.NewString(), true,
|
||||
)
|
||||
selectedModel := insertInternalChatModelConfig(
|
||||
t, db, "general-selected-"+uuid.NewString(), true,
|
||||
)
|
||||
require.NoError(t, db.UpsertChatGeneralModelOverride(
|
||||
ctx, deploymentModel.ID.String()+":medium",
|
||||
))
|
||||
enableInternalChatPersonalModelOverrides(t, db)
|
||||
upsertInternalUserChatPersonalModelOverride(
|
||||
t,
|
||||
db,
|
||||
user.ID,
|
||||
codersdk.ChatPersonalModelOverrideContextGeneral,
|
||||
string(codersdk.ChatPersonalModelOverrideModeModel)+":"+personalModel.ID.String()+":low",
|
||||
)
|
||||
parentChat := createInternalParentChat(
|
||||
ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-explicit-beats-overrides",
|
||||
)
|
||||
|
||||
baselineResp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
|
||||
Type: subagentTypeGeneral,
|
||||
Prompt: "delegate baseline work",
|
||||
})
|
||||
baselineChild, err := db.GetChatByID(ctx, requireSpawnAgentChildChatID(t, baselineResp))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, personalModel.ID, baselineChild.LastModelConfigID)
|
||||
require.Equal(
|
||||
t,
|
||||
database.NullChatReasoningEffort{ChatReasoningEffort: database.ChatReasoningEffortLow, Valid: true},
|
||||
baselineChild.LastReasoningEffort,
|
||||
)
|
||||
|
||||
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
|
||||
Type: subagentTypeGeneral,
|
||||
Prompt: "delegate explicit work",
|
||||
ModelConfigID: selectedModel.ID.String(),
|
||||
})
|
||||
childChat, err := db.GetChatByID(ctx, requireSpawnAgentChildChatID(t, resp))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, selectedModel.ID, childChat.LastModelConfigID)
|
||||
require.False(t, childChat.LastReasoningEffort.Valid)
|
||||
}
|
||||
|
||||
func TestSpawnAgent_ExplicitReasoningEffort(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("EffortOnlyKeepsModelChain", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
|
||||
|
||||
ctx := chatdTestContext(t)
|
||||
user, org, parentModel := seedInternalChatDeps(t, db)
|
||||
parentChat := createInternalParentChat(
|
||||
ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-effort-only",
|
||||
)
|
||||
|
||||
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
|
||||
Type: subagentTypeGeneral,
|
||||
Prompt: "delegate work",
|
||||
ReasoningEffort: "high",
|
||||
})
|
||||
childChat, err := db.GetChatByID(ctx, requireSpawnAgentChildChatID(t, resp))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, parentModel.ID, childChat.LastModelConfigID)
|
||||
require.Equal(
|
||||
t,
|
||||
database.NullChatReasoningEffort{ChatReasoningEffort: database.ChatReasoningEffortHigh, Valid: true},
|
||||
childChat.LastReasoningEffort,
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("CombinedModelAndEffort", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
|
||||
|
||||
ctx := chatdTestContext(t)
|
||||
user, org, parentModel := seedInternalChatDeps(t, db)
|
||||
selectedModel := insertInternalChatModelConfig(
|
||||
t, db, "combined-"+uuid.NewString(), true,
|
||||
)
|
||||
parentChat := createInternalParentChat(
|
||||
ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-combined",
|
||||
)
|
||||
|
||||
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
|
||||
Type: subagentTypeGeneral,
|
||||
Prompt: "delegate work",
|
||||
ModelConfigID: selectedModel.ID.String(),
|
||||
ReasoningEffort: "low",
|
||||
})
|
||||
childChat, err := db.GetChatByID(ctx, requireSpawnAgentChildChatID(t, resp))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, selectedModel.ID, childChat.LastModelConfigID)
|
||||
require.Equal(
|
||||
t,
|
||||
database.NullChatReasoningEffort{ChatReasoningEffort: database.ChatReasoningEffortLow, Valid: true},
|
||||
childChat.LastReasoningEffort,
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("BeatsOverrideCarriedEffort", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
|
||||
|
||||
ctx := chatdTestContext(t)
|
||||
user, org, parentModel := seedInternalChatDeps(t, db)
|
||||
deploymentModel := insertInternalChatModelConfig(
|
||||
t, db, "general-deployment-"+uuid.NewString(), true,
|
||||
)
|
||||
require.NoError(t, db.UpsertChatGeneralModelOverride(
|
||||
ctx, deploymentModel.ID.String()+":low",
|
||||
))
|
||||
parentChat := createInternalParentChat(
|
||||
ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-effort-beats-override",
|
||||
)
|
||||
|
||||
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
|
||||
Type: subagentTypeGeneral,
|
||||
Prompt: "delegate work",
|
||||
ReasoningEffort: "high",
|
||||
})
|
||||
childChat, err := db.GetChatByID(ctx, requireSpawnAgentChildChatID(t, resp))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, deploymentModel.ID, childChat.LastModelConfigID)
|
||||
require.Equal(
|
||||
t,
|
||||
database.NullChatReasoningEffort{ChatReasoningEffort: database.ChatReasoningEffortHigh, Valid: true},
|
||||
childChat.LastReasoningEffort,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSpawnAgent_ModelArgsRejections(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args func(ctx context.Context, t *testing.T, db database.Store, user database.User) spawnAgentArgs
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "MalformedModelConfigID",
|
||||
args: func(context.Context, *testing.T, database.Store, database.User) spawnAgentArgs {
|
||||
return spawnAgentArgs{ModelConfigID: "not-a-uuid"}
|
||||
},
|
||||
wantError: "invalid model_config_id: must be a valid UUID",
|
||||
},
|
||||
{
|
||||
name: "UnknownModelConfigID",
|
||||
args: func(context.Context, *testing.T, database.Store, database.User) spawnAgentArgs {
|
||||
return spawnAgentArgs{ModelConfigID: uuid.NewString()}
|
||||
},
|
||||
wantError: "model_config_id not found or is disabled",
|
||||
},
|
||||
{
|
||||
name: "DisabledModelConfig",
|
||||
args: func(_ context.Context, t *testing.T, db database.Store, _ database.User) spawnAgentArgs {
|
||||
disabled := insertInternalChatModelConfig(
|
||||
t, db, "reject-disabled-"+uuid.NewString(), false,
|
||||
)
|
||||
return spawnAgentArgs{ModelConfigID: disabled.ID.String()}
|
||||
},
|
||||
wantError: "model_config_id not found or is disabled",
|
||||
},
|
||||
{
|
||||
name: "DisabledProvider",
|
||||
args: func(_ context.Context, t *testing.T, db database.Store, user database.User) spawnAgentArgs {
|
||||
dbgen.ChatProvider(t, db, database.ChatProvider{
|
||||
Provider: "openai-compat",
|
||||
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
|
||||
}, func(p *database.InsertChatProviderParams) {
|
||||
p.Enabled = false
|
||||
})
|
||||
config := insertInternalChatModelConfigForProvider(
|
||||
t, db, "openai-compat", "gpt-4o-mini", true,
|
||||
)
|
||||
return spawnAgentArgs{ModelConfigID: config.ID.String()}
|
||||
},
|
||||
wantError: "model_config_id not found or is disabled",
|
||||
},
|
||||
{
|
||||
name: "DeletedProvider",
|
||||
args: func(_ context.Context, t *testing.T, db database.Store, user database.User) spawnAgentArgs {
|
||||
provider := dbgen.ChatProvider(t, db, database.ChatProvider{
|
||||
Provider: "openai-compat",
|
||||
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
|
||||
})
|
||||
config := insertInternalChatModelConfigForProvider(
|
||||
t, db, "openai-compat", "gpt-4o-mini", true,
|
||||
)
|
||||
require.NoError(t, db.DeleteAIProviderByID(
|
||||
systemRestrictedTestContext(t), provider.ID,
|
||||
))
|
||||
return spawnAgentArgs{ModelConfigID: config.ID.String()}
|
||||
},
|
||||
wantError: "model_config_id not found or is disabled",
|
||||
},
|
||||
{
|
||||
name: "MissingCredentials",
|
||||
args: func(_ context.Context, t *testing.T, db database.Store, user database.User) spawnAgentArgs {
|
||||
insertInternalChatProvider(
|
||||
t, db, user.ID, "openai-compat", "", false, true, false,
|
||||
)
|
||||
config := insertInternalChatModelConfigForProvider(
|
||||
t, db, "openai-compat", "gpt-4o-mini", true,
|
||||
)
|
||||
return spawnAgentArgs{ModelConfigID: config.ID.String()}
|
||||
},
|
||||
wantError: `credentials are unavailable for provider "openai-compat"`,
|
||||
},
|
||||
{
|
||||
name: "InvalidReasoningEffort",
|
||||
args: func(context.Context, *testing.T, database.Store, database.User) spawnAgentArgs {
|
||||
return spawnAgentArgs{ReasoningEffort: "ultra"}
|
||||
},
|
||||
wantError: "invalid reasoning_effort: must be one of",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(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)
|
||||
parentChat := createInternalParentChat(
|
||||
ctx, t, server, db, org.ID, user.ID, model.ID, "parent-rejection",
|
||||
)
|
||||
|
||||
args := tt.args(ctx, t, db, user)
|
||||
args.Type = subagentTypeGeneral
|
||||
args.Prompt = "delegate work"
|
||||
|
||||
resp := runSpawnAgentTool(ctx, t, server, parentChat, args)
|
||||
require.True(t, resp.IsError, "expected tool error but got: %s", resp.Content)
|
||||
require.Contains(t, resp.Content, tt.wantError)
|
||||
|
||||
children, err := db.GetChildChatsByParentIDs(ctx, database.GetChildChatsByParentIDsParams{
|
||||
ParentIds: []uuid.UUID{parentChat.ID},
|
||||
Archived: sql.NullBool{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, children, "no child chat may be created on rejection")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnAgent_ComputerUseRejectsModelArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
muter func(*spawnAgentArgs, database.ChatModelConfig)
|
||||
}{
|
||||
{
|
||||
name: "ModelConfigID",
|
||||
muter: func(args *spawnAgentArgs, model database.ChatModelConfig) {
|
||||
args.ModelConfigID = model.ID.String()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ReasoningEffort",
|
||||
muter: func(args *spawnAgentArgs, _ database.ChatModelConfig) {
|
||||
args.ReasoningEffort = "high"
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(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)
|
||||
insertEnabledAnthropicProvider(t, db, user.ID)
|
||||
parentChat := createInternalParentChat(
|
||||
ctx, t, server, db, org.ID, user.ID, model.ID, "parent-computer-use-args",
|
||||
)
|
||||
|
||||
args := spawnAgentArgs{
|
||||
Type: subagentTypeComputerUse,
|
||||
Prompt: "take a screenshot",
|
||||
}
|
||||
tt.muter(&args, model)
|
||||
|
||||
resp := runSpawnAgentTool(ctx, t, server, parentChat, args)
|
||||
require.True(t, resp.IsError, "expected tool error but got: %s", resp.Content)
|
||||
require.Contains(t, resp.Content, `not supported for type "computer_use"`)
|
||||
|
||||
children, err := db.GetChildChatsByParentIDs(ctx, database.GetChildChatsByParentIDsParams{
|
||||
ParentIds: []uuid.UUID{parentChat.ID},
|
||||
Archived: sql.NullBool{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, children, "no child chat may be created on rejection")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSubagentModels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
|
||||
|
||||
ctx := chatdTestContext(t)
|
||||
user, org, defaultModel := seedInternalChatDeps(t, db)
|
||||
effortModel := insertInternalChatModelConfigWithOptions(
|
||||
t,
|
||||
db,
|
||||
"openai",
|
||||
"gpt-effort-"+uuid.NewString(),
|
||||
true,
|
||||
json.RawMessage(`{"reasoning_effort":{"default":"medium","max":"high"}}`),
|
||||
)
|
||||
disabledModel := insertInternalChatModelConfig(
|
||||
t, db, "list-disabled-"+uuid.NewString(), false,
|
||||
)
|
||||
insertInternalChatProvider(t, db, user.ID, "anthropic", "", false, true, false)
|
||||
noCredsModel := insertInternalChatModelConfigForProvider(
|
||||
t, db, "anthropic", "claude-sonnet-4-5", true,
|
||||
)
|
||||
dbgen.ChatProvider(t, db, database.ChatProvider{
|
||||
Provider: "openai-compat",
|
||||
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
|
||||
}, func(p *database.InsertChatProviderParams) {
|
||||
p.Enabled = false
|
||||
})
|
||||
disabledProviderModel := insertInternalChatModelConfigForProvider(
|
||||
t, db, "openai-compat", "gpt-4o-mini", true,
|
||||
)
|
||||
|
||||
parentChat := createInternalParentChat(
|
||||
ctx, t, server, db, org.ID, user.ID, defaultModel.ID, "parent-list-models",
|
||||
)
|
||||
|
||||
resp := runSubagentTool(
|
||||
ctx,
|
||||
t,
|
||||
server,
|
||||
parentChat,
|
||||
parentChat.LastModelConfigID,
|
||||
listSubagentModelsToolName,
|
||||
struct{}{},
|
||||
)
|
||||
require.False(t, resp.IsError, "expected success but got: %s", resp.Content)
|
||||
|
||||
type listedModel struct {
|
||||
ModelConfigID string `json:"model_config_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Model string `json:"model"`
|
||||
Provider string `json:"provider"`
|
||||
ContextLimit int64 `json:"context_limit"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
ReasoningEfforts []string `json:"reasoning_efforts"`
|
||||
}
|
||||
var result struct {
|
||||
Models []listedModel `json:"models"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
|
||||
|
||||
byID := make(map[string]listedModel, len(result.Models))
|
||||
for _, m := range result.Models {
|
||||
byID[m.ModelConfigID] = m
|
||||
}
|
||||
require.Contains(t, byID, defaultModel.ID.String())
|
||||
require.Contains(t, byID, effortModel.ID.String())
|
||||
require.NotContains(t, byID, disabledModel.ID.String())
|
||||
require.NotContains(t, byID, noCredsModel.ID.String())
|
||||
require.NotContains(t, byID, disabledProviderModel.ID.String())
|
||||
|
||||
defaultEntry := byID[defaultModel.ID.String()]
|
||||
require.Equal(t, defaultModel.DisplayName, defaultEntry.DisplayName)
|
||||
require.Equal(t, defaultModel.Model, defaultEntry.Model)
|
||||
require.Equal(t, "openai", defaultEntry.Provider)
|
||||
require.Equal(t, defaultModel.ContextLimit, defaultEntry.ContextLimit)
|
||||
require.True(t, defaultEntry.IsDefault)
|
||||
|
||||
effortEntry := byID[effortModel.ID.String()]
|
||||
require.False(t, effortEntry.IsDefault)
|
||||
require.Equal(
|
||||
t,
|
||||
[]string{"none", "minimal", "low", "medium", "high"},
|
||||
effortEntry.ReasoningEfforts,
|
||||
)
|
||||
}
|
||||
|
||||
func TestListSubagentModels_NotAvailableForChildChats(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)
|
||||
_, child := createParentChildChats(ctx, t, server, user, org, model)
|
||||
|
||||
childChat, err := db.GetChatByID(ctx, child.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, childChat.ParentChatID.Valid, "child chat must have a parent")
|
||||
|
||||
resp := runSubagentTool(
|
||||
ctx,
|
||||
t,
|
||||
server,
|
||||
childChat,
|
||||
childChat.LastModelConfigID,
|
||||
listSubagentModelsToolName,
|
||||
struct{}{},
|
||||
)
|
||||
require.True(t, resp.IsError)
|
||||
require.Contains(t, resp.Content, "delegated chats cannot create child subagents")
|
||||
}
|
||||
|
||||
func TestSpawnAgent_ExploreUsesConfiguredModelOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user