feat(prompts): extend templating system to more prompts

Kieran Klukas created

Change summary

internal/agent/tools/crush_logs.go      | 24 +++++++++++++++++++++---
internal/agent/tools/crush_logs.md.tpl  |  4 ++--
internal/agent/tools/download.go        | 22 +++++++++++++++++++---
internal/agent/tools/download.md        |  1 -
internal/agent/tools/download.md.tpl    |  1 +
internal/agent/tools/fetch.go           | 14 +++++++++++++-
internal/agent/tools/fetch.md.tpl       |  2 +-
internal/agent/tools/glob.go            | 22 +++++++++++++++++++---
internal/agent/tools/glob.md            |  1 -
internal/agent/tools/glob.md.tpl        |  1 +
internal/agent/tools/grep.go            | 22 +++++++++++++++++++---
internal/agent/tools/grep.md            |  1 -
internal/agent/tools/grep.md.tpl        |  1 +
internal/agent/tools/ls.go              | 22 +++++++++++++++++++---
internal/agent/tools/ls.md              |  1 -
internal/agent/tools/ls.md.tpl          |  1 +
internal/agent/tools/sourcegraph.go     | 22 +++++++++++++++++++---
internal/agent/tools/sourcegraph.md.tpl |  2 +-
internal/agent/tools/tools.go           |  9 +++++++++
internal/agent/tools/view.go            | 24 +++++++++++++++++++++---
internal/agent/tools/view.md            |  1 -
internal/agent/tools/view.md.tpl        |  1 +
22 files changed, 168 insertions(+), 31 deletions(-)

Detailed changes

internal/agent/tools/crush_logs.go 🔗

