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
+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()