merge PR #4644: fix(prompting): hide eval guidance when disabled

This commit is contained in:
can1357
2026-07-08 15:19:36 +02:00
14 changed files with 305 additions and 82 deletions
+14 -8
View File
@@ -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 = /(?<!\S)workflowz(?!\S)/;
/** Hidden system notice appended after a user message that mentions "workflowz". */
export const WORKFLOW_NOTICE: string = workflowNotice.trim();
/** WORKFLOW_NOTICE is the default hidden notice for sessions with batched task calls enabled. */
export const WORKFLOW_NOTICE: string = renderWorkflowNotice({ taskBatch: true });
/** renderWorkflowNotice renders the workflow notice for the active task schema. */
export function renderWorkflowNotice({ taskBatch }: { taskBatch: boolean }): string {
return prompt.render(workflowNoticeTemplate, { taskBatch }).trim();
}
/**
* Whether `text` contains the standalone keyword "workflowz"
@@ -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"}}
<critical>
@@ -1,70 +1,89 @@
<system-notice>
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 {{#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.
<when>
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:
- **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.
</when>
<helpers>
State persists across eval calls, so scout in one call and fan out in the next. Every eval call has:
<task-contract>
{{#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.
- `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.
</helpers>
# 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.
{{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.
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}}
<structure>
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 {{#if taskBatch}}batch the independent leaves{{else}}issue one independent task call per leaf in the same turn{{/if}}:
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"]]
{{#if taskBatch}}
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..." },
]
)
{{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}}
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.
{{#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}}
</structure>
<patterns>
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.
</patterns>
<execution>
- 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.
- 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.
{{#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.
</execution>
</system-notice>
@@ -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 (`<<EOF`), `while`/`for`/`if`/`case` shell control flow
- `$(…)` command substitution nested inside another command
- Pipelines with more than two stages, or stages that need control flow or quote/JSON escaping
- Multiline commands, `&&`-chains mixing control flow
- Quote/JSON escaping that fights the shell
- 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), or `eval` for exact text processing.
{{else}}Anything below means you are writing a shell program, not invoking one. Prefer a purpose-built tool, a checked-in script, or a single repo command instead:
- Inline interpreter scripts (`-e`/`-c`/`--eval`)
- Heredocs (`<<EOF`), `while`/`for`/`if`/`case` shell control flow
- `$(…)` command substitution nested inside another command
- Pipelines with more than two stages, or stages that need control flow or quote/JSON escaping
- Multiline commands, `&&`-chains mixing control flow
- Quote/JSON escaping that fights the shell
{{/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}}
<instruction>
- `cwd` sets the working dir, not `cd dir && …`
@@ -23,14 +31,17 @@ Anything below → `eval` cell, not bash:
- `;` 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}}
</instruction>
<critical>
- 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}}
{{#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://<id>`.
</critical>
<output>
+11
View File
@@ -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<string, number>();
const activeToolNames = new Set<string>();
const setActiveToolNames = (names: Iterable<string>): 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() {
@@ -2558,6 +2567,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
});
hasRegistered = true;
setActiveToolNames(initialToolNames);
const { systemPrompt } = await logger.time(
"buildSystemPrompt",
rebuildSystemPrompt,
@@ -2852,6 +2862,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
rebuildSystemPrompt,
reloadSshTool,
requestedToolNames: requestedToolNameSet,
setActiveToolNames,
getMcpServerInstructions: mcpManager
? () => {
const raw = mcpManager.getServerInstructions();
@@ -239,7 +239,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" };
@@ -689,6 +689,8 @@ export interface AgentSessionConfig {
toolRegistry?: Map<string, AgentTool>;
/** Tool names whose current registry entry is still the built-in implementation. */
builtInToolNames?: Iterable<string>;
/** Update tool-session predicates that render guidance from the live active tool set. */
setActiveToolNames?: (names: Iterable<string>) => void;
/** Current session pre-LLM message transform pipeline */
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise<AgentMessage[]>;
/**
@@ -1724,6 +1726,7 @@ export class AgentSession {
#getLocalCalendarDate: () => string;
#getMcpServerInstructions: (() => Map<string, string> | undefined) | undefined;
#reloadSshTool: (() => Promise<AgentTool | null>) | undefined;
#setActiveToolNames: ((names: Iterable<string>) => void) | undefined;
#disconnectOwnedMcpManager: (() => Promise<void>) | undefined;
#requestedToolNames: ReadonlySet<string> | undefined;
#baseSystemPrompt: string[];
@@ -2170,6 +2173,7 @@ export class AgentSession {
this.#getLocalCalendarDate = config.getLocalCalendarDate ?? formatLocalCalendarDate;
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();
@@ -6320,6 +6324,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)) {
@@ -6425,6 +6430,7 @@ export class AgentSession {
async refreshBaseSystemPrompt(): Promise<void> {
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;
@@ -7368,7 +7374,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,
+1 -1
View File
@@ -401,7 +401,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;
+16 -10
View File
@@ -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,
@@ -375,7 +376,21 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
};
readonly label = "Bash";
readonly loadMode = "essential";
readonly description: string;
get description(): string {
const evalBackends = resolveEvalBackends(this.session);
const isToolActive = (name: string, fallback: boolean): boolean => 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
@@ -397,15 +412,6 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
),
);
this.parameters = this.#asyncEnabled ? bashSchemaWithAsync : bashSchemaBase;
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"),
});
}
#formatResultOutput(result: BashResult | BashInteractiveResult): string {
+11
View File
@@ -224,6 +224,10 @@ 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;
/** Update the active built-in tool predicate when a session changes tools mid-run. */
setActiveToolNames?: (names: Iterable<string>) => void;
/** Agent registry for IRC routing across live sessions. */
agentRegistry?: AgentRegistry;
/** Get artifacts directory for artifact:// URLs */
@@ -647,6 +651,13 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
...(goalModeActive ? ([["goal", HIDDEN_TOOLS.goal]] as const) : []),
];
const activeToolNames = new Set(baseEntries.map(([name]) => name));
if (session.setActiveToolNames) {
session.setActiveToolNames(activeToolNames);
} else {
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);
@@ -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;
@@ -178,6 +178,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<string, AgentTool>([
[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 => {
@@ -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.
@@ -48,9 +48,18 @@ 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[]");
});
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");
});
});
@@ -2,13 +2,15 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { buildSystemPrompt as buildSdkSystemPrompt } from "@oh-my-pi/pi-coding-agent/sdk";
import {
buildSystemPrompt,
buildSystemPromptToolMetadata,
DEFAULT_SYSTEM_PROMPT_TOOL_NAMES,
type SystemPromptToolMetadata,
} from "@oh-my-pi/pi-coding-agent/system-prompt";
import type { Tool } from "@oh-my-pi/pi-coding-agent/tools";
import { createTools, type Tool, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { cleanupTempHome } from "./helpers/temp-home-cleanup";
const EMPTY_TREE = {
@@ -92,6 +94,16 @@ describe("system prompt tool inventory", () => {
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,46 @@ 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");
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");
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 () => {
@@ -264,6 +264,37 @@ 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("renders bash guidance from the live active tool predicate", async () => {
const activeToolNames = new Set<string>();
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({