feat(prompts): template prompts and add github and ripgrep info

Kieran Klukas and Tai Groot created

Co-authored-by: Tai Groot <tai@taigrr.com>

Change summary

internal/agent/tools/bash.go           |  4 +++-
internal/agent/tools/bash.md.tpl       |  5 ++++-
internal/agent/tools/fetch.go          | 12 +++++++++---
internal/agent/tools/fetch.md          |  1 -
internal/agent/tools/fetch.md.tpl      |  2 ++
internal/agent/tools/tools.go          | 26 ++++++++++++++++++++++++++
internal/agent/tools/web_fetch.go      | 12 +++++++++---
internal/agent/tools/web_fetch.md      |  1 -
internal/agent/tools/web_fetch.md.tpl  |  2 ++
internal/agent/tools/web_search.go     | 12 +++++++++---
internal/agent/tools/web_search.md     |  1 -
internal/agent/tools/web_search.md.tpl |  2 ++
12 files changed, 66 insertions(+), 14 deletions(-)

Detailed changes

internal/agent/tools/bash.go 🔗

@@ -53,7 +53,7 @@ const (
 	BashNoOutput               = "no output"
 )
 
-//go:embed bash.tpl
+//go:embed bash.md.tpl
 var bashDescriptionTmpl []byte
 
 var bashDescriptionTpl = template.Must(
@@ -66,6 +66,7 @@ type bashDescriptionData struct {
 	MaxOutputLength int
 	Attribution     config.Attribution
 	ModelID         string
+	RgAvailable     bool
 }
 
 var bannedCommands = []string{
@@ -149,6 +150,7 @@ func bashDescription(attribution *config.Attribution, modelID string) string {
 		MaxOutputLength: MaxOutputLength,
 		Attribution:     *attribution,
 		ModelID:         modelID,
+		RgAvailable:     getRg() != "",
 	}); err != nil {
 		// this should never happen.
 		panic("failed to execute bash description template: " + err.Error())

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

@@ -21,6 +21,9 @@ Common shell builtins and core utils available on Windows.
 - Chain with ';' or '&&', avoid newlines except in quoted strings
 - Each command runs in independent shell (no state persistence between calls)
 - Prefer absolute paths over 'cd' (use 'cd' only if user explicitly requests)
+{{- if .RgAvailable }}
+- Ripgrep (`rg`) is available; prefer it over `grep` for faster, more intuitive searching
+{{- end }}
 </usage_notes>
 
 <background_execution>
@@ -82,7 +85,7 @@ When user asks to create git commit:
 
 6. Run git status to verify.
 
-Notes: Use "git commit -am" when possible, don't stage unrelated files, NEVER update config, don't push, no -i flags, no empty commits, return empty response.
+Notes: Use "git commit -am" when possible, don't stage unrelated files, NEVER update config, don't push, no -i flags, no empty commits, return empty response, when rebasing always use -m or GIT_EDITOR=true.
 </git_commits>
 
 <pull_requests>

internal/agent/tools/fetch.go 🔗

@@ -4,6 +4,7 @@ import (
 	"context"
 	_ "embed"
 	"fmt"
+	"html/template"
 	"io"
 	"net/http"
 	"strings"
@@ -21,8 +22,13 @@ const (
 	MaxFetchSize  = 100 * 1024 // 100KB
 )
 
-//go:embed fetch.md
-var fetchDescription string
+//go:embed fetch.md.tpl
+var fetchDescriptionTmpl []byte
+
+var fetchDescriptionTpl = template.Must(
+	template.New("fetchDescription").
+		Parse(string(fetchDescriptionTmpl)),
+)
 
 func NewFetchTool(permissions permission.Service, workingDir string, client *http.Client) fantasy.AgentTool {
 	if client == nil {
@@ -39,7 +45,7 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt
 
 	return fantasy.NewParallelAgentTool(
 		FetchToolName,
-		fetchDescription,
+		renderToolDescription(fetchDescriptionTpl),
 		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 🔗

@@ -1 +0,0 @@
-Fetch raw content from a URL as text, markdown, or html (max 100KB); no AI processing. For analysis or extraction use agentic_fetch.

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

@@ -0,0 +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.
+{{- 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/tools.go 🔗

@@ -1,7 +1,10 @@
 package tools
 
 import (
+	"bytes"
 	"context"
+	"html/template"
+	"os/exec"
 
 	"charm.land/fantasy"
 )
@@ -64,3 +67,26 @@ func NewPermissionDeniedResponse() fantasy.ToolResponse {
 	resp.StopTurn = true
 	return resp
 }
+
+// ghAvailable indicates whether the `gh` CLI is available on PATH.
+var ghAvailable = func() bool {
+	_, err := exec.LookPath("gh")
+	return err == nil
+}()
+
+// toolDescriptionData is the common data structure for tool description templates.
+type toolDescriptionData struct {
+	GhAvailable bool
+}
+
+// renderToolDescription renders a tool description template with the given data.
+func renderToolDescription(tmpl *template.Template) string {
+	data := toolDescriptionData{
+		GhAvailable: ghAvailable,
+	}
+	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/web_fetch.go 🔗

@@ -4,6 +4,7 @@ import (
 	"context"
 	_ "embed"
 	"fmt"
+	"html/template"
 	"net/http"
 	"os"
 	"strings"
@@ -12,8 +13,13 @@ import (
 	"charm.land/fantasy"
 )
 
-//go:embed web_fetch.md
-var webFetchToolDescription string
+//go:embed web_fetch.md.tpl
+var webFetchDescriptionTmpl []byte
+
+var webFetchDescriptionTpl = template.Must(
+	template.New("webFetchDescription").
+		Parse(string(webFetchDescriptionTmpl)),
+)
 
 // NewWebFetchTool creates a simple web fetch tool for sub-agents (no permissions needed).
 func NewWebFetchTool(workingDir string, client *http.Client) fantasy.AgentTool {
@@ -31,7 +37,7 @@ func NewWebFetchTool(workingDir string, client *http.Client) fantasy.AgentTool {
 
 	return fantasy.NewParallelAgentTool(
 		WebFetchToolName,
-		webFetchToolDescription,
+		renderToolDescription(webFetchDescriptionTpl),
 		func(ctx context.Context, params WebFetchParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
 			if params.URL == "" {
 				return fantasy.NewTextErrorResponse("url is required"), nil

internal/agent/tools/web_fetch.md 🔗

@@ -1 +0,0 @@
-Fetch a web URL and return content as markdown; for use inside sub-agents. Large pages (>50KB) are saved to a temp file for grep/view.

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

@@ -0,0 +1,2 @@
+Fetch a web URL and return content as markdown; for use inside sub-agents. Large pages (>50KB) are saved to a temp file for grep/view.
+{{- 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/web_search.go 🔗

@@ -3,6 +3,7 @@ package tools
 import (
 	"context"
 	_ "embed"
+	"html/template"
 	"log/slog"
 	"net/http"
 	"time"
@@ -10,8 +11,13 @@ import (
 	"charm.land/fantasy"
 )
 
-//go:embed web_search.md
-var webSearchToolDescription string
+//go:embed web_search.md.tpl
+var webSearchDescriptionTmpl []byte
+
+var webSearchDescriptionTpl = template.Must(
+	template.New("webSearchDescription").
+		Parse(string(webSearchDescriptionTmpl)),
+)
 
 // NewWebSearchTool creates a web search tool for sub-agents (no permissions needed).
 func NewWebSearchTool(client *http.Client) fantasy.AgentTool {
@@ -29,7 +35,7 @@ func NewWebSearchTool(client *http.Client) fantasy.AgentTool {
 
 	return fantasy.NewParallelAgentTool(
 		WebSearchToolName,
-		webSearchToolDescription,
+		renderToolDescription(webSearchDescriptionTpl),
 		func(ctx context.Context, params WebSearchParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
 			if params.Query == "" {
 				return fantasy.NewTextErrorResponse("query is required"), nil

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

@@ -0,0 +1,2 @@
+Search the web via DuckDuckGo; returns titles, URLs, and snippets. Follow up with web_fetch to get full page content.
+{{- if .GhAvailable }} For GitHub searches when an exact repo name, issue, or link is provided, use `gh search` in bash instead.{{- end }}