From 0c1014e5f7465e09483716b4c3732d1e8af76c84 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Tue, 12 May 2026 11:44:36 -0400 Subject: [PATCH] feat(prompts): extend templating system to more prompts --- internal/agent/tools/crush_logs.go | 24 ++++++++++++++++--- .../{crush_logs.md => 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 ++++++++++++++--- .../{sourcegraph.md => 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(-) rename internal/agent/tools/{crush_logs.md => crush_logs.md.tpl} (56%) delete mode 100644 internal/agent/tools/download.md create mode 100644 internal/agent/tools/download.md.tpl delete mode 100644 internal/agent/tools/glob.md create mode 100644 internal/agent/tools/glob.md.tpl delete mode 100644 internal/agent/tools/grep.md create mode 100644 internal/agent/tools/grep.md.tpl delete mode 100644 internal/agent/tools/ls.md create mode 100644 internal/agent/tools/ls.md.tpl rename internal/agent/tools/{sourcegraph.md => sourcegraph.md.tpl} (68%) delete mode 100644 internal/agent/tools/view.md create mode 100644 internal/agent/tools/view.md.tpl diff --git a/internal/agent/tools/crush_logs.go b/internal/agent/tools/crush_logs.go index 1de2ea3fc8ec1423d0b21f6099766b8f30880e01..0b0cb656cec2c7cd9803867bd91de0af09bb127b 100644 --- a/internal/agent/tools/crush_logs.go +++ b/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 diff --git a/internal/agent/tools/crush_logs.md b/internal/agent/tools/crush_logs.md.tpl similarity index 56% rename from internal/agent/tools/crush_logs.md rename to internal/agent/tools/crush_logs.md.tpl index 918c4505849b3caaf8eb5c1799f1a2b5b67db9b7..4879eb5cf46026669c403dc5f0f7c982465bbe7b 100644 --- a/internal/agent/tools/crush_logs.md +++ b/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. - 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 -- 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 diff --git a/internal/agent/tools/download.go b/internal/agent/tools/download.go index 2cb7e19d61533cc44ca6cd9d1a235ef0c8f08b6a..1e04f9980cb35f595088dfc81d6572dd65351a58 100644 --- a/internal/agent/tools/download.go +++ b/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 diff --git a/internal/agent/tools/download.md b/internal/agent/tools/download.md deleted file mode 100644 index 3adb507e4d49300c8dbd2e263c9f9ec65f54629e..0000000000000000000000000000000000000000 --- a/internal/agent/tools/download.md +++ /dev/null @@ -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. \ No newline at end of file diff --git a/internal/agent/tools/download.md.tpl b/internal/agent/tools/download.md.tpl new file mode 100644 index 0000000000000000000000000000000000000000..2cdc6826934d70b4842fe2a4e912f830fd81fb20 --- /dev/null +++ b/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. diff --git a/internal/agent/tools/fetch.go b/internal/agent/tools/fetch.go index 6a272b65067b221bb0d7bfe6675672c78af5b459..599a32f84c0c33eeaeee65385be2613e635544fc 100644 --- a/internal/agent/tools/fetch.go +++ b/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 diff --git a/internal/agent/tools/fetch.md.tpl b/internal/agent/tools/fetch.md.tpl index a5ed224d540c5d592e9c8b53b911afab9af4ea94..1bffa00179aa77a69f6e30e1235a84caf3a22950 100644 --- a/internal/agent/tools/fetch.md.tpl +++ b/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 }} diff --git a/internal/agent/tools/glob.go b/internal/agent/tools/glob.go index fff81236027112f9722eb84e988926ada6f13f6c..d9c3162e755a655eca78f448c3d523c6e862523d 100644 --- a/internal/agent/tools/glob.go +++ b/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 diff --git a/internal/agent/tools/glob.md b/internal/agent/tools/glob.md deleted file mode 100644 index e1038856691883102e6cdf82315079d91ab25c68..0000000000000000000000000000000000000000 --- a/internal/agent/tools/glob.md +++ /dev/null @@ -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. \ No newline at end of file diff --git a/internal/agent/tools/glob.md.tpl b/internal/agent/tools/glob.md.tpl new file mode 100644 index 0000000000000000000000000000000000000000..23a256976b7b91339d4ac691dd4c771551d1838e --- /dev/null +++ b/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. diff --git a/internal/agent/tools/grep.go b/internal/agent/tools/grep.go index 9b75d400c7d0c1dadbb529ddf49e3301e4892d31..eab649181af705eaca0e6ed6c39007893b164760 100644 --- a/internal/agent/tools/grep.go +++ b/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 diff --git a/internal/agent/tools/grep.md b/internal/agent/tools/grep.md deleted file mode 100644 index 057a20c6ca529455c45c902830af5b11a2726962..0000000000000000000000000000000000000000 --- a/internal/agent/tools/grep.md +++ /dev/null @@ -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. \ No newline at end of file diff --git a/internal/agent/tools/grep.md.tpl b/internal/agent/tools/grep.md.tpl new file mode 100644 index 0000000000000000000000000000000000000000..c1543a929bf2d5f03aa7a87811fc8122e07d7708 --- /dev/null +++ b/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. diff --git a/internal/agent/tools/ls.go b/internal/agent/tools/ls.go index 8bf5f0d414192df05bfa26713295a57fe45e6ae9..750e5797836b5efd884d9fe47b039ab4e00cc95a 100644 --- a/internal/agent/tools/ls.go +++ b/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 { diff --git a/internal/agent/tools/ls.md b/internal/agent/tools/ls.md deleted file mode 100644 index 4ff5001acf0ec4619fce6467d05da70c47cc58ac..0000000000000000000000000000000000000000 --- a/internal/agent/tools/ls.md +++ /dev/null @@ -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. \ No newline at end of file diff --git a/internal/agent/tools/ls.md.tpl b/internal/agent/tools/ls.md.tpl new file mode 100644 index 0000000000000000000000000000000000000000..7fa4c41be37987aefc79bc041364eef81801ef3c --- /dev/null +++ b/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. diff --git a/internal/agent/tools/sourcegraph.go b/internal/agent/tools/sourcegraph.go index 041503ce65d4bedf3245468d155147a372ab9cf6..ee7de67a6674ade83f19f2a78badda3e1c16bde2 100644 --- a/internal/agent/tools/sourcegraph.go +++ b/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 diff --git a/internal/agent/tools/sourcegraph.md b/internal/agent/tools/sourcegraph.md.tpl similarity index 68% rename from internal/agent/tools/sourcegraph.md rename to internal/agent/tools/sourcegraph.md.tpl index bbfd67d7fdfd12bd7eae9de252fc394f3062cbe5..693306874173efe130320c63bbfa24f2cdd2fad2 100644 --- a/internal/agent/tools/sourcegraph.md +++ b/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. \ No newline at end of file +Search code across public GitHub repositories via Sourcegraph; supports regex, language/repo/file filters, and symbol search (max {{ .MaxResults }} results). Only searches public repos. diff --git a/internal/agent/tools/tools.go b/internal/agent/tools/tools.go index a18555a45f48294555179aac8c5f029fc0d39393..bf9eb179cc8320679fa42bf7a85b78e9a547cc5f 100644 --- a/internal/agent/tools/tools.go +++ b/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() +} diff --git a/internal/agent/tools/view.go b/internal/agent/tools/view.go index fef294cecf17efb28d69fe3a9c3b1e8d65de11f2..bc16b0def26ac433641632a9db9bd0ca4dbe4fca 100644 --- a/internal/agent/tools/view.go +++ b/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 diff --git a/internal/agent/tools/view.md b/internal/agent/tools/view.md deleted file mode 100644 index 6d616d661146fc3d17e2267a6ad3a9f73f807add..0000000000000000000000000000000000000000 --- a/internal/agent/tools/view.md +++ /dev/null @@ -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. diff --git a/internal/agent/tools/view.md.tpl b/internal/agent/tools/view.md.tpl new file mode 100644 index 0000000000000000000000000000000000000000..ff2d84022c5d98ec711701f77f9ff9b16b505b4d --- /dev/null +++ b/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.