From 56d3c16059bfe39d006a9d79f3e44c953fddeadc Mon Sep 17 00:00:00 2001 From: Yu Yi Date: Tue, 9 Jun 2026 19:14:04 -0400 Subject: [PATCH] providers, tools, cmd/glue: capability registry + tool-owned prompt snippets (closes #345) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit providers.Capabilities on Factory declares harness-relevant facts per provider — context window, parallel-tool safety, prompt variant, auto-continue proneness — looked up via providers.CapabilitiesFor. gemini/codex declare terse-variant frontier capabilities (gemini also AutoContinue); nvidia/openrouter declare conservative open-weight floors. cmd/glue's hard-coded gemini auto-continue check now reads the registry. Tools own their prompt text (pi's pattern): ToolSpec gains PromptSnippet and PromptGuidelines, set where each tool is constructed (tools/fs, tools/shell, tools/git). The new coding.SystemPrompt(tools, variant) assembles a system prompt from the active toolset — one line per tool, deduplicated guidelines — in two variants: terse for frontier models, explicit numbered-workflow for open-weight models. glue run/serve/goal --coding previously sent NO system prompt; they now send the assembled one via capabilityDefaults, so the prompt tracks the real toolset (--tools allowlists shrink it automatically) and can never drift (snapshot-tested). Sequenced last per the roadmap (P1.5) so the registry fields reflect what #338–#344 actually needed. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 19 +++++++ cmd/glue/goalcmd.go | 15 +++-- cmd/glue/main.go | 35 ++++++++---- cmd/glue/run.go | 18 ++++-- cmd/glue/serve.go | 1 + loop/types.go | 11 ++++ providers/codex/codex.go | 6 ++ providers/gemini/gemini.go | 9 +++ providers/nvidia/nvidia.go | 7 +++ providers/openrouter/openrouter.go | 5 ++ providers/registry.go | 38 +++++++++++++ providers/registry_test.go | 18 ++++++ tools/coding/prompt.go | 67 ++++++++++++++++++++++ tools/coding/prompt_test.go | 90 ++++++++++++++++++++++++++++++ tools/fs/edit.go | 8 ++- tools/fs/nav.go | 18 ++++-- tools/fs/read.go | 8 ++- tools/fs/write.go | 8 ++- tools/git/diff.go | 5 +- tools/git/log.go | 5 +- tools/shell/shell.go | 8 ++- 21 files changed, 357 insertions(+), 42 deletions(-) create mode 100644 tools/coding/prompt.go create mode 100644 tools/coding/prompt_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b1df2b5..8bc990e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,25 @@ and [`agents/peggy/CHANGELOG.md`](agents/peggy/CHANGELOG.md). ## Unreleased +- **Per-model capability registry + tool-owned prompt assembly + (`providers`, `loop`, `tools/*`, `cmd/glue`).** The providers + registry now carries declarative `Capabilities` per provider + (context window, parallel-tool safety, prompt variant, auto-continue + proneness; `providers.CapabilitiesFor(name)`), replacing + if-provider-name switches — the `glue` binary's Gemini auto-continue + gating now reads the registry. Tools own their prompt text: the new + `ToolSpec.PromptSnippet` / `PromptGuidelines` fields (set across + `tools/fs`, `tools/shell`, `tools/git`) feed + `coding.SystemPrompt(tools, variant)`, which assembles a coding + system prompt from the active toolset — one line per tool plus + deduplicated guidelines, in a terse variant for frontier models + (gemini, codex) and an explicit variant for open-weight models. The + `glue` binary previously ran `--coding` with **no** system prompt; + it now gets the assembled one, and the prompt can never drift from + the registered toolset (snapshot-tested) + ([docs/coding-harness-roadmap.md](docs/coding-harness-roadmap.md) + P1.5). Closes #345. + - **Loop guardrails (`loop`).** Two graduated detectors now watch every tool round, on by default (`RunRequest.Guardrails`, zero value = defaults; `Disabled` opts out): repeating the **same tool call with diff --git a/cmd/glue/goalcmd.go b/cmd/glue/goalcmd.go index 945fc25..06e38f6 100644 --- a/cmd/glue/goalcmd.go +++ b/cmd/glue/goalcmd.go @@ -107,13 +107,16 @@ func goalCommand(ctx context.Context, args []string, stdin io.Reader, stdout, st case *coding: permission = newLocalPromptPermission(stdin, stderr) } + systemPrompt, autoContinue := capabilityDefaults(resolvedProvider, tools, *coding) agent := glue.NewAgent(glue.AgentOptions{ - Provider: providerImpl, - Model: normalizeModel(effectiveModel), - Tools: tools, - Store: store, - WorkDir: *workDir, - Permission: permission, + Provider: providerImpl, + Model: normalizeModel(effectiveModel), + Tools: tools, + Store: store, + WorkDir: *workDir, + Permission: permission, + SystemPrompt: systemPrompt, + AutoContinue: autoContinue, }) spec := glue.GoalSpec{ diff --git a/cmd/glue/main.go b/cmd/glue/main.go index 18cca5f..a4e7a2a 100644 --- a/cmd/glue/main.go +++ b/cmd/glue/main.go @@ -27,6 +27,7 @@ import ( "github.com/erain/glue" "github.com/erain/glue/providers" filestore "github.com/erain/glue/stores/file" + toolscoding "github.com/erain/glue/tools/coding" // Register the shipped providers so they resolve through the // providers registry by name (--provider). Importing for side @@ -173,22 +174,32 @@ func newAgent(newProvider providerFactory, cfg agentConfig) (*glue.Agent, error) if err != nil { return nil, err } + systemPrompt, autoContinue := capabilityDefaults(cfg.Provider, cfg.Tools, cfg.Coding) return glue.NewAgent(glue.AgentOptions{ - Provider: provider, - Model: normalizeModel(cfg.Model), - Tools: append([]glue.Tool(nil), cfg.Tools...), - Store: filestore.New(cfg.StoreDir), - WorkDir: cfg.WorkDir, - Permission: cfg.Permission, - // Gemini is prone to the narrate-then-stop stall ("I will now - // edit the file." with no tool call); the loop's bounded - // "Please continue." nudge recovers it. Other providers keep - // the default behavior until a capability registry makes this - // per-model (#345). - AutoContinue: cfg.Provider == "gemini" && len(cfg.Tools) > 0, + Provider: provider, + Model: normalizeModel(cfg.Model), + Tools: append([]glue.Tool(nil), cfg.Tools...), + Store: filestore.New(cfg.StoreDir), + WorkDir: cfg.WorkDir, + Permission: cfg.Permission, + SystemPrompt: systemPrompt, + AutoContinue: autoContinue, }), nil } +// capabilityDefaults derives capability-driven agent settings from the +// providers registry: the coding system prompt is assembled from the +// active toolset in the provider's preferred variant, and the +// narrate-then-stop nudge is enabled only for providers that declare +// the stall (today: gemini). +func capabilityDefaults(providerName string, tools []glue.Tool, coding bool) (systemPrompt string, autoContinue bool) { + caps := providers.CapabilitiesFor(providerName) + if coding && len(tools) > 0 { + systemPrompt = toolscoding.SystemPrompt(tools, caps.PromptVariant) + } + return systemPrompt, caps.AutoContinue && len(tools) > 0 +} + func normalizeModel(model string) string { return strings.TrimPrefix(model, "gemini/") } diff --git a/cmd/glue/run.go b/cmd/glue/run.go index bce7bda..ef93f15 100644 --- a/cmd/glue/run.go +++ b/cmd/glue/run.go @@ -38,6 +38,9 @@ type agentConfig struct { WorkDir string Tools []glue.Tool Permission glue.Permission + // Coding selects the assembled coding system prompt (built from + // the active toolset in the provider's preferred variant). + Coding bool } func runCommand(ctx context.Context, args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer, newProvider providerFactory) error { @@ -190,13 +193,16 @@ func runCommand(ctx context.Context, args []string, stdin io.Reader, stdout io.W return err } storeImpl := filestore.New(*storeDir) + systemPrompt, autoContinue := capabilityDefaults(providerName, tools, *coding) agent := glue.NewAgent(glue.AgentOptions{ - Provider: providerImpl, - Model: normalizeModel(effectiveModel), - Tools: append([]glue.Tool(nil), tools...), - Store: storeImpl, - WorkDir: *workDir, - Permission: permission, + Provider: providerImpl, + Model: normalizeModel(effectiveModel), + Tools: append([]glue.Tool(nil), tools...), + Store: storeImpl, + WorkDir: *workDir, + Permission: permission, + SystemPrompt: systemPrompt, + AutoContinue: autoContinue, }) if interactive { diff --git a/cmd/glue/serve.go b/cmd/glue/serve.go index b476957..f0ac972 100644 --- a/cmd/glue/serve.go +++ b/cmd/glue/serve.go @@ -97,6 +97,7 @@ func serveCommand(ctx context.Context, args []string, stdout io.Writer, stderr i StoreDir: *storeDir, WorkDir: *workDir, Tools: tools, + Coding: *coding, }) if err != nil { return err diff --git a/loop/types.go b/loop/types.go index b96216f..e894571 100644 --- a/loop/types.go +++ b/loop/types.go @@ -105,6 +105,17 @@ type ToolSpec struct { RequiresPermission bool `json:"-"` PermissionAction string `json:"-"` PermissionTarget func(ToolCall) string `json:"-"` + + // PromptSnippet is a one-line description of the tool for system + // prompts assembled from the active toolset (the pi pattern: the + // tool owns its own prompt text, so prompt and toolset cannot + // drift). Empty means the tool is omitted from assembled prompts. + PromptSnippet string `json:"-"` + + // PromptGuidelines are usage rules contributed to an assembled + // system prompt only while this tool is registered. Deduplicated + // across tools at assembly time. + PromptGuidelines []string `json:"-"` } // ToolExecutor runs a tool call locally and returns a normalized result. diff --git a/providers/codex/codex.go b/providers/codex/codex.go index e9e0f0b..12e1a07 100644 --- a/providers/codex/codex.go +++ b/providers/codex/codex.go @@ -45,6 +45,12 @@ func init() { // No env key: subscription auth lives in auth.json, not an env // var. providers.KeyAvailable("codex") will always report false // — agents should probe auth.LoadTokens instead. + Capabilities: providers.Capabilities{ + // gpt-5-codex: 400k window, frontier model, terse steering. + ContextWindow: 400_000, + ParallelTools: true, + PromptVariant: "terse", + }, }) } diff --git a/providers/gemini/gemini.go b/providers/gemini/gemini.go index 8986e67..6987a3d 100644 --- a/providers/gemini/gemini.go +++ b/providers/gemini/gemini.go @@ -46,6 +46,15 @@ func init() { New: func() loop.Provider { return New(Options{}) }, DefaultModel: DefaultModel, EnvKey: EnvKey, + Capabilities: providers.Capabilities{ + // Gemini 3.x Pro: 1M-token window; frontier model that + // prefers terse steering; prone to the narrate-then-stop + // stall the loop's AutoContinue nudge recovers. + ContextWindow: 1_048_576, + ParallelTools: true, + PromptVariant: "terse", + AutoContinue: true, + }, }) } diff --git a/providers/nvidia/nvidia.go b/providers/nvidia/nvidia.go index 94e06f6..07c5a23 100644 --- a/providers/nvidia/nvidia.go +++ b/providers/nvidia/nvidia.go @@ -29,6 +29,13 @@ func init() { New: func() loop.Provider { return New(Options{}) }, DefaultModel: DefaultModel, EnvKey: EnvKey, + Capabilities: providers.Capabilities{ + // Open-weight hosting; window varies by model — 128k is a + // safe floor. Default (explicit) prompt variant; sequential + // tools: open-weight function calling is less reliable + // under concurrency. + ContextWindow: 131_072, + }, }) } diff --git a/providers/openrouter/openrouter.go b/providers/openrouter/openrouter.go index cc85b57..b961686 100644 --- a/providers/openrouter/openrouter.go +++ b/providers/openrouter/openrouter.go @@ -38,6 +38,11 @@ func init() { New: func() loop.Provider { return New(Options{}) }, DefaultModel: DefaultModel, EnvKey: EnvKey, + Capabilities: providers.Capabilities{ + // Aggregator of mostly open-weight models; window varies — + // 128k is a safe floor. Default (explicit) prompt variant. + ContextWindow: 131_072, + }, }) } diff --git a/providers/registry.go b/providers/registry.go index 78ee019..046b313 100644 --- a/providers/registry.go +++ b/providers/registry.go @@ -19,6 +19,30 @@ import ( "github.com/erain/glue/loop" ) +// Capabilities records harness-relevant facts about a provider's +// models, declared at registration instead of scattered through +// if-provider-name switches. The zero value means "unknown / assume +// nothing": consumers must treat absent capabilities conservatively. +type Capabilities struct { + // ContextWindow is the default model's context window in tokens. + // Zero means unknown. + ContextWindow int + + // ParallelTools reports whether tool calls from one assistant turn + // are safe to execute concurrently against this provider's models. + ParallelTools bool + + // PromptVariant selects the system-prompt flavor assembled for + // this provider's models: "terse" for frontier models that need + // minimal steering, "" for the default (more explicit) variant. + PromptVariant string + + // AutoContinue reports that the provider's models are prone to the + // narrate-then-stop stall and benefit from the loop's bounded + // "Please continue." nudge. + AutoContinue bool +} + // Factory describes one registered provider. type Factory struct { // New returns a fresh provider configured with package defaults @@ -33,6 +57,20 @@ type Factory struct { // EnvKey is the environment variable the provider reads when // APIKey is empty. Used by KeyAvailable. EnvKey string + + // Capabilities declares harness-relevant facts about the + // provider's models. Optional; the zero value means unknown. + Capabilities Capabilities +} + +// CapabilitiesFor returns the registered capabilities for name, or the +// zero value when the provider is unknown or declared none. +func CapabilitiesFor(name string) Capabilities { + f, ok := Lookup(name) + if !ok { + return Capabilities{} + } + return f.Capabilities } var ( diff --git a/providers/registry_test.go b/providers/registry_test.go index a0cbd29..b4e447a 100644 --- a/providers/registry_test.go +++ b/providers/registry_test.go @@ -88,3 +88,21 @@ func TestRegistry_CaseInsensitive(t *testing.T) { t.Fatal("Lookup must be case-insensitive on uppercase too") } } + +func TestCapabilitiesForUnknownProvider(t *testing.T) { + caps := CapabilitiesFor("no-such-provider") + if caps != (Capabilities{}) { + t.Fatalf("caps = %#v, want zero value", caps) + } +} + +func TestCapabilitiesForRegistered(t *testing.T) { + Register("caps-test", Factory{ + DefaultModel: "m", + Capabilities: Capabilities{ContextWindow: 42, ParallelTools: true, PromptVariant: "terse", AutoContinue: true}, + }) + caps := CapabilitiesFor("caps-test") + if caps.ContextWindow != 42 || !caps.ParallelTools || caps.PromptVariant != "terse" || !caps.AutoContinue { + t.Fatalf("caps = %#v", caps) + } +} diff --git a/tools/coding/prompt.go b/tools/coding/prompt.go new file mode 100644 index 0000000..6e9686b --- /dev/null +++ b/tools/coding/prompt.go @@ -0,0 +1,67 @@ +package coding + +import ( + "strings" + + "github.com/erain/glue" +) + +// PromptVariantTerse selects the minimal system-prompt flavor for +// frontier models that need little steering. The empty string selects +// the default (more explicit) variant for open-weight models. +const PromptVariantTerse = "terse" + +const terseIntro = `You are a coding agent operating directly in the user's workspace. Make the requested changes using the tools below, verify them (build/tests) when possible, and keep responses concise.` + +const defaultIntro = `You are a coding agent operating directly in the user's workspace. Work in small, verifiable steps: + +1. Read the relevant files before changing them. +2. Make focused edits with the editing tools — do not echo whole files into chat. +3. Verify your changes by running builds or tests when a shell tool is available. +4. Report what you changed and how you verified it, concisely. + +Use one tool call when one suffices; stop and ask only when genuinely blocked.` + +// SystemPrompt assembles a coding system prompt from the active +// toolset: one line per tool (from each tool's PromptSnippet) plus the +// deduplicated union of their PromptGuidelines. The prompt therefore +// can never drift from the tools actually registered — pi's +// tool-owned-prompt pattern. Tools without a snippet are omitted. +// +// variant selects the intro flavor: [PromptVariantTerse] for frontier +// models, "" for the default explicit variant (open-weight models +// benefit from the extra structure). Pick via +// providers.CapabilitiesFor(name).PromptVariant. +func SystemPrompt(tools []glue.Tool, variant string) string { + var b strings.Builder + if variant == PromptVariantTerse { + b.WriteString(terseIntro) + } else { + b.WriteString(defaultIntro) + } + + var lines []string + var guidelines []string + seen := map[string]bool{} + for _, t := range tools { + if t.PromptSnippet != "" { + lines = append(lines, "- "+t.Name+": "+t.PromptSnippet) + } + for _, g := range t.PromptGuidelines { + if g == "" || seen[g] { + continue + } + seen[g] = true + guidelines = append(guidelines, "- "+g) + } + } + if len(lines) > 0 { + b.WriteString("\n\nTools:\n") + b.WriteString(strings.Join(lines, "\n")) + } + if len(guidelines) > 0 { + b.WriteString("\n\nGuidelines:\n") + b.WriteString(strings.Join(guidelines, "\n")) + } + return b.String() +} diff --git a/tools/coding/prompt_test.go b/tools/coding/prompt_test.go new file mode 100644 index 0000000..ef7da61 --- /dev/null +++ b/tools/coding/prompt_test.go @@ -0,0 +1,90 @@ +package coding + +import ( + "strings" + "testing" + + "github.com/erain/glue" +) + +func bundleForPromptTest(t *testing.T) []glue.Tool { + t.Helper() + tools, _, err := Tools(Options{Enabled: true, WorkDir: t.TempDir(), AllowedBinaries: []string{"go"}}) + if err != nil { + t.Fatalf("Tools: %v", err) + } + return tools +} + +// Snapshot tests pin the assembled prompts: a tool rename, a dropped +// snippet, or accidental variant drift fails loudly here instead of +// silently degrading the agent. +func TestSystemPromptDefaultSnapshot(t *testing.T) { + t.Parallel() + got := SystemPrompt(bundleForPromptTest(t), "") + for _, want := range []string{ + "You are a coding agent operating directly in the user's workspace.", + "Work in small, verifiable steps", + "Tools:\n", + "- read_file: Read a file (line-offset paging for large files)", + "- edit_file: Make a surgical string replacement in an existing file", + "- write_file: Create a new file (or overwrite when allowed)", + "- list_dir: List a directory's entries", + "- find_files: Find files by name glob", + "- grep: Search file contents by regex", + "- shell_exec: Run an allowlisted command (argv-style, no shell)", + "Guidelines:\n", + "- Use read_file to examine files instead of shell cat/sed.", + "- Prefer edit_file for changing existing files; write_file is for new files or full rewrites.", + "- Navigate with grep/find_files/list_dir instead of shell find or ls.", + "- Use shell_exec for builds and tests; long output is kept head+tail with the full stream spooled to a named temp file.", + } { + if !strings.Contains(got, want) { + t.Errorf("default prompt missing %q\n--\n%s", want, got) + } + } +} + +func TestSystemPromptTerseSnapshot(t *testing.T) { + t.Parallel() + got := SystemPrompt(bundleForPromptTest(t), PromptVariantTerse) + if !strings.Contains(got, "keep responses concise") { + t.Fatalf("terse intro missing:\n%s", got) + } + if strings.Contains(got, "Work in small, verifiable steps") { + t.Fatalf("terse variant leaked the default intro:\n%s", got) + } + // Tool lines and guidelines are shared across variants. + if !strings.Contains(got, "- edit_file:") || !strings.Contains(got, "Guidelines:") { + t.Fatalf("terse prompt lost the toolset sections:\n%s", got) + } + if len(got) >= len(SystemPrompt(bundleForPromptTest(t), "")) { + t.Fatal("terse prompt should be shorter than the default") + } +} + +func TestSystemPromptTracksToolset(t *testing.T) { + t.Parallel() + all := bundleForPromptTest(t) + var readOnly []glue.Tool + for _, tool := range all { + if tool.Name == "read_file" || tool.Name == "grep" { + readOnly = append(readOnly, tool) + } + } + got := SystemPrompt(readOnly, "") + if strings.Contains(got, "edit_file") || strings.Contains(got, "shell_exec") { + t.Fatalf("prompt mentions tools that are not registered:\n%s", got) + } + if !strings.Contains(got, "- read_file:") { + t.Fatalf("prompt missing registered tool:\n%s", got) + } +} + +func TestSystemPromptNoToolsNoSections(t *testing.T) { + t.Parallel() + got := SystemPrompt(nil, "") + if strings.Contains(got, "Tools:") || strings.Contains(got, "Guidelines:") { + t.Fatalf("empty toolset must not render sections:\n%s", got) + } +} diff --git a/tools/fs/edit.go b/tools/fs/edit.go index a65b9c6..f32ee9d 100644 --- a/tools/fs/edit.go +++ b/tools/fs/edit.go @@ -71,8 +71,12 @@ func FileEdit(opts EditFileOptions) (glue.Tool, error) { return glue.NewTool[fileEditArgs]( glue.ToolSpec{ - Name: "edit_file", - Description: "Replace a string in an existing UTF-8 text file inside the configured workspace. Requires permission. old_string should match the file exactly; small whitespace, indentation, or quote/dash differences are repaired automatically and reported. old_string must match exactly once unless replace_all is set. The result echoes the updated lines — base follow-up edits on them instead of re-reading the file.", + Name: "edit_file", + Description: "Replace a string in an existing UTF-8 text file inside the configured workspace. Requires permission. old_string should match the file exactly; small whitespace, indentation, or quote/dash differences are repaired automatically and reported. old_string must match exactly once unless replace_all is set. The result echoes the updated lines — base follow-up edits on them instead of re-reading the file.", + PromptSnippet: "Make a surgical string replacement in an existing file", + PromptGuidelines: []string{ + "Keep edit_file old_string as small as possible while still unique; base follow-up edits on the updated lines echoed in the result.", + }, RequiresPermission: true, PermissionAction: "edit_file", PermissionTarget: fileEditPermissionTarget, diff --git a/tools/fs/nav.go b/tools/fs/nav.go index fafeee5..a541db4 100644 --- a/tools/fs/nav.go +++ b/tools/fs/nav.go @@ -102,8 +102,9 @@ func ListDirTool(opts NavOptions) (glue.Tool, error) { return glue.NewTool[listDirArgs]( glue.ToolSpec{ - Name: "list_dir", - Description: "List the immediate entries of a directory inside the workspace (non-recursive). Read-only.", + Name: "list_dir", + Description: "List the immediate entries of a directory inside the workspace (non-recursive). Read-only.", + PromptSnippet: "List a directory's entries", Parameters: json.RawMessage(`{ "type": "object", "properties": { @@ -180,8 +181,9 @@ func FindTool(opts NavOptions) (glue.Tool, error) { return glue.NewTool[findFilesArgs]( glue.ToolSpec{ - Name: "find_files", - Description: "Recursively find files whose name matches a glob pattern (e.g. *.go) under a workspace directory. Returns workspace-relative paths. Read-only; skips .git.", + Name: "find_files", + Description: "Recursively find files whose name matches a glob pattern (e.g. *.go) under a workspace directory. Returns workspace-relative paths. Read-only; skips .git.", + PromptSnippet: "Find files by name glob", Parameters: json.RawMessage(`{ "type": "object", "properties": { @@ -275,8 +277,12 @@ func GrepTool(opts NavOptions) (glue.Tool, error) { return glue.NewTool[grepArgs]( glue.ToolSpec{ - Name: "grep", - Description: "Recursively search file contents for a regular expression (RE2) under a workspace directory. Returns path:line:text matches. Read-only; skips .git, secret-shaped files, and files over the size ceiling.", + Name: "grep", + Description: "Recursively search file contents for a regular expression (RE2) under a workspace directory. Returns path:line:text matches. Read-only; skips .git, secret-shaped files, and files over the size ceiling.", + PromptSnippet: "Search file contents by regex", + PromptGuidelines: []string{ + "Navigate with grep/find_files/list_dir instead of shell find or ls.", + }, Parameters: json.RawMessage(`{ "type": "object", "properties": { diff --git a/tools/fs/read.go b/tools/fs/read.go index 831df01..0c30594 100644 --- a/tools/fs/read.go +++ b/tools/fs/read.go @@ -63,8 +63,12 @@ func ReadFileTool(opts ReadFileOptions) glue.Tool { return glue.NewTool[readFileArgs]( glue.ToolSpec{ - Name: "read_file", - Description: fmt.Sprintf("Read a UTF-8 text file from the working directory. Returns at most max_lines lines (default %d) and max_bytes bytes (default %d), whichever cap hits first; truncated reads say how to continue with offset. Refuses to open secret-shaped files (.env, id_rsa, *.pem, credentials.json, etc.).", DefaultReadMaxLines, DefaultReadMaxBytes), + Name: "read_file", + Description: fmt.Sprintf("Read a UTF-8 text file from the working directory. Returns at most max_lines lines (default %d) and max_bytes bytes (default %d), whichever cap hits first; truncated reads say how to continue with offset. Refuses to open secret-shaped files (.env, id_rsa, *.pem, credentials.json, etc.).", DefaultReadMaxLines, DefaultReadMaxBytes), + PromptSnippet: "Read a file (line-offset paging for large files)", + PromptGuidelines: []string{ + "Use read_file to examine files instead of shell cat/sed.", + }, Parameters: json.RawMessage(fmt.Sprintf(`{ "type": "object", "properties": { diff --git a/tools/fs/write.go b/tools/fs/write.go index 7dcc0d8..09de343 100644 --- a/tools/fs/write.go +++ b/tools/fs/write.go @@ -71,8 +71,12 @@ func FileWrite(opts FileWriteOptions) (glue.Tool, error) { return glue.NewTool[fileWriteArgs]( glue.ToolSpec{ - Name: "write_file", - Description: "Write UTF-8 text to a file inside the configured workspace. Requires permission. Refuses path escape, symlink escape, oversized content, and overwrites unless explicitly allowed.", + Name: "write_file", + Description: "Write UTF-8 text to a file inside the configured workspace. Requires permission. Refuses path escape, symlink escape, oversized content, and overwrites unless explicitly allowed.", + PromptSnippet: "Create a new file (or overwrite when allowed)", + PromptGuidelines: []string{ + "Prefer edit_file for changing existing files; write_file is for new files or full rewrites.", + }, RequiresPermission: true, PermissionAction: "write_file", PermissionTarget: fileWritePermissionTarget, diff --git a/tools/git/diff.go b/tools/git/diff.go index b9d51eb..28e3be3 100644 --- a/tools/git/diff.go +++ b/tools/git/diff.go @@ -58,8 +58,9 @@ func DiffBranchTool(opts DiffBranchOptions) glue.Tool { return glue.NewTool[diffBranchArgs]( glue.ToolSpec{ - Name: "git_diff_branch", - Description: "Show the diff of the current branch versus a base ref (default 'main'). Includes file additions, deletions, and modifications. The diff may be pre-filtered by deployment-supplied path globs — only files in scope appear. Use this first to scope the review.", + Name: "git_diff_branch", + Description: "Show the diff of the current branch versus a base ref (default 'main'). Includes file additions, deletions, and modifications. The diff may be pre-filtered by deployment-supplied path globs — only files in scope appear. Use this first to scope the review.", + PromptSnippet: "Show the branch diff vs a base ref", Parameters: json.RawMessage(`{ "type": "object", "properties": { diff --git a/tools/git/log.go b/tools/git/log.go index 541e8c7..922fc15 100644 --- a/tools/git/log.go +++ b/tools/git/log.go @@ -53,8 +53,9 @@ func LogBranchTool(opts LogBranchOptions) glue.Tool { return glue.NewTool[logBranchArgs]( glue.ToolSpec{ - Name: "git_log_branch", - Description: "Show the commit history of the current branch since a base ref (default 'main'). Useful for reading commit messages to understand author intent.", + Name: "git_log_branch", + Description: "Show the commit history of the current branch since a base ref (default 'main'). Useful for reading commit messages to understand author intent.", + PromptSnippet: "Show branch commit history vs a base ref", Parameters: json.RawMessage(`{ "type": "object", "properties": { diff --git a/tools/shell/shell.go b/tools/shell/shell.go index e3299d1..8d1bb50 100644 --- a/tools/shell/shell.go +++ b/tools/shell/shell.go @@ -98,8 +98,12 @@ func Exec(opts ExecOptions) (glue.Tool, error) { return glue.NewTool[execArgs]( glue.ToolSpec{ - Name: ToolName, - Description: "Run a bounded argv-style command in the configured workspace. Requires permission. Commands are not run through a shell; argv[0] must be an allowed binary basename.", + Name: ToolName, + Description: "Run a bounded argv-style command in the configured workspace. Requires permission. Commands are not run through a shell; argv[0] must be an allowed binary basename.", + PromptSnippet: "Run an allowlisted command (argv-style, no shell)", + PromptGuidelines: []string{ + "Use shell_exec for builds and tests; long output is kept head+tail with the full stream spooled to a named temp file.", + }, RequiresPermission: true, PermissionAction: "exec", PermissionTarget: permissionTarget,