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:
+58
-2
@@ -65,6 +65,7 @@ type Chat struct {
|
||||
LastModelConfigID uuid.UUID `json:"last_model_config_id" format:"uuid"`
|
||||
Title string `json:"title"`
|
||||
Status ChatStatus `json:"status"`
|
||||
PlanMode ChatPlanMode `json:"plan_mode,omitempty"`
|
||||
LastError *string `json:"last_error"`
|
||||
DiffStatus *ChatDiffStatus `json:"diff_status,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at" format:"date-time"`
|
||||
@@ -393,12 +394,14 @@ type CreateChatRequest struct {
|
||||
// LLM can invoke. This API is highly experimental and highly
|
||||
// subject to change.
|
||||
UnsafeDynamicTools []DynamicTool `json:"unsafe_dynamic_tools,omitempty"`
|
||||
PlanMode ChatPlanMode `json:"plan_mode,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateChatRequest is the request to update a chat.
|
||||
type UpdateChatRequest struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Archived *bool `json:"archived,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
Archived *bool `json:"archived,omitempty"`
|
||||
WorkspaceID *uuid.UUID `json:"workspace_id,omitempty" format:"uuid"`
|
||||
// PinOrder controls the chat's pinned state and position.
|
||||
// - nil: no change to pin state.
|
||||
// - 0: unpin the chat.
|
||||
@@ -410,6 +413,9 @@ type UpdateChatRequest struct {
|
||||
// value is clamped to [1, pinned_count].
|
||||
PinOrder *int32 `json:"pin_order,omitempty"`
|
||||
Labels *map[string]string `json:"labels,omitempty"`
|
||||
// PlanMode switches the chat's persistent plan mode.
|
||||
// nil: no change, ptr to "plan": enable, ptr to "": clear.
|
||||
PlanMode *ChatPlanMode `json:"plan_mode,omitempty"`
|
||||
}
|
||||
|
||||
// ChatBusyBehavior controls what happens when a user sends a message
|
||||
@@ -427,12 +433,23 @@ const (
|
||||
ChatBusyBehaviorInterrupt ChatBusyBehavior = "interrupt"
|
||||
)
|
||||
|
||||
// ChatPlanMode represents the persistent plan mode state of a chat.
|
||||
type ChatPlanMode string
|
||||
|
||||
const (
|
||||
// ChatPlanModePlan activates plan mode for the chat.
|
||||
ChatPlanModePlan ChatPlanMode = "plan"
|
||||
)
|
||||
|
||||
// CreateChatMessageRequest is the request to add a message to a chat.
|
||||
type CreateChatMessageRequest struct {
|
||||
Content []ChatInputPart `json:"content"`
|
||||
ModelConfigID *uuid.UUID `json:"model_config_id,omitempty" format:"uuid"`
|
||||
MCPServerIDs *[]uuid.UUID `json:"mcp_server_ids,omitempty" format:"uuid"`
|
||||
BusyBehavior ChatBusyBehavior `json:"busy_behavior,omitempty" enums:"queue,interrupt"`
|
||||
// PlanMode switches the chat's persistent plan mode.
|
||||
// nil: no change, ptr to "plan": enable, ptr to "": clear.
|
||||
PlanMode *ChatPlanMode `json:"plan_mode,omitempty"`
|
||||
}
|
||||
|
||||
// EditChatMessageRequest is the request to edit a user message in a chat.
|
||||
@@ -514,6 +531,18 @@ type UpdateChatSystemPromptRequest struct {
|
||||
IncludeDefaultSystemPrompt *bool `json:"include_default_system_prompt,omitempty"`
|
||||
}
|
||||
|
||||
// ChatPlanModeInstructionsResponse is the response body for the
|
||||
// plan mode instructions configuration endpoint.
|
||||
type ChatPlanModeInstructionsResponse struct {
|
||||
PlanModeInstructions string `json:"plan_mode_instructions"`
|
||||
}
|
||||
|
||||
// UpdateChatPlanModeInstructionsRequest is the request body for
|
||||
// updating the plan mode instructions configuration.
|
||||
type UpdateChatPlanModeInstructionsRequest struct {
|
||||
PlanModeInstructions string `json:"plan_mode_instructions"`
|
||||
}
|
||||
|
||||
// UserChatCustomPrompt is the request and response body for the
|
||||
// user chat custom prompt configuration endpoint.
|
||||
type UserChatCustomPrompt struct {
|
||||
@@ -1891,6 +1920,33 @@ func (c *ExperimentalClient) UpdateChatSystemPrompt(ctx context.Context, req Upd
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetChatPlanModeInstructions returns the deployment-wide plan mode instructions.
|
||||
func (c *ExperimentalClient) GetChatPlanModeInstructions(ctx context.Context) (ChatPlanModeInstructionsResponse, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/config/plan-mode-instructions", nil)
|
||||
if err != nil {
|
||||
return ChatPlanModeInstructionsResponse{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return ChatPlanModeInstructionsResponse{}, ReadBodyAsError(res)
|
||||
}
|
||||
var resp ChatPlanModeInstructionsResponse
|
||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
|
||||
// UpdateChatPlanModeInstructions updates the deployment-wide plan mode instructions.
|
||||
func (c *ExperimentalClient) UpdateChatPlanModeInstructions(ctx context.Context, req UpdateChatPlanModeInstructionsRequest) error {
|
||||
res, err := c.Request(ctx, http.MethodPut, "/api/experimental/chats/config/plan-mode-instructions", req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusNoContent {
|
||||
return ReadBodyAsError(res)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserChatCustomPrompt fetches the user's custom chat prompt.
|
||||
func (c *ExperimentalClient) GetUserChatCustomPrompt(ctx context.Context) (UserChatCustomPrompt, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/config/user-prompt", nil)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
neturl "net/url"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -81,6 +82,7 @@ type AgentConn interface {
|
||||
SignalProcess(ctx context.Context, id string, signal string) error
|
||||
StartProcess(ctx context.Context, req StartProcessRequest) (StartProcessResponse, error)
|
||||
LS(ctx context.Context, path string, req LSRequest) (LSResponse, error)
|
||||
ResolvePath(ctx context.Context, path string) (string, error)
|
||||
ReadFile(ctx context.Context, path string, offset, limit int64) (io.ReadCloser, string, error)
|
||||
ReadFileLines(ctx context.Context, path string, offset, limit int64, limits ReadFileLinesLimits) (ReadFileLinesResponse, error)
|
||||
WriteFile(ctx context.Context, path string, reader io.Reader) error
|
||||
@@ -855,7 +857,9 @@ func (c *agentConn) LS(ctx context.Context, path string, req LSRequest) (LSRespo
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
res, err := c.apiRequest(ctx, http.MethodPost, fmt.Sprintf("/api/v0/list-directory?path=%s", path), req)
|
||||
res, err := c.apiRequest(ctx, http.MethodPost, agentAPIPath("/api/v0/list-directory", neturl.Values{
|
||||
"path": []string{path},
|
||||
}), req)
|
||||
if err != nil {
|
||||
return LSResponse{}, xerrors.Errorf("do request: %w", err)
|
||||
}
|
||||
@@ -871,16 +875,50 @@ func (c *agentConn) LS(ctx context.Context, path string, req LSRequest) (LSRespo
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ResolvePathResponse is the response from the agent's path-resolution endpoint.
|
||||
type ResolvePathResponse struct {
|
||||
ResolvedPath string `json:"resolved_path"`
|
||||
}
|
||||
|
||||
// ResolvePath resolves the existing portion of an absolute path through any
|
||||
// symlinks and preserves missing trailing components.
|
||||
func (c *agentConn) ResolvePath(ctx context.Context, path string) (string, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
res, err := c.apiRequest(ctx, http.MethodGet, agentAPIPath("/api/v0/resolve-path", neturl.Values{
|
||||
"path": []string{path},
|
||||
}), nil)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("do request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return "", codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
|
||||
var m ResolvePathResponse
|
||||
if err := json.NewDecoder(res.Body).Decode(&m); err != nil {
|
||||
return "", xerrors.Errorf("decode response body: %w", err)
|
||||
}
|
||||
return m.ResolvedPath, nil
|
||||
}
|
||||
|
||||
// ReadFileLines reads a file with line-based offset and limit, returning
|
||||
// line-numbered content with safety limits.
|
||||
func (c *agentConn) ReadFileLines(ctx context.Context, path string, offset, limit int64, limits ReadFileLinesLimits) (ReadFileLinesResponse, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
res, err := c.apiRequest(ctx, http.MethodGet, fmt.Sprintf(
|
||||
"/api/v0/read-file-lines?path=%s&offset=%d&limit=%d&max_file_size=%d&max_line_bytes=%d&max_response_lines=%d&max_response_bytes=%d",
|
||||
path, offset, limit, limits.MaxFileSize, limits.MaxLineBytes, limits.MaxResponseLines, limits.MaxResponseBytes,
|
||||
), nil)
|
||||
res, err := c.apiRequest(ctx, http.MethodGet, agentAPIPath("/api/v0/read-file-lines", neturl.Values{
|
||||
"path": []string{path},
|
||||
"offset": []string{strconv.FormatInt(offset, 10)},
|
||||
"limit": []string{strconv.FormatInt(limit, 10)},
|
||||
"max_file_size": []string{strconv.FormatInt(limits.MaxFileSize, 10)},
|
||||
"max_line_bytes": []string{strconv.Itoa(limits.MaxLineBytes)},
|
||||
"max_response_lines": []string{strconv.Itoa(limits.MaxResponseLines)},
|
||||
"max_response_bytes": []string{strconv.Itoa(limits.MaxResponseBytes)},
|
||||
}), nil)
|
||||
if err != nil {
|
||||
return ReadFileLinesResponse{}, xerrors.Errorf("do request: %w", err)
|
||||
}
|
||||
@@ -903,7 +941,11 @@ func (c *agentConn) ReadFile(ctx context.Context, path string, offset, limit int
|
||||
defer span.End()
|
||||
|
||||
//nolint:bodyclose // we want to return the body so the caller can stream.
|
||||
res, err := c.apiRequest(ctx, http.MethodGet, fmt.Sprintf("/api/v0/read-file?path=%s&offset=%d&limit=%d", path, offset, limit), nil)
|
||||
res, err := c.apiRequest(ctx, http.MethodGet, agentAPIPath("/api/v0/read-file", neturl.Values{
|
||||
"path": []string{path},
|
||||
"offset": []string{strconv.FormatInt(offset, 10)},
|
||||
"limit": []string{strconv.FormatInt(limit, 10)},
|
||||
}), nil)
|
||||
if err != nil {
|
||||
return nil, "", xerrors.Errorf("do request: %w", err)
|
||||
}
|
||||
@@ -925,7 +967,9 @@ func (c *agentConn) WriteFile(ctx context.Context, path string, reader io.Reader
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
res, err := c.apiRequest(ctx, http.MethodPost, fmt.Sprintf("/api/v0/write-file?path=%s", path), reader)
|
||||
res, err := c.apiRequest(ctx, http.MethodPost, agentAPIPath("/api/v0/write-file", neturl.Values{
|
||||
"path": []string{path},
|
||||
}), reader)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("do request: %w", err)
|
||||
}
|
||||
@@ -1195,6 +1239,14 @@ func (c *agentConn) EditFiles(ctx context.Context, edits FileEditRequest) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func agentAPIPath(path string, query neturl.Values) string {
|
||||
if len(query) == 0 {
|
||||
return path
|
||||
}
|
||||
|
||||
return path + "?" + query.Encode()
|
||||
}
|
||||
|
||||
// apiRequest makes a request to the workspace agent's HTTP API server.
|
||||
func (c *agentConn) apiRequest(ctx context.Context, method, path string, body interface{}) (*http.Response, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//nolint:testpackage // This test exercises the internal query builder directly because agent requests need a live tailnet connection.
|
||||
package workspacesdk
|
||||
|
||||
import (
|
||||
neturl "net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAgentAPIPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("encodes reserved query characters", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := "/tmp/a&b ?#%c.md"
|
||||
got := agentAPIPath("/api/v0/resolve-path", neturl.Values{
|
||||
"path": []string{path},
|
||||
})
|
||||
|
||||
parsed, err := neturl.Parse(got)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "/api/v0/resolve-path", parsed.Path)
|
||||
require.Equal(t, path, parsed.Query().Get("path"))
|
||||
})
|
||||
|
||||
t.Run("preserves all query values", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := agentAPIPath("/api/v0/read-file-lines", neturl.Values{
|
||||
"path": []string{"/tmp/plan v1#.md"},
|
||||
"offset": []string{"10"},
|
||||
"limit": []string{"20"},
|
||||
"max_file_size": []string{"30"},
|
||||
"max_line_bytes": []string{"40"},
|
||||
"max_response_lines": []string{"50"},
|
||||
"max_response_bytes": []string{"60"},
|
||||
})
|
||||
|
||||
parsed, err := neturl.Parse(got)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "/api/v0/read-file-lines", parsed.Path)
|
||||
require.Equal(t, "/tmp/plan v1#.md", parsed.Query().Get("path"))
|
||||
require.Equal(t, "10", parsed.Query().Get("offset"))
|
||||
require.Equal(t, "20", parsed.Query().Get("limit"))
|
||||
require.Equal(t, "30", parsed.Query().Get("max_file_size"))
|
||||
require.Equal(t, "40", parsed.Query().Get("max_line_bytes"))
|
||||
require.Equal(t, "50", parsed.Query().Get("max_response_lines"))
|
||||
require.Equal(t, "60", parsed.Query().Get("max_response_bytes"))
|
||||
})
|
||||
}
|
||||
@@ -449,6 +449,21 @@ func (mr *MockAgentConnMockRecorder) RecreateDevcontainer(ctx, devcontainerID an
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RecreateDevcontainer", reflect.TypeOf((*MockAgentConn)(nil).RecreateDevcontainer), ctx, devcontainerID)
|
||||
}
|
||||
|
||||
// ResolvePath mocks base method.
|
||||
func (m *MockAgentConn) ResolvePath(ctx context.Context, path string) (string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ResolvePath", ctx, path)
|
||||
ret0, _ := ret[0].(string)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// ResolvePath indicates an expected call of ResolvePath.
|
||||
func (mr *MockAgentConnMockRecorder) ResolvePath(ctx, path any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolvePath", reflect.TypeOf((*MockAgentConn)(nil).ResolvePath), ctx, path)
|
||||
}
|
||||
|
||||
// SSH mocks base method.
|
||||
func (m *MockAgentConn) SSH(ctx context.Context) (*gonet.TCPConn, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
Reference in New Issue
Block a user