mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add plan mode with restricted tool boundary (#24236)
> This PR was authored by Mux on behalf of Mike. ## Summary - add persistent plan mode for chats and the chat-specific plan file flow - add structured planning tools such as `ask_user_question` and `propose_plan` - keep `write_file` and `edit_files` constrained to the chat-specific plan file during plan turns - allow shell exploration in plan mode, including subagents, via `execute` and `process_output` - block implementation-oriented, provider-native, MCP, dynamic, and computer-use tools during plan turns - update the chat UI, tests, and docs for the new planning flow
This commit is contained in:
@@ -1181,6 +1181,8 @@ func New(options *Options) *API {
|
||||
r.Route("/config", func(r chi.Router) {
|
||||
r.Get("/system-prompt", api.getChatSystemPrompt)
|
||||
r.Put("/system-prompt", api.putChatSystemPrompt)
|
||||
r.Get("/plan-mode-instructions", api.getChatPlanModeInstructions)
|
||||
r.Put("/plan-mode-instructions", api.putChatPlanModeInstructions)
|
||||
r.Get("/desktop-enabled", api.getChatDesktopEnabled)
|
||||
r.Put("/desktop-enabled", api.putChatDesktopEnabled)
|
||||
r.Get("/user-prompt", api.getUserChatCustomPrompt)
|
||||
|
||||
@@ -1601,6 +1601,9 @@ func Chat(c database.Chat, diffStatus *database.ChatDiffStatus, files []database
|
||||
if c.LastError.Valid {
|
||||
chat.LastError = &c.LastError.String
|
||||
}
|
||||
if c.PlanMode.Valid {
|
||||
chat.PlanMode = codersdk.ChatPlanMode(c.PlanMode.ChatPlanMode)
|
||||
}
|
||||
if c.ParentChatID.Valid {
|
||||
parentChatID := c.ParentChatID.UUID
|
||||
chat.ParentChatID = &parentChatID
|
||||
|
||||
@@ -817,6 +817,7 @@ func TestChat_AllFieldsPopulated(t *testing.T) {
|
||||
UpdatedAt: now,
|
||||
Archived: true,
|
||||
PinOrder: 1,
|
||||
PlanMode: database.NullChatPlanMode{ChatPlanMode: database.ChatPlanModePlan, Valid: true},
|
||||
MCPServerIDs: []uuid.UUID{uuid.New()},
|
||||
Labels: database.StringMap{"env": "prod"},
|
||||
LastInjectedContext: pqtype.NullRawMessage{
|
||||
|
||||
@@ -2798,6 +2798,13 @@ func (q *querier) GetChatModelConfigsForTelemetry(ctx context.Context) ([]databa
|
||||
return q.db.GetChatModelConfigsForTelemetry(ctx)
|
||||
}
|
||||
|
||||
func (q *querier) GetChatPlanModeInstructions(ctx context.Context) (string, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return q.db.GetChatPlanModeInstructions(ctx)
|
||||
}
|
||||
|
||||
func (q *querier) GetChatProviderByID(ctx context.Context, id uuid.UUID) (database.ChatProvider, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil {
|
||||
return database.ChatProvider{}, err
|
||||
@@ -6090,6 +6097,17 @@ func (q *querier) UpdateChatPinOrder(ctx context.Context, arg database.UpdateCha
|
||||
return q.db.UpdateChatPinOrder(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateChatPlanModeByID(ctx context.Context, arg database.UpdateChatPlanModeByIDParams) (database.Chat, error) {
|
||||
chat, err := q.db.GetChatByID(ctx, arg.ID)
|
||||
if err != nil {
|
||||
return database.Chat{}, err
|
||||
}
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
|
||||
return database.Chat{}, err
|
||||
}
|
||||
return q.db.UpdateChatPlanModeByID(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateChatProvider(ctx context.Context, arg database.UpdateChatProviderParams) (database.ChatProvider, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
|
||||
return database.ChatProvider{}, err
|
||||
@@ -7267,6 +7285,13 @@ func (q *querier) UpsertChatIncludeDefaultSystemPrompt(ctx context.Context, incl
|
||||
return q.db.UpsertChatIncludeDefaultSystemPrompt(ctx, includeDefaultSystemPrompt)
|
||||
}
|
||||
|
||||
func (q *querier) UpsertChatPlanModeInstructions(ctx context.Context, value string) error {
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
return q.db.UpsertChatPlanModeInstructions(ctx, value)
|
||||
}
|
||||
|
||||
func (q *querier) UpsertChatRetentionDays(ctx context.Context, retentionDays int32) 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("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)
|
||||
}))
|
||||
s.Run("GetChatTemplateAllowlist", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
dbm.EXPECT().GetChatTemplateAllowlist(gomock.Any()).Return("", nil).AnyTimes()
|
||||
check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead)
|
||||
@@ -949,6 +953,16 @@ func (s *MethodTestSuite) TestChats() {
|
||||
dbm.EXPECT().UpdateChatLastModelConfigByID(gomock.Any(), arg).Return(chat, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(chat)
|
||||
}))
|
||||
s.Run("UpdateChatPlanModeByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
chat := testutil.Fake(s.T(), faker, database.Chat{})
|
||||
arg := database.UpdateChatPlanModeByIDParams{
|
||||
ID: chat.ID,
|
||||
PlanMode: database.NullChatPlanMode{ChatPlanMode: database.ChatPlanModePlan, Valid: true},
|
||||
}
|
||||
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
|
||||
dbm.EXPECT().UpdateChatPlanModeByID(gomock.Any(), arg).Return(chat, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(chat)
|
||||
}))
|
||||
s.Run("UpdateChatStatusPreserveUpdatedAt", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
chat := testutil.Fake(s.T(), faker, database.Chat{})
|
||||
arg := database.UpdateChatStatusPreserveUpdatedAtParams{
|
||||
@@ -1115,6 +1129,10 @@ func (s *MethodTestSuite) TestChats() {
|
||||
dbm.EXPECT().UpsertChatDesktopEnabled(gomock.Any(), false).Return(nil).AnyTimes()
|
||||
check.Args(false).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)
|
||||
}))
|
||||
s.Run("UpsertChatTemplateAllowlist", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
|
||||
dbm.EXPECT().UpsertChatTemplateAllowlist(gomock.Any(), "").Return(nil).AnyTimes()
|
||||
check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
|
||||
|
||||
@@ -1312,6 +1312,14 @@ func (m queryMetricsStore) GetChatModelConfigsForTelemetry(ctx context.Context)
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetChatPlanModeInstructions(ctx context.Context) (string, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetChatPlanModeInstructions(ctx)
|
||||
m.queryLatencies.WithLabelValues("GetChatPlanModeInstructions").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatPlanModeInstructions").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetChatProviderByID(ctx context.Context, id uuid.UUID) (database.ChatProvider, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetChatProviderByID(ctx, id)
|
||||
@@ -4376,6 +4384,14 @@ func (m queryMetricsStore) UpdateChatPinOrder(ctx context.Context, arg database.
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpdateChatPlanModeByID(ctx context.Context, arg database.UpdateChatPlanModeByIDParams) (database.Chat, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.UpdateChatPlanModeByID(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("UpdateChatPlanModeByID").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatPlanModeByID").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpdateChatProvider(ctx context.Context, arg database.UpdateChatProviderParams) (database.ChatProvider, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.UpdateChatProvider(ctx, arg)
|
||||
@@ -5184,6 +5200,14 @@ func (m queryMetricsStore) UpsertChatIncludeDefaultSystemPrompt(ctx context.Cont
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpsertChatPlanModeInstructions(ctx context.Context, value string) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.UpsertChatPlanModeInstructions(ctx, value)
|
||||
m.queryLatencies.WithLabelValues("UpsertChatPlanModeInstructions").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertChatPlanModeInstructions").Inc()
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpsertChatRetentionDays(ctx context.Context, retentionDays int32) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.UpsertChatRetentionDays(ctx, retentionDays)
|
||||
|
||||
@@ -2417,6 +2417,21 @@ func (mr *MockStoreMockRecorder) GetChatModelConfigsForTelemetry(ctx any) *gomoc
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatModelConfigsForTelemetry", reflect.TypeOf((*MockStore)(nil).GetChatModelConfigsForTelemetry), ctx)
|
||||
}
|
||||
|
||||
// GetChatPlanModeInstructions mocks base method.
|
||||
func (m *MockStore) GetChatPlanModeInstructions(ctx context.Context) (string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetChatPlanModeInstructions", ctx)
|
||||
ret0, _ := ret[0].(string)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetChatPlanModeInstructions indicates an expected call of GetChatPlanModeInstructions.
|
||||
func (mr *MockStoreMockRecorder) GetChatPlanModeInstructions(ctx any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatPlanModeInstructions", reflect.TypeOf((*MockStore)(nil).GetChatPlanModeInstructions), ctx)
|
||||
}
|
||||
|
||||
// GetChatProviderByID mocks base method.
|
||||
func (m *MockStore) GetChatProviderByID(ctx context.Context, id uuid.UUID) (database.ChatProvider, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -8282,6 +8297,21 @@ func (mr *MockStoreMockRecorder) UpdateChatPinOrder(ctx, arg any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatPinOrder", reflect.TypeOf((*MockStore)(nil).UpdateChatPinOrder), ctx, arg)
|
||||
}
|
||||
|
||||
// UpdateChatPlanModeByID mocks base method.
|
||||
func (m *MockStore) UpdateChatPlanModeByID(ctx context.Context, arg database.UpdateChatPlanModeByIDParams) (database.Chat, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpdateChatPlanModeByID", ctx, arg)
|
||||
ret0, _ := ret[0].(database.Chat)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UpdateChatPlanModeByID indicates an expected call of UpdateChatPlanModeByID.
|
||||
func (mr *MockStoreMockRecorder) UpdateChatPlanModeByID(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatPlanModeByID", reflect.TypeOf((*MockStore)(nil).UpdateChatPlanModeByID), ctx, arg)
|
||||
}
|
||||
|
||||
// UpdateChatProvider mocks base method.
|
||||
func (m *MockStore) UpdateChatProvider(ctx context.Context, arg database.UpdateChatProviderParams) (database.ChatProvider, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -9741,6 +9771,20 @@ func (mr *MockStoreMockRecorder) UpsertChatIncludeDefaultSystemPrompt(ctx, inclu
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatIncludeDefaultSystemPrompt", reflect.TypeOf((*MockStore)(nil).UpsertChatIncludeDefaultSystemPrompt), ctx, includeDefaultSystemPrompt)
|
||||
}
|
||||
|
||||
// UpsertChatPlanModeInstructions mocks base method.
|
||||
func (m *MockStore) UpsertChatPlanModeInstructions(ctx context.Context, value string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpsertChatPlanModeInstructions", ctx, value)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// UpsertChatPlanModeInstructions indicates an expected call of UpsertChatPlanModeInstructions.
|
||||
func (mr *MockStoreMockRecorder) UpsertChatPlanModeInstructions(ctx, value any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatPlanModeInstructions", reflect.TypeOf((*MockStore)(nil).UpsertChatPlanModeInstructions), ctx, value)
|
||||
}
|
||||
|
||||
// UpsertChatRetentionDays mocks base method.
|
||||
func (m *MockStore) UpsertChatRetentionDays(ctx context.Context, retentionDays int32) error {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
Generated
+6
-1
@@ -287,6 +287,10 @@ CREATE TYPE chat_mode AS ENUM (
|
||||
'computer_use'
|
||||
);
|
||||
|
||||
CREATE TYPE chat_plan_mode AS ENUM (
|
||||
'plan'
|
||||
);
|
||||
|
||||
CREATE TYPE chat_status AS ENUM (
|
||||
'waiting',
|
||||
'pending',
|
||||
@@ -1470,7 +1474,8 @@ CREATE TABLE chats (
|
||||
last_read_message_id bigint,
|
||||
last_injected_context jsonb,
|
||||
dynamic_tools jsonb,
|
||||
organization_id uuid NOT NULL
|
||||
organization_id uuid NOT NULL,
|
||||
plan_mode chat_plan_mode
|
||||
);
|
||||
|
||||
CREATE TABLE connection_logs (
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE chats DROP COLUMN plan_mode;
|
||||
DROP TYPE chat_plan_mode;
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE TYPE chat_plan_mode AS ENUM ('plan');
|
||||
ALTER TABLE chats ADD COLUMN plan_mode chat_plan_mode;
|
||||
@@ -800,6 +800,7 @@ func (q *sqlQuerier) GetAuthorizedChats(ctx context.Context, arg GetChatsParams,
|
||||
&i.Chat.LastInjectedContext,
|
||||
&i.Chat.DynamicTools,
|
||||
&i.Chat.OrganizationID,
|
||||
&i.Chat.PlanMode,
|
||||
&i.HasUnread); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1287,6 +1287,61 @@ func AllChatModeValues() []ChatMode {
|
||||
}
|
||||
}
|
||||
|
||||
type ChatPlanMode string
|
||||
|
||||
const (
|
||||
ChatPlanModePlan ChatPlanMode = "plan"
|
||||
)
|
||||
|
||||
func (e *ChatPlanMode) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = ChatPlanMode(s)
|
||||
case string:
|
||||
*e = ChatPlanMode(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for ChatPlanMode: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullChatPlanMode struct {
|
||||
ChatPlanMode ChatPlanMode `json:"chat_plan_mode"`
|
||||
Valid bool `json:"valid"` // Valid is true if ChatPlanMode is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullChatPlanMode) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.ChatPlanMode, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.ChatPlanMode.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullChatPlanMode) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.ChatPlanMode), nil
|
||||
}
|
||||
|
||||
func (e ChatPlanMode) Valid() bool {
|
||||
switch e {
|
||||
case ChatPlanModePlan:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func AllChatPlanModeValues() []ChatPlanMode {
|
||||
return []ChatPlanMode{
|
||||
ChatPlanModePlan,
|
||||
}
|
||||
}
|
||||
|
||||
type ChatStatus string
|
||||
|
||||
const (
|
||||
@@ -4247,6 +4302,7 @@ type Chat struct {
|
||||
LastInjectedContext pqtype.NullRawMessage `db:"last_injected_context" json:"last_injected_context"`
|
||||
DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"`
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"`
|
||||
}
|
||||
|
||||
type ChatDebugRun struct {
|
||||
|
||||
@@ -306,6 +306,7 @@ type sqlcQuerier interface {
|
||||
GetChatModelConfigs(ctx context.Context) ([]ChatModelConfig, error)
|
||||
// Returns all model configurations for telemetry snapshot collection.
|
||||
GetChatModelConfigsForTelemetry(ctx context.Context) ([]GetChatModelConfigsForTelemetryRow, error)
|
||||
GetChatPlanModeInstructions(ctx context.Context) (string, error)
|
||||
GetChatProviderByID(ctx context.Context, id uuid.UUID) (ChatProvider, error)
|
||||
GetChatProviderByProvider(ctx context.Context, provider string) (ChatProvider, error)
|
||||
GetChatProviders(ctx context.Context) ([]ChatProvider, error)
|
||||
@@ -982,6 +983,7 @@ type sqlcQuerier interface {
|
||||
UpdateChatMessageByID(ctx context.Context, arg UpdateChatMessageByIDParams) (ChatMessage, error)
|
||||
UpdateChatModelConfig(ctx context.Context, arg UpdateChatModelConfigParams) (ChatModelConfig, error)
|
||||
UpdateChatPinOrder(ctx context.Context, arg UpdateChatPinOrderParams) error
|
||||
UpdateChatPlanModeByID(ctx context.Context, arg UpdateChatPlanModeByIDParams) (Chat, error)
|
||||
UpdateChatProvider(ctx context.Context, arg UpdateChatProviderParams) (ChatProvider, error)
|
||||
UpdateChatStatus(ctx context.Context, arg UpdateChatStatusParams) (Chat, error)
|
||||
UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg UpdateChatStatusPreserveUpdatedAtParams) (Chat, error)
|
||||
@@ -1098,6 +1100,7 @@ type sqlcQuerier interface {
|
||||
UpsertChatDiffStatus(ctx context.Context, arg UpsertChatDiffStatusParams) (ChatDiffStatus, error)
|
||||
UpsertChatDiffStatusReference(ctx context.Context, arg UpsertChatDiffStatusReferenceParams) (ChatDiffStatus, error)
|
||||
UpsertChatIncludeDefaultSystemPrompt(ctx context.Context, includeDefaultSystemPrompt bool) error
|
||||
UpsertChatPlanModeInstructions(ctx context.Context, value string) error
|
||||
UpsertChatRetentionDays(ctx context.Context, retentionDays int32) error
|
||||
UpsertChatSystemPrompt(ctx context.Context, value string) error
|
||||
UpsertChatTemplateAllowlist(ctx context.Context, templateAllowlist string) error
|
||||
|
||||
+121
-25
@@ -4816,7 +4816,7 @@ WHERE
|
||||
$3::int
|
||||
)
|
||||
RETURNING
|
||||
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
|
||||
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
|
||||
`
|
||||
|
||||
type AcquireChatsParams struct {
|
||||
@@ -4862,6 +4862,7 @@ func (q *sqlQuerier) AcquireChats(ctx context.Context, arg AcquireChatsParams) (
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -5000,9 +5001,9 @@ WITH chats AS (
|
||||
UPDATE chats
|
||||
SET archived = true, pin_order = 0, updated_at = NOW()
|
||||
WHERE id = $1::uuid OR root_chat_id = $1::uuid
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
|
||||
)
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
|
||||
FROM chats
|
||||
ORDER BY (id = $1::uuid) DESC, created_at ASC, id ASC
|
||||
`
|
||||
@@ -5042,6 +5043,7 @@ func (q *sqlQuerier) ArchiveChatByID(ctx context.Context, id uuid.UUID) ([]Chat,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -5191,7 +5193,7 @@ func (q *sqlQuerier) DeleteOldChats(ctx context.Context, arg DeleteOldChatsParam
|
||||
}
|
||||
|
||||
const getActiveChatsByAgentID = `-- name: GetActiveChatsByAgentID :many
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
|
||||
FROM chats
|
||||
WHERE agent_id = $1::uuid
|
||||
AND archived = false
|
||||
@@ -5237,6 +5239,7 @@ func (q *sqlQuerier) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.U
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -5253,7 +5256,7 @@ func (q *sqlQuerier) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.U
|
||||
|
||||
const getChatByID = `-- name: GetChatByID :one
|
||||
SELECT
|
||||
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
|
||||
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
|
||||
FROM
|
||||
chats
|
||||
WHERE
|
||||
@@ -5289,12 +5292,13 @@ func (q *sqlQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getChatByIDForUpdate = `-- name: GetChatByIDForUpdate :one
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id FROM chats WHERE id = $1::uuid FOR UPDATE
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode FROM chats WHERE id = $1::uuid FOR UPDATE
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Chat, error) {
|
||||
@@ -5326,6 +5330,7 @@ func (q *sqlQuerier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Ch
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -6380,7 +6385,7 @@ func (q *sqlQuerier) GetChatUsageLimitUserOverride(ctx context.Context, userID u
|
||||
|
||||
const getChats = `-- name: GetChats :many
|
||||
SELECT
|
||||
chats.id, chats.owner_id, chats.workspace_id, chats.title, chats.status, chats.worker_id, chats.started_at, chats.heartbeat_at, chats.created_at, chats.updated_at, chats.parent_chat_id, chats.root_chat_id, chats.last_model_config_id, chats.archived, chats.last_error, chats.mode, chats.mcp_server_ids, chats.labels, chats.build_id, chats.agent_id, chats.pin_order, chats.last_read_message_id, chats.last_injected_context, chats.dynamic_tools, chats.organization_id,
|
||||
chats.id, chats.owner_id, chats.workspace_id, chats.title, chats.status, chats.worker_id, chats.started_at, chats.heartbeat_at, chats.created_at, chats.updated_at, chats.parent_chat_id, chats.root_chat_id, chats.last_model_config_id, chats.archived, chats.last_error, chats.mode, chats.mcp_server_ids, chats.labels, chats.build_id, chats.agent_id, chats.pin_order, chats.last_read_message_id, chats.last_injected_context, chats.dynamic_tools, chats.organization_id, chats.plan_mode,
|
||||
EXISTS (
|
||||
SELECT 1 FROM chat_messages cm
|
||||
WHERE cm.chat_id = chats.id
|
||||
@@ -6494,6 +6499,7 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]GetCha
|
||||
&i.Chat.LastInjectedContext,
|
||||
&i.Chat.DynamicTools,
|
||||
&i.Chat.OrganizationID,
|
||||
&i.Chat.PlanMode,
|
||||
&i.HasUnread,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -6510,7 +6516,7 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]GetCha
|
||||
}
|
||||
|
||||
const getChatsByWorkspaceIDs = `-- name: GetChatsByWorkspaceIDs :many
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
|
||||
FROM chats
|
||||
WHERE archived = false
|
||||
AND workspace_id = ANY($1::uuid[])
|
||||
@@ -6552,6 +6558,7 @@ func (q *sqlQuerier) GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -6679,7 +6686,7 @@ func (q *sqlQuerier) GetLastChatMessageByRole(ctx context.Context, arg GetLastCh
|
||||
|
||||
const getStaleChats = `-- name: GetStaleChats :many
|
||||
SELECT
|
||||
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
|
||||
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
|
||||
FROM
|
||||
chats
|
||||
WHERE
|
||||
@@ -6728,6 +6735,7 @@ func (q *sqlQuerier) GetStaleChats(ctx context.Context, staleThreshold time.Time
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -6816,6 +6824,7 @@ INSERT INTO chats (
|
||||
last_model_config_id,
|
||||
title,
|
||||
mode,
|
||||
plan_mode,
|
||||
status,
|
||||
mcp_server_ids,
|
||||
labels,
|
||||
@@ -6831,13 +6840,14 @@ INSERT INTO chats (
|
||||
$8::uuid,
|
||||
$9::text,
|
||||
$10::chat_mode,
|
||||
$11::chat_status,
|
||||
COALESCE($12::uuid[], '{}'::uuid[]),
|
||||
COALESCE($13::jsonb, '{}'::jsonb),
|
||||
$14::jsonb
|
||||
$11::chat_plan_mode,
|
||||
$12::chat_status,
|
||||
COALESCE($13::uuid[], '{}'::uuid[]),
|
||||
COALESCE($14::jsonb, '{}'::jsonb),
|
||||
$15::jsonb
|
||||
)
|
||||
RETURNING
|
||||
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
|
||||
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
|
||||
`
|
||||
|
||||
type InsertChatParams struct {
|
||||
@@ -6851,6 +6861,7 @@ type InsertChatParams struct {
|
||||
LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Mode NullChatMode `db:"mode" json:"mode"`
|
||||
PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"`
|
||||
Status ChatStatus `db:"status" json:"status"`
|
||||
MCPServerIDs []uuid.UUID `db:"mcp_server_ids" json:"mcp_server_ids"`
|
||||
Labels pqtype.NullRawMessage `db:"labels" json:"labels"`
|
||||
@@ -6869,6 +6880,7 @@ func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat
|
||||
arg.LastModelConfigID,
|
||||
arg.Title,
|
||||
arg.Mode,
|
||||
arg.PlanMode,
|
||||
arg.Status,
|
||||
pq.Array(arg.MCPServerIDs),
|
||||
arg.Labels,
|
||||
@@ -6901,6 +6913,7 @@ func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -7429,9 +7442,9 @@ WITH chats AS (
|
||||
archived = false,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1::uuid OR root_chat_id = $1::uuid
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
|
||||
)
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
|
||||
FROM chats
|
||||
ORDER BY (id = $1::uuid) DESC, created_at ASC, id ASC
|
||||
`
|
||||
@@ -7475,6 +7488,7 @@ func (q *sqlQuerier) UnarchiveChatByID(ctx context.Context, id uuid.UUID) ([]Cha
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -7555,7 +7569,7 @@ UPDATE chats SET
|
||||
updated_at = NOW()
|
||||
WHERE
|
||||
id = $3::uuid
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
|
||||
`
|
||||
|
||||
type UpdateChatBuildAgentBindingParams struct {
|
||||
@@ -7593,6 +7607,7 @@ func (q *sqlQuerier) UpdateChatBuildAgentBinding(ctx context.Context, arg Update
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -7606,7 +7621,7 @@ SET
|
||||
WHERE
|
||||
id = $2::uuid
|
||||
RETURNING
|
||||
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
|
||||
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
|
||||
`
|
||||
|
||||
type UpdateChatByIDParams struct {
|
||||
@@ -7643,6 +7658,7 @@ func (q *sqlQuerier) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParam
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -7701,7 +7717,7 @@ SET
|
||||
WHERE
|
||||
id = $2::uuid
|
||||
RETURNING
|
||||
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
|
||||
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
|
||||
`
|
||||
|
||||
type UpdateChatLabelsByIDParams struct {
|
||||
@@ -7738,6 +7754,7 @@ func (q *sqlQuerier) UpdateChatLabelsByID(ctx context.Context, arg UpdateChatLab
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -7747,7 +7764,7 @@ UPDATE chats SET
|
||||
last_injected_context = $1::jsonb
|
||||
WHERE
|
||||
id = $2::uuid
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
|
||||
`
|
||||
|
||||
type UpdateChatLastInjectedContextParams struct {
|
||||
@@ -7788,6 +7805,7 @@ func (q *sqlQuerier) UpdateChatLastInjectedContext(ctx context.Context, arg Upda
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -7801,7 +7819,7 @@ SET
|
||||
WHERE
|
||||
id = $2::uuid
|
||||
RETURNING
|
||||
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
|
||||
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
|
||||
`
|
||||
|
||||
type UpdateChatLastModelConfigByIDParams struct {
|
||||
@@ -7838,6 +7856,7 @@ func (q *sqlQuerier) UpdateChatLastModelConfigByID(ctx context.Context, arg Upda
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -7869,7 +7888,7 @@ SET
|
||||
WHERE
|
||||
id = $2::uuid
|
||||
RETURNING
|
||||
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
|
||||
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
|
||||
`
|
||||
|
||||
type UpdateChatMCPServerIDsParams struct {
|
||||
@@ -7906,6 +7925,7 @@ func (q *sqlQuerier) UpdateChatMCPServerIDs(ctx context.Context, arg UpdateChatM
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -8028,6 +8048,57 @@ func (q *sqlQuerier) UpdateChatPinOrder(ctx context.Context, arg UpdateChatPinOr
|
||||
return err
|
||||
}
|
||||
|
||||
const updateChatPlanModeByID = `-- name: UpdateChatPlanModeByID :one
|
||||
UPDATE
|
||||
chats
|
||||
SET
|
||||
-- NOTE: updated_at is intentionally NOT touched here to avoid changing list ordering.
|
||||
plan_mode = $1::chat_plan_mode
|
||||
WHERE
|
||||
id = $2::uuid
|
||||
RETURNING
|
||||
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
|
||||
`
|
||||
|
||||
type UpdateChatPlanModeByIDParams struct {
|
||||
PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpdateChatPlanModeByID(ctx context.Context, arg UpdateChatPlanModeByIDParams) (Chat, error) {
|
||||
row := q.db.QueryRowContext(ctx, updateChatPlanModeByID, arg.PlanMode, arg.ID)
|
||||
var i Chat
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.OwnerID,
|
||||
&i.WorkspaceID,
|
||||
&i.Title,
|
||||
&i.Status,
|
||||
&i.WorkerID,
|
||||
&i.StartedAt,
|
||||
&i.HeartbeatAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ParentChatID,
|
||||
&i.RootChatID,
|
||||
&i.LastModelConfigID,
|
||||
&i.Archived,
|
||||
&i.LastError,
|
||||
&i.Mode,
|
||||
pq.Array(&i.MCPServerIDs),
|
||||
&i.Labels,
|
||||
&i.BuildID,
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateChatStatus = `-- name: UpdateChatStatus :one
|
||||
UPDATE
|
||||
chats
|
||||
@@ -8041,7 +8112,7 @@ SET
|
||||
WHERE
|
||||
id = $6::uuid
|
||||
RETURNING
|
||||
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
|
||||
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
|
||||
`
|
||||
|
||||
type UpdateChatStatusParams struct {
|
||||
@@ -8089,6 +8160,7 @@ func (q *sqlQuerier) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusP
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -8106,7 +8178,7 @@ SET
|
||||
WHERE
|
||||
id = $7::uuid
|
||||
RETURNING
|
||||
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
|
||||
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
|
||||
`
|
||||
|
||||
type UpdateChatStatusPreserveUpdatedAtParams struct {
|
||||
@@ -8156,6 +8228,7 @@ func (q *sqlQuerier) UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -8167,7 +8240,7 @@ UPDATE chats SET
|
||||
agent_id = $3::uuid,
|
||||
updated_at = NOW()
|
||||
WHERE id = $4::uuid
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
|
||||
`
|
||||
|
||||
type UpdateChatWorkspaceBindingParams struct {
|
||||
@@ -8211,6 +8284,7 @@ func (q *sqlQuerier) UpdateChatWorkspaceBinding(ctx context.Context, arg UpdateC
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -19839,6 +19913,18 @@ func (q *sqlQuerier) GetChatIncludeDefaultSystemPrompt(ctx context.Context) (boo
|
||||
return include_default_system_prompt, err
|
||||
}
|
||||
|
||||
const getChatPlanModeInstructions = `-- name: GetChatPlanModeInstructions :one
|
||||
SELECT
|
||||
COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_plan_mode_instructions'), '') :: text AS plan_mode_instructions
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetChatPlanModeInstructions(ctx context.Context) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, getChatPlanModeInstructions)
|
||||
var plan_mode_instructions string
|
||||
err := row.Scan(&plan_mode_instructions)
|
||||
return plan_mode_instructions, err
|
||||
}
|
||||
|
||||
const getChatRetentionDays = `-- name: GetChatRetentionDays :one
|
||||
SELECT COALESCE(
|
||||
(SELECT value::integer FROM site_configs
|
||||
@@ -20182,6 +20268,16 @@ func (q *sqlQuerier) UpsertChatIncludeDefaultSystemPrompt(ctx context.Context, i
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertChatPlanModeInstructions = `-- name: UpsertChatPlanModeInstructions :exec
|
||||
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'
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) UpsertChatPlanModeInstructions(ctx context.Context, value string) error {
|
||||
_, err := q.db.ExecContext(ctx, upsertChatPlanModeInstructions, value)
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertChatRetentionDays = `-- name: UpsertChatRetentionDays :exec
|
||||
INSERT INTO site_configs (key, value)
|
||||
VALUES ('agents_chat_retention_days', CAST($1 AS integer)::text)
|
||||
|
||||
@@ -402,6 +402,7 @@ INSERT INTO chats (
|
||||
last_model_config_id,
|
||||
title,
|
||||
mode,
|
||||
plan_mode,
|
||||
status,
|
||||
mcp_server_ids,
|
||||
labels,
|
||||
@@ -417,6 +418,7 @@ INSERT INTO chats (
|
||||
@last_model_config_id::uuid,
|
||||
@title::text,
|
||||
sqlc.narg('mode')::chat_mode,
|
||||
sqlc.narg('plan_mode')::chat_plan_mode,
|
||||
@status::chat_status,
|
||||
COALESCE(@mcp_server_ids::uuid[], '{}'::uuid[]),
|
||||
COALESCE(sqlc.narg('labels')::jsonb, '{}'::jsonb),
|
||||
@@ -518,6 +520,17 @@ WHERE
|
||||
RETURNING
|
||||
*;
|
||||
|
||||
-- name: UpdateChatPlanModeByID :one
|
||||
UPDATE
|
||||
chats
|
||||
SET
|
||||
-- NOTE: updated_at is intentionally NOT touched here to avoid changing list ordering.
|
||||
plan_mode = sqlc.narg('plan_mode')::chat_plan_mode
|
||||
WHERE
|
||||
id = @id::uuid
|
||||
RETURNING
|
||||
*;
|
||||
|
||||
-- name: UpdateChatLastModelConfigByID :one
|
||||
UPDATE
|
||||
chats
|
||||
|
||||
@@ -159,6 +159,14 @@ SELECT
|
||||
INSERT INTO site_configs (key, value) VALUES ('agents_chat_system_prompt', $1)
|
||||
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_system_prompt';
|
||||
|
||||
-- name: GetChatPlanModeInstructions :one
|
||||
SELECT
|
||||
COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_plan_mode_instructions'), '') :: text AS plan_mode_instructions;
|
||||
|
||||
-- name: UpsertChatPlanModeInstructions :exec
|
||||
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: GetChatDesktopEnabled :one
|
||||
SELECT
|
||||
COALESCE((SELECT value = 'true' FROM site_configs WHERE key = 'agents_desktop_enabled'), false) :: boolean AS enable_desktop;
|
||||
|
||||
+221
-26
@@ -379,6 +379,16 @@ func (api *API) getChatDiffStatusesByChatID(
|
||||
return statusesByChatID, nil
|
||||
}
|
||||
|
||||
func planModeToNullChatPlanMode(mode codersdk.ChatPlanMode) database.NullChatPlanMode {
|
||||
if mode == "" {
|
||||
return database.NullChatPlanMode{}
|
||||
}
|
||||
return database.NullChatPlanMode{
|
||||
ChatPlanMode: database.ChatPlanMode(mode),
|
||||
Valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
|
||||
func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
@@ -457,6 +467,16 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
switch req.PlanMode {
|
||||
case codersdk.ChatPlanModePlan, "":
|
||||
// Valid.
|
||||
default:
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid plan_mode value.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate MCP server IDs exist.
|
||||
if len(req.MCPServerIDs) > 0 {
|
||||
//nolint:gocritic // Need to validate MCP server IDs exist.
|
||||
@@ -554,6 +574,7 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
|
||||
WorkspaceID: workspaceSelection.WorkspaceID,
|
||||
Title: title,
|
||||
ModelConfigID: modelConfigID,
|
||||
PlanMode: planModeToNullChatPlanMode(req.PlanMode),
|
||||
SystemPrompt: req.SystemPrompt,
|
||||
InitialUserContent: contentBlocks,
|
||||
MCPServerIDs: mcpServerIDs,
|
||||
@@ -1728,7 +1749,7 @@ func (api *API) watchChatDesktop(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// patchChat updates a chat resource. Supports updating labels,
|
||||
// archiving, pinning, and pinned-chat ordering.
|
||||
// workspace binding, archiving, pinning, and pinned-chat ordering.
|
||||
func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
chat := httpmw.ChatParam(r)
|
||||
@@ -1738,6 +1759,21 @@ func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var planModeUpdate *database.NullChatPlanMode
|
||||
if req.PlanMode != nil {
|
||||
switch *req.PlanMode {
|
||||
case codersdk.ChatPlanModePlan, "":
|
||||
// Valid.
|
||||
default:
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid plan_mode value.",
|
||||
})
|
||||
return
|
||||
}
|
||||
resolvedPlanMode := planModeToNullChatPlanMode(*req.PlanMode)
|
||||
planModeUpdate = &resolvedPlanMode
|
||||
}
|
||||
|
||||
if req.Labels != nil {
|
||||
if errs := httpapi.ValidateChatLabels(*req.Labels); len(errs) > 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
@@ -1863,6 +1899,64 @@ func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if req.WorkspaceID != nil {
|
||||
workspaceID := uuid.NullUUID{}
|
||||
workspace := database.Workspace{}
|
||||
if *req.WorkspaceID != uuid.Nil {
|
||||
var status int
|
||||
var resp *codersdk.Response
|
||||
workspaceID, workspace, status, resp = api.validateChatWorkspaceSelection(ctx, r, req.WorkspaceID)
|
||||
if resp != nil {
|
||||
httpapi.Write(ctx, rw, status, *resp)
|
||||
return
|
||||
}
|
||||
if workspace.OrganizationID != chat.OrganizationID {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Workspace does not belong to this chat's organization.",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
updatedChat, err := api.Database.UpdateChatWorkspaceBinding(ctx, database.UpdateChatWorkspaceBindingParams{
|
||||
ID: chat.ID,
|
||||
WorkspaceID: workspaceID,
|
||||
BuildID: uuid.NullUUID{},
|
||||
AgentID: uuid.NullUUID{},
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to update chat workspace binding.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
chat = updatedChat
|
||||
}
|
||||
|
||||
if planModeUpdate != nil {
|
||||
updatedChat, err := api.Database.UpdateChatPlanModeByID(ctx, database.UpdateChatPlanModeByIDParams{
|
||||
PlanMode: *planModeUpdate,
|
||||
ID: chat.ID,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to update chat plan mode.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
chat = updatedChat
|
||||
}
|
||||
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -1937,6 +2031,24 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if req.PlanMode != nil {
|
||||
switch *req.PlanMode {
|
||||
case codersdk.ChatPlanModePlan, "":
|
||||
// Valid.
|
||||
default:
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid plan_mode value.",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var sendPlanMode *database.NullChatPlanMode
|
||||
if req.PlanMode != nil {
|
||||
resolvedPlanMode := planModeToNullChatPlanMode(*req.PlanMode)
|
||||
sendPlanMode = &resolvedPlanMode
|
||||
}
|
||||
|
||||
busyBehavior := chatd.SendMessageBusyBehaviorQueue
|
||||
switch req.BusyBehavior {
|
||||
case codersdk.ChatBusyBehaviorInterrupt:
|
||||
@@ -1959,6 +2071,7 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) {
|
||||
Content: contentBlocks,
|
||||
ModelConfigID: req.ModelConfigID,
|
||||
BusyBehavior: busyBehavior,
|
||||
PlanMode: sendPlanMode,
|
||||
MCPServerIDs: req.MCPServerIDs,
|
||||
},
|
||||
)
|
||||
@@ -2929,6 +3042,46 @@ type createChatWorkspaceSelection struct {
|
||||
WorkspaceID uuid.NullUUID
|
||||
}
|
||||
|
||||
func (api *API) validateChatWorkspaceSelection(
|
||||
ctx context.Context,
|
||||
r *http.Request,
|
||||
workspaceID *uuid.UUID,
|
||||
) (
|
||||
uuid.NullUUID,
|
||||
database.Workspace,
|
||||
int,
|
||||
*codersdk.Response,
|
||||
) {
|
||||
if workspaceID == nil {
|
||||
return uuid.NullUUID{}, database.Workspace{}, 0, nil
|
||||
}
|
||||
|
||||
workspace, err := api.Database.GetWorkspaceByID(ctx, *workspaceID)
|
||||
if err != nil {
|
||||
if httpapi.Is404Error(err) {
|
||||
return uuid.NullUUID{}, database.Workspace{}, http.StatusBadRequest, &codersdk.Response{
|
||||
Message: "Workspace not found or you do not have access to this resource",
|
||||
}
|
||||
}
|
||||
return uuid.NullUUID{}, database.Workspace{}, http.StatusInternalServerError, &codersdk.Response{
|
||||
Message: "Failed to get workspace.",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
selection := uuid.NullUUID{
|
||||
UUID: workspace.ID,
|
||||
Valid: true,
|
||||
}
|
||||
if !api.Authorize(r, policy.ActionSSH, workspace) {
|
||||
return uuid.NullUUID{}, database.Workspace{}, http.StatusBadRequest, &codersdk.Response{
|
||||
Message: "Workspace not found or you do not have access to this resource",
|
||||
}
|
||||
}
|
||||
|
||||
return selection, workspace, 0, nil
|
||||
}
|
||||
|
||||
func (api *API) validateCreateChatWorkspaceSelection(
|
||||
ctx context.Context,
|
||||
r *http.Request,
|
||||
@@ -2939,39 +3092,20 @@ func (api *API) validateCreateChatWorkspaceSelection(
|
||||
*codersdk.Response,
|
||||
) {
|
||||
selection := createChatWorkspaceSelection{}
|
||||
if req.WorkspaceID == nil {
|
||||
workspaceID, workspace, status, resp := api.validateChatWorkspaceSelection(ctx, r, req.WorkspaceID)
|
||||
if resp != nil {
|
||||
return selection, status, resp
|
||||
}
|
||||
selection.WorkspaceID = workspaceID
|
||||
if !workspaceID.Valid {
|
||||
return selection, 0, nil
|
||||
}
|
||||
|
||||
workspace, err := api.Database.GetWorkspaceByID(ctx, *req.WorkspaceID)
|
||||
if err != nil {
|
||||
if httpapi.Is404Error(err) {
|
||||
return selection, http.StatusBadRequest, &codersdk.Response{
|
||||
Message: "Workspace not found or you do not have access to this resource",
|
||||
}
|
||||
}
|
||||
return selection, http.StatusInternalServerError, &codersdk.Response{
|
||||
Message: "Failed to get workspace.",
|
||||
Detail: err.Error(),
|
||||
}
|
||||
}
|
||||
selection.WorkspaceID = uuid.NullUUID{
|
||||
UUID: workspace.ID,
|
||||
Valid: true,
|
||||
}
|
||||
|
||||
if workspace.OrganizationID != req.OrganizationID {
|
||||
return selection, http.StatusBadRequest, &codersdk.Response{
|
||||
Message: "Workspace does not belong to the specified organization.",
|
||||
}
|
||||
}
|
||||
|
||||
if !api.Authorize(r, policy.ActionSSH, workspace) {
|
||||
return selection, http.StatusBadRequest, &codersdk.Response{
|
||||
Message: "Workspace not found or you do not have access to this resource",
|
||||
}
|
||||
}
|
||||
|
||||
return selection, 0, nil
|
||||
}
|
||||
|
||||
@@ -3151,6 +3285,67 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) {
|
||||
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) getChatPlanModeInstructions(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
|
||||
instructions, err := api.Database.GetChatPlanModeInstructions(ctx)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching plan mode instructions.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatPlanModeInstructionsResponse{
|
||||
PlanModeInstructions: instructions,
|
||||
})
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
|
||||
func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
|
||||
httpapi.Forbidden(rw)
|
||||
return
|
||||
}
|
||||
|
||||
// Cap the raw request body to prevent excessive memory use from
|
||||
// payloads padded with invisible characters that sanitize away.
|
||||
r.Body = http.MaxBytesReader(rw, r.Body, int64(2*maxSystemPromptLenBytes))
|
||||
|
||||
var req codersdk.UpdateChatPlanModeInstructionsRequest
|
||||
if !httpapi.Read(ctx, rw, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
sanitizedInstructions := chatd.SanitizePromptText(req.PlanModeInstructions)
|
||||
if len(sanitizedInstructions) > maxSystemPromptLenBytes {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Plan mode instructions exceed maximum length.",
|
||||
Detail: fmt.Sprintf("Maximum length is %d bytes, got %d.", maxSystemPromptLenBytes, len(sanitizedInstructions)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.Database.UpsertChatPlanModeInstructions(ctx, sanitizedInstructions); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error updating plan mode instructions.",
|
||||
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.
|
||||
|
||||
+331
-18
@@ -664,12 +664,10 @@ func TestPostChats(t *testing.T) {
|
||||
|
||||
_, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
|
||||
OrganizationID: uuid.Nil,
|
||||
Content: []codersdk.ChatInputPart{
|
||||
{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
Text: "hello",
|
||||
},
|
||||
},
|
||||
Content: []codersdk.ChatInputPart{{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
Text: "hello",
|
||||
}},
|
||||
})
|
||||
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
|
||||
require.Equal(t, "organization_id is required.", sdkErr.Message)
|
||||
@@ -692,12 +690,10 @@ func TestPostChats(t *testing.T) {
|
||||
|
||||
_, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
|
||||
OrganizationID: secondOrg.ID,
|
||||
Content: []codersdk.ChatInputPart{
|
||||
{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
Text: "hello",
|
||||
},
|
||||
},
|
||||
Content: []codersdk.ChatInputPart{{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
Text: "hello",
|
||||
}},
|
||||
})
|
||||
sdkErr := requireSDKError(t, err, http.StatusForbidden)
|
||||
require.Equal(t, "You are not a member of the specified organization.", sdkErr.Message)
|
||||
@@ -727,12 +723,10 @@ func TestPostChats(t *testing.T) {
|
||||
|
||||
_, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
||||
OrganizationID: secondOrg.ID,
|
||||
Content: []codersdk.ChatInputPart{
|
||||
{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
Text: "hello",
|
||||
},
|
||||
},
|
||||
Content: []codersdk.ChatInputPart{{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
Text: "hello",
|
||||
}},
|
||||
WorkspaceID: &workspaceBuild.Workspace.ID,
|
||||
})
|
||||
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
|
||||
@@ -3615,6 +3609,251 @@ func TestGetChat(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestPatchChat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
createChat := func(ctx context.Context, t *testing.T, client *codersdk.ExperimentalClient, orgID uuid.UUID, text string) codersdk.Chat {
|
||||
t.Helper()
|
||||
|
||||
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
||||
OrganizationID: orgID,
|
||||
Content: []codersdk.ChatInputPart{
|
||||
{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
Text: text,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return chat
|
||||
}
|
||||
|
||||
getChat := func(ctx context.Context, t *testing.T, client *codersdk.ExperimentalClient, chatID uuid.UUID) codersdk.Chat {
|
||||
t.Helper()
|
||||
|
||||
chat, err := client.GetChat(ctx, chatID)
|
||||
require.NoError(t, err)
|
||||
return chat
|
||||
}
|
||||
|
||||
createStoredChat := func(
|
||||
ctx context.Context,
|
||||
t *testing.T,
|
||||
db database.Store,
|
||||
ownerID uuid.UUID,
|
||||
orgID uuid.UUID,
|
||||
modelConfigID uuid.UUID,
|
||||
title string,
|
||||
) codersdk.Chat {
|
||||
t.Helper()
|
||||
|
||||
dbChat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
|
||||
OrganizationID: orgID,
|
||||
Status: database.ChatStatusWaiting,
|
||||
OwnerID: ownerID,
|
||||
LastModelConfigID: modelConfigID,
|
||||
Title: title,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return db2sdk.Chat(dbChat, nil, nil)
|
||||
}
|
||||
t.Run("PlanMode", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("SetToPlan", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client := newChatClient(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
_ = createChatModelConfig(t, client)
|
||||
|
||||
chat := createChat(ctx, t, client, firstUser.OrganizationID, "set plan mode")
|
||||
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
|
||||
PlanMode: ptr.Ref(codersdk.ChatPlanModePlan),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
updated := getChat(ctx, t, client, chat.ID)
|
||||
require.Equal(t, codersdk.ChatPlanModePlan, updated.PlanMode)
|
||||
})
|
||||
|
||||
t.Run("Clear", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client := newChatClient(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
_ = createChatModelConfig(t, client)
|
||||
|
||||
chat := createChat(ctx, t, client, firstUser.OrganizationID, "clear plan mode")
|
||||
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
|
||||
PlanMode: ptr.Ref(codersdk.ChatPlanModePlan),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
|
||||
PlanMode: ptr.Ref(codersdk.ChatPlanMode("")),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
updated := getChat(ctx, t, client, chat.ID)
|
||||
require.Empty(t, updated.PlanMode)
|
||||
})
|
||||
|
||||
t.Run("RejectsInvalidValue", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client := newChatClient(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
_ = createChatModelConfig(t, client)
|
||||
|
||||
chat := createChat(ctx, t, client, firstUser.OrganizationID, "invalid plan mode")
|
||||
invalidPlanMode := codersdk.ChatPlanMode("invalid")
|
||||
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
|
||||
PlanMode: &invalidPlanMode,
|
||||
})
|
||||
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
|
||||
require.Equal(t, "Invalid plan_mode value.", sdkErr.Message)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("WorkspaceBinding", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("BindValidWorkspace", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, db := newChatClientWithDatabase(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: firstUser.OrganizationID,
|
||||
OwnerID: firstUser.UserID,
|
||||
}).WithAgent().Do()
|
||||
chat := createStoredChat(
|
||||
ctx,
|
||||
t,
|
||||
db,
|
||||
firstUser.UserID,
|
||||
firstUser.OrganizationID,
|
||||
modelConfig.ID,
|
||||
"bind workspace",
|
||||
)
|
||||
|
||||
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
|
||||
WorkspaceID: &workspaceBuild.Workspace.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
updated := getChat(ctx, t, client, chat.ID)
|
||||
require.NotNil(t, updated.WorkspaceID)
|
||||
require.Equal(t, workspaceBuild.Workspace.ID, *updated.WorkspaceID)
|
||||
})
|
||||
|
||||
t.Run("WorkspaceNotFound", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, db := newChatClientWithDatabase(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
chat := createStoredChat(
|
||||
ctx,
|
||||
t,
|
||||
db,
|
||||
firstUser.UserID,
|
||||
firstUser.OrganizationID,
|
||||
modelConfig.ID,
|
||||
"missing workspace",
|
||||
)
|
||||
workspaceID := uuid.New()
|
||||
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
|
||||
WorkspaceID: &workspaceID,
|
||||
})
|
||||
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
|
||||
require.Equal(t, "Workspace not found or you do not have access to this resource", sdkErr.Message)
|
||||
})
|
||||
|
||||
t.Run("RejectsCrossOrgWorkspaceBinding", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, db := newChatClientWithDatabase(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
secondOrg := dbgen.Organization(t, db, database.Organization{})
|
||||
dbgen.OrganizationMember(t, db, database.OrganizationMember{
|
||||
OrganizationID: secondOrg.ID,
|
||||
UserID: firstUser.UserID,
|
||||
})
|
||||
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: secondOrg.ID,
|
||||
OwnerID: firstUser.UserID,
|
||||
}).WithAgent().Do()
|
||||
chat := createStoredChat(
|
||||
ctx,
|
||||
t,
|
||||
db,
|
||||
firstUser.UserID,
|
||||
firstUser.OrganizationID,
|
||||
modelConfig.ID,
|
||||
"cross org workspace binding",
|
||||
)
|
||||
|
||||
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
|
||||
WorkspaceID: &workspaceBuild.Workspace.ID,
|
||||
})
|
||||
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
|
||||
require.Equal(t, "Workspace does not belong to this chat's organization.", sdkErr.Message)
|
||||
})
|
||||
|
||||
t.Run("ClearWorkspaceBinding", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, db := newChatClientWithDatabase(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: firstUser.OrganizationID,
|
||||
OwnerID: firstUser.UserID,
|
||||
}).WithAgent().Do()
|
||||
chat := createStoredChat(
|
||||
ctx,
|
||||
t,
|
||||
db,
|
||||
firstUser.UserID,
|
||||
firstUser.OrganizationID,
|
||||
modelConfig.ID,
|
||||
"clear workspace binding",
|
||||
)
|
||||
|
||||
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
|
||||
WorkspaceID: &workspaceBuild.Workspace.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
workspaceID := uuid.Nil
|
||||
err = client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
|
||||
WorkspaceID: &workspaceID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
updated := getChat(ctx, t, client, chat.ID)
|
||||
require.Nil(t, updated.WorkspaceID)
|
||||
require.Nil(t, updated.BuildID)
|
||||
require.Nil(t, updated.AgentID)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestArchiveChat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -7930,6 +8169,80 @@ func TestChatSystemPrompt(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
//nolint:tparallel,paralleltest // Subtests share a single coderdtest instance.
|
||||
func TestChatPlanModeInstructions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminClient, _ := newChatClientWithDatabase(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
|
||||
_ = createChatModelConfig(t, adminClient)
|
||||
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
|
||||
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
|
||||
|
||||
updateChatPlanModeInstructions := func(t *testing.T, ctx context.Context, req codersdk.UpdateChatPlanModeInstructionsRequest) {
|
||||
t.Helper()
|
||||
|
||||
err := adminClient.UpdateChatPlanModeInstructions(ctx, req)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
getChatPlanModeInstructions := func(t *testing.T, ctx context.Context) codersdk.ChatPlanModeInstructionsResponse {
|
||||
t.Helper()
|
||||
|
||||
resp, err := adminClient.GetChatPlanModeInstructions(ctx)
|
||||
require.NoError(t, err)
|
||||
return resp
|
||||
}
|
||||
|
||||
roundTripTests := []struct {
|
||||
name string
|
||||
updates []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "DefaultGETReturnsEmpty",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "PUTThenGETRoundTrips",
|
||||
updates: []string{"Use plan mode for multi-step changes."},
|
||||
want: "Use plan mode for multi-step changes.",
|
||||
},
|
||||
}
|
||||
for _, tt := range roundTripTests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
for _, instructions := range tt.updates {
|
||||
updateChatPlanModeInstructions(t, ctx, codersdk.UpdateChatPlanModeInstructionsRequest{
|
||||
PlanModeInstructions: instructions,
|
||||
})
|
||||
}
|
||||
|
||||
resp := getChatPlanModeInstructions(t, ctx)
|
||||
require.Equal(t, tt.want, resp.PlanModeInstructions)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("OversizedPayloadReturns400", func(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
tooLong := strings.Repeat("a", 131073)
|
||||
|
||||
err := adminClient.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{
|
||||
PlanModeInstructions: tooLong,
|
||||
})
|
||||
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
|
||||
require.Equal(t, "Plan mode instructions exceed maximum length.", sdkErr.Message)
|
||||
})
|
||||
|
||||
t.Run("NonAdminGETReturns404", func(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
_, err := memberClient.GetChatPlanModeInstructions(ctx)
|
||||
requireSDKError(t, err, http.StatusNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatDesktopEnabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+432
-186
@@ -788,6 +788,7 @@ type CreateOptions struct {
|
||||
Title string
|
||||
ModelConfigID uuid.UUID
|
||||
ChatMode database.NullChatMode
|
||||
PlanMode database.NullChatPlanMode
|
||||
SystemPrompt string
|
||||
InitialUserContent []codersdk.ChatMessagePart
|
||||
MCPServerIDs []uuid.UUID
|
||||
@@ -815,6 +816,7 @@ type SendMessageOptions struct {
|
||||
Content []codersdk.ChatMessagePart
|
||||
ModelConfigID *uuid.UUID
|
||||
BusyBehavior SendMessageBusyBehavior
|
||||
PlanMode *database.NullChatPlanMode
|
||||
MCPServerIDs *[]uuid.UUID
|
||||
}
|
||||
|
||||
@@ -882,6 +884,8 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C
|
||||
// another pool checkout.
|
||||
deploymentPrompt := p.resolveDeploymentSystemPrompt(ctx)
|
||||
|
||||
effectivePlanMode := opts.PlanMode
|
||||
|
||||
var chat database.Chat
|
||||
txErr := p.db.InTx(func(tx database.Store) error {
|
||||
if limitErr := p.checkUsageLimit(ctx, tx, opts.OwnerID, uuid.NullUUID{UUID: opts.OrganizationID, Valid: true}); limitErr != nil {
|
||||
@@ -904,6 +908,7 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C
|
||||
LastModelConfigID: opts.ModelConfigID,
|
||||
Title: opts.Title,
|
||||
Mode: opts.ChatMode,
|
||||
PlanMode: effectivePlanMode,
|
||||
// Chats created with an initial user message start pending.
|
||||
// Waiting is reserved for idle chats with no pending work.
|
||||
Status: database.ChatStatusPending,
|
||||
@@ -1040,6 +1045,8 @@ func (p *Server) SendMessage(
|
||||
return SendMessageResult{}, xerrors.Errorf("marshal message content: %w", err)
|
||||
}
|
||||
|
||||
requestedPlanMode := opts.PlanMode
|
||||
|
||||
var (
|
||||
result SendMessageResult
|
||||
queuedMessagesSDK []codersdk.ChatQueuedMessage
|
||||
@@ -1056,6 +1063,16 @@ func (p *Server) SendMessage(
|
||||
return limitErr
|
||||
}
|
||||
|
||||
if requestedPlanMode != nil {
|
||||
lockedChat, err = tx.UpdateChatPlanModeByID(ctx, database.UpdateChatPlanModeByIDParams{
|
||||
PlanMode: *requestedPlanMode,
|
||||
ID: opts.ChatID,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update chat plan mode: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
modelConfigID := lockedChat.LastModelConfigID
|
||||
if opts.ModelConfigID != nil {
|
||||
modelConfigID = *opts.ModelConfigID
|
||||
@@ -4365,6 +4382,336 @@ type runChatResult struct {
|
||||
PendingDynamicToolCalls []chatloop.PendingToolCall
|
||||
}
|
||||
|
||||
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,
|
||||
"write_file": isRootChat,
|
||||
"edit_files": isRootChat,
|
||||
"execute": true,
|
||||
"process_output": true,
|
||||
"process_list": false,
|
||||
"process_signal": false,
|
||||
"list_templates": isRootChat,
|
||||
"read_template": isRootChat,
|
||||
"create_workspace": isRootChat,
|
||||
"start_workspace": isRootChat,
|
||||
"propose_plan": isRootChat,
|
||||
"spawn_agent": isRootChat,
|
||||
"wait_agent": isRootChat,
|
||||
"message_agent": false,
|
||||
"close_agent": false,
|
||||
"spawn_computer_use_agent": false,
|
||||
"read_skill": true,
|
||||
"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 {
|
||||
name := tool.Info().Name
|
||||
if builtinPlanPolicy[name] {
|
||||
toolNames = append(toolNames, name)
|
||||
}
|
||||
}
|
||||
return toolNames
|
||||
}
|
||||
|
||||
func stopAfterPlanTools(mode database.NullChatPlanMode, parentChatID uuid.NullUUID) map[string]struct{} {
|
||||
if !mode.Valid || mode.ChatPlanMode != database.ChatPlanModePlan {
|
||||
return nil
|
||||
}
|
||||
stopTools := map[string]struct{}{
|
||||
"propose_plan": {},
|
||||
}
|
||||
if !parentChatID.Valid {
|
||||
stopTools["ask_user_question"] = struct{}{}
|
||||
}
|
||||
return stopTools
|
||||
}
|
||||
|
||||
type systemPromptPlanContext struct {
|
||||
mode database.NullChatPlanMode
|
||||
planModeInstructions string
|
||||
isRootChat bool
|
||||
}
|
||||
|
||||
// buildSystemPrompt applies system-level prompt injections in the
|
||||
// canonical order. It is used by both the initial prompt assembly
|
||||
// and the ReloadMessages callback to keep them in sync.
|
||||
func buildSystemPrompt(
|
||||
prompt []fantasy.Message,
|
||||
subagentInstruction string,
|
||||
instruction string,
|
||||
skills []chattool.SkillMeta,
|
||||
userPrompt string,
|
||||
planContext systemPromptPlanContext,
|
||||
) []fantasy.Message {
|
||||
if subagentInstruction != "" {
|
||||
prompt = chatprompt.InsertSystem(prompt, subagentInstruction)
|
||||
}
|
||||
if instruction != "" {
|
||||
prompt = chatprompt.InsertSystem(prompt, instruction)
|
||||
}
|
||||
if skillIndex := chattool.FormatSkillIndex(skills); skillIndex != "" {
|
||||
prompt = chatprompt.InsertSystem(prompt, skillIndex)
|
||||
}
|
||||
if userPrompt != "" {
|
||||
prompt = chatprompt.InsertSystem(prompt, userPrompt)
|
||||
}
|
||||
isPlanModeTurn := planContext.mode.Valid && planContext.mode.ChatPlanMode == database.ChatPlanModePlan
|
||||
if isPlanModeTurn {
|
||||
if planContext.isRootChat {
|
||||
prompt = chatprompt.InsertSystem(prompt, PlanningOverlayPrompt)
|
||||
if planContext.planModeInstructions != "" {
|
||||
prompt = chatprompt.InsertSystem(prompt, planContext.planModeInstructions)
|
||||
}
|
||||
} else {
|
||||
prompt = chatprompt.InsertSystem(prompt, PlanningSubagentOverlayPrompt)
|
||||
}
|
||||
}
|
||||
return prompt
|
||||
}
|
||||
|
||||
type rootChatToolsOptions struct {
|
||||
chat database.Chat
|
||||
modelConfigID uuid.UUID
|
||||
workspaceCtx *turnWorkspaceContext
|
||||
workspaceMu *sync.Mutex
|
||||
instruction *string
|
||||
skills *[]chattool.SkillMeta
|
||||
resolvePlanPath func(context.Context) (string, string, error)
|
||||
isPlanModeTurn bool
|
||||
}
|
||||
|
||||
func (p *Server) loadPlanModeInstructions(
|
||||
ctx context.Context,
|
||||
mode database.NullChatPlanMode,
|
||||
logger slog.Logger,
|
||||
) string {
|
||||
if !mode.Valid || mode.ChatPlanMode != database.ChatPlanModePlan {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Plan-mode instructions live in deployment config, but chat workers do
|
||||
// not carry a deployment-config actor during background execution.
|
||||
//nolint:gocritic // Required to read deployment config during background chat processing.
|
||||
systemCtx := dbauthz.AsSystemRestricted(ctx)
|
||||
fetched, err := p.db.GetChatPlanModeInstructions(systemCtx)
|
||||
if err != nil {
|
||||
logger.Warn(ctx,
|
||||
"failed to fetch plan mode instructions",
|
||||
slog.Error(err),
|
||||
)
|
||||
return ""
|
||||
}
|
||||
|
||||
return fetched
|
||||
}
|
||||
|
||||
func (p *Server) appendRootChatTools(
|
||||
ctx context.Context,
|
||||
tools []fantasy.AgentTool,
|
||||
opts rootChatToolsOptions,
|
||||
) []fantasy.AgentTool {
|
||||
onChatUpdated := func(updatedChat database.Chat) {
|
||||
opts.workspaceCtx.selectWorkspace(updatedChat)
|
||||
// Notify the frontend immediately so it can start streaming
|
||||
// build logs before the tool completes.
|
||||
p.publishChatPubsubEvent(updatedChat, codersdk.ChatWatchEventKindStatusChange, nil)
|
||||
|
||||
// When a workspace is first attached mid-turn (e.g. via
|
||||
// create_workspace), fetch and persist instruction files
|
||||
// immediately so the LLM has AGENTS.md context for the remainder
|
||||
// of this turn. The persisted marker prevents redundant fetches on
|
||||
// subsequent turns.
|
||||
if *opts.instruction == "" && updatedChat.WorkspaceID.Valid {
|
||||
newInstruction, discoveredSkills, persistErr := p.persistInstructionFiles(
|
||||
ctx,
|
||||
updatedChat,
|
||||
opts.modelConfigID,
|
||||
opts.workspaceCtx.getWorkspaceAgent,
|
||||
opts.workspaceCtx.getWorkspaceConn,
|
||||
)
|
||||
if persistErr != nil {
|
||||
p.logger.Warn(ctx, "failed to persist instruction files on workspace attach",
|
||||
slog.F("chat_id", updatedChat.ID),
|
||||
slog.Error(persistErr),
|
||||
)
|
||||
} else {
|
||||
*opts.instruction = newInstruction
|
||||
if len(discoveredSkills) > 0 {
|
||||
*opts.skills = discoveredSkills
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tools = append(tools,
|
||||
chattool.ListTemplates(opts.chat.OrganizationID, p.db, chattool.ListTemplatesOptions{
|
||||
OwnerID: opts.chat.OwnerID,
|
||||
AllowedTemplateIDs: p.chatTemplateAllowlist,
|
||||
}),
|
||||
chattool.ReadTemplate(opts.chat.OrganizationID, p.db, chattool.ReadTemplateOptions{
|
||||
OwnerID: opts.chat.OwnerID,
|
||||
AllowedTemplateIDs: p.chatTemplateAllowlist,
|
||||
}),
|
||||
chattool.CreateWorkspace(opts.chat.OrganizationID, p.db, chattool.CreateWorkspaceOptions{
|
||||
OwnerID: opts.chat.OwnerID,
|
||||
ChatID: opts.chat.ID,
|
||||
CreateFn: p.createWorkspaceFn,
|
||||
AgentConnFn: chattool.AgentConnFunc(p.agentConnFn),
|
||||
AgentInactiveDisconnectTimeout: p.agentInactiveDisconnectTimeout,
|
||||
WorkspaceMu: opts.workspaceMu,
|
||||
OnChatUpdated: onChatUpdated,
|
||||
Logger: p.logger,
|
||||
AllowedTemplateIDs: p.chatTemplateAllowlist,
|
||||
}),
|
||||
chattool.StartWorkspace(chattool.StartWorkspaceOptions{
|
||||
DB: p.db,
|
||||
OwnerID: opts.chat.OwnerID,
|
||||
ChatID: opts.chat.ID,
|
||||
StartFn: p.startWorkspaceFn,
|
||||
AgentConnFn: chattool.AgentConnFunc(p.agentConnFn),
|
||||
WorkspaceMu: opts.workspaceMu,
|
||||
OnChatUpdated: onChatUpdated,
|
||||
Logger: p.logger,
|
||||
}),
|
||||
)
|
||||
if opts.isPlanModeTurn {
|
||||
tools = append(tools, chattool.ProposePlan(chattool.ProposePlanOptions{
|
||||
GetWorkspaceConn: opts.workspaceCtx.getWorkspaceConn,
|
||||
ResolvePlanPath: opts.resolvePlanPath,
|
||||
IsPlanTurn: opts.isPlanModeTurn,
|
||||
StoreFile: func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error) {
|
||||
return p.storePlanSnapshotFile(ctx, opts.workspaceCtx, name, mediaType, data)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
return append(tools, p.subagentTools(ctx, func() database.Chat {
|
||||
return opts.chat
|
||||
})...)
|
||||
}
|
||||
|
||||
func (p *Server) storePlanSnapshotFile(
|
||||
ctx context.Context,
|
||||
workspaceCtx *turnWorkspaceContext,
|
||||
name string,
|
||||
mediaType string,
|
||||
data []byte,
|
||||
) (uuid.UUID, error) {
|
||||
chatSnapshot := workspaceCtx.currentChatSnapshot()
|
||||
if !chatSnapshot.WorkspaceID.Valid {
|
||||
return uuid.Nil, xerrors.New("no workspace is associated with this chat. Use the create_workspace tool to create one")
|
||||
}
|
||||
|
||||
ws, err := p.db.GetWorkspaceByID(ctx, chatSnapshot.WorkspaceID.UUID)
|
||||
if err != nil {
|
||||
return uuid.Nil, xerrors.Errorf("resolve workspace: %w", err)
|
||||
}
|
||||
|
||||
row, err := p.db.InsertChatFile(ctx, database.InsertChatFileParams{
|
||||
OwnerID: chatSnapshot.OwnerID,
|
||||
OrganizationID: ws.OrganizationID,
|
||||
Name: name,
|
||||
Mimetype: mediaType,
|
||||
Data: data,
|
||||
})
|
||||
if err != nil {
|
||||
return uuid.Nil, xerrors.Errorf("insert chat file: %w", err)
|
||||
}
|
||||
|
||||
// Cap enforcement and dedup are handled atomically in SQL.
|
||||
// rejected > 0 means the cap was exceeded.
|
||||
rejected, err := p.db.LinkChatFiles(ctx, database.LinkChatFilesParams{
|
||||
ChatID: chatSnapshot.ID,
|
||||
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
|
||||
FileIds: []uuid.UUID{row.ID},
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
p.logger.Error(ctx, "failed to link file to chat",
|
||||
slog.F("chat_id", chatSnapshot.ID),
|
||||
slog.F("file_id", row.ID),
|
||||
slog.Error(err),
|
||||
)
|
||||
case rejected > 0:
|
||||
p.logger.Warn(ctx, "file cap reached, file not linked to chat",
|
||||
slog.F("chat_id", chatSnapshot.ID),
|
||||
slog.F("file_id", row.ID),
|
||||
slog.F("max_file_links", codersdk.MaxChatFileIDs),
|
||||
)
|
||||
}
|
||||
|
||||
return row.ID, nil
|
||||
}
|
||||
|
||||
func appendDynamicTools(
|
||||
ctx context.Context,
|
||||
logger slog.Logger,
|
||||
tools []fantasy.AgentTool,
|
||||
raw pqtype.NullRawMessage,
|
||||
mode database.NullChatPlanMode,
|
||||
parentChatID uuid.NullUUID,
|
||||
) ([]fantasy.AgentTool, map[string]bool, error) {
|
||||
if mode.Valid && mode.ChatPlanMode == database.ChatPlanModePlan {
|
||||
return tools, nil, nil
|
||||
}
|
||||
|
||||
dynamicToolNames, err := parseDynamicToolNames(raw)
|
||||
if err != nil {
|
||||
return nil, nil, xerrors.Errorf("parse dynamic tool names: %w", err)
|
||||
}
|
||||
if len(dynamicToolNames) == 0 {
|
||||
return tools, dynamicToolNames, nil
|
||||
}
|
||||
|
||||
var dynamicToolDefs []codersdk.DynamicTool
|
||||
if raw.Valid {
|
||||
if err := json.Unmarshal(raw.RawMessage, &dynamicToolDefs); err != nil {
|
||||
return nil, nil, xerrors.Errorf("unmarshal dynamic tools: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
activeToolNames := make(map[string]struct{}, len(tools))
|
||||
for _, name := range allowedPlanToolNames(tools, mode, parentChatID) {
|
||||
activeToolNames[name] = struct{}{}
|
||||
}
|
||||
for _, t := range tools {
|
||||
info := t.Info()
|
||||
if _, active := activeToolNames[info.Name]; !active {
|
||||
continue
|
||||
}
|
||||
if dynamicToolNames[info.Name] {
|
||||
logger.Warn(ctx, "dynamic tool name collides with built-in tool, built-in takes precedence",
|
||||
slog.F("tool_name", info.Name))
|
||||
delete(dynamicToolNames, info.Name)
|
||||
}
|
||||
}
|
||||
|
||||
var filteredDefs []codersdk.DynamicTool
|
||||
for _, dt := range dynamicToolDefs {
|
||||
if dynamicToolNames[dt.Name] {
|
||||
filteredDefs = append(filteredDefs, dt)
|
||||
}
|
||||
}
|
||||
|
||||
return append(tools, dynamicToolsFromSDK(logger, filteredDefs)...), dynamicToolNames, nil
|
||||
}
|
||||
|
||||
func (p *Server) runChat(
|
||||
ctx context.Context,
|
||||
chat database.Chat,
|
||||
@@ -4378,6 +4725,7 @@ func (p *Server) runChat(
|
||||
providerKeys chatprovider.ProviderAPIKeys
|
||||
callConfig codersdk.ChatModelCallConfig
|
||||
messages []database.ChatMessage
|
||||
err error
|
||||
)
|
||||
|
||||
// Load MCP server configs and user tokens in parallel with
|
||||
@@ -4445,6 +4793,14 @@ func (p *Server) runChat(
|
||||
if err := g.Wait(); err != nil {
|
||||
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.
|
||||
currentPlanMode := chat.PlanMode
|
||||
isPlanModeTurn := currentPlanMode.Valid && currentPlanMode.ChatPlanMode == database.ChatPlanModePlan
|
||||
planModeInstructions := p.loadPlanModeInstructions(ctx, currentPlanMode, logger)
|
||||
|
||||
chainInfo := resolveChainMode(messages)
|
||||
result.PushSummaryModel = model
|
||||
result.ProviderKeys = providerKeys
|
||||
@@ -4713,9 +5069,23 @@ func (p *Server) runChat(
|
||||
if err := g2.Wait(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if chat.ParentChatID.Valid {
|
||||
prompt = chatprompt.InsertSystem(prompt, defaultSubagentInstruction)
|
||||
isRootChat := !chat.ParentChatID.Valid
|
||||
subagentInstruction := ""
|
||||
if !isRootChat {
|
||||
subagentInstruction = defaultSubagentInstruction
|
||||
}
|
||||
prompt = buildSystemPrompt(
|
||||
prompt,
|
||||
subagentInstruction,
|
||||
instruction,
|
||||
skills,
|
||||
resolvedUserPrompt,
|
||||
systemPromptPlanContext{
|
||||
mode: currentPlanMode,
|
||||
planModeInstructions: planModeInstructions,
|
||||
isRootChat: isRootChat,
|
||||
},
|
||||
)
|
||||
if mcpCleanup != nil {
|
||||
defer mcpCleanup()
|
||||
}
|
||||
@@ -4730,19 +5100,8 @@ func (p *Server) runChat(
|
||||
}
|
||||
}
|
||||
|
||||
var instructionInjected bool
|
||||
if instruction != "" {
|
||||
prompt = chatprompt.InsertSystem(prompt, instruction)
|
||||
instructionInjected = true
|
||||
}
|
||||
instructionInjected := instruction != ""
|
||||
prompt = renderPlanPathPrompt(prompt, resolvePlanPathBlock(ctx))
|
||||
if skillIndex := chattool.FormatSkillIndex(skills); skillIndex != "" {
|
||||
prompt = chatprompt.InsertSystem(prompt, skillIndex)
|
||||
}
|
||||
if resolvedUserPrompt != "" {
|
||||
prompt = chatprompt.InsertSystem(prompt, resolvedUserPrompt)
|
||||
}
|
||||
|
||||
// Use the model config's context_limit as a fallback when the LLM
|
||||
// provider doesn't include context_limit in its response metadata
|
||||
// (which is the common case).
|
||||
@@ -5042,6 +5401,7 @@ func (p *Server) runChat(
|
||||
model = cuModel
|
||||
}
|
||||
|
||||
allowAskUserQuestion := isPlanModeTurn && isRootChat
|
||||
tools := []fantasy.AgentTool{
|
||||
chattool.ReadFile(chattool.ReadFileOptions{
|
||||
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
|
||||
@@ -5049,10 +5409,12 @@ func (p *Server) runChat(
|
||||
chattool.WriteFile(chattool.WriteFileOptions{
|
||||
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
|
||||
ResolvePlanPath: resolvePlanPathForTools,
|
||||
IsPlanTurn: isPlanModeTurn,
|
||||
}),
|
||||
chattool.EditFiles(chattool.EditFilesOptions{
|
||||
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
|
||||
ResolvePlanPath: resolvePlanPathForTools,
|
||||
IsPlanTurn: isPlanModeTurn,
|
||||
}),
|
||||
chattool.Execute(chattool.ExecuteOptions{
|
||||
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
|
||||
@@ -5067,134 +5429,24 @@ func (p *Server) runChat(
|
||||
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
|
||||
}),
|
||||
}
|
||||
if allowAskUserQuestion {
|
||||
tools = append(tools, chattool.NewAskUserQuestionTool())
|
||||
}
|
||||
// Only root chats (not delegated subagents) get workspace
|
||||
// provisioning and subagent tools. Child agents must not
|
||||
// create workspaces or spawn further subagents — they should
|
||||
// create workspaces or spawn further subagents. They should
|
||||
// focus on completing their delegated task.
|
||||
if !chat.ParentChatID.Valid {
|
||||
// Workspace provisioning tools.
|
||||
onChatUpdated := func(updatedChat database.Chat) {
|
||||
workspaceCtx.selectWorkspace(updatedChat)
|
||||
// Notify the frontend immediately so it can
|
||||
// start streaming build logs before the tool
|
||||
// completes.
|
||||
p.publishChatPubsubEvent(updatedChat, codersdk.ChatWatchEventKindStatusChange, nil)
|
||||
|
||||
// When a workspace is first attached mid-turn
|
||||
// (e.g. via create_workspace), fetch and persist
|
||||
// instruction files immediately so the LLM has
|
||||
// AGENTS.md context for the remainder of this
|
||||
// turn. The persisted marker prevents redundant
|
||||
// fetches on subsequent turns.
|
||||
if instruction == "" && updatedChat.WorkspaceID.Valid {
|
||||
newInstruction, discoveredSkills, persistErr := p.persistInstructionFiles(
|
||||
ctx,
|
||||
updatedChat,
|
||||
modelConfig.ID,
|
||||
workspaceCtx.getWorkspaceAgent,
|
||||
workspaceCtx.getWorkspaceConn,
|
||||
)
|
||||
if persistErr != nil {
|
||||
p.logger.Warn(ctx, "failed to persist instruction files on workspace attach",
|
||||
slog.F("chat_id", updatedChat.ID),
|
||||
slog.Error(persistErr),
|
||||
)
|
||||
} else {
|
||||
instruction = newInstruction
|
||||
if len(discoveredSkills) > 0 {
|
||||
skills = discoveredSkills
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tools = append(tools,
|
||||
chattool.ListTemplates(chat.OrganizationID, p.db, chattool.ListTemplatesOptions{
|
||||
OwnerID: chat.OwnerID,
|
||||
AllowedTemplateIDs: p.chatTemplateAllowlist,
|
||||
}),
|
||||
chattool.ReadTemplate(chat.OrganizationID, p.db, chattool.ReadTemplateOptions{
|
||||
OwnerID: chat.OwnerID,
|
||||
AllowedTemplateIDs: p.chatTemplateAllowlist,
|
||||
}),
|
||||
chattool.CreateWorkspace(chat.OrganizationID, p.db, chattool.CreateWorkspaceOptions{
|
||||
OwnerID: chat.OwnerID,
|
||||
ChatID: chat.ID,
|
||||
CreateFn: p.createWorkspaceFn,
|
||||
AgentConnFn: chattool.AgentConnFunc(p.agentConnFn),
|
||||
AgentInactiveDisconnectTimeout: p.agentInactiveDisconnectTimeout,
|
||||
WorkspaceMu: &workspaceMu,
|
||||
OnChatUpdated: onChatUpdated,
|
||||
Logger: p.logger,
|
||||
AllowedTemplateIDs: p.chatTemplateAllowlist,
|
||||
}),
|
||||
|
||||
chattool.StartWorkspace(chattool.StartWorkspaceOptions{
|
||||
DB: p.db,
|
||||
OwnerID: chat.OwnerID,
|
||||
ChatID: chat.ID,
|
||||
StartFn: p.startWorkspaceFn,
|
||||
AgentConnFn: chattool.AgentConnFunc(p.agentConnFn),
|
||||
WorkspaceMu: &workspaceMu,
|
||||
OnChatUpdated: onChatUpdated,
|
||||
Logger: p.logger,
|
||||
}),
|
||||
)
|
||||
// Plan presentation tool.
|
||||
tools = append(tools, chattool.ProposePlan(chattool.ProposePlanOptions{
|
||||
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
|
||||
ResolvePlanPath: resolvePlanPathForTools,
|
||||
StoreFile: func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error) {
|
||||
workspaceCtx.chatStateMu.Lock()
|
||||
chatSnapshot := *workspaceCtx.currentChat
|
||||
workspaceCtx.chatStateMu.Unlock()
|
||||
|
||||
if !chatSnapshot.WorkspaceID.Valid {
|
||||
return uuid.Nil, xerrors.New("no workspace is associated with this chat. Use the create_workspace tool to create one")
|
||||
}
|
||||
|
||||
ws, err := p.db.GetWorkspaceByID(ctx, chatSnapshot.WorkspaceID.UUID)
|
||||
if err != nil {
|
||||
return uuid.Nil, xerrors.Errorf("resolve workspace: %w", err)
|
||||
}
|
||||
|
||||
row, err := p.db.InsertChatFile(ctx, database.InsertChatFileParams{
|
||||
OwnerID: chatSnapshot.OwnerID,
|
||||
OrganizationID: ws.OrganizationID,
|
||||
Name: name,
|
||||
Mimetype: mediaType,
|
||||
Data: data,
|
||||
})
|
||||
if err != nil {
|
||||
return uuid.Nil, xerrors.Errorf("insert chat file: %w", err)
|
||||
}
|
||||
|
||||
// Cap enforcement and dedup are handled atomically
|
||||
// in SQL. rejected > 0 = cap exceeded.
|
||||
rejected, err := p.db.LinkChatFiles(ctx, database.LinkChatFilesParams{
|
||||
ChatID: chatSnapshot.ID,
|
||||
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
|
||||
FileIds: []uuid.UUID{row.ID},
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
p.logger.Error(ctx, "failed to link file to chat",
|
||||
slog.F("chat_id", chatSnapshot.ID),
|
||||
slog.F("file_id", row.ID),
|
||||
slog.Error(err),
|
||||
)
|
||||
case rejected > 0:
|
||||
p.logger.Warn(ctx, "file cap reached, file not linked to chat",
|
||||
slog.F("chat_id", chatSnapshot.ID),
|
||||
slog.F("file_id", row.ID),
|
||||
slog.F("max_file_links", codersdk.MaxChatFileIDs),
|
||||
)
|
||||
}
|
||||
return row.ID, nil
|
||||
},
|
||||
}))
|
||||
tools = append(tools, p.subagentTools(ctx, func() database.Chat {
|
||||
return chat
|
||||
})...)
|
||||
if isRootChat {
|
||||
tools = p.appendRootChatTools(ctx, tools, rootChatToolsOptions{
|
||||
chat: chat,
|
||||
modelConfigID: modelConfig.ID,
|
||||
workspaceCtx: &workspaceCtx,
|
||||
workspaceMu: &workspaceMu,
|
||||
instruction: &instruction,
|
||||
skills: &skills,
|
||||
resolvePlanPath: resolvePlanPathForTools,
|
||||
isPlanModeTurn: isPlanModeTurn,
|
||||
})
|
||||
}
|
||||
|
||||
// Append skill tools when the workspace has skills.
|
||||
@@ -5221,49 +5473,35 @@ 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.
|
||||
tools = append(tools, mcpTools...)
|
||||
tools = append(tools, workspaceMCPTools...)
|
||||
if !isPlanModeTurn {
|
||||
tools = append(tools, mcpTools...)
|
||||
tools = append(tools, workspaceMCPTools...)
|
||||
}
|
||||
// Append dynamic tools declared by the client at chat
|
||||
// creation time. These appear in the LLM's tool list but
|
||||
// are never executed by the chatloop — the client handles
|
||||
// are never executed by the chatloop. The client handles
|
||||
// execution via POST /tool-results.
|
||||
dynamicToolNames, err := parseDynamicToolNames(chat.DynamicTools)
|
||||
var dynamicToolNames map[string]bool
|
||||
tools, dynamicToolNames, err = appendDynamicTools(
|
||||
ctx,
|
||||
logger,
|
||||
tools,
|
||||
chat.DynamicTools,
|
||||
currentPlanMode,
|
||||
chat.ParentChatID,
|
||||
)
|
||||
if err != nil {
|
||||
return result, xerrors.Errorf("parse dynamic tool names: %w", err)
|
||||
}
|
||||
// Unmarshal the full definitions separately so we can
|
||||
// build the filtered list below. parseDynamicToolNames
|
||||
// already validated the JSON, so this cannot fail.
|
||||
var dynamicToolDefs []codersdk.DynamicTool
|
||||
if chat.DynamicTools.Valid {
|
||||
if err := json.Unmarshal(chat.DynamicTools.RawMessage, &dynamicToolDefs); err != nil {
|
||||
return result, xerrors.Errorf("unmarshal dynamic tools: %w", err)
|
||||
}
|
||||
}
|
||||
for _, t := range tools {
|
||||
info := t.Info()
|
||||
if dynamicToolNames[info.Name] {
|
||||
logger.Warn(ctx, "dynamic tool name collides with built-in tool, built-in takes precedence",
|
||||
slog.F("tool_name", info.Name))
|
||||
delete(dynamicToolNames, info.Name)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
var filteredDefs []codersdk.DynamicTool
|
||||
for _, dt := range dynamicToolDefs {
|
||||
if dynamicToolNames[dt.Name] {
|
||||
filteredDefs = append(filteredDefs, dt)
|
||||
}
|
||||
}
|
||||
tools = append(tools, dynamicToolsFromSDK(p.logger, filteredDefs)...)
|
||||
// Build provider-native tools (e.g., web search) based on
|
||||
// the model configuration.
|
||||
var providerTools []chatloop.ProviderTool
|
||||
if callConfig.ProviderOptions != nil {
|
||||
if !isPlanModeTurn && callConfig.ProviderOptions != nil {
|
||||
providerTools = buildProviderTools(model.Provider(), callConfig.ProviderOptions)
|
||||
}
|
||||
|
||||
if isComputerUse {
|
||||
if !isPlanModeTurn && isComputerUse {
|
||||
desktopGeometry := workspacesdk.DefaultDesktopGeometry()
|
||||
providerTools = append(providerTools, chatloop.ProviderTool{
|
||||
Definition: chattool.ComputerUseProviderTool(
|
||||
@@ -5291,7 +5529,8 @@ func (p *Server) runChat(
|
||||
chainModeActive := chatprovider.IsResponsesStoreEnabled(providerOptions) &&
|
||||
chainInfo.previousResponseID != "" &&
|
||||
chainInfo.contributingTrailingUserCount > 0 &&
|
||||
chainInfo.modelConfigID == modelConfig.ID
|
||||
chainInfo.modelConfigID == modelConfig.ID &&
|
||||
!isPlanModeTurn
|
||||
if chainModeActive {
|
||||
providerOptions = chatprovider.CloneWithPreviousResponseID(
|
||||
providerOptions,
|
||||
@@ -5300,9 +5539,12 @@ func (p *Server) runChat(
|
||||
prompt = filterPromptForChainMode(prompt, chainInfo)
|
||||
}
|
||||
err = chatloop.Run(ctx, chatloop.RunOptions{
|
||||
Model: model,
|
||||
Messages: prompt,
|
||||
Tools: tools, MaxSteps: maxChatSteps,
|
||||
Model: model,
|
||||
Messages: prompt,
|
||||
Tools: tools,
|
||||
ActiveTools: allowedPlanToolNames(tools, currentPlanMode, chat.ParentChatID),
|
||||
StopAfterTools: stopAfterPlanTools(currentPlanMode, chat.ParentChatID),
|
||||
MaxSteps: maxChatSteps,
|
||||
Metrics: p.metrics,
|
||||
BuiltinToolNames: builtinToolNames,
|
||||
|
||||
@@ -5337,9 +5579,6 @@ func (p *Server) runChat(
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("convert reloaded messages: %w", err)
|
||||
}
|
||||
if chat.ParentChatID.Valid {
|
||||
reloadedPrompt = chatprompt.InsertSystem(reloadedPrompt, defaultSubagentInstruction)
|
||||
}
|
||||
// Re-derive instruction and skills from the reloaded
|
||||
// messages so that any context added during the
|
||||
// chatloop (e.g. via persistInstructionFiles when
|
||||
@@ -5351,22 +5590,26 @@ func (p *Server) runChat(
|
||||
reloadedInstruction = instructionFromContextFiles(reloadedMsgs)
|
||||
}
|
||||
if reloadedInstruction != "" {
|
||||
reloadedPrompt = chatprompt.InsertSystem(reloadedPrompt, reloadedInstruction)
|
||||
instructionInjected = true
|
||||
}
|
||||
reloadedPrompt = renderPlanPathPrompt(reloadedPrompt, resolvePlanPathBlock(reloadCtx))
|
||||
reloadedSkills := skillsFromParts(reloadedMsgs)
|
||||
if len(reloadedSkills) == 0 {
|
||||
reloadedSkills = skills
|
||||
}
|
||||
|
||||
if skillIndex := chattool.FormatSkillIndex(reloadedSkills); skillIndex != "" {
|
||||
reloadedPrompt = chatprompt.InsertSystem(reloadedPrompt, skillIndex)
|
||||
}
|
||||
reloadUserPrompt := p.resolveUserPrompt(reloadCtx, chat.OwnerID)
|
||||
if reloadUserPrompt != "" {
|
||||
reloadedPrompt = chatprompt.InsertSystem(reloadedPrompt, reloadUserPrompt)
|
||||
}
|
||||
reloadedPrompt = buildSystemPrompt(
|
||||
reloadedPrompt,
|
||||
subagentInstruction,
|
||||
reloadedInstruction,
|
||||
reloadedSkills,
|
||||
reloadUserPrompt,
|
||||
systemPromptPlanContext{
|
||||
mode: currentPlanMode,
|
||||
planModeInstructions: planModeInstructions,
|
||||
isRootChat: isRootChat,
|
||||
},
|
||||
)
|
||||
reloadedPrompt = renderPlanPathPrompt(reloadedPrompt, resolvePlanPathBlock(reloadCtx))
|
||||
if chainModeActive {
|
||||
reloadedPrompt = filterPromptForChainMode(
|
||||
reloadedPrompt,
|
||||
@@ -5416,6 +5659,9 @@ func (p *Server) runChat(
|
||||
p.logger.Warn(ctx, "failed to persist interrupted chat step", slog.Error(err))
|
||||
},
|
||||
})
|
||||
if errors.Is(err, chatloop.ErrStopAfterTool) {
|
||||
err = nil
|
||||
}
|
||||
if errors.Is(err, chatloop.ErrDynamicToolCall) {
|
||||
// The stream event is published in processChat's
|
||||
// defer after the DB status transitions to
|
||||
|
||||
@@ -33,6 +33,209 @@ import (
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
type testAgentTool struct {
|
||||
info fantasy.ToolInfo
|
||||
providerOptions fantasy.ProviderOptions
|
||||
}
|
||||
|
||||
func newTestAgentTool(name string) fantasy.AgentTool {
|
||||
return &testAgentTool{info: fantasy.ToolInfo{Name: name}}
|
||||
}
|
||||
|
||||
func (t *testAgentTool) Info() fantasy.ToolInfo {
|
||||
return t.info
|
||||
}
|
||||
|
||||
func (t *testAgentTool) Run(context.Context, fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
_ = t
|
||||
return fantasy.ToolResponse{}, nil
|
||||
}
|
||||
|
||||
func (t *testAgentTool) ProviderOptions() fantasy.ProviderOptions {
|
||||
return t.providerOptions
|
||||
}
|
||||
|
||||
func (t *testAgentTool) SetProviderOptions(opts fantasy.ProviderOptions) {
|
||||
t.providerOptions = opts
|
||||
}
|
||||
|
||||
func TestAllowedPlanToolNames(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
|
||||
}
|
||||
|
||||
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.Parallel()
|
||||
|
||||
got := allowedPlanToolNames(makeTools(
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_files",
|
||||
"execute",
|
||||
"process_output",
|
||||
"process_list",
|
||||
"process_signal",
|
||||
"list_templates",
|
||||
"read_template",
|
||||
"create_workspace",
|
||||
"start_workspace",
|
||||
"propose_plan",
|
||||
"spawn_agent",
|
||||
"wait_agent",
|
||||
"message_agent",
|
||||
"close_agent",
|
||||
"spawn_computer_use_agent",
|
||||
"read_skill",
|
||||
"read_skill_file",
|
||||
"ask_user_question",
|
||||
), planMode, uuid.NullUUID{})
|
||||
|
||||
require.Equal(t, []string{
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_files",
|
||||
"execute",
|
||||
"process_output",
|
||||
"list_templates",
|
||||
"read_template",
|
||||
"create_workspace",
|
||||
"start_workspace",
|
||||
"propose_plan",
|
||||
"spawn_agent",
|
||||
"wait_agent",
|
||||
"read_skill",
|
||||
"read_skill_file",
|
||||
"ask_user_question",
|
||||
}, got)
|
||||
})
|
||||
|
||||
t.Run("PlanModeChildChatsAllowExplorationOnly", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := allowedPlanToolNames(makeTools(
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_files",
|
||||
"execute",
|
||||
"process_output",
|
||||
"list_templates",
|
||||
"read_template",
|
||||
"create_workspace",
|
||||
"start_workspace",
|
||||
"propose_plan",
|
||||
"spawn_agent",
|
||||
"wait_agent",
|
||||
"read_skill",
|
||||
"read_skill_file",
|
||||
"ask_user_question",
|
||||
), planMode, uuid.NullUUID{UUID: uuid.New(), Valid: true})
|
||||
|
||||
require.Equal(t, []string{
|
||||
"read_file",
|
||||
"execute",
|
||||
"process_output",
|
||||
"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) {
|
||||
t.Parallel()
|
||||
|
||||
planMode := database.NullChatPlanMode{
|
||||
ChatPlanMode: database.ChatPlanModePlan,
|
||||
Valid: true,
|
||||
}
|
||||
|
||||
t.Run("NormalModeReturnsNil", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Nil(t, stopAfterPlanTools(database.NullChatPlanMode{}, uuid.NullUUID{}))
|
||||
})
|
||||
|
||||
t.Run("RootPlanModeIncludesClarificationTool", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, map[string]struct{}{
|
||||
"propose_plan": {},
|
||||
"ask_user_question": {},
|
||||
}, stopAfterPlanTools(planMode, 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}))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -54,6 +54,90 @@ import (
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
type recordedOpenAIRequest struct {
|
||||
Messages []chattest.OpenAIMessage
|
||||
Tools []string
|
||||
Store *bool
|
||||
PreviousResponseID *string
|
||||
ContentLength int64
|
||||
}
|
||||
|
||||
func recordOpenAIRequest(req *chattest.OpenAIRequest) recordedOpenAIRequest {
|
||||
messages := append([]chattest.OpenAIMessage(nil), req.Messages...)
|
||||
tools := make([]string, 0, len(req.Tools))
|
||||
for _, tool := range req.Tools {
|
||||
tools = append(tools, tool.Function.Name)
|
||||
}
|
||||
|
||||
var store *bool
|
||||
if req.Store != nil {
|
||||
value := *req.Store
|
||||
store = &value
|
||||
}
|
||||
|
||||
var previousResponseID *string
|
||||
if req.PreviousResponseID != nil {
|
||||
value := *req.PreviousResponseID
|
||||
previousResponseID = &value
|
||||
}
|
||||
|
||||
var contentLength int64
|
||||
if req.Request != nil {
|
||||
contentLength = req.Request.ContentLength
|
||||
}
|
||||
|
||||
return recordedOpenAIRequest{
|
||||
Messages: messages,
|
||||
Tools: tools,
|
||||
Store: store,
|
||||
PreviousResponseID: previousResponseID,
|
||||
ContentLength: contentLength,
|
||||
}
|
||||
}
|
||||
|
||||
func requestHasSystemSubstring(req recordedOpenAIRequest, want string) bool {
|
||||
for _, msg := range req.Messages {
|
||||
if msg.Role == "system" && strings.Contains(msg.Content, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func newWorkspaceToolTestServer(
|
||||
t *testing.T,
|
||||
db database.Store,
|
||||
ps dbpubsub.Pubsub,
|
||||
agentID uuid.UUID,
|
||||
planContent string,
|
||||
) *chatd.Server {
|
||||
t.Helper()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
mockConn.EXPECT().SetExtraHeaders(gomock.Any()).AnyTimes()
|
||||
mockConn.EXPECT().ContextConfig(gomock.Any()).
|
||||
Return(workspacesdk.ContextConfigResponse{}, xerrors.New("not supported")).AnyTimes()
|
||||
mockConn.EXPECT().ListMCPTools(gomock.Any()).
|
||||
Return(workspacesdk.ListMCPToolsResponse{}, nil).AnyTimes()
|
||||
mockConn.EXPECT().LS(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
Return(workspacesdk.LSResponse{}, nil).AnyTimes()
|
||||
mockConn.EXPECT().ReadFile(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(func(_ context.Context, path string, _, _ int64) (io.ReadCloser, string, error) {
|
||||
if path == "/home/coder/PLAN.md" {
|
||||
return io.NopCloser(strings.NewReader(planContent)), "", nil
|
||||
}
|
||||
return io.NopCloser(strings.NewReader("")), "", nil
|
||||
}).AnyTimes()
|
||||
|
||||
return newActiveTestServer(t, db, ps, func(cfg *chatd.Config) {
|
||||
cfg.AgentConn = func(_ context.Context, gotAgentID uuid.UUID) (workspacesdk.AgentConn, func(), error) {
|
||||
require.Equal(t, agentID, gotAgentID)
|
||||
return mockConn, func() {}, nil
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestInterruptChatBroadcastsStatusAcrossInstances(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -224,7 +308,7 @@ func TestSubagentChatExcludesWorkspaceProvisioningTools(t *testing.T) {
|
||||
require.GreaterOrEqual(t, len(recorded), 2,
|
||||
"expected at least 2 streamed LLM calls (root + subagent)")
|
||||
|
||||
workspaceTools := []string{"propose_plan", "list_templates", "read_template", "create_workspace"}
|
||||
workspaceTools := []string{"list_templates", "read_template", "create_workspace"}
|
||||
subagentTools := []string{"spawn_agent", "wait_agent", "message_agent", "close_agent"}
|
||||
|
||||
// Identify root and subagent calls. Root chat calls include
|
||||
@@ -255,6 +339,10 @@ func TestSubagentChatExcludesWorkspaceProvisioningTools(t *testing.T) {
|
||||
"root chat should have subagent tool %q", tool)
|
||||
}
|
||||
|
||||
// Standard turns (no turn mode) should hide propose_plan.
|
||||
require.NotContains(t, rootCalls[0], "propose_plan",
|
||||
"standard-turn root chat should NOT have propose_plan")
|
||||
|
||||
// Subagent calls must NOT include workspace or subagent tools.
|
||||
for _, tool := range workspaceTools {
|
||||
require.NotContains(t, childCalls[0], tool,
|
||||
@@ -266,6 +354,153 @@ func TestSubagentChatExcludesWorkspaceProvisioningTools(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanModeSubagentChatExcludesAskUserQuestion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
deploymentValues := coderdtest.DeploymentValues(t)
|
||||
deploymentValues.Experiments = []string{string(codersdk.ExperimentAgents)}
|
||||
client := coderdtest.New(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)
|
||||
coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
_ = agenttest.New(t, client.URL, agentToken)
|
||||
|
||||
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_agent", `{"prompt":"inspect 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)
|
||||
|
||||
chat, err := expClient.CreateChat(ctx, codersdk.CreateChatRequest{
|
||||
OrganizationID: user.OrganizationID,
|
||||
PlanMode: codersdk.ChatPlanModePlan,
|
||||
Content: []codersdk.ChatInputPart{
|
||||
{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
Text: "Spawn a subagent to inspect the codebase.",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
got, getErr := expClient.GetChat(ctx, chat.ID)
|
||||
if getErr != nil {
|
||||
return false
|
||||
}
|
||||
if got.Status != codersdk.ChatStatusWaiting && got.Status != codersdk.ChatStatusError {
|
||||
return false
|
||||
}
|
||||
toolsMu.Lock()
|
||||
n := len(toolsByCall)
|
||||
toolsMu.Unlock()
|
||||
return n >= 3
|
||||
}, 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_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], "ask_user_question",
|
||||
"root plan-mode chat should have ask_user_question")
|
||||
require.Contains(t, rootCalls[0], "write_file",
|
||||
"root plan-mode chat should have write_file")
|
||||
require.Contains(t, rootCalls[0], "edit_files",
|
||||
"root plan-mode chat should have edit_files")
|
||||
require.Contains(t, rootCalls[0], "execute",
|
||||
"root plan-mode chat should have execute")
|
||||
require.Contains(t, rootCalls[0], "process_output",
|
||||
"root plan-mode chat should have process_output")
|
||||
require.NotContains(t, childCalls[0], "ask_user_question",
|
||||
"plan-mode subagent should NOT have ask_user_question")
|
||||
require.NotContains(t, childCalls[0], "write_file",
|
||||
"plan-mode subagent should NOT have write_file")
|
||||
require.NotContains(t, childCalls[0], "edit_files",
|
||||
"plan-mode subagent should NOT have edit_files")
|
||||
require.Contains(t, childCalls[0], "execute",
|
||||
"plan-mode subagent should have execute")
|
||||
require.Contains(t, childCalls[0], "process_output",
|
||||
"plan-mode subagent should have process_output")
|
||||
require.True(t, requestHasSystemSubstring(rootRequests[0], "You are in Plan Mode."))
|
||||
require.True(t, requestHasSystemSubstring(childRequests[0], "You are in Plan Mode as a delegated sub-agent."))
|
||||
require.False(t, requestHasSystemSubstring(childRequests[0], "When the plan is ready, call propose_plan"))
|
||||
}
|
||||
|
||||
func TestInterruptChatClearsWorkerInDatabase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -579,6 +814,77 @@ func TestSendMessageQueueBehaviorQueuesWhenBusy(t *testing.T) {
|
||||
require.Len(t, messages, 1)
|
||||
}
|
||||
|
||||
func TestPlanTurnPromptContract(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
|
||||
var (
|
||||
requests []recordedOpenAIRequest
|
||||
requestsMu sync.Mutex
|
||||
)
|
||||
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||
if !req.Stream {
|
||||
return chattest.OpenAINonStreamingResponse("title")
|
||||
}
|
||||
|
||||
requestsMu.Lock()
|
||||
requests = append(requests, recordOpenAIRequest(req))
|
||||
requestsMu.Unlock()
|
||||
|
||||
return chattest.OpenAIStreamingResponse(
|
||||
chattest.OpenAITextChunks("plan acknowledged")...,
|
||||
)
|
||||
})
|
||||
|
||||
user, org, model := seedChatDependenciesWithProvider(ctx, t, db, "openai-compat", openAIURL)
|
||||
planModeInstructions := "Ask about deployment sequencing before finalizing the plan."
|
||||
err := db.UpsertChatPlanModeInstructions(dbauthz.AsSystemRestricted(ctx), planModeInstructions)
|
||||
require.NoError(t, err)
|
||||
ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID)
|
||||
server := newWorkspaceToolTestServer(t, db, ps, dbAgent.ID, "# Plan\n")
|
||||
|
||||
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
|
||||
OwnerID: user.ID,
|
||||
OrganizationID: org.ID,
|
||||
Title: "plan-turn-prompt-contract",
|
||||
ModelConfigID: model.ID,
|
||||
PlanMode: database.NullChatPlanMode{ChatPlanMode: database.ChatPlanModePlan, Valid: true},
|
||||
WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true},
|
||||
InitialUserContent: []codersdk.ChatMessagePart{
|
||||
codersdk.ChatMessageText("Plan the rollout."),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
waitForChatProcessed(ctx, t, db, chat.ID, server)
|
||||
|
||||
requestsMu.Lock()
|
||||
recorded := append([]recordedOpenAIRequest(nil), requests...)
|
||||
requestsMu.Unlock()
|
||||
|
||||
require.Len(t, recorded, 1, "expected exactly 1 streamed model call")
|
||||
require.True(t, requestHasSystemSubstring(recorded[0], "You are in Plan Mode."))
|
||||
require.True(t, requestHasSystemSubstring(recorded[0], "The only intentional authored workspace artifact is the plan file"))
|
||||
require.True(t, requestHasSystemSubstring(recorded[0], "You may use execute and process_output for exploration"))
|
||||
require.True(t, requestHasSystemSubstring(recorded[0], "After a successful propose_plan call, stop immediately"))
|
||||
require.True(t, requestHasSystemSubstring(recorded[0], planModeInstructions))
|
||||
for _, msg := range recorded[0].Messages {
|
||||
if msg.Role != "system" {
|
||||
continue
|
||||
}
|
||||
// The overlay constant includes a placeholder that is replaced at
|
||||
// runtime, so strip only the stable body text before checking.
|
||||
overlayBody := strings.TrimSuffix(
|
||||
chatd.PlanningOverlayPrompt,
|
||||
"{{CODER_CHAT_PLAN_FILE_PATH_BLOCK}}",
|
||||
)
|
||||
sanitized := strings.ReplaceAll(msg.Content, overlayBody, "")
|
||||
require.NotContains(t, sanitized, "propose_plan")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessageQueuesWhenWaitingWithQueuedBacklog(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -2162,6 +2468,91 @@ func TestDynamicToolCallPausesAndResumes(t *testing.T) {
|
||||
"expected second LLM call to include the submitted dynamic tool result")
|
||||
}
|
||||
|
||||
func TestDynamicToolNamedProposePlanRemainsAvailableOutsidePlanMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
var streamedCallsMu sync.Mutex
|
||||
streamedCalls := make([]chattest.OpenAIRequest, 0, 1)
|
||||
|
||||
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||
if !req.Stream {
|
||||
return chattest.OpenAINonStreamingResponse("Dynamic tool collision test")
|
||||
}
|
||||
|
||||
streamedCallsMu.Lock()
|
||||
streamedCalls = append(streamedCalls, chattest.OpenAIRequest{
|
||||
Messages: append([]chattest.OpenAIMessage(nil), req.Messages...),
|
||||
Tools: append([]chattest.OpenAITool(nil), req.Tools...),
|
||||
Stream: req.Stream,
|
||||
})
|
||||
streamedCallsMu.Unlock()
|
||||
|
||||
return chattest.OpenAIStreamingResponse(
|
||||
chattest.OpenAITextChunks("Dynamic tool list captured.")...,
|
||||
)
|
||||
})
|
||||
|
||||
user, org, model := seedChatDependenciesWithProvider(ctx, t, db, "openai-compat", openAIURL)
|
||||
server := newActiveTestServer(t, db, ps)
|
||||
|
||||
dynamicToolsJSON, err := json.Marshal([]mcpgo.Tool{{
|
||||
Name: "propose_plan",
|
||||
Description: "A dynamic tool whose name collides with the hidden built-in.",
|
||||
InputSchema: mcpgo.ToolInputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]any{
|
||||
"input": map[string]any{"type": "string"},
|
||||
},
|
||||
Required: []string{"input"},
|
||||
},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
|
||||
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
|
||||
OrganizationID: org.ID,
|
||||
OwnerID: user.ID,
|
||||
Title: "dynamic-propose-plan-collision",
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: []codersdk.ChatMessagePart{
|
||||
codersdk.ChatMessageText("List the available tools."),
|
||||
},
|
||||
DynamicTools: dynamicToolsJSON,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var chatResult database.Chat
|
||||
require.Eventually(t, func() bool {
|
||||
got, getErr := db.GetChatByID(ctx, chat.ID)
|
||||
if getErr != nil {
|
||||
return false
|
||||
}
|
||||
chatResult = got
|
||||
return got.Status == database.ChatStatusWaiting || got.Status == database.ChatStatusError
|
||||
}, testutil.WaitLong, testutil.IntervalFast)
|
||||
|
||||
if chatResult.Status == database.ChatStatusError {
|
||||
require.FailNowf(t, "chat run failed", "last_error=%q", chatResult.LastError.String)
|
||||
}
|
||||
|
||||
streamedCallsMu.Lock()
|
||||
recordedCalls := append([]chattest.OpenAIRequest(nil), streamedCalls...)
|
||||
streamedCallsMu.Unlock()
|
||||
require.NotEmpty(t, recordedCalls)
|
||||
|
||||
var foundDynamicTool bool
|
||||
for _, tool := range recordedCalls[0].Tools {
|
||||
if tool.Function.Name == "propose_plan" {
|
||||
foundDynamicTool = true
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, foundDynamicTool,
|
||||
"expected the dynamic propose_plan tool to remain visible outside plan mode")
|
||||
}
|
||||
|
||||
func TestDynamicToolCallMixedWithBuiltIn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -43,6 +43,10 @@ const (
|
||||
var (
|
||||
ErrInterrupted = xerrors.New("chat interrupted")
|
||||
ErrDynamicToolCall = xerrors.New("dynamic tool call")
|
||||
// ErrStopAfterTool is returned when a tool listed in
|
||||
// StopAfterTools produces a successful result, indicating
|
||||
// the run should terminate cleanly after persistence.
|
||||
ErrStopAfterTool = xerrors.New("stop after tool")
|
||||
|
||||
errStartupTimeout = xerrors.New(
|
||||
"chat response did not start before the startup timeout",
|
||||
@@ -114,6 +118,11 @@ type RunOptions struct {
|
||||
// the chatloop persists partial results and exits with
|
||||
// ErrDynamicToolCall instead of executing the tool.
|
||||
DynamicToolNames map[string]bool
|
||||
// StopAfterTools lists tool names that, when they produce a
|
||||
// successful result, cause the run to stop after persisting
|
||||
// the current step. This is used for plan turns where
|
||||
// propose_plan should terminate the run on success.
|
||||
StopAfterTools map[string]struct{}
|
||||
|
||||
// ModelConfig holds per-call LLM parameters (temperature,
|
||||
// max tokens, etc.) read from the chat model configuration.
|
||||
@@ -472,7 +481,7 @@ func Run(ctx context.Context, opts RunOptions) error {
|
||||
}
|
||||
|
||||
// Execute only built-in tools.
|
||||
toolResults = executeTools(ctx, opts.Tools, opts.ProviderTools, builtinCalls, opts.Metrics, provider, opts.BuiltinToolNames, func(tr fantasy.ToolResultContent, completedAt time.Time) {
|
||||
toolResults = executeTools(ctx, opts.Tools, opts.ActiveTools, opts.ProviderTools, builtinCalls, opts.Metrics, provider, opts.BuiltinToolNames, func(tr fantasy.ToolResultContent, completedAt time.Time) {
|
||||
recordToolResultTimestamp(&result, tr.ToolCallID, completedAt)
|
||||
ssePart := chatprompt.PartFromContent(tr)
|
||||
ssePart.CreatedAt = &completedAt
|
||||
@@ -566,6 +575,12 @@ func Run(ctx context.Context, opts RunOptions) error {
|
||||
lastUsage = result.usage
|
||||
lastProviderMetadata = result.providerMetadata
|
||||
|
||||
// Check if any executed tool triggers an early stop.
|
||||
if shouldStopAfterTools(opts.StopAfterTools, toolResults) {
|
||||
tryCompactOnExit(ctx, opts, result.usage, result.providerMetadata)
|
||||
return ErrStopAfterTool
|
||||
}
|
||||
|
||||
// When chain mode is active (PreviousResponseID set), exit
|
||||
// it after persisting the first chained step. Continuation
|
||||
// steps include tool-result messages, which fantasy rejects
|
||||
@@ -1022,6 +1037,7 @@ func processStepStream(
|
||||
func executeTools(
|
||||
ctx context.Context,
|
||||
allTools []fantasy.AgentTool,
|
||||
activeTools []string,
|
||||
providerTools []ProviderTool,
|
||||
toolCalls []fantasy.ToolCallContent,
|
||||
metrics *Metrics,
|
||||
@@ -1051,11 +1067,14 @@ func executeTools(
|
||||
for _, t := range allTools {
|
||||
toolMap[t.Info().Name] = t
|
||||
}
|
||||
providerRunnerNames := make(map[string]struct{}, len(providerTools))
|
||||
// Include runners from provider tools so locally-executed
|
||||
// provider tools (e.g. computer use) can be dispatched.
|
||||
for _, pt := range providerTools {
|
||||
if pt.Runner != nil {
|
||||
toolMap[pt.Runner.Info().Name] = pt.Runner
|
||||
name := pt.Runner.Info().Name
|
||||
toolMap[name] = pt.Runner
|
||||
providerRunnerNames[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1081,7 +1100,7 @@ func executeTools(
|
||||
// accurate individual completion times.
|
||||
completedAt[i] = dbtime.Now()
|
||||
}()
|
||||
results[i] = executeSingleTool(ctx, toolMap, tc, metrics, provider, builtinToolNames)
|
||||
results[i] = executeSingleTool(ctx, toolMap, tc, metrics, provider, builtinToolNames, activeTools, providerRunnerNames)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -1105,6 +1124,8 @@ func executeSingleTool(
|
||||
metrics *Metrics,
|
||||
provider string,
|
||||
builtinToolNames map[string]bool,
|
||||
activeTools []string,
|
||||
providerRunnerNames map[string]struct{},
|
||||
) fantasy.ToolResultContent {
|
||||
result := fantasy.ToolResultContent{
|
||||
ToolCallID: tc.ToolCallID,
|
||||
@@ -1121,6 +1142,13 @@ func executeSingleTool(
|
||||
)
|
||||
}()
|
||||
|
||||
if _, isProviderRunner := providerRunnerNames[tc.ToolName]; !isProviderRunner && !isToolActive(tc.ToolName, activeTools) {
|
||||
result.Result = fantasy.ToolResultOutputContentError{
|
||||
Error: xerrors.New("Tool not active in this turn: " + tc.ToolName),
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
tool, exists := toolMap[tc.ToolName]
|
||||
if !exists {
|
||||
result.Result = fantasy.ToolResultOutputContentError{
|
||||
@@ -1325,6 +1353,10 @@ func tryCompactOnExit(
|
||||
}
|
||||
}
|
||||
|
||||
func isToolActive(name string, activeTools []string) bool {
|
||||
return len(activeTools) == 0 || slices.Contains(activeTools, name)
|
||||
}
|
||||
|
||||
// buildToolDefinitions converts AgentTool definitions into the
|
||||
// fantasy.Tool slice expected by fantasy.Call. When activeTools
|
||||
// is non-empty, only function tools whose name appears in the
|
||||
@@ -1334,7 +1366,7 @@ func buildToolDefinitions(tools []fantasy.AgentTool, activeTools []string, provi
|
||||
prepared := make([]fantasy.Tool, 0, len(tools)+len(providerTools))
|
||||
for _, tool := range tools {
|
||||
info := tool.Info()
|
||||
if len(activeTools) > 0 && !slices.Contains(activeTools, info.Name) {
|
||||
if !isToolActive(info.Name, activeTools) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1361,6 +1393,24 @@ func buildToolDefinitions(tools []fantasy.AgentTool, activeTools []string, provi
|
||||
return prepared
|
||||
}
|
||||
|
||||
// shouldStopAfterTools returns true if any tool result in the
|
||||
// slice matches a name in stopTools and produced a successful
|
||||
// (non-error) result.
|
||||
func shouldStopAfterTools(stopTools map[string]struct{}, results []fantasy.ToolResultContent) bool {
|
||||
if len(stopTools) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, tr := range results {
|
||||
if _, ok := stopTools[tr.ToolName]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, isErr := tr.Result.(fantasy.ToolResultOutputContentError); !isErr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shouldApplyAnthropicPromptCaching(model fantasy.LanguageModel) bool {
|
||||
if model == nil {
|
||||
return false
|
||||
|
||||
@@ -101,6 +101,150 @@ func TestRun_ActiveToolsPrepareBehavior(t *testing.T) {
|
||||
require.True(t, hasAnthropicEphemeralCacheControl(capturedCall.Prompt[4]))
|
||||
}
|
||||
|
||||
func TestRun_ActiveToolsRejectsDisallowedExecution(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var blockedCalls atomic.Int32
|
||||
blockedToolName := "write_file"
|
||||
model := &chattest.FakeModel{
|
||||
ProviderName: "fake",
|
||||
StreamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeToolInputStart, ID: "tc-blocked", ToolCallName: blockedToolName},
|
||||
{Type: fantasy.StreamPartTypeToolInputDelta, ID: "tc-blocked", Delta: `{"path":"/tmp/nope"}`},
|
||||
{Type: fantasy.StreamPartTypeToolInputEnd, ID: "tc-blocked"},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeToolCall,
|
||||
ID: "tc-blocked",
|
||||
ToolCallName: blockedToolName,
|
||||
ToolCallInput: `{"path":"/tmp/nope"}`,
|
||||
},
|
||||
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonToolCalls},
|
||||
}), nil
|
||||
},
|
||||
}
|
||||
|
||||
blockedTool := fantasy.NewAgentTool(
|
||||
blockedToolName,
|
||||
"blocked tool",
|
||||
func(context.Context, struct{}, fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
blockedCalls.Add(1)
|
||||
return fantasy.NewTextResponse("should not run"), nil
|
||||
},
|
||||
)
|
||||
|
||||
var persistedStep PersistedStep
|
||||
err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "try the blocked tool"),
|
||||
},
|
||||
Tools: []fantasy.AgentTool{
|
||||
newNoopTool(activeToolName),
|
||||
blockedTool,
|
||||
},
|
||||
ActiveTools: []string{activeToolName},
|
||||
MaxSteps: 1,
|
||||
PersistStep: func(_ context.Context, step PersistedStep) error {
|
||||
persistedStep = step
|
||||
return nil
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, blockedCalls.Load(), "disallowed tool must not execute")
|
||||
|
||||
var foundToolError bool
|
||||
for _, block := range persistedStep.Content {
|
||||
toolResult, ok := fantasy.AsContentType[fantasy.ToolResultContent](block)
|
||||
if !ok || toolResult.ToolName != blockedToolName {
|
||||
continue
|
||||
}
|
||||
errResult, ok := toolResult.Result.(fantasy.ToolResultOutputContentError)
|
||||
require.True(t, ok)
|
||||
assert.EqualError(t, errResult.Error, "Tool not active in this turn: "+blockedToolName)
|
||||
foundToolError = true
|
||||
}
|
||||
require.True(t, foundToolError, "persisted step should include the rejected tool result")
|
||||
}
|
||||
|
||||
func TestRun_ActiveToolsAllowsProviderRunnerExecution(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
providerRunnerName := "computer"
|
||||
var runnerCalls atomic.Int32
|
||||
model := &chattest.FakeModel{
|
||||
ProviderName: "fake",
|
||||
StreamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeToolInputStart, ID: "tc-provider-runner", ToolCallName: providerRunnerName},
|
||||
{Type: fantasy.StreamPartTypeToolInputDelta, ID: "tc-provider-runner", Delta: `{}`},
|
||||
{Type: fantasy.StreamPartTypeToolInputEnd, ID: "tc-provider-runner"},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeToolCall,
|
||||
ID: "tc-provider-runner",
|
||||
ToolCallName: providerRunnerName,
|
||||
ToolCallInput: `{}`,
|
||||
},
|
||||
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonToolCalls},
|
||||
}), nil
|
||||
},
|
||||
}
|
||||
|
||||
runnerTool := fantasy.NewAgentTool(
|
||||
providerRunnerName,
|
||||
"provider runner",
|
||||
func(context.Context, struct{}, fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
runnerCalls.Add(1)
|
||||
return fantasy.NewTextResponse("ran provider runner"), nil
|
||||
},
|
||||
)
|
||||
|
||||
var persistedStep PersistedStep
|
||||
err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "use the computer"),
|
||||
},
|
||||
Tools: []fantasy.AgentTool{newNoopTool(activeToolName)},
|
||||
ActiveTools: []string{activeToolName},
|
||||
ProviderTools: []ProviderTool{
|
||||
{
|
||||
Definition: fantasy.FunctionTool{
|
||||
Name: providerRunnerName,
|
||||
Description: "provider runner",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
},
|
||||
},
|
||||
Runner: runnerTool,
|
||||
},
|
||||
},
|
||||
MaxSteps: 1,
|
||||
PersistStep: func(_ context.Context, step PersistedStep) error {
|
||||
persistedStep = step
|
||||
return nil
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int32(1), runnerCalls.Load(),
|
||||
"provider runner should execute even when omitted from active tools")
|
||||
|
||||
var foundToolResult bool
|
||||
for _, block := range persistedStep.Content {
|
||||
toolResult, ok := fantasy.AsContentType[fantasy.ToolResultContent](block)
|
||||
if !ok || toolResult.ToolName != providerRunnerName {
|
||||
continue
|
||||
}
|
||||
textResult, ok := toolResult.Result.(fantasy.ToolResultOutputContentText)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "ran provider runner", textResult.Text)
|
||||
foundToolResult = true
|
||||
}
|
||||
require.True(t, foundToolResult,
|
||||
"persisted step should include the provider runner result")
|
||||
}
|
||||
|
||||
func TestProcessStepStream_AnthropicUsageMatchesFinalDelta(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -921,6 +1065,144 @@ func TestRun_MultiStepToolExecution(t *testing.T) {
|
||||
"tool-result timestamp must be >= tool-call timestamp")
|
||||
}
|
||||
|
||||
func TestStopAfterTool_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
streamCalls := 0
|
||||
model := &chattest.FakeModel{
|
||||
ProviderName: "fake",
|
||||
StreamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
streamCalls++
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeToolInputStart, ID: "tc-plan", ToolCallName: "propose_plan"},
|
||||
{Type: fantasy.StreamPartTypeToolInputDelta, ID: "tc-plan", Delta: `{"path":"/tmp/plan.md"}`},
|
||||
{Type: fantasy.StreamPartTypeToolInputEnd, ID: "tc-plan"},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeToolCall,
|
||||
ID: "tc-plan",
|
||||
ToolCallName: "propose_plan",
|
||||
ToolCallInput: `{"path":"/tmp/plan.md"}`,
|
||||
},
|
||||
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonToolCalls},
|
||||
}), nil
|
||||
},
|
||||
}
|
||||
|
||||
proposePlanTool := fantasy.NewAgentTool(
|
||||
"propose_plan",
|
||||
"writes a plan",
|
||||
func(context.Context, struct{}, fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
return fantasy.NewTextResponse("plan saved"), nil
|
||||
},
|
||||
)
|
||||
|
||||
var persistedSteps []PersistedStep
|
||||
persistStepCalls := 0
|
||||
|
||||
err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "propose a plan"),
|
||||
},
|
||||
Tools: []fantasy.AgentTool{proposePlanTool},
|
||||
MaxSteps: 5,
|
||||
StopAfterTools: map[string]struct{}{
|
||||
"propose_plan": {},
|
||||
},
|
||||
PersistStep: func(_ context.Context, step PersistedStep) error {
|
||||
persistStepCalls++
|
||||
persistedSteps = append(persistedSteps, step)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
require.ErrorIs(t, err, ErrStopAfterTool)
|
||||
require.Equal(t, 1, streamCalls)
|
||||
require.Equal(t, 1, persistStepCalls)
|
||||
require.Len(t, persistedSteps, 1)
|
||||
|
||||
var foundToolResult bool
|
||||
for _, block := range persistedSteps[0].Content {
|
||||
toolResult, ok := fantasy.AsContentType[fantasy.ToolResultContent](block)
|
||||
if !ok || toolResult.ToolName != "propose_plan" {
|
||||
continue
|
||||
}
|
||||
foundToolResult = true
|
||||
_, isErr := toolResult.Result.(fantasy.ToolResultOutputContentError)
|
||||
require.False(t, isErr, "stop-after-tool should only trigger on successful tool results")
|
||||
}
|
||||
require.True(t, foundToolResult, "persisted step should include the successful tool result before stopping")
|
||||
}
|
||||
|
||||
func TestStopAfterTool_IgnoresErrorResults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
streamCalls := 0
|
||||
model := &chattest.FakeModel{
|
||||
ProviderName: "fake",
|
||||
StreamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
streamCalls++
|
||||
if streamCalls == 1 {
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeToolInputStart, ID: "tc-plan", ToolCallName: "propose_plan"},
|
||||
{Type: fantasy.StreamPartTypeToolInputDelta, ID: "tc-plan", Delta: `{"path":"/tmp/plan.md"}`},
|
||||
{Type: fantasy.StreamPartTypeToolInputEnd, ID: "tc-plan"},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeToolCall,
|
||||
ID: "tc-plan",
|
||||
ToolCallName: "propose_plan",
|
||||
ToolCallInput: `{"path":"/tmp/plan.md"}`,
|
||||
},
|
||||
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonToolCalls},
|
||||
}), nil
|
||||
}
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "tool failed, continue"},
|
||||
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop},
|
||||
}), nil
|
||||
},
|
||||
}
|
||||
|
||||
proposePlanTool := fantasy.NewAgentTool(
|
||||
"propose_plan",
|
||||
"writes a plan",
|
||||
func(context.Context, struct{}, fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
return fantasy.NewTextErrorResponse("plan failed"), nil
|
||||
},
|
||||
)
|
||||
|
||||
var persistedSteps []PersistedStep
|
||||
err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "propose a plan"),
|
||||
},
|
||||
Tools: []fantasy.AgentTool{proposePlanTool},
|
||||
MaxSteps: 5,
|
||||
StopAfterTools: map[string]struct{}{
|
||||
"propose_plan": {},
|
||||
},
|
||||
PersistStep: func(_ context.Context, step PersistedStep) error {
|
||||
persistedSteps = append(persistedSteps, step)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, streamCalls)
|
||||
require.Len(t, persistedSteps, 2)
|
||||
|
||||
var foundToolError bool
|
||||
for _, block := range persistedSteps[0].Content {
|
||||
toolResult, ok := fantasy.AsContentType[fantasy.ToolResultContent](block)
|
||||
if !ok || toolResult.ToolName != "propose_plan" {
|
||||
continue
|
||||
}
|
||||
_, foundToolError = toolResult.Result.(fantasy.ToolResultOutputContentError)
|
||||
}
|
||||
require.True(t, foundToolError, "first step should persist the failed tool result")
|
||||
}
|
||||
|
||||
func TestRun_ParallelToolExecutionTimestamps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package chattool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
const (
|
||||
askUserQuestionToolName = "ask_user_question"
|
||||
askUserQuestionToolDesc = "Ask the user one or more structured clarification questions during plan mode. Use this instead of listing open questions in prose. Each question should have a short label, a detailed question, and 2-4 answer options."
|
||||
)
|
||||
|
||||
var (
|
||||
_ fantasy.AgentTool = (*askUserQuestionTool)(nil)
|
||||
_ fantasy.Tool = (*askUserQuestionTool)(nil)
|
||||
)
|
||||
|
||||
type askUserQuestionOption struct {
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type askUserQuestion struct {
|
||||
Header string `json:"header"`
|
||||
Question string `json:"question"`
|
||||
Options []askUserQuestionOption `json:"options"`
|
||||
}
|
||||
|
||||
type askUserQuestionArgs struct {
|
||||
Questions []askUserQuestion `json:"questions"`
|
||||
}
|
||||
|
||||
// NewAskUserQuestionTool creates the ask_user_question tool.
|
||||
func NewAskUserQuestionTool() fantasy.AgentTool {
|
||||
return &askUserQuestionTool{}
|
||||
}
|
||||
|
||||
type askUserQuestionTool struct {
|
||||
providerOptions fantasy.ProviderOptions
|
||||
}
|
||||
|
||||
func (*askUserQuestionTool) GetType() fantasy.ToolType {
|
||||
return fantasy.ToolTypeFunction
|
||||
}
|
||||
|
||||
func (*askUserQuestionTool) GetName() string {
|
||||
return askUserQuestionToolName
|
||||
}
|
||||
|
||||
func (*askUserQuestionTool) Info() fantasy.ToolInfo {
|
||||
return fantasy.ToolInfo{
|
||||
Name: askUserQuestionToolName,
|
||||
Description: askUserQuestionToolDesc,
|
||||
Parameters: map[string]any{
|
||||
"questions": map[string]any{
|
||||
"type": "array",
|
||||
"description": "The structured clarification questions to present to the user.",
|
||||
"minItems": 1,
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"header": map[string]any{
|
||||
"type": "string",
|
||||
"description": "A short label for the question.",
|
||||
},
|
||||
"question": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The detailed question text.",
|
||||
},
|
||||
"options": map[string]any{
|
||||
"type": "array",
|
||||
"description": "The answer options the user can choose from. Do not include an 'Other' or freeform option; one is provided automatically by the UI.",
|
||||
"minItems": 2,
|
||||
"maxItems": 4,
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"label": map[string]any{
|
||||
"type": "string",
|
||||
"description": "A short answer label.",
|
||||
},
|
||||
"description": map[string]any{
|
||||
"type": "string",
|
||||
"description": "More detail about what this option means.",
|
||||
},
|
||||
},
|
||||
"required": []string{"label", "description"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []string{"header", "question", "options"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"questions"},
|
||||
}
|
||||
}
|
||||
|
||||
func (*askUserQuestionTool) Run(_ context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
var args askUserQuestionArgs
|
||||
if err := json.Unmarshal([]byte(call.Input), &args); err != nil {
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid parameters: %s", err)), nil
|
||||
}
|
||||
|
||||
if err := validateAskUserQuestionArgs(args); err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
|
||||
data, err := json.Marshal(map[string]any{"questions": args.Questions})
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to marshal questions: " + err.Error()), nil
|
||||
}
|
||||
return fantasy.NewTextResponse(string(data)), nil
|
||||
}
|
||||
|
||||
func (t *askUserQuestionTool) ProviderOptions() fantasy.ProviderOptions {
|
||||
return t.providerOptions
|
||||
}
|
||||
|
||||
func (t *askUserQuestionTool) SetProviderOptions(opts fantasy.ProviderOptions) {
|
||||
t.providerOptions = opts
|
||||
}
|
||||
|
||||
func validateAskUserQuestionArgs(args askUserQuestionArgs) error {
|
||||
if len(args.Questions) == 0 {
|
||||
return xerrors.New("questions is required")
|
||||
}
|
||||
for i, question := range args.Questions {
|
||||
if strings.TrimSpace(question.Header) == "" {
|
||||
return xerrors.Errorf("questions[%d].header is required", i)
|
||||
}
|
||||
if strings.TrimSpace(question.Question) == "" {
|
||||
return xerrors.Errorf("questions[%d].question is required", i)
|
||||
}
|
||||
if len(question.Options) < 2 || len(question.Options) > 4 {
|
||||
return xerrors.Errorf("questions[%d].options must contain 2-4 items", i)
|
||||
}
|
||||
for j, option := range question.Options {
|
||||
if strings.TrimSpace(option.Label) == "" {
|
||||
return xerrors.Errorf("questions[%d].options[%d].label is required", i, j)
|
||||
}
|
||||
if strings.TrimSpace(option.Description) == "" {
|
||||
return xerrors.Errorf("questions[%d].options[%d].description is required", i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package chattool //nolint:testpackage // Uses internal symbols.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidateAskUserQuestionArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args askUserQuestionArgs
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "QuestionsRequired",
|
||||
args: askUserQuestionArgs{},
|
||||
wantErr: "questions is required",
|
||||
},
|
||||
{
|
||||
name: "HeaderRequired",
|
||||
args: askUserQuestionArgs{Questions: []askUserQuestion{{
|
||||
Header: " \t ",
|
||||
Question: "What should we build?",
|
||||
Options: validAskUserQuestionOptions(2),
|
||||
}}},
|
||||
wantErr: "questions[0].header is required",
|
||||
},
|
||||
{
|
||||
name: "QuestionRequired",
|
||||
args: askUserQuestionArgs{Questions: []askUserQuestion{{
|
||||
Header: "Scope",
|
||||
Question: "\n\t ",
|
||||
Options: validAskUserQuestionOptions(2),
|
||||
}}},
|
||||
wantErr: "questions[0].question is required",
|
||||
},
|
||||
{
|
||||
name: "TooFewOptions",
|
||||
args: askUserQuestionArgs{Questions: []askUserQuestion{{
|
||||
Header: "Scope",
|
||||
Question: "What should we build?",
|
||||
Options: validAskUserQuestionOptions(1),
|
||||
}}},
|
||||
wantErr: "questions[0].options must contain 2-4 items",
|
||||
},
|
||||
{
|
||||
name: "TooManyOptions",
|
||||
args: askUserQuestionArgs{Questions: []askUserQuestion{{
|
||||
Header: "Scope",
|
||||
Question: "What should we build?",
|
||||
Options: validAskUserQuestionOptions(5),
|
||||
}}},
|
||||
wantErr: "questions[0].options must contain 2-4 items",
|
||||
},
|
||||
{
|
||||
name: "OptionLabelRequired",
|
||||
args: askUserQuestionArgs{Questions: []askUserQuestion{{
|
||||
Header: "Scope",
|
||||
Question: "What should we build?",
|
||||
Options: []askUserQuestionOption{
|
||||
{Label: " ", Description: "Build the API first."},
|
||||
{Label: "Frontend", Description: "Build the UI first."},
|
||||
},
|
||||
}}},
|
||||
wantErr: "questions[0].options[0].label is required",
|
||||
},
|
||||
{
|
||||
name: "OptionDescriptionRequired",
|
||||
args: askUserQuestionArgs{Questions: []askUserQuestion{{
|
||||
Header: "Scope",
|
||||
Question: "What should we build?",
|
||||
Options: []askUserQuestionOption{
|
||||
{Label: "Backend", Description: "\t"},
|
||||
{Label: "Frontend", Description: "Build the UI first."},
|
||||
},
|
||||
}}},
|
||||
wantErr: "questions[0].options[0].description is required",
|
||||
},
|
||||
{
|
||||
name: "ValidTwoOptions",
|
||||
args: askUserQuestionArgs{Questions: []askUserQuestion{{
|
||||
Header: "Scope",
|
||||
Question: "What should we build?",
|
||||
Options: validAskUserQuestionOptions(2),
|
||||
}}},
|
||||
},
|
||||
{
|
||||
name: "ValidFourOptions",
|
||||
args: askUserQuestionArgs{Questions: []askUserQuestion{{
|
||||
Header: "Scope",
|
||||
Question: "What should we build?",
|
||||
Options: validAskUserQuestionOptions(4),
|
||||
}}},
|
||||
},
|
||||
{
|
||||
name: "SecondQuestionInvalid",
|
||||
args: askUserQuestionArgs{Questions: []askUserQuestion{
|
||||
{
|
||||
Header: "Scope",
|
||||
Question: "What should we build?",
|
||||
Options: validAskUserQuestionOptions(2),
|
||||
},
|
||||
{
|
||||
Header: "Timeline",
|
||||
Question: "\t ",
|
||||
Options: validAskUserQuestionOptions(2),
|
||||
},
|
||||
}},
|
||||
wantErr: "questions[1].question is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateAskUserQuestionArgs(testCase.args)
|
||||
if testCase.wantErr == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.EqualError(t, err, testCase.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func validAskUserQuestionOptions(count int) []askUserQuestionOption {
|
||||
options := []askUserQuestionOption{
|
||||
{Label: "Backend", Description: "Build the API first."},
|
||||
{Label: "Frontend", Description: "Build the UI first."},
|
||||
{Label: "Docs", Description: "Write the docs first."},
|
||||
{Label: "Tests", Description: "Start with tests first."},
|
||||
{Label: "Research", Description: "Investigate the problem first."},
|
||||
}
|
||||
|
||||
return append([]askUserQuestionOption(nil), options[:count]...)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
type EditFilesOptions struct {
|
||||
GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error)
|
||||
ResolvePlanPath func(context.Context) (chatPath string, home string, err error)
|
||||
IsPlanTurn bool
|
||||
}
|
||||
|
||||
type EditFilesArgs struct {
|
||||
@@ -24,6 +25,20 @@ func EditFiles(options EditFilesOptions) fantasy.AgentTool {
|
||||
"Perform search-and-replace edits on one or more files in the workspace."+
|
||||
" Each file can have multiple edits applied atomically.",
|
||||
func(ctx context.Context, args EditFilesArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
var planPath string
|
||||
if options.IsPlanTurn && len(args.Files) > 0 {
|
||||
resolvedPlanPath, err := resolvePlanTurnPath(ctx, options.ResolvePlanPath)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
for i := range args.Files {
|
||||
args.Files[i].Path = strings.TrimSpace(args.Files[i].Path)
|
||||
if args.Files[i].Path != resolvedPlanPath {
|
||||
return fantasy.NewTextErrorResponse("during plan turns, edit_files is restricted to " + resolvedPlanPath), nil
|
||||
}
|
||||
}
|
||||
planPath = resolvedPlanPath
|
||||
}
|
||||
if options.GetWorkspaceConn == nil {
|
||||
return fantasy.NewTextErrorResponse("workspace connection resolver is not configured"), nil
|
||||
}
|
||||
@@ -31,6 +46,11 @@ func EditFiles(options EditFilesOptions) fantasy.AgentTool {
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
if planPath != "" {
|
||||
if err := ensurePlanPathResolvesToItself(ctx, conn, planPath); err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
}
|
||||
return executeEditFilesTool(ctx, conn, args, options.ResolvePlanPath)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ package chattool_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
@@ -18,6 +19,164 @@ import (
|
||||
func TestEditFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("PlanTurnRejectsNonPlanPath", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
planPath := "/home/coder/.coder/plans/PLAN-test-uuid.md"
|
||||
getWorkspaceConnCalled := false
|
||||
tool := chattool.EditFiles(chattool.EditFilesOptions{
|
||||
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
|
||||
getWorkspaceConnCalled = true
|
||||
return mockConn, nil
|
||||
},
|
||||
ResolvePlanPath: func(context.Context) (string, string, error) {
|
||||
return planPath, "/home/coder", nil
|
||||
},
|
||||
IsPlanTurn: true,
|
||||
})
|
||||
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "edit_files",
|
||||
Input: `{"files":[{"path":"/home/coder/README.md","edits":[{"search":"old","replace":"new"}]}]}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Equal(t, "during plan turns, edit_files is restricted to "+planPath, resp.Content)
|
||||
assert.False(t, getWorkspaceConnCalled)
|
||||
})
|
||||
|
||||
t.Run("PlanTurnRejectsMixedPaths", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
planPath := "/home/coder/.coder/plans/PLAN-test-uuid.md"
|
||||
getWorkspaceConnCalled := false
|
||||
tool := chattool.EditFiles(chattool.EditFilesOptions{
|
||||
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
|
||||
getWorkspaceConnCalled = true
|
||||
return mockConn, nil
|
||||
},
|
||||
ResolvePlanPath: func(context.Context) (string, string, error) {
|
||||
return planPath, "/home/coder", nil
|
||||
},
|
||||
IsPlanTurn: true,
|
||||
})
|
||||
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "edit_files",
|
||||
Input: `{"files":[` +
|
||||
`{"path":"` + planPath + `","edits":[{"search":"old","replace":"new"}]},` +
|
||||
`{"path":"/home/coder/README.md","edits":[{"search":"old","replace":"new"}]}` +
|
||||
`]}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Equal(t, "during plan turns, edit_files is restricted to "+planPath, resp.Content)
|
||||
assert.False(t, getWorkspaceConnCalled)
|
||||
})
|
||||
|
||||
t.Run("PlanTurnAllowsResolvedPlanPath", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
planPath := "/home/coder/.coder/plans/PLAN-test-uuid.md"
|
||||
resolvePlanPathCalls := 0
|
||||
mockConn.EXPECT().ResolvePath(gomock.Any(), planPath).Return(planPath, nil)
|
||||
request := workspacesdk.FileEditRequest{Files: []workspacesdk.FileEdits{{
|
||||
Path: planPath,
|
||||
Edits: []workspacesdk.FileEdit{{
|
||||
Search: "old",
|
||||
Replace: "new",
|
||||
}},
|
||||
}}}
|
||||
mockConn.EXPECT().EditFiles(gomock.Any(), request).Return(nil)
|
||||
|
||||
tool := chattool.EditFiles(chattool.EditFilesOptions{
|
||||
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
|
||||
return mockConn, nil
|
||||
},
|
||||
ResolvePlanPath: func(context.Context) (string, string, error) {
|
||||
resolvePlanPathCalls++
|
||||
return planPath, "/home/coder", nil
|
||||
},
|
||||
IsPlanTurn: true,
|
||||
})
|
||||
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "edit_files",
|
||||
Input: `{"files":[{"path":"` + planPath + `","edits":[{"search":"old","replace":"new"}]}]}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.IsError)
|
||||
assert.Equal(t, 1, resolvePlanPathCalls)
|
||||
})
|
||||
|
||||
t.Run("PlanTurnAllowsLegacyAgentWithoutResolvePath", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
planPath := "/home/coder/.coder/plans/PLAN-test-uuid.md"
|
||||
mockConn.EXPECT().
|
||||
ResolvePath(gomock.Any(), planPath).
|
||||
Return("", statusError{statusCode: http.StatusNotFound, message: "missing resolve-path endpoint"})
|
||||
request := workspacesdk.FileEditRequest{Files: []workspacesdk.FileEdits{{
|
||||
Path: planPath,
|
||||
Edits: []workspacesdk.FileEdit{{
|
||||
Search: "old",
|
||||
Replace: "new",
|
||||
}},
|
||||
}}}
|
||||
mockConn.EXPECT().EditFiles(gomock.Any(), request).Return(nil)
|
||||
|
||||
tool := chattool.EditFiles(chattool.EditFilesOptions{
|
||||
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
|
||||
return mockConn, nil
|
||||
},
|
||||
ResolvePlanPath: func(context.Context) (string, string, error) {
|
||||
return planPath, "/home/coder", nil
|
||||
},
|
||||
IsPlanTurn: true,
|
||||
})
|
||||
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "edit_files",
|
||||
Input: `{"files":[{"path":"` + planPath + `","edits":[{"search":"old","replace":"new"}]}]}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.IsError)
|
||||
})
|
||||
|
||||
t.Run("PlanTurnRejectsSymlinkedPlanPath", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
planPath := "/home/coder/.coder/plans/PLAN-test-uuid.md"
|
||||
mockConn.EXPECT().ResolvePath(gomock.Any(), planPath).Return("/home/coder/README.md", nil)
|
||||
tool := chattool.EditFiles(chattool.EditFilesOptions{
|
||||
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
|
||||
return mockConn, nil
|
||||
},
|
||||
ResolvePlanPath: func(context.Context) (string, string, error) {
|
||||
return planPath, "/home/coder", nil
|
||||
},
|
||||
IsPlanTurn: true,
|
||||
})
|
||||
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "edit_files",
|
||||
Input: `{"files":[{"path":"` + planPath + `","edits":[{"search":"old","replace":"new"}]}]}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Equal(t, "the chat-specific plan path /home/coder/.coder/plans/PLAN-test-uuid.md resolves to /home/coder/README.md; symlinked plan paths are not allowed during plan turns", resp.Content)
|
||||
})
|
||||
|
||||
t.Run("RejectsPlanPathsWhenResolvePlanPathIsConfigured", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -53,6 +53,26 @@ func PlanPathForChat(home string, chatID uuid.UUID) string {
|
||||
)
|
||||
}
|
||||
|
||||
func resolvePlanTurnPath(
|
||||
ctx context.Context,
|
||||
resolvePlanPath func(context.Context) (chatPath string, home string, err error),
|
||||
) (string, error) {
|
||||
if resolvePlanPath == nil {
|
||||
return "", xerrors.New("chat-specific plan path resolver is not configured")
|
||||
}
|
||||
|
||||
planPath, _, err := resolvePlanPath(ctx)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("resolve chat-specific plan path: %w", err)
|
||||
}
|
||||
planPath = strings.TrimSpace(planPath)
|
||||
if planPath == "" {
|
||||
return "", xerrors.New("chat-specific plan path is empty")
|
||||
}
|
||||
|
||||
return planPath, nil
|
||||
}
|
||||
|
||||
// chatd consumes agent-normalized POSIX paths. Workspace agents are
|
||||
// expected to convert separators to forward slashes before these
|
||||
// helpers run.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package chattool_test
|
||||
|
||||
func sharedPlanPathResolvedMessage(requestedPath, planPath string) string {
|
||||
return "the plan path " + requestedPath +
|
||||
" is no longer supported at the home root; use the chat-specific plan path: " + planPath
|
||||
}
|
||||
|
||||
func planPathVerificationMessage(requestedPath string) string {
|
||||
return "the plan path " + requestedPath +
|
||||
" could not be verified because the workspace is currently unavailable to resolve the chat-specific plan path, try again shortly"
|
||||
}
|
||||
|
||||
func editFilesBatchRejectedMessage(message string) string {
|
||||
return message + "; no files in this batch were applied"
|
||||
}
|
||||
|
||||
func relativePlanPathMessage() string {
|
||||
return "plan files must use absolute paths; use the chat-specific absolute plan path"
|
||||
}
|
||||
@@ -46,6 +46,14 @@ func sharedPlanPathMessage(requestedPath, chatPath string) string {
|
||||
)
|
||||
}
|
||||
|
||||
func symlinkedPlanPathMessage(planPath, resolvedPath string) string {
|
||||
return fmt.Sprintf(
|
||||
"the chat-specific plan path %s resolves to %s; symlinked plan paths are not allowed during plan turns",
|
||||
planPath,
|
||||
resolvedPath,
|
||||
)
|
||||
}
|
||||
|
||||
func planPathVerificationMessage(requestedPath string) string {
|
||||
return fmt.Sprintf(
|
||||
"the plan path %s could not be verified because the workspace is currently unavailable to resolve the chat-specific plan path, try again shortly",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package chattool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
)
|
||||
|
||||
func ensurePlanPathResolvesToItself(
|
||||
ctx context.Context,
|
||||
conn workspacesdk.AgentConn,
|
||||
planPath string,
|
||||
) error {
|
||||
if conn == nil {
|
||||
return xerrors.New("workspace connection is required")
|
||||
}
|
||||
|
||||
normalizedPlanPath := normalizeWorkspacePath(planPath)
|
||||
resolvedPath, err := conn.ResolvePath(ctx, planPath)
|
||||
if err != nil {
|
||||
if resolvePathUnsupported(err) {
|
||||
// Older workspace agents do not expose /resolve-path yet. Keep
|
||||
// plan turns working during rolling upgrades, even though they
|
||||
// cannot enforce the symlink guard until the agent is upgraded.
|
||||
return nil
|
||||
}
|
||||
return xerrors.Errorf("resolve plan path: %w", err)
|
||||
}
|
||||
resolvedPath = normalizeWorkspacePath(resolvedPath)
|
||||
if resolvedPath != normalizedPlanPath {
|
||||
return xerrors.New(symlinkedPlanPathMessage(normalizedPlanPath, resolvedPath))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolvePathUnsupported(err error) bool {
|
||||
var statusErr interface{ StatusCode() int }
|
||||
return xerrors.As(err, &statusErr) && statusErr.StatusCode() == http.StatusNotFound
|
||||
}
|
||||
|
||||
func normalizeWorkspacePath(pathString string) string {
|
||||
pathString = strings.TrimSpace(pathString)
|
||||
if pathString == "" {
|
||||
return ""
|
||||
}
|
||||
return path.Clean(filepath.ToSlash(pathString))
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type ProposePlanOptions struct {
|
||||
GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error)
|
||||
ResolvePlanPath func(context.Context) (chatPath string, home string, err error)
|
||||
StoreFile func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error)
|
||||
IsPlanTurn bool
|
||||
}
|
||||
|
||||
// ProposePlanArgs are the arguments for the propose_plan tool.
|
||||
@@ -36,6 +37,21 @@ func ProposePlan(options ProposePlanOptions) fantasy.AgentTool {
|
||||
"Pass the absolute file path to the plan. Important: use the chat-specific absolute plan path, not a generic path like PLAN.md in the home directory. "+
|
||||
"The tool reads the content from the workspace.",
|
||||
func(ctx context.Context, args ProposePlanArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
if options.IsPlanTurn {
|
||||
planPath, err := resolvePlanTurnPath(ctx, options.ResolvePlanPath)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
path := strings.TrimSpace(args.Path)
|
||||
switch {
|
||||
case path == "":
|
||||
args.Path = planPath
|
||||
case path != planPath:
|
||||
return fantasy.NewTextErrorResponse("during plan turns, propose_plan path must be " + planPath), nil
|
||||
default:
|
||||
args.Path = path
|
||||
}
|
||||
}
|
||||
if options.GetWorkspaceConn == nil {
|
||||
return fantasy.NewTextErrorResponse("workspace connection resolver is not configured"), nil
|
||||
}
|
||||
@@ -90,6 +106,9 @@ func executeProposePlanTool(
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
if len(data) == 0 || strings.TrimSpace(string(data)) == "" {
|
||||
return fantasy.NewTextErrorResponse("plan file is empty; write your plan to " + requestedPath + " before proposing"), nil
|
||||
}
|
||||
if int64(len(data)) > maxProposePlanSize {
|
||||
return fantasy.NewTextErrorResponse("plan file exceeds 32 KiB size limit"), nil
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/iotest"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/google/uuid"
|
||||
@@ -31,13 +30,13 @@ type proposePlanResponse struct {
|
||||
func TestProposePlan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("EmptyPathReturnsError", func(t *testing.T) {
|
||||
t.Run("RejectsEmptyPath", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
storeFile, _ := fakeStoreFile(t)
|
||||
tool := newProposePlanTool(t, mockConn, storeFile)
|
||||
tool := newProposePlanToolWithPlanPath(t, mockConn, storeFile, nil, false)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
@@ -45,326 +44,133 @@ func TestProposePlan(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Contains(t, resp.Content, "path is required")
|
||||
assert.Equal(t, "path is required (use the chat-specific absolute plan path)", resp.Content)
|
||||
})
|
||||
|
||||
t.Run("WhitespaceOnlyPathReturnsError", func(t *testing.T) {
|
||||
t.Run("RejectsNonMarkdownPath", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
storeFile, _ := fakeStoreFile(t)
|
||||
tool := newProposePlanTool(t, mockConn, storeFile)
|
||||
tool := newProposePlanToolWithPlanPath(t, mockConn, storeFile, nil, false)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":" "}`,
|
||||
Input: `{"path":"/home/coder/.coder/plans/PLAN-chat.txt"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Contains(t, resp.Content, "path is required")
|
||||
assert.Equal(t, "path must end with .md", resp.Content)
|
||||
})
|
||||
|
||||
t.Run("NonMdPathReturnsError", func(t *testing.T) {
|
||||
t.Run("PlanTurnDefaultsEmptyPathToResolvedPath", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
storeFile, _ := fakeStoreFile(t)
|
||||
tool := newProposePlanTool(t, mockConn, storeFile)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"/home/coder/plan.txt"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Contains(t, resp.Content, "path must end with .md")
|
||||
})
|
||||
|
||||
t.Run("RelativePlanPathReturnsError", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
storeFile, _ := fakeStoreFile(t)
|
||||
resolvePlanPathCalled := false
|
||||
tool := newProposePlanToolWithPlanPath(
|
||||
t,
|
||||
mockConn,
|
||||
storeFile,
|
||||
func(context.Context) (string, string, error) {
|
||||
resolvePlanPathCalled = true
|
||||
return "/home/coder/.coder/plans/PLAN-chat.md", "/home/coder", nil
|
||||
},
|
||||
)
|
||||
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"plan.md"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.False(t, resolvePlanPathCalled)
|
||||
assert.Equal(t, relativePlanPathMessage(), resp.Content)
|
||||
})
|
||||
|
||||
t.Run("OversizedFileRejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
largeContent := strings.Repeat("x", 32*1024+1)
|
||||
|
||||
mockConn.EXPECT().
|
||||
ReadFile(gomock.Any(), "/home/coder/PLAN.md", int64(0), int64(32*1024+1)).
|
||||
Return(io.NopCloser(strings.NewReader(largeContent)), "text/markdown", nil)
|
||||
|
||||
storeFile, _ := fakeStoreFile(t)
|
||||
tool := newProposePlanTool(t, mockConn, storeFile)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"/home/coder/PLAN.md"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Contains(t, resp.Content, "plan file exceeds 32 KiB size limit")
|
||||
})
|
||||
|
||||
t.Run("ExactBoundaryFileSucceeds", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
content := strings.Repeat("x", 32*1024)
|
||||
|
||||
mockConn.EXPECT().
|
||||
ReadFile(gomock.Any(), "/home/coder/PLAN.md", int64(0), int64(32*1024+1)).
|
||||
Return(io.NopCloser(strings.NewReader(content)), "text/markdown", nil)
|
||||
|
||||
storeFile, _ := fakeStoreFile(t)
|
||||
tool := newProposePlanTool(t, mockConn, storeFile)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"/home/coder/PLAN.md"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.IsError)
|
||||
})
|
||||
|
||||
t.Run("ValidPlanReadsFile", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
mockConn.EXPECT().
|
||||
ReadFile(gomock.Any(), "/home/coder/docs/PLAN.md", int64(0), int64(32*1024+1)).
|
||||
Return(io.NopCloser(strings.NewReader("# Plan\n\nContent")), "text/markdown", nil)
|
||||
|
||||
storeFile, stored := fakeStoreFile(t)
|
||||
planPathCalled := false
|
||||
tool := newProposePlanToolWithPlanPath(
|
||||
t,
|
||||
mockConn,
|
||||
storeFile,
|
||||
func(context.Context) (string, string, error) {
|
||||
planPathCalled = true
|
||||
return "/home/coder/.coder/plans/PLAN-xxx.md", "/home/coder", nil
|
||||
},
|
||||
)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"/home/coder/docs/PLAN.md"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.IsError)
|
||||
assert.True(t, planPathCalled)
|
||||
|
||||
result := decodeProposePlanResponse(t, resp)
|
||||
assert.True(t, result.OK)
|
||||
assert.Equal(t, "/home/coder/docs/PLAN.md", result.Path)
|
||||
assert.Equal(t, "plan", result.Kind)
|
||||
assert.Equal(t, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", result.FileID)
|
||||
assert.Equal(t, "text/markdown", result.MediaType)
|
||||
assert.Equal(t, []byte("# Plan\n\nContent"), *stored)
|
||||
assert.NotContains(t, resp.Content, "content")
|
||||
})
|
||||
|
||||
t.Run("NestedPlanPathUnderHomeIsAllowed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
mockConn.EXPECT().
|
||||
ReadFile(gomock.Any(), "/home/coder/myproject/plan.md", int64(0), int64(32*1024+1)).
|
||||
Return(io.NopCloser(strings.NewReader("# Nested Plan")), "text/markdown", nil)
|
||||
|
||||
storeFile, stored := fakeStoreFile(t)
|
||||
planPathCalled := false
|
||||
tool := newProposePlanToolWithPlanPath(
|
||||
t,
|
||||
mockConn,
|
||||
storeFile,
|
||||
func(context.Context) (string, string, error) {
|
||||
planPathCalled = true
|
||||
return "/home/coder/.coder/plans/PLAN-chat.md", "/home/coder", nil
|
||||
},
|
||||
)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"/home/coder/myproject/plan.md"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.IsError)
|
||||
assert.True(t, planPathCalled)
|
||||
|
||||
result := decodeProposePlanResponse(t, resp)
|
||||
assert.True(t, result.OK)
|
||||
assert.Equal(t, "/home/coder/myproject/plan.md", result.Path)
|
||||
assert.Equal(t, []byte("# Nested Plan"), *stored)
|
||||
})
|
||||
|
||||
t.Run("FileNotFound", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
mockConn.EXPECT().
|
||||
ReadFile(gomock.Any(), "/home/coder/PLAN.md", int64(0), int64(32*1024+1)).
|
||||
Return(nil, "", xerrors.New("file not found"))
|
||||
|
||||
storeFile, _ := fakeStoreFile(t)
|
||||
tool := newProposePlanTool(t, mockConn, storeFile)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"/home/coder/PLAN.md"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Contains(t, resp.Content, "file not found")
|
||||
})
|
||||
|
||||
t.Run("ReadAllError", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
mockConn.EXPECT().
|
||||
ReadFile(gomock.Any(), "/home/coder/PLAN.md", int64(0), int64(32*1024+1)).
|
||||
Return(io.NopCloser(iotest.ErrReader(xerrors.New("connection reset"))), "text/markdown", nil)
|
||||
|
||||
storeFile, _ := fakeStoreFile(t)
|
||||
tool := newProposePlanTool(t, mockConn, storeFile)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"/home/coder/PLAN.md"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Contains(t, resp.Content, "connection reset")
|
||||
})
|
||||
|
||||
t.Run("StoreFileError", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
mockConn.EXPECT().
|
||||
ReadFile(gomock.Any(), "/home/coder/PLAN.md", int64(0), int64(32*1024+1)).
|
||||
Return(io.NopCloser(strings.NewReader("# Plan")), "text/markdown", nil)
|
||||
|
||||
tool := newProposePlanTool(t, mockConn, func(_ context.Context, _ string, _ string, _ []byte) (uuid.UUID, error) {
|
||||
return uuid.Nil, xerrors.New("storage unavailable")
|
||||
})
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"/home/coder/PLAN.md"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Contains(t, resp.Content, "storage unavailable")
|
||||
})
|
||||
|
||||
t.Run("RejectsSharedPlanPathWithResolvedPath", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
storeFile, _ := fakeStoreFile(t)
|
||||
tool := newProposePlanToolWithPlanPath(
|
||||
t,
|
||||
mockConn,
|
||||
storeFile,
|
||||
func(context.Context) (string, string, error) {
|
||||
return "/home/coder/.coder/plans/PLAN-chat.md", "/home/coder", nil
|
||||
},
|
||||
)
|
||||
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"` + chattool.LegacySharedPlanPath + `"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Equal(
|
||||
t,
|
||||
sharedPlanPathResolvedMessage(chattool.LegacySharedPlanPath, "/home/coder/.coder/plans/PLAN-chat.md"),
|
||||
resp.Content,
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("RejectsSharedPlanPathWhenResolverFails", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
storeFile, _ := fakeStoreFile(t)
|
||||
tool := newProposePlanToolWithPlanPath(
|
||||
t,
|
||||
mockConn,
|
||||
storeFile,
|
||||
func(context.Context) (string, string, error) {
|
||||
return "", "", xerrors.New("workspace unavailable")
|
||||
},
|
||||
)
|
||||
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"` + chattool.LegacySharedPlanPath + `"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Equal(t, planPathVerificationMessage(chattool.LegacySharedPlanPath), resp.Content)
|
||||
})
|
||||
|
||||
t.Run("PerChatPlanPathIsAllowed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
chatPlanPath := "/home/coder/.coder/plans/PLAN-123e4567-e89b-12d3-a456-426614174000.md"
|
||||
chatPlanPath := "/home/coder/.coder/plans/PLAN-chat.md"
|
||||
|
||||
mockConn.EXPECT().
|
||||
ReadFile(gomock.Any(), chatPlanPath, int64(0), int64(32*1024+1)).
|
||||
Return(io.NopCloser(strings.NewReader("# Per-Chat Plan")), "text/markdown", nil)
|
||||
Return(io.NopCloser(strings.NewReader("# Plan")), "text/markdown", nil)
|
||||
|
||||
storeFile, stored := fakeStoreFile(t)
|
||||
resolvePlanPathCalled := false
|
||||
tool := newProposePlanToolWithPlanPath(
|
||||
t,
|
||||
mockConn,
|
||||
storeFile,
|
||||
func(context.Context) (string, string, error) {
|
||||
resolvePlanPathCalled = true
|
||||
return chatPlanPath, "/home/coder", nil
|
||||
},
|
||||
true,
|
||||
)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":""}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.IsError)
|
||||
|
||||
result := decodeProposePlanResponse(t, resp)
|
||||
assert.True(t, result.OK)
|
||||
assert.Equal(t, chatPlanPath, result.Path)
|
||||
assert.Equal(t, "plan", result.Kind)
|
||||
assert.Equal(t, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", result.FileID)
|
||||
assert.Equal(t, "text/markdown", result.MediaType)
|
||||
assert.Equal(t, "# Plan", string(*stored))
|
||||
})
|
||||
|
||||
t.Run("PlanTurnRejectsWrongPath", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
chatPlanPath := "/home/coder/.coder/plans/PLAN-chat.md"
|
||||
|
||||
storeFile, _ := fakeStoreFile(t)
|
||||
tool := newProposePlanToolWithPlanPath(
|
||||
t,
|
||||
mockConn,
|
||||
storeFile,
|
||||
func(context.Context) (string, string, error) {
|
||||
return chatPlanPath, "/home/coder", nil
|
||||
},
|
||||
true,
|
||||
)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"/home/coder/README.md"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Equal(t, "during plan turns, propose_plan path must be "+chatPlanPath, resp.Content)
|
||||
})
|
||||
t.Run("RejectsReadFileErrors", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
chatPlanPath := "/home/coder/.coder/plans/PLAN-chat.md"
|
||||
|
||||
mockConn.EXPECT().
|
||||
ReadFile(gomock.Any(), chatPlanPath, int64(0), int64(32*1024+1)).
|
||||
Return(nil, "", xerrors.New("read failed"))
|
||||
|
||||
storeFile, _ := fakeStoreFile(t)
|
||||
tool := newProposePlanToolWithPlanPath(t, mockConn, storeFile, nil, false)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"` + chatPlanPath + `"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Equal(t, "read failed", resp.Content)
|
||||
})
|
||||
|
||||
t.Run("PlanTurnRejectsEmptyPlan", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
chatPlanPath := "/home/coder/.coder/plans/PLAN-chat.md"
|
||||
|
||||
mockConn.EXPECT().
|
||||
ReadFile(gomock.Any(), chatPlanPath, int64(0), int64(32*1024+1)).
|
||||
Return(io.NopCloser(strings.NewReader("")), "text/markdown", nil)
|
||||
|
||||
storeFile, stored := fakeStoreFile(t)
|
||||
storeCalled := false
|
||||
tool := newProposePlanToolWithPlanPath(
|
||||
t,
|
||||
mockConn,
|
||||
func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error) {
|
||||
storeCalled = true
|
||||
return storeFile(ctx, name, mediaType, data)
|
||||
},
|
||||
func(context.Context) (string, string, error) {
|
||||
return chatPlanPath, "/home/coder", nil
|
||||
},
|
||||
true,
|
||||
)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
@@ -372,117 +178,83 @@ func TestProposePlan(t *testing.T) {
|
||||
Input: `{"path":"` + chatPlanPath + `"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.IsError)
|
||||
assert.False(t, resolvePlanPathCalled)
|
||||
|
||||
result := decodeProposePlanResponse(t, resp)
|
||||
assert.True(t, result.OK)
|
||||
assert.Equal(t, chatPlanPath, result.Path)
|
||||
assert.Equal(t, []byte("# Per-Chat Plan"), *stored)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Contains(t, resp.Content, "plan file is empty")
|
||||
assert.Contains(t, resp.Content, chatPlanPath)
|
||||
assert.False(t, storeCalled)
|
||||
assert.Nil(t, *stored)
|
||||
})
|
||||
|
||||
t.Run("NestedPlanPathAllowedWhenResolverFails", func(t *testing.T) {
|
||||
t.Run("RejectsOversizedPlan", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
chatPlanPath := "/home/coder/.coder/plans/PLAN-chat.md"
|
||||
|
||||
mockConn.EXPECT().
|
||||
ReadFile(gomock.Any(), "/home/coder/myproject/plan.md", int64(0), int64(32*1024+1)).
|
||||
Return(io.NopCloser(strings.NewReader("# Nested Plan")), "text/markdown", nil)
|
||||
ReadFile(gomock.Any(), chatPlanPath, int64(0), int64(32*1024+1)).
|
||||
Return(io.NopCloser(strings.NewReader(strings.Repeat("x", 32*1024+1))), "text/markdown", nil)
|
||||
|
||||
storeFile, stored := fakeStoreFile(t)
|
||||
storeCalled := false
|
||||
tool := newProposePlanToolWithPlanPath(
|
||||
t,
|
||||
mockConn,
|
||||
storeFile,
|
||||
func(context.Context) (string, string, error) {
|
||||
return "", "", xerrors.New("workspace unavailable")
|
||||
func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error) {
|
||||
storeCalled = true
|
||||
return storeFile(ctx, name, mediaType, data)
|
||||
},
|
||||
nil,
|
||||
false,
|
||||
)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"/home/coder/myproject/plan.md"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.IsError)
|
||||
|
||||
result := decodeProposePlanResponse(t, resp)
|
||||
assert.True(t, result.OK)
|
||||
assert.Equal(t, "/home/coder/myproject/plan.md", result.Path)
|
||||
assert.Equal(t, []byte("# Nested Plan"), *stored)
|
||||
})
|
||||
|
||||
t.Run("WorkspaceConnectionError", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
storeFile, _ := fakeStoreFile(t)
|
||||
tool := chattool.ProposePlan(chattool.ProposePlanOptions{
|
||||
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
|
||||
return nil, xerrors.New("connection failed")
|
||||
},
|
||||
StoreFile: storeFile,
|
||||
})
|
||||
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"/home/coder/PLAN.md"}`,
|
||||
Input: `{"path":"` + chatPlanPath + `"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Contains(t, resp.Content, "connection failed")
|
||||
assert.Equal(t, "plan file exceeds 32 KiB size limit", resp.Content)
|
||||
assert.False(t, storeCalled)
|
||||
assert.Nil(t, *stored)
|
||||
})
|
||||
|
||||
t.Run("NilWorkspaceResolver", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tool := chattool.ProposePlan(chattool.ProposePlanOptions{})
|
||||
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"/home/coder/PLAN.md"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Contains(t, resp.Content, "workspace connection resolver is not configured")
|
||||
})
|
||||
|
||||
t.Run("NilStoreFile", func(t *testing.T) {
|
||||
t.Run("PropagatesStoreFileErrors", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
chatPlanPath := "/home/coder/.coder/plans/PLAN-chat.md"
|
||||
|
||||
tool := chattool.ProposePlan(chattool.ProposePlanOptions{
|
||||
GetWorkspaceConn: func(_ context.Context) (workspacesdk.AgentConn, error) {
|
||||
return mockConn, nil
|
||||
mockConn.EXPECT().
|
||||
ReadFile(gomock.Any(), chatPlanPath, int64(0), int64(32*1024+1)).
|
||||
Return(io.NopCloser(strings.NewReader("# Plan")), "text/markdown", nil)
|
||||
|
||||
tool := newProposePlanToolWithPlanPath(
|
||||
t,
|
||||
mockConn,
|
||||
func(context.Context, string, string, []byte) (uuid.UUID, error) {
|
||||
return uuid.Nil, xerrors.New("store failed")
|
||||
},
|
||||
})
|
||||
|
||||
nil,
|
||||
false,
|
||||
)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"/home/coder/PLAN.md"}`,
|
||||
Input: `{"path":"` + chatPlanPath + `"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Contains(t, resp.Content, "file storage is not configured")
|
||||
assert.Equal(t, "failed to store plan file: store failed", resp.Content)
|
||||
})
|
||||
}
|
||||
|
||||
func newProposePlanTool(
|
||||
t *testing.T,
|
||||
mockConn *agentconnmock.MockAgentConn,
|
||||
storeFile func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error),
|
||||
) fantasy.AgentTool {
|
||||
t.Helper()
|
||||
return newProposePlanToolWithPlanPath(t, mockConn, storeFile, nil)
|
||||
}
|
||||
|
||||
func newProposePlanToolWithPlanPath(
|
||||
t *testing.T,
|
||||
mockConn *agentconnmock.MockAgentConn,
|
||||
storeFile func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error),
|
||||
resolvePlanPath func(context.Context) (string, string, error),
|
||||
isPlanTurn bool,
|
||||
) fantasy.AgentTool {
|
||||
t.Helper()
|
||||
return chattool.ProposePlan(chattool.ProposePlanOptions{
|
||||
@@ -491,27 +263,10 @@ func newProposePlanToolWithPlanPath(
|
||||
},
|
||||
ResolvePlanPath: resolvePlanPath,
|
||||
StoreFile: storeFile,
|
||||
IsPlanTurn: isPlanTurn,
|
||||
})
|
||||
}
|
||||
|
||||
func sharedPlanPathResolvedMessage(requestedPath, planPath string) string {
|
||||
return "the plan path " + requestedPath +
|
||||
" is no longer supported at the home root; use the chat-specific plan path: " + planPath
|
||||
}
|
||||
|
||||
func planPathVerificationMessage(requestedPath string) string {
|
||||
return "the plan path " + requestedPath +
|
||||
" could not be verified because the workspace is currently unavailable to resolve the chat-specific plan path, try again shortly"
|
||||
}
|
||||
|
||||
func editFilesBatchRejectedMessage(message string) string {
|
||||
return message + "; no files in this batch were applied"
|
||||
}
|
||||
|
||||
func relativePlanPathMessage() string {
|
||||
return "plan files must use absolute paths; use the chat-specific absolute plan path"
|
||||
}
|
||||
|
||||
func fakeStoreFile(t *testing.T) (func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error), *[]byte) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package chattool_test
|
||||
|
||||
import "fmt"
|
||||
|
||||
type statusError struct {
|
||||
statusCode int
|
||||
message string
|
||||
}
|
||||
|
||||
func (e statusError) Error() string {
|
||||
if e.message != "" {
|
||||
return e.message
|
||||
}
|
||||
return fmt.Sprintf("status %d", e.statusCode)
|
||||
}
|
||||
|
||||
func (e statusError) StatusCode() int {
|
||||
return e.statusCode
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
type WriteFileOptions struct {
|
||||
GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error)
|
||||
ResolvePlanPath func(context.Context) (chatPath string, home string, err error)
|
||||
IsPlanTurn bool
|
||||
}
|
||||
|
||||
type WriteFileArgs struct {
|
||||
@@ -24,6 +25,18 @@ func WriteFile(options WriteFileOptions) fantasy.AgentTool {
|
||||
"write_file",
|
||||
"Write a file to the workspace.",
|
||||
func(ctx context.Context, args WriteFileArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
var planPath string
|
||||
if options.IsPlanTurn {
|
||||
args.Path = strings.TrimSpace(args.Path)
|
||||
resolvedPlanPath, err := resolvePlanTurnPath(ctx, options.ResolvePlanPath)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
if args.Path != resolvedPlanPath {
|
||||
return fantasy.NewTextErrorResponse("during plan turns, write_file is restricted to " + resolvedPlanPath), nil
|
||||
}
|
||||
planPath = resolvedPlanPath
|
||||
}
|
||||
if options.GetWorkspaceConn == nil {
|
||||
return fantasy.NewTextErrorResponse("workspace connection resolver is not configured"), nil
|
||||
}
|
||||
@@ -31,6 +44,11 @@ func WriteFile(options WriteFileOptions) fantasy.AgentTool {
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
if planPath != "" {
|
||||
if err := ensurePlanPathResolvesToItself(ctx, conn, planPath); err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
}
|
||||
return executeWriteFileTool(ctx, conn, args, options.ResolvePlanPath)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ package chattool_test
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -20,6 +21,136 @@ import (
|
||||
func TestWriteFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("PlanTurnRejectsNonPlanPath", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
planPath := "/home/coder/.coder/plans/PLAN-test-uuid.md"
|
||||
getWorkspaceConnCalled := false
|
||||
tool := chattool.WriteFile(chattool.WriteFileOptions{
|
||||
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
|
||||
getWorkspaceConnCalled = true
|
||||
return mockConn, nil
|
||||
},
|
||||
ResolvePlanPath: func(context.Context) (string, string, error) {
|
||||
return planPath, "/home/coder", nil
|
||||
},
|
||||
IsPlanTurn: true,
|
||||
})
|
||||
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "write_file",
|
||||
Input: `{"path":"/home/coder/README.md","content":"# Plan"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Equal(t, "during plan turns, write_file is restricted to "+planPath, resp.Content)
|
||||
assert.False(t, getWorkspaceConnCalled)
|
||||
})
|
||||
|
||||
t.Run("PlanTurnAllowsResolvedPlanPath", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
planPath := "/home/coder/.coder/plans/PLAN-test-uuid.md"
|
||||
resolvePlanPathCalls := 0
|
||||
mockConn.EXPECT().ResolvePath(gomock.Any(), planPath).Return(planPath, nil)
|
||||
mockConn.EXPECT().
|
||||
WriteFile(gomock.Any(), planPath, gomock.Any()).
|
||||
DoAndReturn(func(_ context.Context, path string, reader io.Reader) error {
|
||||
data, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, planPath, path)
|
||||
require.Equal(t, "# Plan", string(data))
|
||||
return nil
|
||||
})
|
||||
|
||||
tool := chattool.WriteFile(chattool.WriteFileOptions{
|
||||
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
|
||||
return mockConn, nil
|
||||
},
|
||||
ResolvePlanPath: func(context.Context) (string, string, error) {
|
||||
resolvePlanPathCalls++
|
||||
return planPath, "/home/coder", nil
|
||||
},
|
||||
IsPlanTurn: true,
|
||||
})
|
||||
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "write_file",
|
||||
Input: `{"path":"` + planPath + `","content":"# Plan"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.IsError)
|
||||
assert.Equal(t, 1, resolvePlanPathCalls)
|
||||
assert.Equal(t, `{"ok":true}`, strings.TrimSpace(resp.Content))
|
||||
})
|
||||
|
||||
t.Run("PlanTurnAllowsLegacyAgentWithoutResolvePath", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
planPath := "/home/coder/.coder/plans/PLAN-test-uuid.md"
|
||||
mockConn.EXPECT().
|
||||
ResolvePath(gomock.Any(), planPath).
|
||||
Return("", statusError{statusCode: http.StatusNotFound, message: "missing resolve-path endpoint"})
|
||||
mockConn.EXPECT().
|
||||
WriteFile(gomock.Any(), planPath, gomock.Any()).
|
||||
DoAndReturn(func(_ context.Context, path string, reader io.Reader) error {
|
||||
data, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, planPath, path)
|
||||
require.Equal(t, "# Plan", string(data))
|
||||
return nil
|
||||
})
|
||||
tool := chattool.WriteFile(chattool.WriteFileOptions{
|
||||
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
|
||||
return mockConn, nil
|
||||
},
|
||||
ResolvePlanPath: func(context.Context) (string, string, error) {
|
||||
return planPath, "/home/coder", nil
|
||||
},
|
||||
IsPlanTurn: true,
|
||||
})
|
||||
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "write_file",
|
||||
Input: `{"path":"` + planPath + `","content":"# Plan"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.IsError)
|
||||
assert.Equal(t, `{"ok":true}`, strings.TrimSpace(resp.Content))
|
||||
})
|
||||
|
||||
t.Run("PlanTurnRejectsSymlinkedPlanPath", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
planPath := "/home/coder/.coder/plans/PLAN-test-uuid.md"
|
||||
mockConn.EXPECT().ResolvePath(gomock.Any(), planPath).Return("/home/coder/README.md", nil)
|
||||
tool := chattool.WriteFile(chattool.WriteFileOptions{
|
||||
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
|
||||
return mockConn, nil
|
||||
},
|
||||
ResolvePlanPath: func(context.Context) (string, string, error) {
|
||||
return planPath, "/home/coder", nil
|
||||
},
|
||||
IsPlanTurn: true,
|
||||
})
|
||||
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "write_file",
|
||||
Input: `{"path":"` + planPath + `","content":"# Plan"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Equal(t, "the chat-specific plan path /home/coder/.coder/plans/PLAN-test-uuid.md resolves to /home/coder/README.md; symlinked plan paths are not allowed during plan turns", resp.Content)
|
||||
})
|
||||
|
||||
t.Run("RejectsHomeRootPlanVariantsWhenResolvePlanPathIsConfigured", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ Ask concise clarifying questions only when:
|
||||
- architecture, tooling, or style preferences would change the implementation;
|
||||
- the action is destructive, irreversible, or expensive; or
|
||||
- you cannot make progress with confidence.
|
||||
If a task is too ambiguous to implement with confidence, or the user asks for a plan, write a plan before implementing. Use propose_plan to present it for review.
|
||||
If a task is too ambiguous to implement with confidence, ask for clarification before proceeding.
|
||||
</behavior>
|
||||
|
||||
<personality>
|
||||
@@ -94,12 +94,35 @@ Once a workspace is available:
|
||||
chat-specific path from the <plan-file-path> block below when it is
|
||||
available.
|
||||
3. Iterate on the plan with edit_files if needed.
|
||||
4. Call propose_plan with the same absolute plan file path from the
|
||||
<plan-file-path> block below.
|
||||
5. Wait for the user to review and approve the plan before starting implementation.
|
||||
4. Present the plan to the user and wait for review before starting implementation.
|
||||
|
||||
The propose_plan tool reads the file from the workspace. Do not pass content directly.
|
||||
Write the file first, then present it. All file paths must be absolute.
|
||||
When the <plan-file-path> block below is present, use that exact path.
|
||||
` + defaultSystemPromptPlanPathBlockPlaceholder + `
|
||||
</planning>`
|
||||
|
||||
// PlanningOverlayPrompt contains plan-mode-only instructions appended
|
||||
// when the chat is in plan mode.
|
||||
const PlanningOverlayPrompt = `You are in Plan Mode.
|
||||
Every response must work toward producing a plan.
|
||||
The only intentional authored workspace artifact is the plan file at the path specified in the <plan-file-path> block below.
|
||||
You may use execute and process_output for exploration, including cloning repositories, searching code, and running inspection commands needed to build the plan.
|
||||
Do not use Plan Mode to implement the requested changes or intentionally modify project files outside the plan file.
|
||||
If no workspace is attached to this chat yet, create and start one with create_workspace and start_workspace before investigating.
|
||||
If the plan file already exists, read it first with read_file before replacing or refining it.
|
||||
Use read_file, execute, process_output, list_templates, read_template, and spawn_agent to gather context. In Plan Mode, spawn_agent delegation is for investigation and planning support, not code writing or implementation.
|
||||
Use write_file to create the plan file and edit_files to refine it.
|
||||
Use ask_user_question for structured clarification instead of freeform questions.
|
||||
When the plan is ready, call propose_plan with the plan file path.
|
||||
After a successful propose_plan call, stop immediately. Do not produce follow-up output.
|
||||
` + defaultSystemPromptPlanPathBlockPlaceholder
|
||||
|
||||
// PlanningSubagentOverlayPrompt contains plan-mode instructions for
|
||||
// delegated child chats. Child chats may investigate with shell tools
|
||||
// but should return findings to the parent instead of authoring the
|
||||
// final plan.
|
||||
const PlanningSubagentOverlayPrompt = `You are in Plan Mode as a delegated sub-agent.
|
||||
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.`
|
||||
|
||||
+30
-18
@@ -97,26 +97,36 @@ func (p *Server) isDesktopEnabled(ctx context.Context) bool {
|
||||
}
|
||||
|
||||
func (p *Server) subagentTools(ctx context.Context, currentChat func() database.Chat) []fantasy.AgentTool {
|
||||
var planMode database.NullChatPlanMode
|
||||
if currentChat != nil {
|
||||
planMode = currentChat().PlanMode
|
||||
}
|
||||
|
||||
spawnAgentDescription := "Spawn a delegated child agent to work on a clearly scoped, " +
|
||||
"independent task in parallel. Use this when the task is " +
|
||||
"self-contained and would benefit from a separate agent " +
|
||||
"(e.g. fixing a specific bug, writing a single module, " +
|
||||
"running a migration). Do NOT use for simple or quick " +
|
||||
"operations you can handle directly with execute, " +
|
||||
"read_file, or write_file - for example, reading a group " +
|
||||
"of files and outputting them verbatim does not need a " +
|
||||
"subagent. Reserve subagents for tasks that require " +
|
||||
"intellectual work such as code analysis, writing new " +
|
||||
"code, or complex refactoring. Be careful when running " +
|
||||
"parallel subagents: if two subagents modify the same " +
|
||||
"files they will conflict with each other, so ensure " +
|
||||
"parallel subagent tasks are independent. " +
|
||||
"The child agent receives the same workspace tools but " +
|
||||
"cannot spawn its own subagents. After spawning, use " +
|
||||
"wait_agent to collect the result."
|
||||
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."
|
||||
}
|
||||
|
||||
tools := []fantasy.AgentTool{
|
||||
fantasy.NewAgentTool(
|
||||
"spawn_agent",
|
||||
"Spawn a delegated child agent to work on a clearly scoped, "+
|
||||
"independent task in parallel. Use this when the task is "+
|
||||
"self-contained and would benefit from a separate agent "+
|
||||
"(e.g. fixing a specific bug, writing a single module, "+
|
||||
"running a migration). Do NOT use for simple or quick "+
|
||||
"operations you can handle directly with execute, "+
|
||||
"read_file, or write_file - for example, reading a group "+
|
||||
"of files and outputting them verbatim does not need a "+
|
||||
"subagent. Reserve subagents for tasks that require "+
|
||||
"intellectual work such as code analysis, writing new "+
|
||||
"code, or complex refactoring. Be careful when running "+
|
||||
"parallel subagents: if two subagents modify the same "+
|
||||
"files they will conflict with each other, so ensure "+
|
||||
"parallel subagent tasks are independent. "+
|
||||
"The child agent receives the same workspace tools but "+
|
||||
"cannot spawn its own subagents. After spawning, use "+
|
||||
"wait_agent to collect the result.",
|
||||
spawnAgentDescription,
|
||||
func(ctx context.Context, args spawnAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
if currentChat == nil {
|
||||
return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil
|
||||
@@ -131,11 +141,12 @@ func (p *Server) subagentTools(ctx context.Context, currentChat func() database.
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
childChat, err := p.createChildSubagentChat(
|
||||
childChat, err := p.createChildSubagentChatWithOptions(
|
||||
ctx,
|
||||
parent,
|
||||
args.Prompt,
|
||||
args.Title,
|
||||
childSubagentChatOptions{},
|
||||
)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
@@ -474,6 +485,7 @@ func (p *Server) createChildSubagentChatWithOptions(
|
||||
LastModelConfigID: parent.LastModelConfigID,
|
||||
Title: title,
|
||||
Mode: opts.chatMode,
|
||||
PlanMode: parent.PlanMode,
|
||||
Status: database.ChatStatusPending,
|
||||
MCPServerIDs: mcpServerIDs,
|
||||
Labels: pqtype.NullRawMessage{
|
||||
|
||||
@@ -256,6 +256,43 @@ func TestCreateChildSubagentChatInheritsWorkspaceBinding(t *testing.T) {
|
||||
require.Equal(t, parentChat.AgentID, childChat.AgentID)
|
||||
}
|
||||
|
||||
func TestCreateChildSubagentChatCopiesPlanMode(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)
|
||||
planMode := database.NullChatPlanMode{
|
||||
ChatPlanMode: database.ChatPlanModePlan,
|
||||
Valid: true,
|
||||
}
|
||||
|
||||
parent, err := server.CreateChat(ctx, CreateOptions{
|
||||
OrganizationID: org.ID,
|
||||
OwnerID: user.ID,
|
||||
Title: "plan-parent",
|
||||
ModelConfigID: model.ID,
|
||||
PlanMode: planMode,
|
||||
InitialUserContent: []codersdk.ChatMessagePart{
|
||||
codersdk.ChatMessageText("plan this change"),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
parentChat, err := db.GetChatByID(ctx, parent.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, planMode, parentChat.PlanMode)
|
||||
|
||||
child, err := server.createChildSubagentChat(ctx, parentChat, "inspect bindings", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
childChat, err := db.GetChatByID(ctx, child.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, planMode, childChat.PlanMode)
|
||||
}
|
||||
|
||||
func TestSpawnComputerUseAgent_NoAnthropicProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user