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:
Michael Suchacz
2026-04-16 11:12:01 +02:00
committed by GitHub
parent e996f6d44b
commit 1cf0354f72
76 changed files with 6398 additions and 889 deletions
+58 -2
View File
@@ -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)
+59 -7
View File
@@ -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)
+52
View File
@@ -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()