Files
coder/coderd/x/chatd/chattool/planpath.go
T
Michael Suchacz 1cf0354f72 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
2026-04-16 11:12:01 +02:00

111 lines
3.1 KiB
Go

package chattool
import (
"context"
"path"
"strings"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
const planFileNamePrefix = "PLAN-"
// LegacySharedPlanPath is the original shared plan file path used by
// every chat in a workspace.
const LegacySharedPlanPath = "/home/coder/PLAN.md"
// ResolveWorkspaceHome returns the workspace user's home directory.
func ResolveWorkspaceHome(
ctx context.Context,
conn workspacesdk.AgentConn,
) (string, error) {
if conn == nil {
return "", xerrors.New("workspace connection is required")
}
resp, err := conn.LS(ctx, "", workspacesdk.LSRequest{
Path: []string{},
Relativity: workspacesdk.LSRelativityHome,
})
if err != nil {
return "", xerrors.Errorf("resolve workspace home: %w", err)
}
home := strings.TrimSpace(resp.AbsolutePathString)
if home == "" {
return "", xerrors.New("workspace home path is empty")
}
return home, nil
}
// PlanPathForChat returns the per-chat plan file path rooted in the
// workspace home directory.
func PlanPathForChat(home string, chatID uuid.UUID) string {
return path.Join(
home,
".coder",
"plans",
planFileNamePrefix+chatID.String()+".md",
)
}
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.
// isAbsolutePath reports whether p is an absolute POSIX path.
func isAbsolutePath(p string) bool {
return path.IsAbs(p)
}
// looksLikePlanFileName reports whether the base name of requestedPath
// is "plan.md" (case-insensitive), ignoring the directory component.
func looksLikePlanFileName(requestedPath string) bool {
cleaned := path.Clean(requestedPath)
return strings.EqualFold(path.Base(cleaned), "plan.md")
}
// LooksLikeHomePlanFile reports whether requestedPath is a plan.md
// variant (case-insensitive) sitting directly in the workspace home
// directory.
// The filename is compared case-insensitively because LLM output varies.
func LooksLikeHomePlanFile(requestedPath, home string) bool {
normalized := path.Clean(requestedPath)
normalizedHome := path.Clean(home)
return looksLikePlanFileName(normalized) &&
strings.EqualFold(path.Dir(normalized), normalizedHome)
}
// looksLikeLegacySharedPlanPath reports whether requestedPath
// matches the legacy shared plan path (case-insensitive). Used as a
// narrow fallback when the workspace home cannot be resolved.
func looksLikeLegacySharedPlanPath(requestedPath string) bool {
normalized := path.Clean(requestedPath)
return strings.EqualFold(normalized, LegacySharedPlanPath)
}