feat: add Explore mode as subagent-only modality (#24448)

> This PR was authored by Mux on behalf of Mike.

Introduce Explore mode, a read-only subagent modality for delegated
discovery and code investigation.

## What

Adds a `spawn_explore_agent` tool that creates child chats restricted to
read-only operations. An admin can optionally configure a
deployment-wide
model override so Explore subagents use a model optimized for large
context
or reasoning without changing the root chat's model.

### Backend

- New `ChatModeExplore` enum value (migration 000471).
- `spawn_explore_agent` tool definition with read-only allowlist:
`read_file`, `execute`, `process_output`, `read_skill`,
`read_skill_file`.
  Write tools, file editors, and nested subagent spawning are blocked.
- Deployment config storage for the Explore model override
  (`agents_chat_explore_model_override` in `site_configs`).
- Model resolution hierarchy: configured override, then current turn
model,
then global default. Silent fallback with warning log when the override
  becomes unavailable.
- RBAC: `AsChatd` for daemon reads, `ActionRead` and `ActionUpdate` on
  `ResourceDeploymentConfig` for admin API calls.
- Plan mode root chats can use `spawn_explore_agent` for read-only
research,
  matching the planning prompt guidance.
- The Explore override config API now reports malformed saved overrides
as
  "treated as unset" so admins can clear them explicitly.

### Frontend

- `ExploreModelOverrideSettings` component in admin agent behavior
settings.
  Uses `ModelSelector`, handles unavailable model warnings, and supports
  explicit Save and Clear actions.
- Malformed saved overrides show a warning and require an explicit Save
to
  clear, instead of Clear auto-submitting behind the scenes.

### Tests

- Integration: `TestExploreSubagentIsReadOnly` (full spawn flow, tool
  verification, prompt overlay, DB state).
- Unit: tool allowlist tests for explore, plan, and default modes.
- Internal: model override resolution with valid, invalid UUID,
disabled, and
  unconfigured override scenarios.
- RBAC: `dbauthz_test.go` for `GetChatExploreModelOverride` and
  `UpsertChatExploreModelOverride`.
- API: admin set and clear, malformed stored override reporting,
disabled
  model rejection, non-admin denial.
This commit is contained in:
Michael Suchacz
2026-04-17 13:40:17 +02:00
committed by GitHub
parent 890c610e08
commit 73b5058923
42 changed files with 1852 additions and 157 deletions
+2
View File
@@ -1183,6 +1183,8 @@ func New(options *Options) *API {
r.Put("/system-prompt", api.putChatSystemPrompt)
r.Get("/plan-mode-instructions", api.getChatPlanModeInstructions)
r.Put("/plan-mode-instructions", api.putChatPlanModeInstructions)
r.Get("/explore-model-override", api.getChatExploreModelOverride)
r.Put("/explore-model-override", api.putChatExploreModelOverride)
r.Get("/desktop-enabled", api.getChatDesktopEnabled)
r.Put("/desktop-enabled", api.putChatDesktopEnabled)
r.Get("/user-prompt", api.getUserChatCustomPrompt)
+15 -1
View File
@@ -833,7 +833,7 @@ func AsWorkspaceBuilder(ctx context.Context) context.Context {
}
// AsChatd returns a context with an actor scoped to the chat
// daemon's background worker. It can manage chats and read
// daemon's background worker. It can manage chats and access
// workspaces and deployment config, but nothing else.
func AsChatd(ctx context.Context) context.Context {
return As(ctx, subjectChatd)
@@ -2679,6 +2679,13 @@ func (q *querier) GetChatDiffStatusesByChatIDs(ctx context.Context, chatIDs []uu
return q.db.GetChatDiffStatusesByChatIDs(ctx, chatIDs)
}
func (q *querier) GetChatExploreModelOverride(ctx context.Context) (string, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil {
return "", err
}
return q.db.GetChatExploreModelOverride(ctx)
}
func (q *querier) GetChatFileByID(ctx context.Context, id uuid.UUID) (database.ChatFile, error) {
file, err := q.db.GetChatFileByID(ctx, id)
if err != nil {
@@ -7296,6 +7303,13 @@ func (q *querier) UpsertChatDiffStatusReference(ctx context.Context, arg databas
return q.db.UpsertChatDiffStatusReference(ctx, arg)
}
func (q *querier) UpsertChatExploreModelOverride(ctx context.Context, value string) error {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
return err
}
return q.db.UpsertChatExploreModelOverride(ctx, value)
}
func (q *querier) UpsertChatIncludeDefaultSystemPrompt(ctx context.Context, includeDefaultSystemPrompt bool) error {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
return err
+11 -1
View File
@@ -834,6 +834,10 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().GetChatDesktopEnabled(gomock.Any()).Return(false, nil).AnyTimes()
check.Args().Asserts()
}))
s.Run("GetChatExploreModelOverride", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetChatExploreModelOverride(gomock.Any()).Return("", nil).AnyTimes()
check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead)
}))
s.Run("GetChatPlanModeInstructions", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetChatPlanModeInstructions(gomock.Any()).Return("", nil).AnyTimes()
check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
@@ -1135,6 +1139,10 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().UpsertChatDesktopEnabled(gomock.Any(), false).Return(nil).AnyTimes()
check.Args(false).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
}))
s.Run("UpsertChatExploreModelOverride", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().UpsertChatExploreModelOverride(gomock.Any(), "").Return(nil).AnyTimes()
check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
}))
s.Run("UpsertChatPlanModeInstructions", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().UpsertChatPlanModeInstructions(gomock.Any(), "").Return(nil).AnyTimes()
check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
@@ -6099,9 +6107,11 @@ func TestAsChatd(t *testing.T) {
require.NoError(t, err, "workspace %s should be allowed", action)
}
// DeploymentConfig read.
// DeploymentConfig reads are allowed, but writes are not.
err := auth.Authorize(ctx, actor, policy.ActionRead, rbac.ResourceDeploymentConfig)
require.NoError(t, err, "deployment config read should be allowed")
err = auth.Authorize(ctx, actor, policy.ActionUpdate, rbac.ResourceDeploymentConfig)
require.Error(t, err, "deployment config update should not be allowed")
// User read_personal (needed for GetUserChatCustomPrompt).
err = auth.Authorize(ctx, actor, policy.ActionReadPersonal, rbac.ResourceUser)
+16
View File
@@ -1208,6 +1208,14 @@ func (m queryMetricsStore) GetChatDiffStatusesByChatIDs(ctx context.Context, cha
return r0, r1
}
func (m queryMetricsStore) GetChatExploreModelOverride(ctx context.Context) (string, error) {
start := time.Now()
r0, r1 := m.s.GetChatExploreModelOverride(ctx)
m.queryLatencies.WithLabelValues("GetChatExploreModelOverride").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatExploreModelOverride").Inc()
return r0, r1
}
func (m queryMetricsStore) GetChatFileByID(ctx context.Context, id uuid.UUID) (database.ChatFile, error) {
start := time.Now()
r0, r1 := m.s.GetChatFileByID(ctx, id)
@@ -5200,6 +5208,14 @@ func (m queryMetricsStore) UpsertChatDiffStatusReference(ctx context.Context, ar
return r0, r1
}
func (m queryMetricsStore) UpsertChatExploreModelOverride(ctx context.Context, value string) error {
start := time.Now()
r0 := m.s.UpsertChatExploreModelOverride(ctx, value)
m.queryLatencies.WithLabelValues("UpsertChatExploreModelOverride").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertChatExploreModelOverride").Inc()
return r0
}
func (m queryMetricsStore) UpsertChatIncludeDefaultSystemPrompt(ctx context.Context, includeDefaultSystemPrompt bool) error {
start := time.Now()
r0 := m.s.UpsertChatIncludeDefaultSystemPrompt(ctx, includeDefaultSystemPrompt)
+29
View File
@@ -2222,6 +2222,21 @@ func (mr *MockStoreMockRecorder) GetChatDiffStatusesByChatIDs(ctx, chatIds any)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatDiffStatusesByChatIDs", reflect.TypeOf((*MockStore)(nil).GetChatDiffStatusesByChatIDs), ctx, chatIds)
}
// GetChatExploreModelOverride mocks base method.
func (m *MockStore) GetChatExploreModelOverride(ctx context.Context) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChatExploreModelOverride", ctx)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatExploreModelOverride indicates an expected call of GetChatExploreModelOverride.
func (mr *MockStoreMockRecorder) GetChatExploreModelOverride(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatExploreModelOverride", reflect.TypeOf((*MockStore)(nil).GetChatExploreModelOverride), ctx)
}
// GetChatFileByID mocks base method.
func (m *MockStore) GetChatFileByID(ctx context.Context, id uuid.UUID) (database.ChatFile, error) {
m.ctrl.T.Helper()
@@ -9772,6 +9787,20 @@ func (mr *MockStoreMockRecorder) UpsertChatDiffStatusReference(ctx, arg any) *go
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatDiffStatusReference", reflect.TypeOf((*MockStore)(nil).UpsertChatDiffStatusReference), ctx, arg)
}
// UpsertChatExploreModelOverride mocks base method.
func (m *MockStore) UpsertChatExploreModelOverride(ctx context.Context, value string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpsertChatExploreModelOverride", ctx, value)
ret0, _ := ret[0].(error)
return ret0
}
// UpsertChatExploreModelOverride indicates an expected call of UpsertChatExploreModelOverride.
func (mr *MockStoreMockRecorder) UpsertChatExploreModelOverride(ctx, value any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatExploreModelOverride", reflect.TypeOf((*MockStore)(nil).UpsertChatExploreModelOverride), ctx, value)
}
// UpsertChatIncludeDefaultSystemPrompt mocks base method.
func (m *MockStore) UpsertChatIncludeDefaultSystemPrompt(ctx context.Context, includeDefaultSystemPrompt bool) error {
m.ctrl.T.Helper()
+2 -1
View File
@@ -289,7 +289,8 @@ CREATE TYPE chat_message_visibility AS ENUM (
);
CREATE TYPE chat_mode AS ENUM (
'computer_use'
'computer_use',
'explore'
);
CREATE TYPE chat_plan_mode AS ENUM (
@@ -0,0 +1,2 @@
-- No-op: enum values remain to avoid churn. Removing chat_mode enum values
-- requires a create/cast/drop cycle which is intentionally omitted here.
@@ -0,0 +1 @@
ALTER TYPE chat_mode ADD VALUE IF NOT EXISTS 'explore';
+4 -1
View File
@@ -1294,6 +1294,7 @@ type ChatMode string
const (
ChatModeComputerUse ChatMode = "computer_use"
ChatModeExplore ChatMode = "explore"
)
func (e *ChatMode) Scan(src interface{}) error {
@@ -1333,7 +1334,8 @@ func (ns NullChatMode) Value() (driver.Value, error) {
func (e ChatMode) Valid() bool {
switch e {
case ChatModeComputerUse:
case ChatModeComputerUse,
ChatModeExplore:
return true
}
return false
@@ -1342,6 +1344,7 @@ func (e ChatMode) Valid() bool {
func AllChatModeValues() []ChatMode {
return []ChatMode{
ChatModeComputerUse,
ChatModeExplore,
}
}
+2
View File
@@ -282,6 +282,7 @@ type sqlcQuerier interface {
GetChatDesktopEnabled(ctx context.Context) (bool, error)
GetChatDiffStatusByChatID(ctx context.Context, chatID uuid.UUID) (ChatDiffStatus, error)
GetChatDiffStatusesByChatIDs(ctx context.Context, chatIds []uuid.UUID) ([]ChatDiffStatus, error)
GetChatExploreModelOverride(ctx context.Context) (string, error)
GetChatFileByID(ctx context.Context, id uuid.UUID) (ChatFile, error)
// GetChatFileMetadataByChatID returns lightweight file metadata for
// all files linked to a chat. The data column is excluded to avoid
@@ -1102,6 +1103,7 @@ type sqlcQuerier interface {
UpsertChatDesktopEnabled(ctx context.Context, enableDesktop bool) error
UpsertChatDiffStatus(ctx context.Context, arg UpsertChatDiffStatusParams) (ChatDiffStatus, error)
UpsertChatDiffStatusReference(ctx context.Context, arg UpsertChatDiffStatusReferenceParams) (ChatDiffStatus, error)
UpsertChatExploreModelOverride(ctx context.Context, value string) error
UpsertChatIncludeDefaultSystemPrompt(ctx context.Context, includeDefaultSystemPrompt bool) error
UpsertChatPlanModeInstructions(ctx context.Context, value string) error
UpsertChatRetentionDays(ctx context.Context, retentionDays int32) error
+22
View File
@@ -19954,6 +19954,18 @@ func (q *sqlQuerier) GetChatDesktopEnabled(ctx context.Context) (bool, error) {
return enable_desktop, err
}
const getChatExploreModelOverride = `-- name: GetChatExploreModelOverride :one
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_explore_model_override'), '') :: text AS model_config_id
`
func (q *sqlQuerier) GetChatExploreModelOverride(ctx context.Context) (string, error) {
row := q.db.QueryRowContext(ctx, getChatExploreModelOverride)
var model_config_id string
err := row.Scan(&model_config_id)
return model_config_id, err
}
const getChatIncludeDefaultSystemPrompt = `-- name: GetChatIncludeDefaultSystemPrompt :one
SELECT
COALESCE(
@@ -20311,6 +20323,16 @@ func (q *sqlQuerier) UpsertChatDesktopEnabled(ctx context.Context, enableDesktop
return err
}
const upsertChatExploreModelOverride = `-- name: UpsertChatExploreModelOverride :exec
INSERT INTO site_configs (key, value) VALUES ('agents_chat_explore_model_override', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_explore_model_override'
`
func (q *sqlQuerier) UpsertChatExploreModelOverride(ctx context.Context, value string) error {
_, err := q.db.ExecContext(ctx, upsertChatExploreModelOverride, value)
return err
}
const upsertChatIncludeDefaultSystemPrompt = `-- name: UpsertChatIncludeDefaultSystemPrompt :exec
INSERT INTO site_configs (key, value)
VALUES (
+8
View File
@@ -167,6 +167,14 @@ SELECT
INSERT INTO site_configs (key, value) VALUES ('agents_chat_plan_mode_instructions', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_plan_mode_instructions';
-- name: GetChatExploreModelOverride :one
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_explore_model_override'), '') :: text AS model_config_id;
-- name: UpsertChatExploreModelOverride :exec
INSERT INTO site_configs (key, value) VALUES ('agents_chat_explore_model_override', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_explore_model_override';
-- name: GetChatDesktopEnabled :one
SELECT
COALESCE((SELECT value = 'true' FROM site_configs WHERE key = 'agents_desktop_enabled'), false) :: boolean AS enable_desktop;
+137 -12
View File
@@ -391,6 +391,85 @@ func planModeToNullChatPlanMode(mode codersdk.ChatPlanMode) database.NullChatPla
}
}
func validateChatPlanMode(mode codersdk.ChatPlanMode) bool {
switch mode {
case "", codersdk.ChatPlanModePlan:
return true
default:
return false
}
}
func parseChatExploreModelOverride(raw string) (*uuid.UUID, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
//nolint:nilnil // Empty site-config value means the override is unset.
return nil, nil
}
modelConfigID, err := uuid.Parse(trimmed)
if err != nil {
return nil, xerrors.Errorf("parse explore model override: %w", err)
}
return &modelConfigID, nil
}
func formatChatExploreModelOverride(id *uuid.UUID) string {
if id == nil {
return ""
}
return id.String()
}
func validateChatExploreModelOverrideID(
ctx context.Context,
db database.Store,
id *uuid.UUID,
) (int, *codersdk.Response) {
if id == nil {
return 0, nil
}
if *id == uuid.Nil {
return http.StatusBadRequest, &codersdk.Response{
Message: "Invalid model_config_id.",
}
}
//nolint:gocritic // Validation lookup uses system context to check model
// availability independently of the caller's read permissions.
_, err := db.GetEnabledChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), *id)
if err == nil {
return 0, nil
}
if xerrors.Is(err, sql.ErrNoRows) {
return http.StatusBadRequest, &codersdk.Response{
Message: "Invalid model_config_id.",
}
}
return http.StatusInternalServerError, &codersdk.Response{
Message: "Internal error validating model config override.",
Detail: err.Error(),
}
}
func (api *API) getChatExploreModelOverrideConfig(
ctx context.Context,
) (*uuid.UUID, bool, error) {
raw, err := api.Database.GetChatExploreModelOverride(ctx)
if err != nil {
return nil, false, xerrors.Errorf("get explore model override: %w", err)
}
id, err := parseChatExploreModelOverride(raw)
if err != nil {
// Degrade malformed values to unset so the admin settings page
// remains accessible and the bad value can be cleared.
api.Logger.Warn(ctx, "malformed explore model override in site config, treating as unset",
slog.F("raw_value", raw),
slog.Error(err),
)
return nil, true, nil
}
return id, false, nil
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -469,10 +548,7 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
return
}
switch req.PlanMode {
case codersdk.ChatPlanModePlan, "":
// Valid.
default:
if !validateChatPlanMode(req.PlanMode) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid plan_mode value.",
})
@@ -1776,10 +1852,7 @@ func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) {
var planModeUpdate *database.NullChatPlanMode
if req.PlanMode != nil {
switch *req.PlanMode {
case codersdk.ChatPlanModePlan, "":
// Valid.
default:
if !validateChatPlanMode(*req.PlanMode) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid plan_mode value.",
})
@@ -2047,10 +2120,7 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) {
}
if req.PlanMode != nil {
switch *req.PlanMode {
case codersdk.ChatPlanModePlan, "":
// Valid.
default:
if !validateChatPlanMode(*req.PlanMode) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid plan_mode value.",
})
@@ -3395,6 +3465,61 @@ func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Requ
rw.WriteHeader(http.StatusNoContent)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getChatExploreModelOverride(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, policy.ActionRead, rbac.ResourceDeploymentConfig) {
httpapi.ResourceNotFound(rw)
return
}
modelConfigID, hasMalformedOverride, err := api.getChatExploreModelOverrideConfig(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching Explore model override.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatExploreModelOverrideResponse{
ModelConfigID: modelConfigID,
HasMalformedOverride: hasMalformedOverride,
})
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
func (api *API) putChatExploreModelOverride(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
var req codersdk.UpdateChatExploreModelOverrideRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
status, resp := validateChatExploreModelOverrideID(ctx, api.Database, req.ModelConfigID)
if resp != nil {
httpapi.Write(ctx, rw, status, *resp)
return
}
if err := api.Database.UpsertChatExploreModelOverride(ctx, formatChatExploreModelOverride(req.ModelConfigID)); err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating Explore model override.",
Detail: err.Error(),
})
return
}
rw.WriteHeader(http.StatusNoContent)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
+127
View File
@@ -8353,6 +8353,133 @@ func TestChatPlanModeInstructions(t *testing.T) {
})
}
//nolint:tparallel,paralleltest // Subtests share a single coderdtest instance.
func TestChatExploreModelOverride(t *testing.T) {
t.Parallel()
adminClient, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
defaultModel := createChatModelConfig(t, adminClient)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
createAdditionalModel := func(t *testing.T, model string, enabled bool) codersdk.ChatModelConfig {
t.Helper()
ctx := testutil.Context(t, testutil.WaitLong)
contextLimit := int64(4096)
isDefault := false
modelConfig, err := adminClient.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{
Provider: defaultModel.Provider,
Model: model,
ContextLimit: &contextLimit,
IsDefault: &isDefault,
})
require.NoError(t, err)
if enabled {
return modelConfig
}
updated, err := adminClient.UpdateChatModelConfig(ctx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{
Enabled: ptr.Ref(false),
})
require.NoError(t, err)
return updated
}
t.Run("DefaultGETReturnsEmpty", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
resp, err := adminClient.GetChatExploreModelOverride(ctx)
require.NoError(t, err)
require.Nil(t, resp.ModelConfigID)
require.False(t, resp.HasMalformedOverride)
})
t.Run("AdminCanSetAndClear", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
overrideModel := createAdditionalModel(t, "gpt-4.1-mini", true)
err := adminClient.UpdateChatExploreModelOverride(ctx, codersdk.UpdateChatExploreModelOverrideRequest{
ModelConfigID: &overrideModel.ID,
})
require.NoError(t, err)
resp, err := adminClient.GetChatExploreModelOverride(ctx)
require.NoError(t, err)
require.NotNil(t, resp.ModelConfigID)
require.Equal(t, overrideModel.ID, *resp.ModelConfigID)
require.False(t, resp.HasMalformedOverride)
err = adminClient.UpdateChatExploreModelOverride(ctx, codersdk.UpdateChatExploreModelOverrideRequest{})
require.NoError(t, err)
resp, err = adminClient.GetChatExploreModelOverride(ctx)
require.NoError(t, err)
require.Nil(t, resp.ModelConfigID)
require.False(t, resp.HasMalformedOverride)
})
t.Run("MalformedStoredOverrideIsReportedAndCanBeCleared", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
require.NoError(t, db.UpsertChatExploreModelOverride(
dbauthz.AsSystemRestricted(ctx),
"not-a-uuid",
))
resp, err := adminClient.GetChatExploreModelOverride(ctx)
require.NoError(t, err)
require.Nil(t, resp.ModelConfigID)
require.True(t, resp.HasMalformedOverride)
err = adminClient.UpdateChatExploreModelOverride(ctx, codersdk.UpdateChatExploreModelOverrideRequest{})
require.NoError(t, err)
resp, err = adminClient.GetChatExploreModelOverride(ctx)
require.NoError(t, err)
require.Nil(t, resp.ModelConfigID)
require.False(t, resp.HasMalformedOverride)
})
t.Run("DisabledModelReturns400", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
disabledModel := createAdditionalModel(t, "gpt-4.1-disabled", false)
err := adminClient.UpdateChatExploreModelOverride(ctx, codersdk.UpdateChatExploreModelOverrideRequest{
ModelConfigID: &disabledModel.ID,
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Invalid model_config_id.", sdkErr.Message)
})
t.Run("UnknownModelReturns400", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
unknownModelID := uuid.New()
err := adminClient.UpdateChatExploreModelOverride(ctx, codersdk.UpdateChatExploreModelOverrideRequest{
ModelConfigID: &unknownModelID,
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Invalid model_config_id.", sdkErr.Message)
})
t.Run("NonAdminGETReturns404", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
_, err := memberClient.GetChatExploreModelOverride(ctx)
requireSDKError(t, err, http.StatusNotFound)
})
t.Run("NonAdminPUTReturns403", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
err := memberClient.UpdateChatExploreModelOverride(ctx, codersdk.UpdateChatExploreModelOverrideRequest{
ModelConfigID: &defaultModel.ID,
})
requireSDKError(t, err, http.StatusForbidden)
})
}
func TestChatDesktopEnabled(t *testing.T) {
t.Parallel()
+104 -34
View File
@@ -4388,12 +4388,22 @@ type runChatResult struct {
PendingDynamicToolCalls []chatloop.PendingToolCall
}
func allToolNames(allTools []fantasy.AgentTool) []string {
toolNames := make([]string, 0, len(allTools))
for _, tool := range allTools {
toolNames = append(toolNames, tool.Info().Name)
}
return toolNames
}
func isExploreSubagentMode(mode database.NullChatMode) bool {
return mode.Valid && mode.ChatMode == database.ChatModeExplore
}
func allowedPlanToolNames(
allTools []fantasy.AgentTool,
mode database.NullChatPlanMode,
parentChatID uuid.NullUUID,
) []string {
isPlanModeTurn := mode.Valid && mode.ChatPlanMode == database.ChatPlanModePlan
isRootChat := !parentChatID.Valid
builtinPlanPolicy := map[string]bool{
"read_file": true,
@@ -4409,6 +4419,7 @@ func allowedPlanToolNames(
"start_workspace": isRootChat,
"propose_plan": isRootChat,
"spawn_agent": isRootChat,
"spawn_explore_agent": isRootChat,
"wait_agent": isRootChat,
"message_agent": false,
"close_agent": false,
@@ -4417,13 +4428,6 @@ func allowedPlanToolNames(
"read_skill_file": true,
"ask_user_question": isRootChat,
}
if !isPlanModeTurn {
toolNames := make([]string, 0, len(allTools))
for _, tool := range allTools {
toolNames = append(toolNames, tool.Info().Name)
}
return toolNames
}
toolNames := make([]string, 0, len(allTools))
for _, tool := range allTools {
@@ -4435,8 +4439,65 @@ func allowedPlanToolNames(
return toolNames
}
func stopAfterPlanTools(mode database.NullChatPlanMode, parentChatID uuid.NullUUID) map[string]struct{} {
if !mode.Valid || mode.ChatPlanMode != database.ChatPlanModePlan {
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,
"propose_plan": false,
"spawn_agent": false,
"spawn_explore_agent": false,
"wait_agent": false,
"message_agent": false,
"close_agent": false,
"spawn_computer_use_agent": false,
"read_skill": true,
"read_skill_file": true,
"ask_user_question": false,
}
toolNames := make([]string, 0, len(allTools))
for _, tool := range allTools {
name := tool.Info().Name
if builtinExplorePolicy[name] {
toolNames = append(toolNames, name)
}
}
return toolNames
}
// allowedBehaviorToolNames applies behavior-specific precedence for
// tool filtering: Explore mode wins over plan mode, and plan mode wins
// over the default behavior that allows all tools.
func allowedBehaviorToolNames(
allTools []fantasy.AgentTool,
planMode database.NullChatPlanMode,
chatMode database.NullChatMode,
parentChatID uuid.NullUUID,
) []string {
if isExploreSubagentMode(chatMode) {
return allowedExploreToolNames(allTools)
}
if planMode.Valid && planMode.ChatPlanMode == database.ChatPlanModePlan {
return allowedPlanToolNames(allTools, parentChatID)
}
return allToolNames(allTools)
}
func stopAfterBehaviorTools(
planMode database.NullChatPlanMode,
chatMode database.NullChatMode,
parentChatID uuid.NullUUID,
) map[string]struct{} {
if isExploreSubagentMode(chatMode) || !planMode.Valid || planMode.ChatPlanMode != database.ChatPlanModePlan {
return nil
}
stopTools := map[string]struct{}{
@@ -4448,8 +4509,9 @@ func stopAfterPlanTools(mode database.NullChatPlanMode, parentChatID uuid.NullUU
return stopTools
}
type systemPromptPlanContext struct {
mode database.NullChatPlanMode
type systemPromptBehaviorContext struct {
planMode database.NullChatPlanMode
chatMode database.NullChatMode
planModeInstructions string
isRootChat bool
}
@@ -4463,7 +4525,7 @@ func buildSystemPrompt(
instruction string,
skills []chattool.SkillMeta,
userPrompt string,
planContext systemPromptPlanContext,
behaviorContext systemPromptBehaviorContext,
) []fantasy.Message {
if subagentInstruction != "" {
prompt = chatprompt.InsertSystem(prompt, subagentInstruction)
@@ -4477,12 +4539,16 @@ func buildSystemPrompt(
if userPrompt != "" {
prompt = chatprompt.InsertSystem(prompt, userPrompt)
}
isPlanModeTurn := planContext.mode.Valid && planContext.mode.ChatPlanMode == database.ChatPlanModePlan
if isExploreSubagentMode(behaviorContext.chatMode) {
prompt = chatprompt.InsertSystem(prompt, ExploreSubagentOverlayPrompt)
return prompt
}
isPlanModeTurn := behaviorContext.planMode.Valid && behaviorContext.planMode.ChatPlanMode == database.ChatPlanModePlan
if isPlanModeTurn {
if planContext.isRootChat {
if behaviorContext.isRootChat {
prompt = chatprompt.InsertSystem(prompt, PlanningOverlayPrompt)
if planContext.planModeInstructions != "" {
prompt = chatprompt.InsertSystem(prompt, planContext.planModeInstructions)
if behaviorContext.planModeInstructions != "" {
prompt = chatprompt.InsertSystem(prompt, behaviorContext.planModeInstructions)
}
} else {
prompt = chatprompt.InsertSystem(prompt, PlanningSubagentOverlayPrompt)
@@ -4609,7 +4675,7 @@ func (p *Server) appendRootChatTools(
return append(tools, p.subagentTools(ctx, func() database.Chat {
return opts.chat
})...)
}, opts.modelConfigID)...)
}
func (p *Server) storePlanSnapshotFile(
@@ -4670,10 +4736,11 @@ func appendDynamicTools(
logger slog.Logger,
tools []fantasy.AgentTool,
raw pqtype.NullRawMessage,
mode database.NullChatPlanMode,
planMode database.NullChatPlanMode,
chatMode database.NullChatMode,
parentChatID uuid.NullUUID,
) ([]fantasy.AgentTool, map[string]bool, error) {
if mode.Valid && mode.ChatPlanMode == database.ChatPlanModePlan {
if isExploreSubagentMode(chatMode) || (planMode.Valid && planMode.ChatPlanMode == database.ChatPlanModePlan) {
return tools, nil, nil
}
@@ -4693,7 +4760,7 @@ func appendDynamicTools(
}
activeToolNames := make(map[string]struct{}, len(tools))
for _, name := range allowedPlanToolNames(tools, mode, parentChatID) {
for _, name := range allowedBehaviorToolNames(tools, planMode, chatMode, parentChatID) {
activeToolNames[name] = struct{}{}
}
for _, t := range tools {
@@ -4800,11 +4867,11 @@ func (p *Server) runChat(
return result, err
}
// Capture the current turn's mode from the chat plan mode so prompt
// and tool behavior can be resolved consistently for the rest of the
// turn.
// Capture the current turn's mode so prompt and tool behavior can
// be resolved consistently for the rest of the turn.
currentPlanMode := chat.PlanMode
isPlanModeTurn := currentPlanMode.Valid && currentPlanMode.ChatPlanMode == database.ChatPlanModePlan
isExploreSubagent := isExploreSubagentMode(chat.Mode)
planModeInstructions := p.loadPlanModeInstructions(ctx, currentPlanMode, logger)
chainInfo := resolveChainMode(messages)
@@ -5086,8 +5153,9 @@ func (p *Server) runChat(
instruction,
skills,
resolvedUserPrompt,
systemPromptPlanContext{
mode: currentPlanMode,
systemPromptBehaviorContext{
planMode: currentPlanMode,
chatMode: chat.Mode,
planModeInstructions: planModeInstructions,
isRootChat: isRootChat,
},
@@ -5479,7 +5547,7 @@ func (p *Server) runChat(
// Append tools from external MCP servers. These appear
// after the built-in tools so the LLM sees them as
// additional capabilities.
if !isPlanModeTurn {
if !isPlanModeTurn && !isExploreSubagent {
tools = append(tools, mcpTools...)
tools = append(tools, workspaceMCPTools...)
}
@@ -5494,6 +5562,7 @@ func (p *Server) runChat(
tools,
chat.DynamicTools,
currentPlanMode,
chat.Mode,
chat.ParentChatID,
)
if err != nil {
@@ -5503,11 +5572,11 @@ func (p *Server) runChat(
// Build provider-native tools (e.g., web search) based on
// the model configuration.
var providerTools []chatloop.ProviderTool
if !isPlanModeTurn && callConfig.ProviderOptions != nil {
if !isPlanModeTurn && !isExploreSubagent && callConfig.ProviderOptions != nil {
providerTools = buildProviderTools(model.Provider(), callConfig.ProviderOptions)
}
if !isPlanModeTurn && isComputerUse {
if !isPlanModeTurn && !isExploreSubagent && isComputerUse {
desktopGeometry := workspacesdk.DefaultDesktopGeometry()
providerTools = append(providerTools, chatloop.ProviderTool{
Definition: chattool.ComputerUseProviderTool(
@@ -5548,8 +5617,8 @@ func (p *Server) runChat(
Model: model,
Messages: prompt,
Tools: tools,
ActiveTools: allowedPlanToolNames(tools, currentPlanMode, chat.ParentChatID),
StopAfterTools: stopAfterPlanTools(currentPlanMode, chat.ParentChatID),
ActiveTools: allowedBehaviorToolNames(tools, currentPlanMode, chat.Mode, chat.ParentChatID),
StopAfterTools: stopAfterBehaviorTools(currentPlanMode, chat.Mode, chat.ParentChatID),
MaxSteps: maxChatSteps,
Metrics: p.metrics,
BuiltinToolNames: builtinToolNames,
@@ -5609,8 +5678,9 @@ func (p *Server) runChat(
reloadedInstruction,
reloadedSkills,
reloadUserPrompt,
systemPromptPlanContext{
mode: currentPlanMode,
systemPromptBehaviorContext{
planMode: currentPlanMode,
chatMode: chat.Mode,
planModeInstructions: planModeInstructions,
isRootChat: isRootChat,
},
+113 -70
View File
@@ -70,30 +70,7 @@ func TestAllowedPlanToolNames(t *testing.T) {
return tools
}
planMode := database.NullChatPlanMode{
ChatPlanMode: database.ChatPlanModePlan,
Valid: true,
}
t.Run("NormalModeReturnsAllRegisteredTools", func(t *testing.T) {
t.Parallel()
got := allowedPlanToolNames(makeTools(
"read_file",
"propose_plan",
"custom_tool",
"execute",
), database.NullChatPlanMode{}, uuid.NullUUID{})
require.Equal(t, []string{
"read_file",
"propose_plan",
"custom_tool",
"execute",
}, got)
})
t.Run("PlanModeIncludesOnlyAllowlistedBuiltIns", func(t *testing.T) {
t.Run("RootPlanModeIncludesOnlyAllowlistedBuiltIns", func(t *testing.T) {
t.Parallel()
got := allowedPlanToolNames(makeTools(
@@ -110,6 +87,7 @@ func TestAllowedPlanToolNames(t *testing.T) {
"start_workspace",
"propose_plan",
"spawn_agent",
"spawn_explore_agent",
"wait_agent",
"message_agent",
"close_agent",
@@ -117,7 +95,7 @@ func TestAllowedPlanToolNames(t *testing.T) {
"read_skill",
"read_skill_file",
"ask_user_question",
), planMode, uuid.NullUUID{})
), uuid.NullUUID{})
require.Equal(t, []string{
"read_file",
@@ -131,6 +109,7 @@ func TestAllowedPlanToolNames(t *testing.T) {
"start_workspace",
"propose_plan",
"spawn_agent",
"spawn_explore_agent",
"wait_agent",
"read_skill",
"read_skill_file",
@@ -138,7 +117,7 @@ func TestAllowedPlanToolNames(t *testing.T) {
}, got)
})
t.Run("PlanModeChildChatsAllowExplorationOnly", func(t *testing.T) {
t.Run("ChildPlanModeAllowsExplorationOnly", func(t *testing.T) {
t.Parallel()
got := allowedPlanToolNames(makeTools(
@@ -153,11 +132,12 @@ func TestAllowedPlanToolNames(t *testing.T) {
"start_workspace",
"propose_plan",
"spawn_agent",
"spawn_explore_agent",
"wait_agent",
"read_skill",
"read_skill_file",
"ask_user_question",
), planMode, uuid.NullUUID{UUID: uuid.New(), Valid: true})
), uuid.NullUUID{UUID: uuid.New(), Valid: true})
require.Equal(t, []string{
"read_file",
@@ -166,58 +146,116 @@ func TestAllowedPlanToolNames(t *testing.T) {
"read_skill",
"read_skill_file",
}, got)
require.NotContains(t, got, "write_file")
require.NotContains(t, got, "edit_files")
require.NotContains(t, got, "ask_user_question")
require.NotContains(t, got, "propose_plan")
})
t.Run("PlanModeStillExcludesDangerousTools", func(t *testing.T) {
t.Parallel()
got := allowedPlanToolNames(makeTools(
"execute",
"process_output",
"message_agent",
"spawn_computer_use_agent",
"propose_plan",
), planMode, uuid.NullUUID{})
require.Equal(t, []string{"execute", "process_output", "propose_plan"}, got)
require.NotContains(t, got, "message_agent")
require.NotContains(t, got, "spawn_computer_use_agent")
})
t.Run("PlanModeExcludesUnknownTools", func(t *testing.T) {
t.Parallel()
got := allowedPlanToolNames(makeTools(
"read_file",
"custom_tool",
"another_custom_tool",
"propose_plan",
), planMode, uuid.NullUUID{})
require.Equal(t, []string{
"read_file",
"propose_plan",
}, got)
require.NotContains(t, got, "custom_tool")
require.NotContains(t, got, "another_custom_tool")
})
}
func TestStopAfterPlanTools(t *testing.T) {
func TestAllowedExploreToolNames(t *testing.T) {
t.Parallel()
makeTools := func(names ...string) []fantasy.AgentTool {
tools := make([]fantasy.AgentTool, 0, len(names))
for _, name := range names {
tools = append(tools, newTestAgentTool(name))
}
return tools
}
got := allowedExploreToolNames(makeTools(
"read_file",
"write_file",
"edit_files",
"execute",
"process_output",
"process_list",
"process_signal",
"spawn_agent",
"spawn_explore_agent",
"wait_agent",
"read_skill",
"read_skill_file",
"ask_user_question",
))
require.Equal(t, []string{
"read_file",
"execute",
"process_output",
"read_skill",
"read_skill_file",
}, got)
}
func TestAllowedBehaviorToolNames(t *testing.T) {
t.Parallel()
makeTools := func(names ...string) []fantasy.AgentTool {
tools := make([]fantasy.AgentTool, 0, len(names))
for _, name := range names {
tools = append(tools, newTestAgentTool(name))
}
return tools
}
allTools := makeTools("read_file", "custom_tool", "spawn_explore_agent")
planMode := database.NullChatPlanMode{
ChatPlanMode: database.ChatPlanModePlan,
Valid: true,
}
exploreMode := database.NullChatMode{
ChatMode: database.ChatModeExplore,
Valid: true,
}
t.Run("DefaultModeReturnsAllTools", func(t *testing.T) {
t.Parallel()
require.Equal(t, []string{"read_file", "custom_tool", "spawn_explore_agent"}, allowedBehaviorToolNames(
allTools,
database.NullChatPlanMode{},
database.NullChatMode{},
uuid.NullUUID{},
))
})
t.Run("PlanModeUsesPlanAllowlist", func(t *testing.T) {
t.Parallel()
require.Equal(t, []string{"read_file", "spawn_explore_agent"}, allowedBehaviorToolNames(
allTools,
planMode,
database.NullChatMode{},
uuid.NullUUID{},
))
})
t.Run("ExploreModeUsesExploreAllowlist", func(t *testing.T) {
t.Parallel()
require.Equal(t, []string{"read_file"}, allowedBehaviorToolNames(
allTools,
database.NullChatPlanMode{},
exploreMode,
uuid.NullUUID{UUID: uuid.New(), Valid: true},
))
})
}
func TestStopAfterBehaviorTools(t *testing.T) {
t.Parallel()
planMode := database.NullChatPlanMode{
ChatPlanMode: database.ChatPlanModePlan,
Valid: true,
}
exploreMode := database.NullChatMode{
ChatMode: database.ChatModeExplore,
Valid: true,
}
t.Run("NormalModeReturnsNil", func(t *testing.T) {
t.Run("DefaultModeReturnsNil", func(t *testing.T) {
t.Parallel()
require.Nil(t, stopAfterPlanTools(database.NullChatPlanMode{}, uuid.NullUUID{}))
require.Nil(t, stopAfterBehaviorTools(
database.NullChatPlanMode{},
database.NullChatMode{},
uuid.NullUUID{},
))
})
t.Run("RootPlanModeIncludesClarificationTool", func(t *testing.T) {
@@ -225,14 +263,19 @@ func TestStopAfterPlanTools(t *testing.T) {
require.Equal(t, map[string]struct{}{
"propose_plan": {},
"ask_user_question": {},
}, stopAfterPlanTools(planMode, uuid.NullUUID{}))
}, stopAfterBehaviorTools(planMode, database.NullChatMode{}, uuid.NullUUID{}))
})
t.Run("ChildPlanModeSkipsClarificationTool", func(t *testing.T) {
t.Parallel()
require.Equal(t, map[string]struct{}{
"propose_plan": {},
}, stopAfterPlanTools(planMode, uuid.NullUUID{UUID: uuid.New(), Valid: true}))
}, stopAfterBehaviorTools(planMode, database.NullChatMode{}, uuid.NullUUID{UUID: uuid.New(), Valid: true}))
})
t.Run("ExploreModeReturnsNil", func(t *testing.T) {
t.Parallel()
require.Nil(t, stopAfterBehaviorTools(planMode, exploreMode, uuid.NullUUID{}))
})
}
+154
View File
@@ -501,6 +501,160 @@ func TestPlanModeSubagentChatExcludesAskUserQuestion(t *testing.T) {
require.False(t, requestHasSystemSubstring(childRequests[0], "When the plan is ready, call propose_plan"))
}
func TestExploreSubagentIsReadOnly(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
deploymentValues := coderdtest.DeploymentValues(t)
deploymentValues.Experiments = []string{string(codersdk.ExperimentAgents)}
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: deploymentValues,
IncludeProvisionerDaemon: true,
})
user := coderdtest.CreateFirstUser(t, client)
expClient := codersdk.NewExperimentalClient(client)
agentToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.PlanComplete,
ProvisionApply: echo.ApplyComplete,
ProvisionGraph: echo.ProvisionGraphWithAgent(agentToken),
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
cwr.AutomaticUpdates = codersdk.AutomaticUpdatesNever
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
_ = agenttest.New(t, client.URL, agentToken)
coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
var toolsMu sync.Mutex
toolsByCall := make([][]string, 0, 2)
requestsByCall := make([]recordedOpenAIRequest, 0, 2)
var callCount atomic.Int32
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("ok")
}
names := make([]string, 0, len(req.Tools))
for _, tool := range req.Tools {
names = append(names, tool.Function.Name)
}
toolsMu.Lock()
toolsByCall = append(toolsByCall, names)
requestsByCall = append(requestsByCall, recordOpenAIRequest(req))
toolsMu.Unlock()
if callCount.Add(1) == 1 {
return chattest.OpenAIStreamingResponse(
chattest.OpenAIToolCallChunk("spawn_explore_agent", `{"prompt":"investigate the codebase","title":"sub"}`),
)
}
return chattest.OpenAIStreamingResponse(
chattest.OpenAITextChunks("done")...,
)
})
_, err := expClient.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{
Provider: "openai-compat",
APIKey: "test-api-key",
BaseURL: openAIURL,
})
require.NoError(t, err)
contextLimit := int64(4096)
isDefault := true
_, err = expClient.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{
Provider: "openai-compat",
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
IsDefault: &isDefault,
})
require.NoError(t, err)
_, err = expClient.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
WorkspaceID: &workspace.ID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "Spawn an Explore subagent to inspect the codebase.",
},
},
})
require.NoError(t, err)
require.Eventually(t, func() bool {
toolsMu.Lock()
defer toolsMu.Unlock()
sawRoot := false
sawChild := false
for _, tools := range toolsByCall {
if slice.Contains(tools, "spawn_explore_agent") {
sawRoot = true
continue
}
sawChild = true
}
return sawRoot && sawChild
}, testutil.WaitLong, testutil.IntervalFast)
toolsMu.Lock()
recorded := append([][]string(nil), toolsByCall...)
recordedRequests := append([]recordedOpenAIRequest(nil), requestsByCall...)
toolsMu.Unlock()
require.GreaterOrEqual(t, len(recorded), 2,
"expected at least 2 streamed LLM calls (root + subagent)")
require.Len(t, recordedRequests, len(recorded))
var rootCalls, childCalls [][]string
var rootRequests, childRequests []recordedOpenAIRequest
for i, tools := range recorded {
if slice.Contains(tools, "spawn_explore_agent") {
rootCalls = append(rootCalls, tools)
rootRequests = append(rootRequests, recordedRequests[i])
continue
}
childCalls = append(childCalls, tools)
childRequests = append(childRequests, recordedRequests[i])
}
require.NotEmpty(t, rootCalls, "expected at least one root chat LLM call")
require.NotEmpty(t, childCalls, "expected at least one subagent LLM call")
require.NotEmpty(t, rootRequests, "expected at least one root prompt")
require.NotEmpty(t, childRequests, "expected at least one subagent prompt")
require.Contains(t, rootCalls[0], "spawn_agent")
require.Contains(t, rootCalls[0], "spawn_explore_agent")
require.Contains(t, rootCalls[0], "write_file")
require.Contains(t, rootCalls[0], "edit_files")
require.NotContains(t, childCalls[0], "write_file")
require.NotContains(t, childCalls[0], "edit_files")
require.NotContains(t, childCalls[0], "spawn_agent")
require.NotContains(t, childCalls[0], "spawn_explore_agent")
require.NotContains(t, childCalls[0], "wait_agent")
require.Contains(t, childCalls[0], "read_file")
require.Contains(t, childCalls[0], "execute")
require.Contains(t, childCalls[0], "process_output")
require.True(t, requestHasSystemSubstring(childRequests[0], "You are in Explore Mode as a delegated sub-agent."))
require.False(t, requestHasSystemSubstring(rootRequests[0], "You are in Explore Mode as a delegated sub-agent."))
allChats, err := db.GetChats(dbauthz.AsChatd(ctx), database.GetChatsParams{OwnerID: user.UserID})
require.NoError(t, err)
var exploreChildren []database.Chat
for _, candidate := range allChats {
if candidate.Chat.ParentChatID.Valid && candidate.Chat.Mode.Valid && candidate.Chat.Mode.ChatMode == database.ChatModeExplore {
exploreChildren = append(exploreChildren, candidate.Chat)
}
}
require.Len(t, exploreChildren, 1)
}
func TestInterruptChatClearsWorkerInDatabase(t *testing.T) {
t.Parallel()
+9 -1
View File
@@ -89,7 +89,7 @@ Propose a plan when:
If no workspace is attached to this chat yet, create and start one first using create_workspace and start_workspace.
Once a workspace is available:
1. Use spawn_agent and wait_agent to research the codebase and gather context as needed.
1. Use spawn_explore_agent and wait_agent to research the codebase and gather context as needed. Reserve spawn_agent for writable delegated work.
2. Use write_file to create a Markdown plan file at the absolute
chat-specific path from the <plan-file-path> block below when it is
available.
@@ -126,3 +126,11 @@ Every response must help the parent agent produce a plan.
You may use read_file, execute, process_output, read_skill, and read_skill_file for exploration, including cloning repositories, searching code, and running inspection commands.
Do not implement changes or intentionally modify workspace files.
Return concise findings and recommendations to the parent agent.`
// ExploreSubagentOverlayPrompt contains Explore-mode instructions for
// delegated child chats.
const ExploreSubagentOverlayPrompt = `You are in Explore Mode as a delegated sub-agent.
Focus on discovery, code reading, and understanding the existing system.
Use read_file, read_skill, execute, and process_output to inspect the workspace.
Do not intentionally modify workspace files.
Return concise findings and recommendations to the parent agent.`
+2 -2
View File
@@ -130,7 +130,7 @@ func invokeWaitAgentTool(
parentChat, err := db.GetChatByID(ctx, parentID)
require.NoError(t, err)
tools := server.subagentTools(ctx, func() database.Chat { return parentChat })
tools := server.subagentTools(ctx, func() database.Chat { return parentChat }, parentChat.LastModelConfigID)
tool := findToolByName(tools, "wait_agent")
require.NotNil(t, tool, "wait_agent tool must be present")
@@ -525,7 +525,7 @@ func TestWaitAgentTimeoutLeavesRecordingRunning(t *testing.T) {
parentChat, err := db.GetChatByID(ctx, child.ParentChatID.UUID)
require.NoError(t, err)
tools := server.subagentTools(ctx, func() database.Chat { return parentChat })
tools := server.subagentTools(ctx, func() database.Chat { return parentChat }, parentChat.LastModelConfigID)
tool := findToolByName(tools, "wait_agent")
require.NotNil(t, tool, "wait_agent tool must be present")
+134 -7
View File
@@ -16,6 +16,7 @@ import (
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
coderdpubsub "github.com/coder/coder/v2/coderd/pubsub"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
@@ -96,7 +97,71 @@ func (p *Server) isDesktopEnabled(ctx context.Context) bool {
return enabled
}
func (p *Server) subagentTools(ctx context.Context, currentChat func() database.Chat) []fantasy.AgentTool {
func (p *Server) resolveExploreSubagentModelConfigID(
ctx context.Context,
ownerID uuid.UUID,
fallback uuid.UUID,
) (uuid.UUID, error) {
//nolint:gocritic // Chatd needs its scoped deployment-config read access here.
chatdCtx := dbauthz.AsChatd(ctx)
raw, err := p.db.GetChatExploreModelOverride(chatdCtx)
if err != nil {
return uuid.Nil, xerrors.Errorf("get Explore model override: %w", err)
}
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return fallback, nil
}
configuredModelConfigID, err := uuid.Parse(trimmed)
if err != nil {
p.logger.Warn(ctx,
"invalid Explore model override, falling back to current turn model",
slog.F("raw_model_config_id", trimmed),
slog.Error(err),
)
return fallback, nil
}
modelConfig, err := p.db.GetEnabledChatModelConfigByID(
chatdCtx,
configuredModelConfigID,
)
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
p.logger.Warn(ctx,
"explore model override is unavailable, falling back to current turn model",
slog.F("model_config_id", configuredModelConfigID),
)
return fallback, nil
}
return uuid.Nil, xerrors.Errorf("get enabled chat model config by id: %w", err)
}
providerName, _, err := chatprovider.ResolveModelWithProviderHint(
modelConfig.Model,
modelConfig.Provider,
)
if err != nil {
return uuid.Nil, xerrors.Errorf("resolve Explore model provider: %w", err)
}
providerKeys, err := p.resolveUserProviderAPIKeys(ctx, ownerID)
if err != nil {
return uuid.Nil, xerrors.Errorf("resolve provider API keys: %w", err)
}
if providerKeys.APIKey(providerName) == "" {
p.logger.Warn(ctx,
"explore model override credentials are unavailable, falling back to current turn model",
slog.F("model_config_id", configuredModelConfigID),
slog.F("provider", providerName),
)
return fallback, nil
}
return modelConfig.ID, nil
}
func (p *Server) subagentTools(
ctx context.Context,
currentChat func() database.Chat,
currentModelConfigID uuid.UUID,
) []fantasy.AgentTool {
var planMode database.NullChatPlanMode
if currentChat != nil {
planMode = currentChat().PlanMode
@@ -108,9 +173,9 @@ func (p *Server) subagentTools(ctx context.Context, currentChat func() database.
"(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 " +
"read_file, or write_file. For read-only investigation and " +
"codebase discovery, prefer spawn_explore_agent instead. " +
"Reserve writable 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 " +
@@ -122,6 +187,7 @@ func (p *Server) subagentTools(ctx context.Context, currentChat func() database.
if planMode.Valid && planMode.ChatPlanMode == database.ChatPlanModePlan {
spawnAgentDescription += " During plan mode, spawned agents may use shell commands for exploration, such as cloning repositories, searching code, and running inspection commands, but they must not implement changes or intentionally modify workspace files."
}
spawnExploreAgentDescription := "Spawn a read-only delegated child agent for discovery, code reading, and system understanding. Use this when you need investigation, tracing, codebase research, or architecture discovery without intentionally modifying workspace files. The child agent cannot spawn its own subagents and has a restricted toolset focused on reading files and inspection commands. After spawning, use wait_agent to collect the result."
tools := []fantasy.AgentTool{
fantasy.NewAgentTool(
@@ -159,12 +225,67 @@ func (p *Server) subagentTools(ctx context.Context, currentChat func() database.
}), nil
},
),
fantasy.NewAgentTool(
"spawn_explore_agent",
spawnExploreAgentDescription,
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
}
modelConfigID, err := p.resolveExploreSubagentModelConfigID(
ctx,
parent.OwnerID,
currentModelConfigID,
)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
// Explore subagents operate independently of planning.
// Clear plan mode to prevent the child from inheriting
// parent planning behavior.
clearPlanMode := database.NullChatPlanMode{}
childChat, err := p.createChildSubagentChatWithOptions(
ctx,
parent,
args.Prompt,
args.Title,
childSubagentChatOptions{
chatMode: database.NullChatMode{
ChatMode: database.ChatModeExplore,
Valid: true,
},
modelConfigIDOverride: &modelConfigID,
planModeOverride: &clearPlanMode,
},
)
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.",
"Call this after spawn_agent, spawn_explore_agent, or "+
"spawn_computer_use_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
@@ -414,6 +535,7 @@ type childSubagentChatOptions struct {
chatMode database.NullChatMode
systemPrompt string
modelConfigIDOverride *uuid.UUID
planModeOverride *database.NullChatPlanMode
}
func (p *Server) createChildSubagentChat(
@@ -459,6 +581,11 @@ func (p *Server) createChildSubagentChatWithOptions(
return database.Chat{}, xerrors.New("model config is required")
}
childPlanMode := parent.PlanMode
if opts.planModeOverride != nil {
childPlanMode = *opts.planModeOverride
}
mcpServerIDs := parent.MCPServerIDs
if mcpServerIDs == nil {
mcpServerIDs = []uuid.UUID{}
@@ -491,7 +618,7 @@ func (p *Server) createChildSubagentChatWithOptions(
LastModelConfigID: modelConfigID,
Title: title,
Mode: opts.chatMode,
PlanMode: parent.PlanMode,
PlanMode: childPlanMode,
ClientType: parent.ClientType,
Status: database.ChatStatusPending,
MCPServerIDs: mcpServerIDs,
@@ -479,7 +479,7 @@ func TestSpawnComputerUseAgentInheritsContext(t *testing.T) {
ctx := chatdTestContext(t)
parentChat := createParentChatWithInheritedContext(ctx, t, db, server)
tools := server.subagentTools(ctx, func() database.Chat { return parentChat })
tools := server.subagentTools(ctx, func() database.Chat { return parentChat }, parentChat.LastModelConfigID)
tool := findToolByName(tools, "spawn_computer_use_agent")
require.NotNil(t, tool)
+237 -21
View File
@@ -310,6 +310,38 @@ func createInternalParentChat(
return parentChat
}
func runSubagentTool(
ctx context.Context,
t *testing.T,
server *Server,
parentChat database.Chat,
currentModelConfigID uuid.UUID,
toolName string,
args spawnAgentArgs,
) fantasy.ToolResponse {
t.Helper()
tools := server.subagentTools(
ctx,
func() database.Chat { return parentChat },
currentModelConfigID,
)
tool := findToolByName(tools, toolName)
require.NotNil(t, tool, "%s tool must be present", toolName)
input, err := json.Marshal(args)
require.NoError(t, err)
resp, err := tool.Run(ctx, fantasy.ToolCall{
ID: uuid.NewString(),
Name: toolName,
Input: string(input),
})
require.NoError(t, err)
return resp
}
func runSpawnAgentTool(
ctx context.Context,
t *testing.T,
@@ -318,22 +350,15 @@ func runSpawnAgentTool(
args spawnAgentArgs,
) fantasy.ToolResponse {
t.Helper()
tools := server.subagentTools(ctx, func() database.Chat { return parentChat })
tool := findToolByName(tools, "spawn_agent")
require.NotNil(t, tool, "spawn_agent tool must be present")
input, err := json.Marshal(args)
require.NoError(t, err)
resp, err := tool.Run(ctx, fantasy.ToolCall{
ID: uuid.NewString(),
Name: "spawn_agent",
Input: string(input),
})
require.NoError(t, err)
return resp
return runSubagentTool(
ctx,
t,
server,
parentChat,
parentChat.LastModelConfigID,
"spawn_agent",
args,
)
}
func requireSpawnAgentChildChatID(t *testing.T, resp fantasy.ToolResponse) uuid.UUID {
@@ -442,6 +467,197 @@ func TestCreateChildSubagentChat_OverrideWorksWhenParentHasNoModel(t *testing.T)
require.Equal(t, overrideModel.ID, childChat.LastModelConfigID)
}
func TestSpawnExploreAgent_UsesConfiguredModelOverride(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(ctx, t, db)
overrideModel := insertInternalChatModelConfig(
ctx, t, db, user.ID, "explore-override-"+uuid.NewString(), true,
)
require.NoError(t, db.UpsertChatExploreModelOverride(ctx, overrideModel.ID.String()))
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, model.ID, "parent-explore-override",
)
resp := runSubagentTool(
ctx,
t,
server,
parentChat,
parentChat.LastModelConfigID,
"spawn_explore_agent",
spawnAgentArgs{Prompt: "investigate the codebase"},
)
childID := requireSpawnAgentChildChatID(t, resp)
childChat, err := db.GetChatByID(ctx, childID)
require.NoError(t, err)
require.Equal(t, overrideModel.ID, childChat.LastModelConfigID)
require.True(t, childChat.Mode.Valid)
require.Equal(t, database.ChatModeExplore, childChat.Mode.ChatMode)
require.False(t, childChat.PlanMode.Valid)
}
func TestSpawnExploreAgent_FallsBackToCurrentTurnModel(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, parentModel := seedInternalChatDeps(ctx, t, db)
currentTurnModel := insertInternalChatModelConfig(
ctx, t, db, user.ID, "explore-current-turn-"+uuid.NewString(), true,
)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-explore-fallback",
)
resp := runSubagentTool(
ctx,
t,
server,
parentChat,
currentTurnModel.ID,
"spawn_explore_agent",
spawnAgentArgs{Prompt: "trace the request flow"},
)
childID := requireSpawnAgentChildChatID(t, resp)
childChat, err := db.GetChatByID(ctx, childID)
require.NoError(t, err)
require.Equal(t, currentTurnModel.ID, childChat.LastModelConfigID)
require.Equal(t, parentModel.ID, parentChat.LastModelConfigID)
}
func TestSpawnExploreAgent_FallsBackOnInvalidUUID(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, parentModel := seedInternalChatDeps(ctx, t, db)
currentTurnModel := insertInternalChatModelConfig(
ctx, t, db, user.ID, "explore-invalid-override-"+uuid.NewString(), true,
)
require.NoError(t, db.UpsertChatExploreModelOverride(ctx, "not-a-uuid"))
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-explore-invalid-override",
)
resp := runSubagentTool(
ctx,
t,
server,
parentChat,
currentTurnModel.ID,
"spawn_explore_agent",
spawnAgentArgs{Prompt: "inspect the handler flow"},
)
childID := requireSpawnAgentChildChatID(t, resp)
childChat, err := db.GetChatByID(ctx, childID)
require.NoError(t, err)
require.Equal(t, currentTurnModel.ID, childChat.LastModelConfigID)
}
func TestSpawnExploreAgent_FallsBackWhenOverrideIsUnavailable(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, parentModel := seedInternalChatDeps(ctx, t, db)
currentTurnModel := insertInternalChatModelConfig(
ctx, t, db, user.ID, "explore-fallback-current-"+uuid.NewString(), true,
)
disabledModel := insertInternalChatModelConfig(
ctx, t, db, user.ID, "explore-disabled-"+uuid.NewString(), false,
)
require.NoError(t, db.UpsertChatExploreModelOverride(ctx, disabledModel.ID.String()))
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-explore-disabled",
)
resp := runSubagentTool(
ctx,
t,
server,
parentChat,
currentTurnModel.ID,
"spawn_explore_agent",
spawnAgentArgs{Prompt: "inspect the service boundaries"},
)
childID := requireSpawnAgentChildChatID(t, resp)
childChat, err := db.GetChatByID(ctx, childID)
require.NoError(t, err)
require.Equal(t, currentTurnModel.ID, childChat.LastModelConfigID)
}
func TestSpawnExploreAgent_FallsBackWhenOverrideCredentialsAreUnavailable(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, parentModel := seedInternalChatDeps(ctx, t, db)
currentTurnModel := insertInternalChatModelConfig(
ctx, t, db, user.ID, "explore-missing-user-key-current-"+uuid.NewString(), true,
)
_, err := db.InsertChatProvider(ctx, database.InsertChatProviderParams{
Provider: "openai-compat",
DisplayName: "OpenAI Compat",
APIKey: "",
BaseUrl: "",
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
Enabled: true,
CentralApiKeyEnabled: false,
AllowUserApiKey: true,
AllowCentralApiKeyFallback: false,
})
require.NoError(t, err)
overrideModel, err := db.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{
Provider: "openai-compat",
Model: "gpt-4o-mini",
DisplayName: "Explore Override Missing User Key",
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
Enabled: true,
IsDefault: false,
ContextLimit: 128000,
CompressionThreshold: 70,
Options: json.RawMessage(`{}`),
})
require.NoError(t, err)
require.NoError(t, db.UpsertChatExploreModelOverride(ctx, overrideModel.ID.String()))
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-explore-missing-user-key",
)
resp := runSubagentTool(
ctx,
t,
server,
parentChat,
currentTurnModel.ID,
"spawn_explore_agent",
spawnAgentArgs{Prompt: "inspect provider credential handling"},
)
childID := requireSpawnAgentChildChatID(t, resp)
childChat, err := db.GetChatByID(ctx, childID)
require.NoError(t, err)
require.Equal(t, currentTurnModel.ID, childChat.LastModelConfigID)
}
func TestSpawnComputerUseAgent_NoAnthropicProvider(t *testing.T) {
t.Parallel()
@@ -467,7 +683,7 @@ func TestSpawnComputerUseAgent_NoAnthropicProvider(t *testing.T) {
parentChat, err := db.GetChatByID(ctx, parent.ID)
require.NoError(t, err)
tools := server.subagentTools(ctx, func() database.Chat { return parentChat })
tools := server.subagentTools(ctx, func() database.Chat { return parentChat }, parentChat.LastModelConfigID)
tool := findToolByName(tools, "spawn_computer_use_agent")
assert.Nil(t, tool, "spawn_computer_use_agent tool must be omitted when Anthropic is not configured")
}
@@ -520,7 +736,7 @@ func TestSpawnComputerUseAgent_NotAvailableForChildChats(t *testing.T) {
"child chat must have a parent")
// Get tools as if the child chat is the current chat.
tools := server.subagentTools(ctx, func() database.Chat { return childChat })
tools := server.subagentTools(ctx, func() database.Chat { return childChat }, childChat.LastModelConfigID)
tool := findToolByName(tools, "spawn_computer_use_agent")
require.NotNil(t, tool, "spawn_computer_use_agent tool must be present")
@@ -556,7 +772,7 @@ func TestSpawnComputerUseAgent_DesktopDisabled(t *testing.T) {
parentChat, err := db.GetChatByID(ctx, parent.ID)
require.NoError(t, err)
tools := server.subagentTools(ctx, func() database.Chat { return parentChat })
tools := server.subagentTools(ctx, func() database.Chat { return parentChat }, parentChat.LastModelConfigID)
tool := findToolByName(tools, "spawn_computer_use_agent")
assert.Nil(t, tool, "spawn_computer_use_agent tool must be omitted when desktop is disabled")
}
@@ -603,7 +819,7 @@ func TestSpawnComputerUseAgent_UsesComputerUseModelNotParent(t *testing.T) {
parentChat, err := db.GetChatByID(ctx, parent.ID)
require.NoError(t, err)
tools := server.subagentTools(ctx, func() database.Chat { return parentChat })
tools := server.subagentTools(ctx, func() database.Chat { return parentChat }, parentChat.LastModelConfigID)
tool := findToolByName(tools, "spawn_computer_use_agent")
require.NotNil(t, tool)
@@ -768,7 +984,7 @@ func TestSpawnComputerUseAgent_InheritsMCPServerIDs(t *testing.T) {
require.NoError(t, err)
// Call spawn_computer_use_agent via the tool.
tools := server.subagentTools(ctx, func() database.Chat { return parentChat })
tools := server.subagentTools(ctx, func() database.Chat { return parentChat }, parentChat.LastModelConfigID)
tool := findToolByName(tools, "spawn_computer_use_agent")
require.NotNil(t, tool)