From b765657f28e292f3f66304d2293611563a67a69c Mon Sep 17 00:00:00 2001 From: Christian Stewart Date: Sun, 28 Jun 2026 01:30:51 -0700 Subject: [PATCH 1/5] fix(prompting): hide eval guidance when disabled Stop advertising eval in the default prompt and workflow notice when no eval backend is enabled. Gate bash guidance on live eval backend availability and cover the disabled-backend rendering contract. Agent-Milestone: tooling: hide eval prompt guidance when eval backends are disabled Signed-off-by: Christian Stewart --- .../src/prompts/system/system-prompt.md | 3 +- .../src/prompts/system/workflow-notice.md | 10 ++-- .../coding-agent/src/prompts/tools/bash.md | 17 +++++-- packages/coding-agent/src/system-prompt.ts | 2 +- packages/coding-agent/src/tools/bash.ts | 3 ++ .../test/system-prompt-inventory.test.ts | 50 ++++++++++++++++++- 6 files changed, 73 insertions(+), 12 deletions(-) diff --git a/packages/coding-agent/src/prompts/system/system-prompt.md b/packages/coding-agent/src/prompts/system/system-prompt.md index e852b653a..e90d57c5e 100644 --- a/packages/coding-agent/src/prompts/system/system-prompt.md +++ b/packages/coding-agent/src/prompts/system/system-prompt.md @@ -110,9 +110,8 @@ You MUST use the specialized tool over its shell equivalent: {{#has tools "lsp"}}- Code intelligence → `{{toolRefs.lsp}}`.{{/has}} {{#has tools "grep"}}- Regex search → `{{toolRefs.grep}}`, not `grep`, `rg`, or `awk`.{{/has}} {{#has tools "glob"}}- Globbing → `{{toolRefs.glob}}`, not `ls **/*.ext` or `fd`.{{/has}} -{{#has tools "eval"}}- Default for any compute: `{{toolRefs.eval}}` cells. Bash is the EXCEPTION — only single binary calls or short fact-computing pipelines (`wc -l`, `sort | uniq -c`, `diff`, checksums). The moment a command grows a loop, conditional, heredoc, `-e`/`-c` script, `$(…)` nesting, or >2 pipe stages, it's a program → `{{toolRefs.eval}}`. NEVER write multiline or inline-script bash.{{/has}} {{#has tools "bash"}}- `{{toolRefs.bash}}`: real binaries and short fact pipelines only. Commands shadowing the specialized tools above are blocked.{{/has}} -{{#has tools "bash"}}- Litmus: one external-CLI call or short pipeline returning a count, frequency, set difference, or checksum → bash.{{#has tools "eval"}} Needs control flow, state, or fights shell quoting → `{{toolRefs.eval}}`.{{/has}} Merely moves, pages, or trims bytes a tool can fetch → use the tool.{{/has}} +{{#has tools "bash"}}- Litmus: one external-CLI call or short pipeline returning a count, frequency, set difference, or checksum → bash. Merely moves, pages, or trims bytes a tool can fetch → use the tool.{{/has}} {{#has tools "report_tool_issue"}} diff --git a/packages/coding-agent/src/prompts/system/workflow-notice.md b/packages/coding-agent/src/prompts/system/workflow-notice.md index 4071c9df5..0ed092a44 100644 --- a/packages/coding-agent/src/prompts/system/workflow-notice.md +++ b/packages/coding-agent/src/prompts/system/workflow-notice.md @@ -1,8 +1,8 @@ -The user's message above contains the **workflowz** keyword: drive this task as a deterministic multi-subagent workflow. Author the orchestration as Python in the `eval` tool and fan out subagents — to be comprehensive (decompose and cover in parallel), to be confident (independent perspectives and adversarial checks before you commit), or to take on scale one context can't hold (audits, migrations, broad sweeps). This overrides any default tendency to do the whole task inline when fanning out would be more thorough. +The user's message above contains the **workflowz** keyword: drive this task as a deterministic multi-subagent workflow. Use the `task` tool for batched fan-out — to be comprehensive (decompose and cover in parallel), to be confident (independent perspectives and adversarial checks before you commit), or to take on scale one context can't hold (audits, migrations, broad sweeps). This overrides any default tendency to do the whole task inline when fanning out would be more thorough. -Worth it when the task benefits from decomposition + parallel coverage, or from independent/adversarial cross-checking before you commit. For a quick lookup or single edit, just do it directly — don't spin up agents. Scout inline FIRST (list the files, scope the diff, find the call sites) to discover the work-list, then fan out over it — you don't need to know the shape before the *task*, only before the *fan-out*. Common shapes, each a well-scoped `eval` call you can chain across turns: +Worth it when the task benefits from decomposition + parallel coverage, or from independent/adversarial cross-checking before you commit. For a quick lookup or single edit, just do it directly — don't spin up agents. Scout inline FIRST (list the files, scope the diff, find the call sites) to discover the work-list, then fan out over it — you don't need to know the shape before the *task*, only before the *fan-out*. Common shapes: - **Understand** — parallel readers over subsystems → structured map - **Design** — judge panel of N independent approaches → scored synthesis - **Review** — split into dimensions → find per dimension → adversarially verify each finding @@ -60,11 +60,11 @@ Compose the harness the task calls for: Scale to the ask: "find any bugs" → a few finders, single-vote verify. "thoroughly audit / be comprehensive" → larger finder pool, 3–5-vote adversarial pass, a synthesis stage. - - Decompose the surface first; capture it in `todo` when it spans phases. -- Prefer `schema=` for any agent whose output you branch on. -- After a fan-out returns, YOU own correctness: read the artifacts, run the gate, verify before acting. Subagents do the legwork; they don't get the last word. +- Batch independent subagents in one `task` call when the available `task` schema supports batching; otherwise issue independent task calls in the same assistant turn. +- Give every subagent a narrow target, explicit non-goals, and a concrete return packet. Shared background goes in a `local://` file referenced from each prompt, not pasted repeatedly. +- After fan-out returns, YOU own correctness: read the artifacts, run the gate, verify before acting. Subagents do the legwork; they don't get the last word. - Keep going until the task is closed — a returned fan-out is a step, not a stopping point. diff --git a/packages/coding-agent/src/prompts/tools/bash.md b/packages/coding-agent/src/prompts/tools/bash.md index 04bcca23c..5d4fb8d69 100644 --- a/packages/coding-agent/src/prompts/tools/bash.md +++ b/packages/coding-agent/src/prompts/tools/bash.md @@ -6,14 +6,22 @@ The shell invokes **real binaries** with simple args. It is NOT full GNU Bash. Use bash ONLY for: a single binary call, or one short pipeline that COMPUTES a fact and does not depend on shell-specific regex/quoting (`wc -l`, `sort | uniq -c`, `comm`, `diff`, a checksum, `git status`). -Anything below → `eval` cell, not bash: +{{#if hasEval}}Anything below → `eval` cell, not bash: - Inline interpreter scripts (`-e`/`-c`/`--eval`) when an eval runtime exists for that language - Heredocs (`< - `cwd` sets the working dir, not `cd dir && …` @@ -30,7 +38,10 @@ Anything below → `eval` cell, not bash: -- The embedded shell invokes real binaries with simple args; it is NOT full GNU Bash. Loops, conditionals, heredocs, inline interpreter scripts (`-e`/`-c`/`--eval`) when an eval runtime exists, several piped stages, exact pipeline semantics, or quote/JSON escaping mean you're writing a program → use `eval` cells: restartable, stateful, and free of shell-quoting traps. +{{#if hasEval}}- The embedded shell invokes real binaries with simple args; it is NOT full GNU Bash and NOT a scripting surface. Loops, conditionals, heredocs, inline interpreter scripts (`-e`/`-c`/`--eval`) when an eval runtime exists, several piped stages, exact pipeline semantics, or quote/JSON escaping mean you're writing a program → use `eval` cells: restartable, stateful, and free of shell-quoting traps.{{else}}- The embedded shell invokes real binaries with simple args; it is NOT full GNU Bash and NOT a scripting surface. Loops, conditionals, heredocs, inline interpreter scripts, several piped stages, exact pipeline semantics, or quote/JSON escaping mean you're writing a shell program; use a purpose-built tool or checked-in script instead.{{/if}} +- NEVER shell out to search content or files: `grep/rg` → `grep`. +- NEVER use `ls` or `find` to list or locate files — `ls` → `read` (a directory path lists entries), `find` → the `glob` tool (globbing). This is non-negotiable, even for a single quick listing. +- Avoid head/tail/redirections: stderr already merged; long output auto-truncated, FULL capture kept at `artifact://`. diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index 890e37484..a22d957a0 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -388,7 +388,7 @@ export async function loadSystemPromptFiles(options: LoadContextFilesOptions = { return userLevel?.content ?? null; } -export const DEFAULT_SYSTEM_PROMPT_TOOL_NAMES = ["read", "bash", "eval", "edit", "write"] as const; +export const DEFAULT_SYSTEM_PROMPT_TOOL_NAMES = ["read", "bash", "edit", "write"] as const; export interface SystemPromptToolMetadata { label: string; diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index ad8d8faff..aace6a3bb 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -27,6 +27,7 @@ import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-intera import { checkBashInterception } from "./bash-interceptor"; import { canUseInteractiveBashPty } from "./bash-pty-selection"; import { expandInternalUrls, type InternalUrlExpansionOptions } from "./bash-skill-urls"; +import { resolveEvalBackends } from "./eval-backends"; import { invalidateGithubCacheForBashCommand } from "./gh-cache-invalidation"; import { formatStyledTruncationWarning, @@ -397,6 +398,7 @@ export class BashTool implements AgentTool { return text.slice(inventoryStart, inventoryEnd); } + function makeToolSession(settings: Settings): ToolSession { + return { + cwd: tempDir, + hasUI: false, + getSessionFile: () => null, + getSessionSpawns: () => "*", + settings, + } as ToolSession; + } + it("renders a compact name list only when native tools are active and descriptors stay in schemas", async () => { const text = await render({ nativeTools: true, inlineToolDescriptors: false }); expect(text).toContain("- Read: `read`"); @@ -132,6 +144,42 @@ describe("system prompt tool inventory", () => { } expect(inventory).not.toContain("- `browser`"); expect(inventory).not.toContain("- `task`"); + expect(inventory).not.toContain("- `eval`"); + }); + + it("omits eval prompt guidance when every eval backend is disabled", async () => { + const settings = Settings.isolated({ + "eval.py": false, + "eval.js": false, + "eval.rb": false, + "eval.jl": false, + }); + const session = makeToolSession(settings); + const tools = await createTools(session, ["bash", "eval"]); + const toolNames = tools.map(tool => tool.name); + const bash = tools.find(tool => tool.name === "bash"); + + expect(toolNames).toContain("bash"); + expect(toolNames).not.toContain("eval"); + expect(bash?.description).toContain("purpose-built tool"); + expect(bash?.description).not.toContain("eval` cell"); + expect(bash?.description).not.toContain("use `eval` cells"); + + const { systemPrompt } = await buildSystemPrompt({ + cwd: tempDir, + contextFiles: [], + skills: [], + rules: [], + toolNames, + tools: buildSystemPromptToolMetadata(new Map(tools.map(tool => [tool.name, tool]))), + workspaceTree: { ...EMPTY_TREE, rootPath: tempDir }, + nativeTools: true, + inlineToolDescriptors: true, + }); + const text = systemPrompt.join("\n\n"); + + expect(text).not.toContain("Default for any compute"); + expect(text).not.toContain("use `eval` cells"); }); it("SDK wrapper renders provided tools instead of the fallback inventory", async () => { From 92765fc409a956c520617d6291d2c367d5ded3ac Mon Sep 17 00:00:00 2001 From: Christian Stewart Date: Sun, 5 Jul 2026 16:59:54 -0700 Subject: [PATCH 2/5] fix(prompting): align workflow and bash guidance with active tools Signed-off-by: Christian Stewart --- .../src/prompts/system/workflow-notice.md | 99 +++++++++---------- .../coding-agent/src/prompts/tools/bash.md | 6 +- packages/coding-agent/src/tools/bash.ts | 12 ++- packages/coding-agent/src/tools/index.ts | 10 +- .../coding-agent/test/modes/workflow.test.ts | 5 +- .../test/system-prompt-inventory.test.ts | 3 + 6 files changed, 73 insertions(+), 62 deletions(-) diff --git a/packages/coding-agent/src/prompts/system/workflow-notice.md b/packages/coding-agent/src/prompts/system/workflow-notice.md index 0ed092a44..3a12bb8ce 100644 --- a/packages/coding-agent/src/prompts/system/workflow-notice.md +++ b/packages/coding-agent/src/prompts/system/workflow-notice.md @@ -2,69 +2,66 @@ The user's message above contains the **workflowz** keyword: drive this task as a deterministic multi-subagent workflow. Use the `task` tool for batched fan-out — to be comprehensive (decompose and cover in parallel), to be confident (independent perspectives and adversarial checks before you commit), or to take on scale one context can't hold (audits, migrations, broad sweeps). This overrides any default tendency to do the whole task inline when fanning out would be more thorough. -Worth it when the task benefits from decomposition + parallel coverage, or from independent/adversarial cross-checking before you commit. For a quick lookup or single edit, just do it directly — don't spin up agents. Scout inline FIRST (list the files, scope the diff, find the call sites) to discover the work-list, then fan out over it — you don't need to know the shape before the *task*, only before the *fan-out*. Common shapes: -- **Understand** — parallel readers over subsystems → structured map -- **Design** — judge panel of N independent approaches → scored synthesis -- **Review** — split into dimensions → find per dimension → adversarially verify each finding -- **Research** — multi-modal sweep → deep-read the hits → synthesize -- **Migrate** — discover sites → transform each → verify +Worth it when the task benefits from decomposition + parallel coverage, or from independent/adversarial cross-checking before you commit. For a quick lookup or single edit, just do it directly — don't spin up agents. Scout inline first (list the files, scope the diff, find the call sites) to discover the work list, then fan out over it. Common shapes: +- **Understand** — parallel readers over subsystems → structured map. +- **Design** — independent approaches → scored synthesis. +- **Review** — split dimensions → find per dimension → adversarially verify each finding. +- **Research** — multi-modal sweep → deep-read the hits → synthesize. +- **Migrate** — discover sites → transform each → verify. - -State persists across eval calls, so scout in one call and fan out in the next. Every eval call has: + +Call `task` once per independent fan-out batch. Put shared background in `context`, and put each independent work item in `tasks[]`. Do not emulate batching with shell loops or eval helper APIs. -- `agent(prompt, *, agent="task", model=None, label=None, schema=None, isolated=None, apply=None, merge=None, handle=False)` — run ONE subagent; returns its final text, or the validated object when `schema` (a JSON Schema dict) is given. With `schema` the subagent is forced to emit structured output that is validated for you — branch on the object, not on parsed prose. `agent` picks a discovered agent ("explore", "reviewer", …); `label` names the artifact. Shared background goes in a `local://` file referenced from each prompt, not a parameter. Subagents are told their final text IS the return value, so they hand back raw data. `agent()` blocks until the subagent finishes. Recursion follows `task.maxRecursionDepth` (default 2; `-1` uses eval's hard cap 3): main agent depth = 0, each `agent()` child increments depth by 1, and a spawner may call `agent()` only while its current `taskDepth < effective cap`. Pass `isolated=True` to run the spawn in a copy-on-write worktree so parallel `agent()` calls can edit overlapping files safely — strict opt-in, mirrors the `task` tool, defaults off regardless of `task.isolation.mode`; `isolated=True` while the setting is `"none"` errors out instead of silently downgrading. With isolation, `apply=False` keeps changes in the worktree, and `merge=False` forces patch mode even when the setting is `"branch"`. Captured root patch path, branch name, nested repo patches, and apply summary reach the workflow through `handle=True` — combine it with `apply=False` (or `apply=False, schema=…`) and read `node["patch_path"]`, `node["branch_name"]`, `node["nested_patches"]`, `node["changes_applied"]`, `node["isolation_summary"]` (JS: same keys camelCased) to recover artifacts. -- `parallel(thunks)` — run zero-arg callables concurrently through a bounded pool, preserving input order; returns once all finish. The pool is bounded by the session's `task` concurrency — don't hand-tune it; fan out as wide as the work divides. A thunk that raises propagates — wrap risky work in `try/except` inside the thunk to keep partial results. In a loop, bind each closure's value with a default arg (`lambda d=d: …`) or every thunk captures the last one. -- `pipeline(items, *stages)` — map items through `stages` left-to-right. There is a BARRIER between stages: ALL items clear stage N before stage N+1 begins. Each stage is a one-arg callable; stage 1 gets the original item, later stages get the previous result. Same pool width as `parallel()`. -- `completion(prompt, *, model="default", system=None, schema=None)` — oneshot, stateless model call (no tools, no history). Tiers: "smol", "default", "slow". Cheap classification/scoring inside a fan-out. -- `log(message)` — emit a progress line above the status tree. `phase(title)` — start a phase; the status lines that follow group under it. -- `budget` — `budget.total` (output-token ceiling, or `None` when none is set), `budget.spent()` (tokens spent this turn — main loop + eval subagents), `budget.remaining()` (`math.inf` when total is `None`), `budget.hard` (whether it's enforced). A ceiling is set by the user: `+Nk` in their message is advisory (you self-limit via `budget.remaining()`), `+Nk!` (or Goal Mode) is hard — `agent()` refuses to spawn once spent reaches it. Gate loops on `budget.total` first, since it's `None` when the user set no budget. +`context` must carry the shared contract: -Everything runs INLINE and synchronously inside the eval call — no background mode, no resume, no separate progress app. Each eval call is one well-scoped fan-out; chain several across calls and turns for multi-phase work, reading each result before you decide the next phase. - + # Goal + What the batch accomplishes. + # Constraints + Rules, non-goals, permissions, and verification limits. + # Contract + Shared interfaces, output shape, branch/base assumptions, and coordination rules. + +Each task assignment must be self-contained: + + # Target + Exact files, symbols, subsystem, or evidence surface; explicit non-goals. + # Change + What to inspect or modify, step by step, including APIs and patterns to reuse. + # Acceptance + Observable result, return packet, and local verification. Subagents skip formatters, + linters, and project-wide tests; the parent runs shared proof once. + +Use specific roles (`Storage Reviewer`, `CLI Migrator`, `Security Skeptic`) rather than generic workers. Dispatch at most one layer unless the user grants a named second-layer purpose. + -For independent per-item chains (review → verify, fetch → extract → score), wrap the WHOLE chain in one function and run it with `parallel()` — then each item flows through its own steps without waiting on the others: +Decompose first, then batch the independent leaves: - DIMENSIONS = [{"key": "bugs", "prompt": "…"}, {"key": "perf", "prompt": "…"}] - def review_and_verify(d): - found = agent(d["prompt"], label=f"review:{d['key']}", schema=FINDINGS_SCHEMA) - return parallel([lambda f=f: {**f, "verdict": agent( - f"Refute if you can (default refuted when unsure): {f['title']}", - label=f"verify:{f['file']}", schema=VERDICT_SCHEMA)} for f in found["findings"]]) - phase("Review") - results = parallel([lambda d=d: review_and_verify(d) for d in DIMENSIONS]) - confirmed = [f for group in results for f in group if f["verdict"]["is_real"]] + task( + context: "# Goal\nReview the auth diff...\n# Constraints\nRead-only...\n# Contract\nReturn findings as severity/file/line/fix...", + tasks: [ + { id: "AuthOwner", role: "Auth Storage Reviewer", assignment: "# Target\npackages/ai/src/auth-storage.ts\n# Change\nTrace credential selection...\n# Acceptance\nReturn confirmed findings only..." }, + { id: "PromptOwner", role: "Prompt Contract Reviewer", assignment: "# Target\npackages/coding-agent/src/prompts/**\n# Change\nCheck active-tool guidance...\n# Acceptance\nReturn mismatches and exact prompt lines..." }, + ] + ) -Reach for `pipeline()` only when a stage genuinely needs ALL of the previous stage first — dedup/merge across the whole set, early-exit on zero, or "compare against the other findings" — because its inter-stage barrier makes every item wait for the slowest peer: - - phase("Find") - found = parallel([lambda d=d: agent(d["prompt"], schema=FINDINGS_SCHEMA) for d in DIMENSIONS]) - findings = dedupe([f for r in found for f in r["findings"]]) # needs everything at once - phase("Verify") - verdicts = parallel([lambda f=f: agent(verify_prompt(f), schema=VERDICT_SCHEMA) for f in findings]) - -Don't add a barrier just to flatten/map/filter — do that with plain Python between calls. Nested `parallel()` pools each cap independently, so keep total fan-out sane. +Prefer one wide batch over serial subagent calls when work items do not share files. If tasks overlap, name the overlap and have agents coordinate through IRC before editing. -Compose the harness the task calls for: -- **Adversarial verify** — N independent skeptics per finding, each prompted to REFUTE; keep it only if a majority survive. `votes = parallel([lambda i=i: agent(f"Refute: {claim}. refuted=true if unsure.", schema=VERDICT) for i in range(3)])`, then keep when `sum(not v["refuted"] for v in votes) ≥ 2`. -- **Perspective-diverse verify** — give each verifier a distinct lens (correctness, security, perf, does-it-reproduce) instead of N identical refuters. -- **Judge panel** — N attempts from different angles, scored by parallel judges; synthesize from the winner, graft the best of the rest. -- **Loop-until-dry** — for unknown-size discovery, keep spawning finders until K consecutive rounds surface nothing new; dedup against everything SEEN, not just what was confirmed, or it never converges. -- **Multi-modal sweep** — parallel finders each searching a different way (by-container, by-content, by-entity, by-time), each blind to the others. -- **Completeness critic** — a final agent that asks "what's missing — modality not run, claim unverified, file unread?"; its answer is the next round. -- **Budget/count loops** — `while len(bugs) < 10:` to hit a target, or `while budget.total and budget.remaining() > 50_000:` to scale depth to the turn budget; `log()` each round. -- **No silent caps** — if you bound coverage (top-N, no-retry, sampling), `log()` what you dropped; silent truncation reads as "covered everything" when it didn't. - -Scale to the ask: "find any bugs" → a few finders, single-vote verify. "thoroughly audit / be comprehensive" → larger finder pool, 3–5-vote adversarial pass, a synthesis stage. +- **Adversarial verify** — dispatch skeptical reviewers with distinct targets, then keep only findings the parent can verify against source. +- **Perspective-diverse review** — use separate correctness, security, performance, and maintainability roles instead of identical reviewers. +- **Completeness critic** — after the first batch, dispatch one read-only critic that asks what modality, file, claim, or proof was missed. +- **No silent caps** — if you bound coverage (top-N, no retry, sampling), state what was dropped and why before acting. +- **Parent owns closure** — subagents return evidence; the parent reads it, resolves contradictions, runs proof, and makes the final decision. + -- Decompose the surface first; capture it in `todo` when it spans phases. -- Batch independent subagents in one `task` call when the available `task` schema supports batching; otherwise issue independent task calls in the same assistant turn. -- Give every subagent a narrow target, explicit non-goals, and a concrete return packet. Shared background goes in a `local://` file referenced from each prompt, not pasted repeatedly. -- After fan-out returns, YOU own correctness: read the artifacts, run the gate, verify before acting. Subagents do the legwork; they don't get the last word. -- Keep going until the task is closed — a returned fan-out is a step, not a stopping point. +- Capture multi-phase workflow state in the visible todo system when available. +- Batch independent subagents in one `task` call. +- Give every subagent a narrow target, explicit non-goals, and a concrete return packet. +- After fan-out returns, read the artifacts, patch or decide, and run the shared gate. +- Keep going until the task is closed — returned fan-out is a step, not a stopping point. diff --git a/packages/coding-agent/src/prompts/tools/bash.md b/packages/coding-agent/src/prompts/tools/bash.md index 5d4fb8d69..9af269ac3 100644 --- a/packages/coding-agent/src/prompts/tools/bash.md +++ b/packages/coding-agent/src/prompts/tools/bash.md @@ -21,7 +21,7 @@ Use bash ONLY for: a single binary call, or one short pipeline that COMPUTES a f - Multiline commands, `&&`-chains mixing control flow - Quote/JSON escaping that fights the shell {{/if}} -- GNU grep BRE extensions are not guaranteed in the embedded shell: use `grep -E 'json|tool'` for alternation instead of `grep 'json\|tool'`; use the built-in `grep` tool with `pattern: "json|tool"` (Rust regex, so `\bword\b` works there){{#if hasEval}}, or `eval` for exact text processing{{/if}}. +{{#if hasGrep}}- GNU grep BRE extensions are not guaranteed in the embedded shell: use `grep -E 'json|tool'` for alternation instead of `grep 'json\|tool'`; use the built-in `grep` tool with `pattern: "json|tool"` (Rust regex, so `\bword\b` works there){{#if hasEval}}, or `eval` for exact text processing{{/if}}.{{else}}- GNU grep BRE extensions are not guaranteed in the embedded shell: use `grep -E 'json|tool'` for alternation instead of `grep 'json\|tool'`{{#if hasEval}}, or use `eval` for exact text processing{{/if}}.{{/if}} - `cwd` sets the working dir, not `cd dir && …` @@ -39,8 +39,8 @@ Use bash ONLY for: a single binary call, or one short pipeline that COMPUTES a f {{#if hasEval}}- The embedded shell invokes real binaries with simple args; it is NOT full GNU Bash and NOT a scripting surface. Loops, conditionals, heredocs, inline interpreter scripts (`-e`/`-c`/`--eval`) when an eval runtime exists, several piped stages, exact pipeline semantics, or quote/JSON escaping mean you're writing a program → use `eval` cells: restartable, stateful, and free of shell-quoting traps.{{else}}- The embedded shell invokes real binaries with simple args; it is NOT full GNU Bash and NOT a scripting surface. Loops, conditionals, heredocs, inline interpreter scripts, several piped stages, exact pipeline semantics, or quote/JSON escaping mean you're writing a shell program; use a purpose-built tool or checked-in script instead.{{/if}} -- NEVER shell out to search content or files: `grep/rg` → `grep`. -- NEVER use `ls` or `find` to list or locate files — `ls` → `read` (a directory path lists entries), `find` → the `glob` tool (globbing). This is non-negotiable, even for a single quick listing. +{{#if hasGrep}}- NEVER shell out to search content or files: `grep/rg` → `grep`.{{else}}- Avoid shelling out for broad content search; use an active search/read tool when one is available.{{/if}} +{{#if hasRead}}{{#if hasGlob}}- NEVER use `ls` or `find` to list or locate files — `ls` → `read` (a directory path lists entries), `find` → the `glob` tool (globbing). This is non-negotiable, even for a single quick listing.{{else}}- Prefer `read` for known file and directory reads. Only use shell listing when no file-listing tool is active.{{/if}}{{else}}{{#if hasGlob}}- Prefer `glob` for file discovery; avoid `find` when `glob` is active.{{else}}- If no file read/listing tool is active, keep shell inspection narrow and state that limitation.{{/if}}{{/if}} - Avoid head/tail/redirections: stderr already merged; long output auto-truncated, FULL capture kept at `artifact://`. diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index aace6a3bb..9feef6a76 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -399,15 +399,17 @@ export class BashTool implements AgentTool this.session.isToolActive?.(name) ?? fallback; this.description = prompt.render(bashDescription, { asyncEnabled: this.#asyncEnabled, autoBackgroundEnabled: this.#autoBackgroundEnabled, autoBackgroundThresholdSeconds: Math.max(0, Math.floor(this.#autoBackgroundThresholdMs / 1000)), - hasAstGrep: this.session.settings.get("astGrep.enabled"), - hasAstEdit: this.session.settings.get("astEdit.enabled"), - hasGrep: this.session.settings.get("grep.enabled"), - hasGlob: this.session.settings.get("glob.enabled"), - hasEval: evalBackends.python || evalBackends.js || evalBackends.ruby || evalBackends.julia, + hasAstGrep: isToolActive("ast_grep", this.session.settings.get("astGrep.enabled")), + hasAstEdit: isToolActive("ast_edit", this.session.settings.get("astEdit.enabled")), + hasGrep: isToolActive("grep", this.session.settings.get("grep.enabled")), + hasGlob: isToolActive("glob", this.session.settings.get("glob.enabled")), + hasRead: isToolActive("read", true), + hasEval: isToolActive("eval", evalBackends.python || evalBackends.js || evalBackends.ruby || evalBackends.julia), }); } diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index f42e49741..621c80f86 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -224,6 +224,8 @@ export interface ToolSession { getAgentId?: () => string | null; /** Look up a registered tool by name (used by the eval js backend's tool bridge). */ getToolByName?: (name: string) => AgentTool | undefined; + /** Return whether a built-in tool is active in this turn's tool set. */ + isToolActive?: (name: string) => boolean; /** Agent registry for IRC routing across live sessions. */ agentRegistry?: AgentRegistry; /** Get artifacts directory for artifact:// URLs */ @@ -647,9 +649,15 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P ...(goalModeActive ? ([["goal", HIDDEN_TOOLS.goal]] as const) : []), ]; + const activeToolNames = new Set(baseEntries.map(([name]) => name)); + const toolFactorySession: ToolSession = { + ...session, + isToolActive: name => activeToolNames.has(name), + }; + const baseResults = await Promise.all( baseEntries.map(async ([name, factory]) => { - const tool = await logger.time(`createTools:${name}`, factory as ToolFactory, session); + const tool = await logger.time(`createTools:${name}`, factory as ToolFactory, toolFactorySession); return tool ? wrapToolWithMetaNotice(tool) : null; }), ); diff --git a/packages/coding-agent/test/modes/workflow.test.ts b/packages/coding-agent/test/modes/workflow.test.ts index 3d23c40b3..a9e719b5b 100644 --- a/packages/coding-agent/test/modes/workflow.test.ts +++ b/packages/coding-agent/test/modes/workflow.test.ts @@ -48,9 +48,10 @@ describe("workflow keyword highlighting", () => { }); describe("workflow notice", () => { - it("is a non-empty system notice carrying the eval-fan-out contract", () => { + it("is a non-empty system notice carrying the task fan-out contract", () => { expect(WORKFLOW_NOTICE.length).toBeGreaterThan(0); expect(WORKFLOW_NOTICE).toContain("**workflowz** keyword"); - expect(WORKFLOW_NOTICE).toContain("parallel("); + expect(WORKFLOW_NOTICE).toContain("Use the `task` tool for batched fan-out"); + expect(WORKFLOW_NOTICE).toContain("tasks[]"); }); }); diff --git a/packages/coding-agent/test/system-prompt-inventory.test.ts b/packages/coding-agent/test/system-prompt-inventory.test.ts index 82b51b791..cf06b0cd5 100644 --- a/packages/coding-agent/test/system-prompt-inventory.test.ts +++ b/packages/coding-agent/test/system-prompt-inventory.test.ts @@ -164,6 +164,9 @@ describe("system prompt tool inventory", () => { expect(bash?.description).toContain("purpose-built tool"); expect(bash?.description).not.toContain("eval` cell"); expect(bash?.description).not.toContain("use `eval` cells"); + expect(bash?.description).not.toContain("`grep` tool"); + expect(bash?.description).not.toContain("`ls` → `read`"); + expect(bash?.description).not.toContain("`find` → the `glob` tool"); const { systemPrompt } = await buildSystemPrompt({ cwd: tempDir, From 25c94fadba5a0e0cc12f44265b76bbb8962053f3 Mon Sep 17 00:00:00 2001 From: Christian Stewart Date: Sun, 5 Jul 2026 17:34:17 -0700 Subject: [PATCH 3/5] fix(prompting): match workflowz task schema Signed-off-by: Christian Stewart --- packages/coding-agent/src/modes/workflow.ts | 22 +++++++----- .../src/prompts/system/workflow-notice.md | 34 +++++++++++++++---- .../coding-agent/src/session/agent-session.ts | 4 +-- .../test/agent-session-magic-keywords.test.ts | 16 +++++++++ .../coding-agent/test/modes/workflow.test.ts | 10 +++++- 5 files changed, 69 insertions(+), 17 deletions(-) diff --git a/packages/coding-agent/src/modes/workflow.ts b/packages/coding-agent/src/modes/workflow.ts index ab7ae17fa..e8d4832cc 100644 --- a/packages/coding-agent/src/modes/workflow.ts +++ b/packages/coding-agent/src/modes/workflow.ts @@ -1,4 +1,5 @@ -import workflowNotice from "../prompts/system/workflow-notice.md" with { type: "text" }; +import { prompt } from "@oh-my-pi/pi-utils"; +import workflowNoticeTemplate from "../prompts/system/workflow-notice.md" with { type: "text" }; import { createGradientHighlighter, type KeywordHighlighter } from "./gradient-highlight"; import { keywordInProse } from "./markdown-prose"; @@ -7,18 +8,23 @@ import { keywordInProse } from "./markdown-prose"; * * Typing the standalone word in the input editor paints it with a warm * amber→green gradient ({@link highlightWorkflow}); submitting a message that - * mentions it appends a hidden {@link WORKFLOW_NOTICE} that steers the model to - * author a deterministic multi-subagent workflow in eval cells (agent/parallel/ - * pipeline). Matching is whitespace-delimited and case-sensitive (lowercase - * only) — "workflowz" triggers, but "workflowzed", "Workflowz", and - * "workflowz.ts" never do. + * mentions it appends a hidden workflow notice that steers the model to author + * a deterministic multi-subagent workflow through the active task schema. + * Matching is whitespace-delimited and case-sensitive (lowercase only) — + * "workflowz" triggers, but "workflowzed", "Workflowz", and "workflowz.ts" + * never do. */ // Detection: lowercase keyword flanked by whitespace or a string edge. Non-global so `.test` stays stateless. const WORKFLOW_WORD = /(? -The user's message above contains the **workflowz** keyword: drive this task as a deterministic multi-subagent workflow. Use the `task` tool for batched fan-out — to be comprehensive (decompose and cover in parallel), to be confident (independent perspectives and adversarial checks before you commit), or to take on scale one context can't hold (audits, migrations, broad sweeps). This overrides any default tendency to do the whole task inline when fanning out would be more thorough. +The user's message above contains the **workflowz** keyword: drive this task as a deterministic multi-subagent workflow. Use the `task` tool {{#if taskBatch}}for batched fan-out{{else}}once per independent subagent{{/if}} — to be comprehensive (decompose and cover in parallel), to be confident (independent perspectives and adversarial checks before you commit), or to take on scale one context can't hold (audits, migrations, broad sweeps). This overrides any default tendency to do the whole task inline when fanning out would be more thorough. Worth it when the task benefits from decomposition + parallel coverage, or from independent/adversarial cross-checking before you commit. For a quick lookup or single edit, just do it directly — don't spin up agents. Scout inline first (list the files, scope the diff, find the call sites) to discover the work list, then fan out over it. Common shapes: @@ -11,6 +11,7 @@ Worth it when the task benefits from decomposition + parallel coverage, or from +{{#if taskBatch}} Call `task` once per independent fan-out batch. Put shared background in `context`, and put each independent work item in `tasks[]`. Do not emulate batching with shell loops or eval helper APIs. `context` must carry the shared contract: @@ -31,13 +32,24 @@ Each task assignment must be self-contained: # Acceptance Observable result, return packet, and local verification. Subagents skip formatters, linters, and project-wide tests; the parent runs shared proof once. +{{else}} +Call `task` once per independent subagent. Put the full shared background and the leaf work in that call's `assignment`. Do not pass `context` or `tasks[]`: the flat task schema rejects them when batch calls are disabled. -Use specific roles (`Storage Reviewer`, `CLI Migrator`, `Security Skeptic`) rather than generic workers. Dispatch at most one layer unless the user grants a named second-layer purpose. - +Each assignment must be self-contained: + + # Target + Exact files, symbols, subsystem, or evidence surface; explicit non-goals. + # Change + Shared background plus what to inspect or modify, step by step, including APIs and patterns to reuse. + # Acceptance + Observable result, return packet, and local verification. Subagents skip formatters, + linters, and project-wide tests; the parent runs shared proof once. +{{/if}} -Decompose first, then batch the independent leaves: +Decompose first, then {{#if taskBatch}}batch the independent leaves{{else}}issue one independent task call per leaf in the same turn{{/if}}: +{{#if taskBatch}} task( context: "# Goal\nReview the auth diff...\n# Constraints\nRead-only...\n# Contract\nReturn findings as severity/file/line/fix...", tasks: [ @@ -45,8 +57,18 @@ Decompose first, then batch the independent leaves: { id: "PromptOwner", role: "Prompt Contract Reviewer", assignment: "# Target\npackages/coding-agent/src/prompts/**\n# Change\nCheck active-tool guidance...\n# Acceptance\nReturn mismatches and exact prompt lines..." }, ] ) +{{else}} + task( + role: "Auth Storage Reviewer", + assignment: "# Target\npackages/ai/src/auth-storage.ts\n# Change\nReview the auth diff. Shared contract: read-only; return findings as severity/file/line/fix.\n# Acceptance\nReturn confirmed findings only..." + ) + task( + role: "Prompt Contract Reviewer", + assignment: "# Target\npackages/coding-agent/src/prompts/**\n# Change\nCheck active-tool guidance. Shared contract: read-only; return mismatches and exact prompt lines.\n# Acceptance\nReturn confirmed findings only..." + ) +{{/if}} -Prefer one wide batch over serial subagent calls when work items do not share files. If tasks overlap, name the overlap and have agents coordinate through IRC before editing. +{{#if taskBatch}}Prefer one wide batch over serial subagent calls when work items do not share files. If tasks overlap, name the overlap and have agents coordinate through IRC before editing.{{else}}Prefer issuing all independent task calls in one assistant turn over serial dispatch when work items do not share files. If tasks overlap, name the overlap and have agents coordinate through IRC before editing.{{/if}} @@ -59,7 +81,7 @@ Prefer one wide batch over serial subagent calls when work items do not share fi - Capture multi-phase workflow state in the visible todo system when available. -- Batch independent subagents in one `task` call. +{{#if taskBatch}}- Batch independent subagents in one `task` call.{{else}}- Dispatch independent subagents as separate `task` calls in the same turn.{{/if}} - Give every subagent a narrow target, explicit non-goals, and a concrete return packet. - After fan-out returns, read the artifacts, patch or decide, and run the shared gate. - Keep going until the task is closed — returned fan-out is a step, not a stopping point. diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index da4492fc4..5b36e37a6 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -237,7 +237,7 @@ import { theme } from "../modes/theme/theme"; import { parseTurnBudget } from "../modes/turn-budget"; import { containsUltrathink, ULTRATHINK_NOTICE } from "../modes/ultrathink"; import { computeNonMessageBreakdown, computeNonMessageTokens } from "../modes/utils/context-usage"; -import { containsWorkflow, WORKFLOW_NOTICE } from "../modes/workflow"; +import { containsWorkflow, renderWorkflowNotice } from "../modes/workflow"; import { createPlanReadMatcher } from "../plan-mode/plan-protection"; import type { PlanModeState } from "../plan-mode/state"; import advisorSystemPrompt from "../prompts/advisor/system.md" with { type: "text" }; @@ -7338,7 +7338,7 @@ export class AgentSession { keywordNotices.push({ role: "custom", customType: "workflow-notice", - content: WORKFLOW_NOTICE, + content: renderWorkflowNotice({ taskBatch: this.settings.get("task.batch") }), display: false, attribution: "user", timestamp, diff --git a/packages/coding-agent/test/agent-session-magic-keywords.test.ts b/packages/coding-agent/test/agent-session-magic-keywords.test.ts index d0e09a7ae..4ac59444d 100644 --- a/packages/coding-agent/test/agent-session-magic-keywords.test.ts +++ b/packages/coding-agent/test/agent-session-magic-keywords.test.ts @@ -103,6 +103,22 @@ describe("AgentSession magic keyword settings", () => { ]); }); + it("renders workflowz notice for the active task schema", async () => { + const created = await createMagicKeywordSession(root); + session = created.session; + authStorage = created.authStorage; + created.settings.set("task.batch", false); + const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined); + + await session.prompt("please workflowz this"); + + const promptMessages = promptSpy.mock.calls[0]![0] as unknown as Array<{ content?: string; customType?: string }>; + const notice = promptMessages.find(message => message.customType === "workflow-notice")?.content ?? ""; + expect(notice).toContain("once per independent subagent"); + expect(notice).toContain("Do not pass `context` or `tasks[]`"); + expect(notice).not.toContain("Call `task` once per independent fan-out batch"); + }); + it("does not use a disabled ultrathink keyword to force auto thinking", async () => { const created = await createMagicKeywordSession(root); session = created.session; diff --git a/packages/coding-agent/test/modes/workflow.test.ts b/packages/coding-agent/test/modes/workflow.test.ts index a9e719b5b..32b01f31b 100644 --- a/packages/coding-agent/test/modes/workflow.test.ts +++ b/packages/coding-agent/test/modes/workflow.test.ts @@ -1,6 +1,6 @@ import { beforeAll, describe, expect, it } from "bun:test"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; -import { containsWorkflow, highlightWorkflow, WORKFLOW_NOTICE } from "@oh-my-pi/pi-coding-agent/modes/workflow"; +import { containsWorkflow, highlightWorkflow, renderWorkflowNotice, WORKFLOW_NOTICE } from "@oh-my-pi/pi-coding-agent/modes/workflow"; beforeAll(() => { // highlightWorkflow reads the global theme's color mode. @@ -54,4 +54,12 @@ describe("workflow notice", () => { expect(WORKFLOW_NOTICE).toContain("Use the `task` tool for batched fan-out"); expect(WORKFLOW_NOTICE).toContain("tasks[]"); }); + + it("renders flat task-call guidance when task.batch is disabled", () => { + const notice = renderWorkflowNotice({ taskBatch: false }); + expect(notice).toContain("once per independent subagent"); + expect(notice).toContain("Do not pass `context` or `tasks[]`"); + expect(notice).toContain("one independent task call per leaf"); + expect(notice).not.toContain("Call `task` once per independent fan-out batch"); + }); }); From 96850a1642c0f8b0f9cb92d9b67e06f27d2ef067 Mon Sep 17 00:00:00 2001 From: Christian Stewart Date: Sun, 5 Jul 2026 18:04:24 -0700 Subject: [PATCH 4/5] fix(prompting): keep eval-disabled tool state coherent Signed-off-by: Christian Stewart --- packages/coding-agent/src/prompts/tools/bash.md | 2 +- packages/coding-agent/src/tools/index.ts | 7 ++----- .../coding-agent/test/system-prompt-inventory.test.ts | 1 + packages/coding-agent/test/tools/index.test.ts | 9 +++++++++ 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/prompts/tools/bash.md b/packages/coding-agent/src/prompts/tools/bash.md index 9af269ac3..f95e22d8d 100644 --- a/packages/coding-agent/src/prompts/tools/bash.md +++ b/packages/coding-agent/src/prompts/tools/bash.md @@ -31,7 +31,7 @@ Use bash ONLY for: a single binary call, or one short pipeline that COMPUTES a f - `;` only when later commands should run despite earlier failures - Multiple bash calls per message run concurrently. NEVER split order-dependent commands across parallel calls — chain with `&&` in one call. - Internal URIs (`skill://`, `agent://`, …) auto-resolve to FS paths -- Need exact pipeline semantics (`cmd | head`, multi-stage filtering) or output truncation? Prefer `eval` and process the stream directly. +{{#if hasEval}}- Need exact pipeline semantics (`cmd | head`, multi-stage filtering) or output truncation? Prefer `eval` and process the stream directly.{{else}}- Need exact pipeline semantics (`cmd | head`, multi-stage filtering) or output truncation? Use a checked-in script, purpose-built tool, or single command that owns the output shape.{{/if}} {{#if asyncEnabled}} - `async: true` for long-running commands when you don't need immediate output: returns a background job ID; result delivered as a follow-up. {{/if}} diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index 621c80f86..83e39a517 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -650,14 +650,11 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P ]; const activeToolNames = new Set(baseEntries.map(([name]) => name)); - const toolFactorySession: ToolSession = { - ...session, - isToolActive: name => activeToolNames.has(name), - }; + session.isToolActive = name => activeToolNames.has(name); const baseResults = await Promise.all( baseEntries.map(async ([name, factory]) => { - const tool = await logger.time(`createTools:${name}`, factory as ToolFactory, toolFactorySession); + const tool = await logger.time(`createTools:${name}`, factory as ToolFactory, session); return tool ? wrapToolWithMetaNotice(tool) : null; }), ); diff --git a/packages/coding-agent/test/system-prompt-inventory.test.ts b/packages/coding-agent/test/system-prompt-inventory.test.ts index cf06b0cd5..a142f978a 100644 --- a/packages/coding-agent/test/system-prompt-inventory.test.ts +++ b/packages/coding-agent/test/system-prompt-inventory.test.ts @@ -164,6 +164,7 @@ describe("system prompt tool inventory", () => { expect(bash?.description).toContain("purpose-built tool"); expect(bash?.description).not.toContain("eval` cell"); expect(bash?.description).not.toContain("use `eval` cells"); + expect(bash?.description).not.toContain("Prefer `eval`"); expect(bash?.description).not.toContain("`grep` tool"); expect(bash?.description).not.toContain("`ls` → `read`"); expect(bash?.description).not.toContain("`find` → the `glob` tool"); diff --git a/packages/coding-agent/test/tools/index.test.ts b/packages/coding-agent/test/tools/index.test.ts index eae16b79c..8978dbfb3 100644 --- a/packages/coding-agent/test/tools/index.test.ts +++ b/packages/coding-agent/test/tools/index.test.ts @@ -264,6 +264,15 @@ describe("createTools", () => { expect(names).toEqual(["read", "goal", "resolve"]); }); + it("records active tools on the original session object", async () => { + const session = createTestSession(); + + await createTools(session, ["bash"]); + + expect(session.isToolActive?.("bash")).toBe(true); + expect(session.isToolActive?.("read")).toBe(false); + }); + it("includes search_tool_bm25 when MCP tool discovery is enabled and executable", async () => { const session = createTestSession({ settings: createSettingsWithOverrides({ From 1936d4f25003ea03551c98fd08d05916633536bf Mon Sep 17 00:00:00 2001 From: Christian Stewart Date: Sun, 5 Jul 2026 18:15:22 -0700 Subject: [PATCH 5/5] fix(prompting): refresh bash guidance on tool changes Signed-off-by: Christian Stewart --- packages/coding-agent/src/sdk.ts | 11 +++++ .../coding-agent/src/session/agent-session.ts | 6 +++ packages/coding-agent/src/tools/bash.ts | 29 ++++++------ packages/coding-agent/src/tools/index.ts | 8 +++- .../agent-session-tool-rebuild-skip.test.ts | 46 +++++++++++++++++++ .../coding-agent/test/tools/index.test.ts | 22 +++++++++ 6 files changed, 107 insertions(+), 15 deletions(-) diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index bca73c40e..3321e78ac 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -1525,10 +1525,19 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // entries capture it at fetch time and are dropped at injection if a newer // mutation (any tool) bumped it in the meantime. const fileMutationVersions = new Map(); + const activeToolNames = new Set(); + const setActiveToolNames = (names: Iterable): void => { + activeToolNames.clear(); + for (const name of names) { + activeToolNames.add(name); + } + }; const toolSession: ToolSession = { get cwd() { return sessionManager.getCwd(); }, + isToolActive: name => activeToolNames.has(name), + setActiveToolNames, hasUI: options.hasUI ?? false, enableLsp, get hasEditTool() { @@ -2540,6 +2549,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} }); hasRegistered = true; + setActiveToolNames(initialToolNames); const { systemPrompt } = await logger.time( "buildSystemPrompt", rebuildSystemPrompt, @@ -2834,6 +2844,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} rebuildSystemPrompt, reloadSshTool, requestedToolNames: requestedToolNameSet, + setActiveToolNames, getMcpServerInstructions: mcpManager ? () => { const raw = mcpManager.getServerInstructions(); diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 5b36e37a6..8be2067d4 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -686,6 +686,8 @@ export interface AgentSessionConfig { toolRegistry?: Map; /** Tool names whose current registry entry is still the built-in implementation. */ builtInToolNames?: Iterable; + /** Update tool-session predicates that render guidance from the live active tool set. */ + setActiveToolNames?: (names: Iterable) => void; /** Current session pre-LLM message transform pipeline */ transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise; /** @@ -1717,6 +1719,7 @@ export class AgentSession { | undefined; #getMcpServerInstructions: (() => Map | undefined) | undefined; #reloadSshTool: (() => Promise) | undefined; + #setActiveToolNames: ((names: Iterable) => void) | undefined; #disconnectOwnedMcpManager: (() => Promise) | undefined; #requestedToolNames: ReadonlySet | undefined; #baseSystemPrompt: string[]; @@ -2155,6 +2158,7 @@ export class AgentSession { this.#rebuildSystemPrompt = config.rebuildSystemPrompt; this.#getMcpServerInstructions = config.getMcpServerInstructions; this.#reloadSshTool = config.reloadSshTool; + this.#setActiveToolNames = config.setActiveToolNames; this.#disconnectOwnedMcpManager = config.disconnectOwnedMcpManager; this.#baseSystemPrompt = this.agent.state.systemPrompt; this.#promptModelKey = this.#currentPromptModelKey(); @@ -6290,6 +6294,7 @@ export class AgentSession { ), ); } + this.#setActiveToolNames?.(validToolNames); const activeNameSet = new Set(validToolNames); for (const name of Array.from(this.#selectedDiscoveredToolNames)) { if (!activeNameSet.has(name) || isMCPToolName(name) || !this.#toolRegistry.has(name)) { @@ -6395,6 +6400,7 @@ export class AgentSession { async refreshBaseSystemPrompt(): Promise { if (!this.#rebuildSystemPrompt) return; const activeToolNames = this.getActiveToolNames(); + this.#setActiveToolNames?.(activeToolNames); const built = await this.#rebuildSystemPrompt(activeToolNames, this.#toolRegistry); this.#baseSystemPrompt = built.systemPrompt; this.#baseSystemPromptBeforeMemoryPromotion = undefined; diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index 9feef6a76..ea9f4a031 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -376,7 +376,21 @@ export class BashTool implements AgentTool this.session.isToolActive?.(name) ?? fallback; + return prompt.render(bashDescription, { + asyncEnabled: this.#asyncEnabled, + autoBackgroundEnabled: this.#autoBackgroundEnabled, + autoBackgroundThresholdSeconds: Math.max(0, Math.floor(this.#autoBackgroundThresholdMs / 1000)), + hasAstGrep: isToolActive("ast_grep", this.session.settings.get("astGrep.enabled")), + hasAstEdit: isToolActive("ast_edit", this.session.settings.get("astEdit.enabled")), + hasGrep: isToolActive("grep", this.session.settings.get("grep.enabled")), + hasGlob: isToolActive("glob", this.session.settings.get("glob.enabled")), + hasRead: isToolActive("read", true), + hasEval: isToolActive("eval", evalBackends.python || evalBackends.js || evalBackends.ruby || evalBackends.julia), + }); + } readonly parameters: BashToolSchema; // Non-pty calls run alongside each other (the executor isolates overlapping // runs on the same shell session); pty takes over the terminal UI and must @@ -398,19 +412,6 @@ export class BashTool implements AgentTool this.session.isToolActive?.(name) ?? fallback; - this.description = prompt.render(bashDescription, { - asyncEnabled: this.#asyncEnabled, - autoBackgroundEnabled: this.#autoBackgroundEnabled, - autoBackgroundThresholdSeconds: Math.max(0, Math.floor(this.#autoBackgroundThresholdMs / 1000)), - hasAstGrep: isToolActive("ast_grep", this.session.settings.get("astGrep.enabled")), - hasAstEdit: isToolActive("ast_edit", this.session.settings.get("astEdit.enabled")), - hasGrep: isToolActive("grep", this.session.settings.get("grep.enabled")), - hasGlob: isToolActive("glob", this.session.settings.get("glob.enabled")), - hasRead: isToolActive("read", true), - hasEval: isToolActive("eval", evalBackends.python || evalBackends.js || evalBackends.ruby || evalBackends.julia), - }); } #formatResultOutput(result: BashResult | BashInteractiveResult): string { diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index 83e39a517..8ea30c85d 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -226,6 +226,8 @@ export interface ToolSession { getToolByName?: (name: string) => AgentTool | undefined; /** Return whether a built-in tool is active in this turn's tool set. */ isToolActive?: (name: string) => boolean; + /** Update the active built-in tool predicate when a session changes tools mid-run. */ + setActiveToolNames?: (names: Iterable) => void; /** Agent registry for IRC routing across live sessions. */ agentRegistry?: AgentRegistry; /** Get artifacts directory for artifact:// URLs */ @@ -650,7 +652,11 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P ]; const activeToolNames = new Set(baseEntries.map(([name]) => name)); - session.isToolActive = name => activeToolNames.has(name); + if (session.setActiveToolNames) { + session.setActiveToolNames(activeToolNames); + } else { + session.isToolActive = name => activeToolNames.has(name); + } const baseResults = await Promise.all( baseEntries.map(async ([name, factory]) => { diff --git a/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts b/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts index 6cefd9c5b..221720e06 100644 --- a/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts +++ b/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts @@ -176,6 +176,52 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => { expect(rebuildCount).toBe(baseline + 2); }); + it("updates live active-tool predicates before rebuilding the prompt", async () => { + const activeToolNames = new Set(["read", "bash", "grep"]); + const readTool = createBasicTool("read", "Read"); + const bashTool = createBasicTool("bash", "Bash"); + const grepTool = createBasicTool("grep", "Grep"); + Object.defineProperty(bashTool, "description", { + get: () => (activeToolNames.has("grep") ? "bash sees grep" : "bash hides grep"), + enumerable: true, + configurable: true, + }); + const toolRegistry = new Map([ + [readTool.name, readTool], + [bashTool.name, bashTool], + [grepTool.name, grepTool], + ]); + const agent = new Agent({ + initialState: { + model: createModel(), + systemPrompt: ["initial"], + tools: [readTool, bashTool, grepTool], + messages: [], + }, + }); + const session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings: Settings.isolated({ "compaction.enabled": false }), + modelRegistry: {} as never, + toolRegistry, + setActiveToolNames: names => { + activeToolNames.clear(); + for (const name of names) { + activeToolNames.add(name); + } + }, + rebuildSystemPrompt: async (_toolNames, tools) => ({ + systemPrompt: [tools.get("bash")?.description ?? "missing bash"], + }), + }); + sessions.push(session); + + await session.setActiveToolsByName(["read", "bash"]); + + expect(agent.state.systemPrompt).toEqual(["bash hides grep"]); + }); + it("does not skip when refreshBaseSystemPrompt is called explicitly", async () => { let rebuildCount = 0; const { session } = newSession(async toolNames => { diff --git a/packages/coding-agent/test/tools/index.test.ts b/packages/coding-agent/test/tools/index.test.ts index 8978dbfb3..845a285b1 100644 --- a/packages/coding-agent/test/tools/index.test.ts +++ b/packages/coding-agent/test/tools/index.test.ts @@ -273,6 +273,28 @@ describe("createTools", () => { expect(session.isToolActive?.("read")).toBe(false); }); + it("renders bash guidance from the live active tool predicate", async () => { + const activeToolNames = new Set(); + const session = createTestSession({ + isToolActive: name => activeToolNames.has(name), + setActiveToolNames: names => { + activeToolNames.clear(); + for (const name of names) { + activeToolNames.add(name); + } + }, + }); + + const tools = await createTools(session, ["bash", "grep", "read", "glob"]); + const bash = tools.find(tool => tool.name === "bash"); + + expect(bash?.description).toContain("`grep` tool"); + session.setActiveToolNames?.(["bash"]); + expect(bash?.description).not.toContain("`grep` tool"); + expect(bash?.description).not.toContain("`ls` → `read`"); + expect(bash?.description).not.toContain("`find` → the `glob` tool"); + }); + it("includes search_tool_bm25 when MCP tool discovery is enabled and executable", async () => { const session = createTestSession({ settings: createSettingsWithOverrides({