Files
coder/coderd/x/chatd/chattool/chattool.go
T
Ethan 1203f625b7 feat(coderd): accept parameters in start_workspace tool (#24434)
When the chat `start_workspace` tool triggers an active-version upgrade
that introduces new required parameters, the build fails with a
parameter validation error. Previously this returned a message telling
the user to update from the UI — a dead end for the model.

This PR lets the model recover inside the chat by:

1. Accepting an optional `parameters` map on `start_workspace` (same
schema as `create_workspace`), forwarded as `RichParameterValues`.
2. Returning structured JSON error responses that preserve validation
details and the workspace's `template_id`, so the model can call
`read_template` to discover what changed.
3. Replacing the UI-only guidance in `exp_chats.go` with
model-actionable retry instructions.

The expected model flow on an active-version parameter failure is now:

```
start_workspace → fails (structured error with template_id + validations)
read_template   → discovers new required parameters
start_workspace → retries with parameters map → workspace starts
```
<img width="846" height="511" alt="image"
src="https://github.com/user-attachments/assets/d18b6864-5970-4225-8da0-0f2ab134ccb4"
/>
2026-04-21 11:36:20 +10:00

119 lines
3.2 KiB
Go

package chattool
import (
"encoding/json"
"unicode/utf8"
"charm.land/fantasy"
"github.com/google/uuid"
"github.com/coder/coder/v2/codersdk"
)
// toolResponse builds a fantasy.ToolResponse from a JSON-serializable
// result map. The map constraint ensures all tool results serialize
// to JSON objects so the frontend can safely parse them.
func toolResponse(result map[string]any) fantasy.ToolResponse {
data, err := json.Marshal(result)
if err != nil {
return fantasy.NewTextResponse("{}")
}
return fantasy.NewTextResponse(string(data))
}
// buildToolResponse marshals a buildErrorResult into a tool response.
// Separate from toolResponse to keep the map[string]any constraint
// on the general helper while allowing typed error structs.
func buildToolResponse(r buildErrorResult) fantasy.ToolResponse {
data, err := json.Marshal(r)
if err != nil {
return fantasy.NewTextResponse("{}")
}
return fantasy.NewTextResponse(string(data))
}
// responseErrorResult converts a codersdk.Response into a structured
// tool result. We return these via toolResponse rather than
// NewTextErrorResponse because the fantasy/chatprompt pipeline flattens
// IsError content into a single string and drops validation details.
func responseErrorResult(resp codersdk.Response) map[string]any {
message := resp.Message
if message == "" {
message = "request failed"
}
result := map[string]any{
"error": message,
}
if resp.Detail != "" {
result["detail"] = resp.Detail
}
if len(resp.Validations) > 0 {
result["validations"] = resp.Validations
}
return result
}
func truncateRunes(value string, maxLen int) string {
if maxLen <= 0 || value == "" {
return ""
}
if utf8.RuneCountInString(value) <= maxLen {
return value
}
runes := []rune(value)
if maxLen > len(runes) {
maxLen = len(runes)
}
return string(runes[:maxLen])
}
// buildErrorResult is a structured error response that preserves
// the build ID alongside the error message. This lets the frontend
// keep showing build logs when a build fails instead of losing
// them on the error transition.
type buildErrorResult struct {
Error string `json:"error"`
BuildID string `json:"build_id,omitempty"`
}
func newBuildError(msg string, buildID uuid.UUID) buildErrorResult {
r := buildErrorResult{Error: msg}
if buildID != uuid.Nil {
r.BuildID = buildID.String()
}
return r
}
// setBuildID adds the build_id field to a tool response map when
// the build ID is known (non-zero).
func setBuildID(result map[string]any, buildID uuid.UUID) {
if buildID != uuid.Nil {
result["build_id"] = buildID.String()
}
}
// setNoBuild marks the response with no_build: true when no build
// was triggered. The frontend uses this flag to suppress the
// build-log section for already-running workspaces.
func setNoBuild(result map[string]any, buildID uuid.UUID) {
if buildID == uuid.Nil {
result["no_build"] = true
}
}
// isTemplateAllowed checks whether a template ID is permitted by the
// configured allowlist. A nil function or an empty allowlist means
// all templates are allowed.
func isTemplateAllowed(getAllowlist func() map[uuid.UUID]bool, id uuid.UUID) bool {
if getAllowlist == nil {
return true
}
allowlist := getAllowlist()
if len(allowlist) == 0 {
return true
}
return allowlist[id]
}