@@ -5,6 +5,7 @@ import (
 	_ "embed"
 	"encoding/json"
 	"fmt"
+	"html/template"
 	"io"
 	"os"
 	"path/filepath"
@@ -18,8 +19,25 @@ import (
 
 const CrushLogsToolName = "crush_logs"
 
-//go:embed crush_logs.md
-var crushLogsDescription string
+//go:embed crush_logs.md.tpl
+var crushLogsDescriptionTmpl []byte
+
+var crushLogsDescriptionTpl = template.Must(
+	template.New("crushLogsDescription").
+		Parse(string(crushLogsDescriptionTmpl)),
+)
+
+type crushLogsDescriptionData struct {
+	DefaultLines int
+	MaxLines     int
+}
+
+func crushLogsDescription() string {
+	return renderTemplate(crushLogsDescriptionTpl, crushLogsDescriptionData{
+		DefaultLines: defaultLogLines,
+		MaxLines:     maxLogLines,
+	})
+}
 
 // Max line size to prevent memory issues with very long log lines (1 MB).
 const maxLogLineSize = 1024 * 1024
@@ -58,7 +76,7 @@ type CrushLogsParams struct {
 func NewCrushLogsTool(logFile string) fantasy.AgentTool {
 	return fantasy.NewAgentTool(
 		CrushLogsToolName,
-		crushLogsDescription,
+		crushLogsDescription(),
 		func(ctx context.Context, params CrushLogsParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
 			result := runCrushLogs(logFile, params)
 			return fantasy.NewTextResponse(result), nil

internal/agent/tools/crush_logs.md → internal/agent/tools/crush_logs.md.tpl 🔗

@@ -1,4 +1,4 @@
-Read Crush's internal application logs (default 50 entries, max 100); useful for diagnosing provider errors, tool failures, LSP/MCP issues.
+Read Crush's internal application logs (default {{ .DefaultLines }} entries, max {{ .MaxLines }}); useful for diagnosing provider errors, tool failures, LSP/MCP issues.
 
 <usage>
 - Returns recent log entries from Crush's internal log file
@@ -8,6 +8,6 @@ Read Crush's internal application logs (default 50 entries, max 100); useful for
 </usage>
 
 <tips>
-- Default returns last 50 entries; use lines parameter for more (max 100)
+- Default returns last {{ .DefaultLines }} entries; use lines parameter for more (max {{ .MaxLines }})
 - Look for ERROR and WARN entries first when diagnosing problems
 </tips>

internal/agent/tools/download.go 🔗

@@ -5,6 +5,7 @@ import (
 	"context"
 	_ "embed"
 	"fmt"
+	"html/template"
 	"io"
 	"net/http"
 	"os"
@@ -31,8 +32,23 @@ type DownloadPermissionsParams struct {
 
 const DownloadToolName = "download"
 
-//go:embed download.md
-var downloadDescription string
+//go:embed download.md.tpl
+var downloadDescriptionTmpl []byte
+
+var downloadDescriptionTpl = template.Must(
+	template.New("downloadDescription").
+		Parse(string(downloadDescriptionTmpl)),
+)
+
+type downloadDescriptionData struct {
+	MaxDownloadTimeout int
+}
+
+func downloadDescription() string {
+	return renderTemplate(downloadDescriptionTpl, downloadDescriptionData{
+		MaxDownloadTimeout: 600,
+	})
+}
 
 func NewDownloadTool(permissions permission.Service, workingDir string, client *http.Client) fantasy.AgentTool {
 	if client == nil {
@@ -48,7 +64,7 @@ func NewDownloadTool(permissions permission.Service, workingDir string, client *
 	}
 	return fantasy.NewParallelAgentTool(
 		DownloadToolName,
-		downloadDescription,
+		downloadDescription(),
 		func(ctx context.Context, params DownloadParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
 			if params.URL == "" {
 				return fantasy.NewTextErrorResponse("URL parameter is required"), nil

internal/agent/tools/download.md 🔗

@@ -1 +0,0 @@
-Download a URL directly to a local file (binary-safe, streaming, max 100MB); overwrites without warning. For reading content into context use fetch.

internal/agent/tools/download.md.tpl 🔗

@@ -0,0 +1 @@
+Download a URL directly to a local file (binary-safe, streaming, max {{ .MaxDownloadTimeout }}s timeout); overwrites without warning. For reading content into context use fetch.

internal/agent/tools/fetch.go 🔗

@@ -30,6 +30,18 @@ var fetchDescriptionTpl = template.Must(
 		Parse(string(fetchDescriptionTmpl)),
 )
 
+type fetchDescriptionData struct {
+	GhAvailable    bool
+	MaxFetchSizeKB int
+}
+
+func fetchDescription() string {
+	return renderTemplate(fetchDescriptionTpl, fetchDescriptionData{
+		GhAvailable:    ghAvailable,
+		MaxFetchSizeKB: MaxFetchSize / 1024,
+	})
+}
+
 func NewFetchTool(permissions permission.Service, workingDir string, client *http.Client) fantasy.AgentTool {
 	if client == nil {
 		transport := http.DefaultTransport.(*http.Transport).Clone()
@@ -45,7 +57,7 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt
 
 	return fantasy.NewParallelAgentTool(
 		FetchToolName,
-		renderToolDescription(fetchDescriptionTpl),
+		fetchDescription(),
 		func(ctx context.Context, params FetchParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
 			if params.URL == "" {
 				return fantasy.NewTextErrorResponse("URL parameter is required"), nil

internal/agent/tools/fetch.md.tpl 🔗

@@ -1,2 +1,2 @@
-Fetch raw content from a URL as text, markdown, or html (max 100KB); no AI processing. For analysis or extraction use agentic_fetch.
+Fetch raw content from a URL as text, markdown, or html (max {{ .MaxFetchSizeKB }}KB); no AI processing. For analysis or extraction use agentic_fetch.
 {{- if .GhAvailable }} For GitHub content when an exact repo, issue, or PR link is provided, use `gh` CLI in bash instead.{{- end }}

internal/agent/tools/glob.go 🔗

@@ -6,6 +6,7 @@ import (
 	"context"
 	_ "embed"
 	"fmt"
+	"html/template"
 	"log/slog"
 	"os/exec"
 	"path/filepath"
@@ -19,8 +20,23 @@ import (
 
 const GlobToolName = "glob"
 
-//go:embed glob.md
-var globDescription string
+//go:embed glob.md.tpl
+var globDescriptionTmpl []byte
+
+var globDescriptionTpl = template.Must(
+	template.New("globDescription").
+		Parse(string(globDescriptionTmpl)),
+)
+
+type globDescriptionData struct {
+	MaxResults int
+}
+
+func globDescription() string {
+	return renderTemplate(globDescriptionTpl, globDescriptionData{
+		MaxResults: 100,
+	})
+}
 
 type GlobParams struct {
 	Pattern string `json:"pattern" description:"The glob pattern to match files against"`
@@ -35,7 +51,7 @@ type GlobResponseMetadata struct {
 func NewGlobTool(workingDir string) fantasy.AgentTool {
 	return fantasy.NewAgentTool(
 		GlobToolName,
-		globDescription,
+		globDescription(),
 		func(ctx context.Context, params GlobParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
 			if params.Pattern == "" {
 				return fantasy.NewTextErrorResponse("pattern is required"), nil

internal/agent/tools/glob.md 🔗

@@ -1 +0,0 @@
-Find files by name/pattern (glob syntax), sorted by modification time; max 100 results; skips hidden files. Use grep to search file contents.

internal/agent/tools/glob.md.tpl 🔗

@@ -0,0 +1 @@
+Find files by name/pattern (glob syntax), sorted by modification time; max {{ .MaxResults }} results; skips hidden files. Use grep to search file contents.

internal/agent/tools/grep.go 🔗

@@ -8,6 +8,7 @@ import (
 	_ "embed"
 	"encoding/json"
 	"fmt"
+	"html/template"
 	"io"
 	"net/http"
 	"os"
@@ -89,8 +90,23 @@ const (
 	maxGrepContentWidth = 500
 )
 
-//go:embed grep.md
-var grepDescription string
+//go:embed grep.md.tpl
+var grepDescriptionTmpl []byte
+
+var grepDescriptionTpl = template.Must(
+	template.New("grepDescription").
+		Parse(string(grepDescriptionTmpl)),
+)
+
+type grepDescriptionData struct {
+	MaxResults int
+}
+
+func grepDescription() string {
+	return renderTemplate(grepDescriptionTpl, grepDescriptionData{
+		MaxResults: 100,
+	})
+}
 
 // escapeRegexPattern escapes special regex characters so they're treated as literal characters
 func escapeRegexPattern(pattern string) string {
@@ -107,7 +123,7 @@ func escapeRegexPattern(pattern string) string {
 func NewGrepTool(workingDir string, config config.ToolGrep) fantasy.AgentTool {
 	return fantasy.NewAgentTool(
 		GrepToolName,
-		grepDescription,
+		grepDescription(),
 		func(ctx context.Context, params GrepParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
 			if params.Pattern == "" {
 				return fantasy.NewTextErrorResponse("pattern is required"), nil

internal/agent/tools/grep.md 🔗

@@ -1 +0,0 @@
-Search file contents by regex or literal text; returns matching file paths sorted by modification time (max 100); respects .gitignore. Use glob to filter by filename, not contents.

internal/agent/tools/grep.md.tpl 🔗

@@ -0,0 +1 @@
+Search file contents by regex or literal text; returns matching file paths sorted by modification time (max {{ .MaxResults }}); respects .gitignore. Use glob to filter by filename, not contents.

internal/agent/tools/ls.go 🔗

@@ -5,6 +5,7 @@ import (
 	"context"
 	_ "embed"
 	"fmt"
+	"html/template"
 	"os"
 	"path/filepath"
 	"strings"
@@ -52,13 +53,28 @@ const (
 	maxLSFiles = 1000
 )
 
-//go:embed ls.md
-var lsDescription string
+//go:embed ls.md.tpl
+var lsDescriptionTmpl []byte
+
+var lsDescriptionTpl = template.Must(
+	template.New("lsDescription").
+		Parse(string(lsDescriptionTmpl)),
+)
+
+type lsDescriptionData struct {
+	MaxFiles int
+}
+
+func lsDescription() string {
+	return renderTemplate(lsDescriptionTpl, lsDescriptionData{
+		MaxFiles: maxLSFiles,
+	})
+}
 
 func NewLsTool(permissions permission.Service, workingDir string, lsConfig config.ToolLs) fantasy.AgentTool {
 	return fantasy.NewAgentTool(
 		LSToolName,
-		lsDescription,
+		lsDescription(),
 		func(ctx context.Context, params LSParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
 			searchPath, err := fsext.Expand(cmp.Or(params.Path, workingDir))
 			if err != nil {

internal/agent/tools/ls.md 🔗

@@ -1 +0,0 @@
-List files and directories as a tree; skips hidden files and common system dirs; max 1000 files. Use glob to find files by pattern, grep to search contents.

internal/agent/tools/ls.md.tpl 🔗

@@ -0,0 +1 @@
+List files and directories as a tree; skips hidden files and common system dirs; max {{ .MaxFiles }} files. Use glob to find files by pattern, grep to search contents.

internal/agent/tools/sourcegraph.go 🔗

@@ -6,6 +6,7 @@ import (
 	_ "embed"
 	"encoding/json"
 	"fmt"
+	"html/template"
 	"io"
 	"net/http"
 	"strings"
@@ -28,8 +29,23 @@ type SourcegraphResponseMetadata struct {
 
 const SourcegraphToolName = "sourcegraph"
 
-//go:embed sourcegraph.md
-var sourcegraphDescription string
+//go:embed sourcegraph.md.tpl
+var sourcegraphDescriptionTmpl []byte
+
+var sourcegraphDescriptionTpl = template.Must(
+	template.New("sourcegraphDescription").
+		Parse(string(sourcegraphDescriptionTmpl)),
+)
+
+type sourcegraphDescriptionData struct {
+	MaxResults int
+}
+
+func sourcegraphDescription() string {
+	return renderTemplate(sourcegraphDescriptionTpl, sourcegraphDescriptionData{
+		MaxResults: 20,
+	})
+}
 
 func NewSourcegraphTool(client *http.Client) fantasy.AgentTool {
 	if client == nil {
@@ -45,7 +61,7 @@ func NewSourcegraphTool(client *http.Client) fantasy.AgentTool {
 	}
 	return fantasy.NewParallelAgentTool(
 		SourcegraphToolName,
-		sourcegraphDescription,
+		sourcegraphDescription(),
 		func(ctx context.Context, params SourcegraphParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
 			if params.Query == "" {
 				return fantasy.NewTextErrorResponse("Query parameter is required"), nil

internal/agent/tools/sourcegraph.md → internal/agent/tools/sourcegraph.md.tpl 🔗

@@ -1 +1 @@
-Search code across public GitHub repositories via Sourcegraph; supports regex, language/repo/file filters, and symbol search (max 20 results). Only searches public repos.
+Search code across public GitHub repositories via Sourcegraph; supports regex, language/repo/file filters, and symbol search (max {{ .MaxResults }} results). Only searches public repos.

internal/agent/tools/tools.go 🔗

@@ -90,3 +90,12 @@ func renderToolDescription(tmpl *template.Template) string {
 	}
 	return out.String()
 }
+
+// renderTemplate renders a Go template with the given data.
+func renderTemplate(tmpl *template.Template, data any) string {
+	var out bytes.Buffer
+	if err := tmpl.Execute(&out, data); err != nil {
+		panic("failed to execute tool description template: " + err.Error())
+	}
+	return out.String()
+}

internal/agent/tools/view.go 🔗

@@ -6,6 +6,7 @@ import (
 	_ "embed"
 	"errors"
 	"fmt"
+	"html/template"
 	"io"
 	"io/fs"
 	"net/http"
@@ -23,8 +24,25 @@ import (
 	"github.com/charmbracelet/crush/internal/skills"
 )
 
-//go:embed view.md
-var viewDescription string
+//go:embed view.md.tpl
+var viewDescriptionTmpl []byte
+
+var viewDescriptionTpl = template.Must(
+	template.New("viewDescription").
+		Parse(string(viewDescriptionTmpl)),
+)
+
+type viewDescriptionData struct {
+	DefaultReadLimit int
+	MaxViewSizeKB    int
+}
+
+func viewDescription() string {
+	return renderTemplate(viewDescriptionTpl, viewDescriptionData{
+		DefaultReadLimit: DefaultReadLimit,
+		MaxViewSizeKB:    MaxViewSize / 1024,
+	})
+}
 
 type ViewParams struct {
 	FilePath string `json:"file_path" description:"The path to the file to read"`
@@ -79,7 +97,7 @@ func NewViewTool(
 ) fantasy.AgentTool {
 	return fantasy.NewAgentTool(
 		ViewToolName,
-		viewDescription,
+		viewDescription(),
 		func(ctx context.Context, params ViewParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
 			if params.FilePath == "" {
 				return fantasy.NewTextErrorResponse("file_path is required"), nil

internal/agent/tools/view.md 🔗

@@ -1 +0,0 @@
-Read a file by path with line numbers; supports offset and line limit (default 2000, max 200KB); renders images (PNG, JPEG, GIF, BMP, SVG, WebP); use ls for directories.

internal/agent/tools/view.md.tpl 🔗

@@ -0,0 +1 @@
+Read a file by path with line numbers; supports offset and line limit (default {{ .DefaultReadLimit }}, max {{ .MaxViewSizeKB }}KB returned file content section); renders images (PNG, JPEG, GIF, WebP); use ls for directories.