mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Generated
+2
-1
@@ -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';
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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{}))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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.`
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user