feat: add propose_plan tool for markdown plan proposals (#23452)

Adds a `propose_plan` tool that presents a workspace markdown file as a
dedicated plan card in the agent UI.

The workflow is: the agent uses `write_file`/`edit_files` to build a
plan file (e.g. `/home/coder/PLAN.md`), then calls `propose_plan(path)`
to present it. The backend reads the file via `ReadFile` and the
frontend renders it as an expanded markdown preview card.

**Backend** (`coderd/x/chatd/chattool/proposeplan.go`): new tool
registered as root-chat-only. Validates `.md` suffix, requires an
absolute path, reads raw file content from the workspace agent. Includes
1 MiB size cap.

**Frontend** (`site/src/components/ai-elements/tool/`): dedicated
`ProposePlanTool` component with `ToolCollapsible` + `ScrollArea` +
`Response` markdown renderer, expanded by default. Custom icon
(`ClipboardListIcon`) and filename-based label.

**System prompt** (`coderd/x/chatd/prompt.go`): added `<planning>`
section guiding the agent to research → write plan file → iterate → call
`propose_plan`.
This commit is contained in:
Michael Suchacz
2026-03-24 15:06:22 +01:00
committed by GitHub
parent 02356c61f6
commit 19e86628da
12 changed files with 957 additions and 3 deletions
+32
View File
@@ -3334,6 +3334,7 @@ func (p *Server) runChat(
// create workspaces or spawn further subagents — they should
// focus on completing their delegated task.
if !chat.ParentChatID.Valid {
// Workspace provisioning tools.
tools = append(tools,
chattool.ListTemplates(chattool.ListTemplatesOptions{
DB: p.db,
@@ -3361,6 +3362,37 @@ func (p *Server) runChat(
WorkspaceMu: &workspaceMu,
}),
)
// Plan presentation tool.
tools = append(tools, chattool.ProposePlan(chattool.ProposePlanOptions{
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
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("chat has no workspace")
}
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)
}
return row.ID, nil
},
}))
tools = append(tools, p.subagentTools(ctx, func() database.Chat {
return chat
})...)