pi-workflow-engine 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,6 +40,9 @@ import { registerWorkflowRunCommand, WorkflowRunController } from "./src/workflo
40
40
  import { completeCurrentArgument, splitArgumentPrefix } from "./src/command-completions.ts";
41
41
  import { assertSupportedPiVersion } from "./src/pi-compat.ts";
42
42
  import { formatWorkflowInspection, workflowInspectionSnapshot } from "./src/ui/workflow-format.ts";
43
+ import { workflowUsageForPi, type WorkflowUsageSnapshot } from "./src/usage.ts";
44
+ import { unknownErrorMessage } from "./src/unknown-error.ts";
45
+ import { createHostToolBridge } from "./src/host-tools.ts";
43
46
 
44
47
  /** Extension root (this file lives in <repo>/.pi/extensions/pi-workflow-engine/index.ts). */
45
48
  const EXTENSION_DIR = fileURLToPath(new URL(".", import.meta.url));
@@ -195,15 +198,7 @@ export function buildTemporaryWorkflowAuthorPrompt(brief: string): string {
195
198
  User brief:
196
199
  ${brief.trim()}
197
200
 
198
- Use the workflow tool with a script argument, not a saved workflow name.
199
- The script must start with export const meta = { ... } and default-export an async workflow function.
200
- Use the injected Type object for schemas. Do not import anything or use dynamic import().
201
- Set profile to "small", "medium", or "big" on each agent() call; use explicit model/thinkingLevel only for an intentional override.
202
- Always pass a plain string as the first api.agent() argument; build prompts with template strings before calling agent().
203
- If using \`isolation: "worktree"\`, remember api.agent() returns \`{ result, patch, changed }\`; read \`.result\` for the agent answer and \`.patch\` for the diff.
204
- When the run is budgeted, guard expensive loops with \`while (api.budget.total && api.budget.remaining() > N) { ... }\`; api.agent() throws once the budget is spent.
205
- Subagents receive no skills by default. When the brief asks for a skill or a stage clearly benefits from one, pass \`skills: ["skill-name"]\` on that agent call only.
206
- ${ADAPTIVE_WORKFLOW_GUIDANCE}
201
+ Use the workflow tool with a script argument, not a saved workflow name, and follow its guidelines for inline scripts.
207
202
  Do not edit files unless the user explicitly requested edits.`;
208
203
  }
209
204
 
@@ -219,9 +214,17 @@ export type WorkflowToolRequest =
219
214
  | { readonly kind: "inline"; readonly script: string }
220
215
  | { readonly kind: "error"; readonly error: "invalid_workflow_invocation"; readonly message: string };
221
216
 
222
- export interface WorkflowToolErrorResult {
217
+ export interface WorkflowToolErrorResult<TDetails = WorkflowToolErrorDetails> {
223
218
  readonly content: Array<{ readonly type: "text"; readonly text: string }>;
224
- readonly details: { readonly error: "invalid_workflow_invocation" } | { readonly error: "inline_compile_error"; readonly message: string };
219
+ readonly details: TDetails;
220
+ /** The model sees a failed call while the renderer still reads `details`. */
221
+ readonly isError: true;
222
+ }
223
+
224
+ type WorkflowToolErrorDetails = { readonly error: "invalid_workflow_invocation" } | { readonly error: "inline_compile_error"; readonly message: string };
225
+
226
+ function workflowToolError<TDetails>(text: string, details: TDetails): WorkflowToolErrorResult<TDetails> {
227
+ return { content: [{ type: "text", text }], details, isError: true };
225
228
  }
226
229
 
227
230
  const INVALID_WORKFLOW_INVOCATION_MESSAGE = "Provide exactly one workflow name or inline workflow script.";
@@ -236,11 +239,11 @@ export function normalizeWorkflowToolRequest(params: WorkflowToolRequestParams):
236
239
  }
237
240
 
238
241
  export function invalidWorkflowInvocationResult(): WorkflowToolErrorResult {
239
- return { content: [{ type: "text", text: INVALID_WORKFLOW_INVOCATION_MESSAGE }], details: { error: "invalid_workflow_invocation" } };
242
+ return workflowToolError(INVALID_WORKFLOW_INVOCATION_MESSAGE, { error: "invalid_workflow_invocation" });
240
243
  }
241
244
 
242
245
  export function inlineCompileErrorResult(message: string): WorkflowToolErrorResult {
243
- return { content: [{ type: "text", text: `Inline workflow did not compile: ${message}` }], details: { error: "inline_compile_error", message } };
246
+ return workflowToolError(`Inline workflow did not compile: ${message}`, { error: "inline_compile_error", message });
244
247
  }
245
248
 
246
249
  export type WorkflowPickerSelection =
@@ -485,6 +488,10 @@ function registerWorkflowTool(
485
488
  description:
486
489
  "ONLY call workflow when the user opted into multi-agent orchestration via the literal token `dynamax`, sticky `/workflow:dynamax on`, an explicit request to run or author a workflow, or a command/skill instruction. Runs either a registered named workflow or an inline one-off workflow script (fan-out → verify → synthesize), synchronously by default or explicitly in the background.",
487
490
  promptSnippet: "Run an existing named workflow or an inline one-off workflow script",
491
+ // Like codemode itself, an orchestrating tool stays a top-level model action: from a codemode script a
492
+ // run would lose its tool row, hit the script's output cap, and parallel runs would each get their own
493
+ // concurrency cap. It also stays declared under `codemode.mode: "only"`, which hides direct tools.
494
+ exposure: "model-only",
488
495
  promptGuidelines: [
489
496
  "Use workflow only when the user opted into workflow orchestration via `dynamax`, `/workflow:dynamax on`, an explicit request to run/author a workflow, or a command/skill instruction.",
490
497
  "Use workflow with `name` for existing registered workflows such as code-review, diagnose, refactor-scout, or perf-review.",
@@ -492,9 +499,13 @@ function registerWorkflowTool(
492
499
  "Inline workflow scripts must use the injected `Type` object for schemas and must not contain imports or dynamic import().",
493
500
  "Inline scripts may compose registered workflows in-process via `api.workflow(\"<name>\", args)` (e.g. `await api.workflow(\"code-review\", \"HEAD~3\")`); it returns the sub-workflow's result and nests one level only.",
494
501
  "Subagents receive no skills by default. In inline workflows, pass `skills: [\"skill-name\"]` per `agent()` call when the user asks for a skill or a stage should use one; grant only the needed skills.",
502
+ "Set `profile: \"small\"`, `\"medium\"`, or `\"big\"` on every `api.agent()` call so the user's central model routes apply; use `model`/`thinkingLevel` only for an intentional override.",
495
503
  "Always pass a plain string as the first `api.agent()` argument; build prompts with template strings before calling agent().",
496
504
  "When using `isolation: \"worktree\"`, `api.agent()` returns `{ result, patch, changed }`; use `.result` for the answer and `.patch` for the isolated diff.",
497
505
  "If an inline subagent needs grep/find/code-search helpers, use `tools: [\"read\", \"bash\", \"grep\", \"find\", \"ls\"]` plus `toolHints: [\"search\"]` so installed tools such as ast-grep, mgrep, ffgrep, or fffind are discovered dynamically.",
506
+ "Add \"codemode\" to an inline agent's `tools` when it should batch or filter many tool calls in one script; its scripts can call only that agent's other allowed tools.",
507
+ "`api.classify({ state, questions }, { label })` answers typed choice/score/bool questions with a classifier model and no chat session; use it for cheap gates such as dedup or triage, and handle its rejection when no classifier is configured.",
508
+ "Host MCP tools reach subagents only in synchronous workflow tool runs: name them in `tools` (e.g. `mcp__server__tool`), or use `toolHints: [\"external-search\"]` to pick up MCP web research tools.",
498
509
  "`api.budget` exposes `{ total, spent(), remaining() }` (output tokens). When the run is budgeted, scale fleets from `budget.total` and guard loops with `while (budget.total && budget.remaining() > N) { await api.agent(...) }`; `api.agent()` throws once the ceiling is reached.",
499
510
  ADAPTIVE_WORKFLOW_GUIDANCE,
500
511
  "Set background: true only when the user explicitly wants the workflow to continue after this tool call; the tool returns a durable run ID and completion is delivered later.",
@@ -565,16 +576,10 @@ function registerWorkflowTool(
565
576
  if (request.kind === "error") return invalidWorkflowInvocationResult();
566
577
  const resumeFromRunId = params.resumeFromRunId?.trim();
567
578
  if (params.resumeFromRunId !== undefined && resumeFromRunId === "") {
568
- return {
569
- content: [{ type: "text", text: "resumeFromRunId must be non-empty." }],
570
- details: { error: "invalid_resume_from_run_id" },
571
- };
579
+ return workflowToolError("resumeFromRunId must be non-empty.", { error: "invalid_resume_from_run_id" });
572
580
  }
573
581
  if (params.resumeEditedWorkflow && !resumeFromRunId) {
574
- return {
575
- content: [{ type: "text", text: "resumeEditedWorkflow requires resumeFromRunId." }],
576
- details: { error: "invalid_edited_workflow_resume" },
577
- };
582
+ return workflowToolError("resumeEditedWorkflow requires resumeFromRunId.", { error: "invalid_edited_workflow_resume" });
578
583
  }
579
584
  if (params.background) {
580
585
  const unavailable = backgroundUnavailableResult(ctx.mode);
@@ -606,10 +611,7 @@ function registerWorkflowTool(
606
611
  const named = workflows.get(request.name);
607
612
  if (!named) {
608
613
  const available = [...workflows.keys()].join(", ") || "(none)";
609
- return {
610
- content: [{ type: "text", text: `Unknown workflow "${request.name}". Available: ${available}` }],
611
- details: { error: "unknown_workflow", available },
612
- };
614
+ return workflowToolError(`Unknown workflow "${request.name}". Available: ${available}`, { error: "unknown_workflow", available });
613
615
  }
614
616
  mod = named;
615
617
  resultName = request.name;
@@ -647,11 +649,30 @@ function registerWorkflowTool(
647
649
  }
648
650
  // Marks the tool row as running (rendered `● running <name>`) until the envelope replaces it.
649
651
  onUpdate?.({ content: [{ type: "text", text: `Running workflow ${resultName}.` }], details: { state: "running", name: resultName } });
650
- const envelope = await executeResolvedWorkflow(pi, ctx, resultName, mod, resultArgs, runOptions, perfRecorder);
652
+ // Only this synchronous path may bridge host MCP tools: executeTool ends with the tool call.
653
+ const hostTools = createHostToolBridge(() => pi.getAllTools(), ctx);
654
+ // Keep the latest usage: a failed run still reports what its agents spent instead of dropping it with the error.
655
+ let spent: WorkflowUsageSnapshot | undefined;
656
+ const onUsageSnapshot = (snapshot: WorkflowUsageSnapshot) => {
657
+ spent = snapshot;
658
+ return runOptions.onUsageSnapshot?.(snapshot);
659
+ };
660
+ let envelope: WorkflowResultEnvelope;
661
+ try {
662
+ envelope = await executeResolvedWorkflow(pi, ctx, resultName, mod, resultArgs, { ...runOptions, hostTools, onUsageSnapshot }, perfRecorder);
663
+ } catch (error) {
664
+ const message = unknownErrorMessage(error);
665
+ return {
666
+ ...workflowToolError(`Workflow ${resultName} failed: ${message}`, { error: "workflow_failed", name: resultName, message }),
667
+ usage: workflowUsageForPi(spent),
668
+ };
669
+ }
651
670
  reviewSessions.remember(ctx, envelope, runOptions);
652
671
  return {
653
672
  content: [{ type: "text", text: formatWorkflowResultForContext(envelope) }],
654
673
  details: envelope,
674
+ // pi counts tool-result usage toward the session cost; subagent spend was invisible there before.
675
+ usage: workflowUsageForPi(envelope.usage),
655
676
  };
656
677
  },
657
678
  });
@@ -49,16 +49,19 @@ export function dedupeCandidates<T extends IdentifiedAdvisoryCandidate>(candidat
49
49
  const key = candidateDedupKey(candidate);
50
50
  const previous = seen.get(key);
51
51
  if (!previous) seen.set(key, { ...candidate });
52
- else {
53
- previous.sourceCandidateIds = [...new Set([...previous.sourceCandidateIds, ...candidate.sourceCandidateIds])];
54
- previous.locations = uniqueLocations([...previous.locations, ...candidate.locations]);
55
- previous.impact = [...new Set([previous.impact, candidate.impact])].join("\n");
56
- previous.discoveryEvidence = [...new Set([...(previous.discoveryEvidence ?? []), ...(candidate.discoveryEvidence ?? [])])];
57
- }
52
+ else mergeCandidateInto(previous, candidate);
58
53
  }
59
54
  return [...seen.values()];
60
55
  }
61
56
 
57
+ /** Fold a duplicate into the kept candidate, keeping every source id, location, impact, and piece of evidence. */
58
+ export function mergeCandidateInto<T extends IdentifiedAdvisoryCandidate>(kept: T, duplicate: T): void {
59
+ kept.sourceCandidateIds = [...new Set([...kept.sourceCandidateIds, ...duplicate.sourceCandidateIds])];
60
+ kept.locations = uniqueLocations([...kept.locations, ...duplicate.locations]);
61
+ kept.impact = [...new Set([kept.impact, duplicate.impact])].join("\n");
62
+ kept.discoveryEvidence = [...new Set([...(kept.discoveryEvidence ?? []), ...(duplicate.discoveryEvidence ?? [])])];
63
+ }
64
+
62
65
  export function uniqueLocations(locations: AdvisoryCandidate["locations"]): AdvisoryCandidate["locations"] {
63
66
  return [...new Map(locations.map((location) => [JSON.stringify(location), location])).values()];
64
67
  }
@@ -1,4 +1,4 @@
1
- import type { Api, Model } from "@earendil-works/pi-ai";
1
+ import type { Api, ClassifierApi, ClassifierModel, Model } from "@earendil-works/pi-ai";
2
2
  import type {
3
3
  AgentSession,
4
4
  CreateAgentSessionOptions,
@@ -8,6 +8,7 @@ import type { WorkflowBudget } from "./budget.ts";
8
8
  import type { Semaphore } from "./concurrency.ts";
9
9
  import type { WorkflowAgentLimiter } from "./agent-limits.ts";
10
10
  import type { AgentRetryScheduler } from "./agent-retry.ts";
11
+ import type { HostToolBridge } from "./host-tools.ts";
11
12
  import type { ResolvedWorkflowModelProfiles } from "./model-profiles.ts";
12
13
  import type { WorkflowJournal } from "./journal.ts";
13
14
  import type { PerfSink } from "./perf.ts";
@@ -63,6 +64,10 @@ interface RunContextBase {
63
64
  budget: WorkflowBudget;
64
65
  journal: WorkflowJournal;
65
66
  worktrees: WorktreeRegistry;
67
+ /** Host MCP tools subagents may call; set only for a synchronous `workflow` tool run. */
68
+ hostTools?: HostToolBridge;
69
+ /** Classifier routes resolved this run, so repeated `api.classify()` calls skip the credential lookup. */
70
+ classifierRoutes?: Map<string, Promise<ClassifierModel<ClassifierApi>>>;
66
71
  }
67
72
 
68
73
  /** Shared per-run context threaded into every agent() call. */
@@ -74,7 +79,7 @@ export type RunContext = RunContextBase & (
74
79
  }
75
80
  | {
76
81
  /** Injected test sessions bypass Pi's resource loader and therefore do not resolve skills. */
77
- modelRegistry: Pick<ModelRegistry, "find">;
82
+ modelRegistry: Pick<ModelRegistry, "find" | "classify" | "getAvailableOfType" | "getModelOfType">;
78
83
  createSession: CreateAgentSession;
79
84
  }
80
85
  );
@@ -2,9 +2,11 @@ import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
2
2
  import {
3
3
  createAgentSessionFromServices,
4
4
  createAgentSessionServices,
5
+ createCodemodeExtension,
5
6
  defineTool,
6
7
  SessionManager,
7
8
  type CreateAgentSessionOptions,
9
+ type CreateAgentSessionServicesOptions,
8
10
  type ModelRegistry,
9
11
  type Skill,
10
12
  type ToolDefinition,
@@ -23,11 +25,15 @@ import {
23
25
  } from "./structured-output.ts";
24
26
  import { providerErrorFromMessages } from "./agent-retry.ts";
25
27
  import { synchronizeWorkflowModelRuntime } from "./agent-session-providers.ts";
28
+ import { hostToolProxy, isMcpToolName, WorkflowHostToolUnavailableError, type HostToolBridge } from "./host-tools.ts";
26
29
  import { matchesAgentToolHint, WorkflowToolHintUnavailableError } from "./tool-capabilities.ts";
30
+ import { parseAgentModelRef } from "./model-ref.ts";
27
31
  import { truncateText } from "./text.ts";
28
32
  import { WORKFLOW_TOOL_NAME, type AgentToolHint } from "./types.ts";
29
33
 
30
34
  export const FINAL_TOOL = "final_answer";
35
+ /** pi's codemode tool; SDK sessions only have it when their resource loader registers the extension. */
36
+ export const CODEMODE_TOOL = "codemode";
31
37
 
32
38
  const SCHEMA_REPROMPT =
33
39
  `You ended your turn without calling the ${FINAL_TOOL} tool, so no result was recorded. ` +
@@ -76,6 +82,8 @@ export async function openAgentSession(input: {
76
82
  description:
77
83
  "Return your final structured answer. This MUST be your last action — do not write a normal reply after calling it.",
78
84
  parameters: opts.schema,
85
+ // The agent's own terminal action: a codemode script must not answer on its behalf.
86
+ exposure: "model-only",
79
87
  async execute(_toolCallId, params) {
80
88
  captured = true;
81
89
  structuredResult = params;
@@ -84,6 +92,7 @@ export async function openAgentSession(input: {
84
92
  }),
85
93
  ]
86
94
  : [];
95
+ customTools.push(...requestedHostTools(opts, rc.hostTools));
87
96
  let session: AgentRunnerSession | undefined;
88
97
  try {
89
98
  throwIfAborted(rc.signal);
@@ -105,7 +114,7 @@ export async function openAgentSession(input: {
105
114
  if (!opts.tools?.includes(WORKFLOW_TOOL_NAME)) withholdWorkflowTool(session);
106
115
  if (opts.requireToolHints) {
107
116
  const missing = toolSelection.toolHints.filter((hint) => !matchedToolHints.has(hint));
108
- if (missing.length > 0) throw new WorkflowToolHintUnavailableError(missing);
117
+ if (missing.length > 0) throw new WorkflowToolHintUnavailableError(missing, { hostToolsReachable: rc.hostTools !== undefined });
109
118
  }
110
119
  throwIfAborted(rc.signal);
111
120
  return {
@@ -200,24 +209,6 @@ export async function promptAgentSession(input: {
200
209
  }
201
210
  }
202
211
 
203
- function parseAgentModelRef(modelRef: string): { readonly provider: string; readonly id: string } {
204
- const normalized = modelRef.trim();
205
- if (normalized.length === 0) {
206
- throw new Error('Invalid agent model ref: expected a bare model id or "provider/id".');
207
- }
208
- if (normalized !== modelRef) {
209
- throw new Error(`Invalid agent model ref "${modelRef}": remove leading or trailing whitespace.`);
210
- }
211
-
212
- const slash = modelRef.indexOf("/");
213
- if (slash === -1) return { provider: "anthropic", id: modelRef };
214
- const provider = modelRef.slice(0, slash);
215
- const id = modelRef.slice(slash + 1);
216
- if (provider.length === 0 || id.length === 0 || id.startsWith("/")) {
217
- throw new Error(`Invalid agent model ref "${modelRef}": expected "provider/id".`);
218
- }
219
- return { provider, id };
220
- }
221
212
 
222
213
  interface AgentSessionResources {
223
214
  readonly selectedSkills: readonly Skill[];
@@ -262,7 +253,7 @@ async function prepareAgentSessionResources(input: {
262
253
  const preparedSkills = prepareAgentSkillResources(skillOptions);
263
254
  const services = await createAgentSessionServices({
264
255
  cwd,
265
- resourceLoaderOptions: preparedSkills.resourceLoaderOptions,
256
+ resourceLoaderOptions: { ...preparedSkills.resourceLoaderOptions, ...codemodeExtension(opts) },
266
257
  });
267
258
  await synchronizeWorkflowModelRuntime({
268
259
  host: rc.modelRegistry,
@@ -289,6 +280,36 @@ async function prepareAgentSessionResources(input: {
289
280
  };
290
281
  }
291
282
 
283
+ /**
284
+ * SDK sessions load none of pi's built-in extensions, so codemode is registered only for agents whose
285
+ * allowlist names it. Scripts can call just the agent's other allowed tools. Mode "on" keeps those
286
+ * tools declared whatever the user's `codemode.mode`, so the author's allowlist stays what the model sees.
287
+ */
288
+ function codemodeExtension(opts: AgentExecutionOptions): Pick<SessionResourceLoaderOptions, "extensionFactories"> {
289
+ if (!opts.tools?.includes(CODEMODE_TOOL)) return {};
290
+ return { extensionFactories: [{ name: CODEMODE_TOOL, factory: createCodemodeExtension({ mode: "on" }), replaceable: true }] };
291
+ }
292
+
293
+ type SessionResourceLoaderOptions = NonNullable<CreateAgentSessionServicesOptions["resourceLoaderOptions"]>;
294
+
295
+ /**
296
+ * Proxies for the host MCP tools this agent asked for: those named in its allowlist, and open-world
297
+ * research tools when it hints `external-search`. Local `search` hints never bridge, because host tools
298
+ * run in the host session's working directory, not the agent's (an isolated worktree, for example).
299
+ * A named tool the run cannot reach fails the agent, like `requireToolHints`, rather than letting it
300
+ * work without a tool its prompt may rely on.
301
+ */
302
+ function requestedHostTools(opts: AgentExecutionOptions, bridge: HostToolBridge | undefined): ToolDefinition[] {
303
+ const named = new Set((opts.tools ?? []).filter(isMcpToolName));
304
+ const externalSearch = opts.toolHints?.includes("external-search") ?? false;
305
+ if (named.size === 0 && !externalSearch) return [];
306
+ const available = bridge?.tools() ?? [];
307
+ const selected = available.filter((tool) => named.has(tool.name) || (externalSearch && matchesAgentToolHint(tool, "external-search")));
308
+ const missing = [...named].filter((name) => !selected.some((tool) => tool.name === name));
309
+ if (missing.length > 0) throw new WorkflowHostToolUnavailableError(missing, bridge !== undefined);
310
+ return bridge ? selected.map((tool) => hostToolProxy(tool, bridge)) : [];
311
+ }
312
+
292
313
  function linkSessionAbort(signal: AbortSignal | undefined, session: AgentRunnerSession): () => void {
293
314
  if (!signal) return () => {};
294
315
  const onAbort = () => {
@@ -7,6 +7,7 @@ import { unknownErrorMessage } from "./unknown-error.ts";
7
7
  export interface BackgroundWorkflowToolResult {
8
8
  readonly content: Array<{ readonly type: "text"; readonly text: string }>;
9
9
  readonly details: Readonly<Record<string, unknown>>;
10
+ readonly isError?: true;
10
11
  }
11
12
 
12
13
  export interface BackgroundWorkflowToolStartInput {
@@ -25,6 +26,7 @@ export function backgroundUnavailableResult(mode: ExtensionContext["mode"]): Bac
25
26
  text: `Background workflows are unavailable in ${mode} mode because pi exits after the prompt. Run synchronously or use TUI/RPC mode.`,
26
27
  }],
27
28
  details: { error: "background_unavailable", mode },
29
+ isError: true,
28
30
  };
29
31
  }
30
32
 
@@ -63,6 +65,7 @@ export async function startBackgroundWorkflowTool(
63
65
  return {
64
66
  content: [{ type: "text", text: `Background workflow did not start: ${message}` }],
65
67
  details: { error: "background_start_failed", message, runId },
68
+ isError: true,
66
69
  };
67
70
  }
68
71
  return {
@@ -0,0 +1,107 @@
1
+ import type { ClassifierAnswer, ClassifierApi, ClassifierContext, ClassifierModel } from "@earendil-works/pi-ai";
2
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
3
+ import type { RunContext } from "./agent-runner-types.ts";
4
+ import { parseAgentModelRef } from "./model-ref.ts";
5
+ import { assertWorkflowBudgetAvailable } from "./budget.ts";
6
+ import { throwIfAborted } from "./cancellation.ts";
7
+ import type { ClassifyOptions } from "./types.ts";
8
+
9
+ type ClassifierRegistry = Pick<ModelRegistry, "classify" | "getAvailableOfType" | "getModelOfType">;
10
+
11
+ /** No classifier model is configured, or the requested one is unknown. Recoverable inside `parallel`. */
12
+ export class WorkflowClassifierUnavailableError extends Error {
13
+ readonly code = "WORKFLOW_CLASSIFIER_UNAVAILABLE";
14
+
15
+ constructor(message: string) {
16
+ super(message);
17
+ this.name = "WorkflowClassifierUnavailableError";
18
+ }
19
+ }
20
+
21
+ /** The classifier answered with an error or no answer. Recoverable inside `parallel`. */
22
+ export class WorkflowClassifierError extends Error {
23
+ readonly code = "WORKFLOW_CLASSIFIER_FAILED";
24
+
25
+ constructor(message: string) {
26
+ super(message);
27
+ this.name = "WorkflowClassifierError";
28
+ }
29
+ }
30
+
31
+ /**
32
+ * `api.classify()`: answer typed questions about JSON state with a classifier model through the host's
33
+ * credentials, without a chat session. It shares the run's concurrency cap, budget, and per-agent timeout,
34
+ * records usage like an agent, and is not journaled, so a resumed run asks again.
35
+ */
36
+ export async function runClassifier(
37
+ rc: RunContext,
38
+ context: ClassifierContext,
39
+ opts: ClassifyOptions = {},
40
+ ): Promise<Record<string, ClassifierAnswer>> {
41
+ const registry = rc.modelRegistry;
42
+ return await rc.semaphore.run(async () => {
43
+ assertWorkflowBudgetAvailable(rc.budget);
44
+ const model = await cachedClassifierModel(rc, registry, opts.model);
45
+ const label = opts.label ?? `classify:${model.provider}/${model.id}`;
46
+ // A stalled classifier must not hold a concurrency slot past the limit agents get.
47
+ const timeout = AbortSignal.timeout(rc.agentTimeoutMs);
48
+ const result = await registry.classify(model, context, { signal: rc.signal ? AbortSignal.any([rc.signal, timeout]) : timeout });
49
+ // One classifier response is one model response: recording it like one keeps per-model usage and the budget
50
+ // whole. Record before the abort check, since the provider charged for a call that returned as the run stopped.
51
+ rc.usage.recordAgentSession({
52
+ label,
53
+ phase: opts.phase,
54
+ messages: [{ role: "assistant", provider: result.provider, model: result.model, usage: result.usage }],
55
+ });
56
+ throwIfAborted(rc.signal);
57
+ if (result.stopReason !== "stop" && timeout.aborted) {
58
+ throw new WorkflowClassifierError(`${label}: classifier timed out after ${rc.agentTimeoutMs}ms`);
59
+ }
60
+ if (result.stopReason !== "stop") {
61
+ throw new WorkflowClassifierError(`${label}: classifier ${result.stopReason}${result.errorMessage ? `: ${result.errorMessage}` : ""}`);
62
+ }
63
+ return result.answers;
64
+ }, { signal: rc.signal });
65
+ }
66
+
67
+ /** One credential lookup per route and run; a route that failed to resolve keeps failing for the run. */
68
+ function cachedClassifierModel(
69
+ rc: RunContext,
70
+ registry: ClassifierRegistry,
71
+ ref: string | readonly string[] | undefined,
72
+ ): Promise<ClassifierModel<ClassifierApi>> {
73
+ const key = JSON.stringify(ref ?? null);
74
+ const cached = rc.classifierRoutes?.get(key);
75
+ if (cached) return cached;
76
+ const resolved = resolveClassifierModel(registry, ref);
77
+ rc.classifierRoutes?.set(key, resolved);
78
+ return resolved;
79
+ }
80
+
81
+ async function resolveClassifierModel(
82
+ registry: ClassifierRegistry,
83
+ ref: string | readonly string[] | undefined,
84
+ ): Promise<ClassifierModel<ClassifierApi>> {
85
+ if (ref !== undefined && typeof ref !== "string") {
86
+ const available = await registry.getAvailableOfType("classifier");
87
+ const preferred = ref.map((candidate) => parseAgentModelRef(candidate))
88
+ .map(({ provider, id }) => available.find((model) => model.provider === provider && model.id === id))
89
+ .find((model) => model !== undefined);
90
+ if (!preferred) throw new WorkflowClassifierUnavailableError(`None of the classifier models ${ref.join(", ")} has working credentials.`);
91
+ return preferred;
92
+ }
93
+ if (ref === undefined) {
94
+ const [available] = await registry.getAvailableOfType("classifier");
95
+ if (!available) {
96
+ throw new WorkflowClassifierUnavailableError(
97
+ "No classifier model has working credentials. Configure one (for example TypeSafe Jev via OpenRouter) or pass { model: \"provider/id\" }.",
98
+ );
99
+ }
100
+ return available;
101
+ }
102
+ // Same ref rules as agent({ model }): malformed refs throw, bare ids mean Anthropic.
103
+ const { provider, id } = parseAgentModelRef(ref);
104
+ const model = registry.getModelOfType("classifier", provider, id);
105
+ if (!model) throw new WorkflowClassifierUnavailableError(`Classifier model "${ref}" not found (resolved as ${provider}/${id}).`);
106
+ return model;
107
+ }
@@ -68,20 +68,13 @@ Adaptive multi-pass workflows are optional. Use a simple single-pass fan-out whe
68
68
  - synthesize the first-pass and follow-up results together.
69
69
  Do not generate a second pass when the first pass is sufficient, and do not invent iteration, quorum, graph, reduction, or retry primitives for this pattern.`;
70
70
 
71
- export const DYNAMAX_REMINDER = `
72
- ## dynamax workflow opt-in
71
+ /** System prompt section pi wraps as `<dynamax>`; adding or dropping it appends a section patch instead of rewriting the prompt. */
72
+ export const DYNAMAX_SECTION = "dynamax";
73
73
 
74
- The user has opted into dynamax multi-agent orchestration. The workflow tool is permitted for this task. You may either run an existing named workflow or author a new inline workflow script when that best serves the user's request.
75
-
76
- Inline workflow rules:
77
- - Use the injected Type object for schemas, for example Type.Object({ ok: Type.Boolean() }); do not import typebox.
78
- - Do not use import statements or dynamic import() in inline workflow scripts.
79
- - Set profile to "small", "medium", or "big" on each agent() call so routing remains explicit; use model/thinkingLevel only for an intentional override.
80
- - Subagents receive no skills by default. Add \`skills: ["skill-name"]\` per agent only when that stage should load that skill; grant the smallest useful set.
81
- - Provide exactly one of workflow.name or workflow.script.
82
-
83
- ${ADAPTIVE_WORKFLOW_GUIDANCE}
84
- `;
74
+ /** Only the opt-in itself: the authoring rules already reach the model as the workflow tool's guidelines. */
75
+ export const DYNAMAX_REMINDER =
76
+ "The user opted into dynamax multi-agent orchestration for this request, so the workflow tool is permitted. " +
77
+ "Run a registered workflow by name, or author an inline workflow script when none fits, following the workflow tool's guidelines.";
85
78
 
86
79
  export function createDynamaxState(): DynamaxState {
87
80
  return { sticky: false, oneShotPending: false, turnActive: false };
@@ -140,11 +133,16 @@ export function dynamaxStatusText(state: DynamaxState, theme: Theme): string {
140
133
  return mode ? `${label}${dot(theme)}${theme.fg("muted", mode)}` : label;
141
134
  }
142
135
 
143
- export function appendDynamaxSystemReminder(systemPrompt: string, state: DynamaxState): string {
144
- if (!state.sticky && !state.oneShotPending) return systemPrompt;
136
+ /**
137
+ * Add the opt-in section for this run and consume a pending one-shot. pi rebuilds `sections` for every
138
+ * run, so an inactive run simply leaves it out and pi records the removal as a patch.
139
+ */
140
+ export function applyDynamaxPromptSection(sections: Record<string, string>, state: DynamaxState): boolean {
141
+ if (!state.sticky && !state.oneShotPending) return false;
145
142
  state.oneShotPending = false;
146
143
  state.turnActive = true;
147
- return `${systemPrompt}\n\n${DYNAMAX_REMINDER.trim()}`;
144
+ sections[DYNAMAX_SECTION] = DYNAMAX_REMINDER;
145
+ return true;
148
146
  }
149
147
 
150
148
  export function registerDynamax(pi: ExtensionAPI, shortcuts: DynamaxShortcuts, options: DynamaxRegistrationOptions): DynamaxHandle {
@@ -238,10 +236,12 @@ export function registerDynamax(pi: ExtensionAPI, shortcuts: DynamaxShortcuts, o
238
236
 
239
237
  pi.on("before_agent_start", (event, ctx) => {
240
238
  const runtime = getDynamaxRuntime(runtimes, ctx);
241
- const systemPrompt = appendDynamaxSystemReminder(event.systemPrompt, runtime.state);
242
- if (systemPrompt === event.systemPrompt) return undefined;
239
+ const options = event.systemPromptOptions;
240
+ if (!applyDynamaxPromptSection(options.sections, runtime.state)) return undefined;
243
241
  updateDynamaxSurfaces(ctx, runtime);
244
- return { systemPrompt };
242
+ // An earlier extension forced the whole prompt, and pi renders no sections then; append to its text instead.
243
+ if (options.forceSystemPrompt === undefined) return undefined;
244
+ return { systemPrompt: `${options.forceSystemPrompt}\n\n<${DYNAMAX_SECTION}>\n${DYNAMAX_REMINDER}\n</${DYNAMAX_SECTION}>` };
245
245
  });
246
246
 
247
247
  pi.on("agent_end", (_event, ctx) => {
@@ -7,6 +7,7 @@ import { resolveWorkflowModelProfiles, type ResolvedWorkflowModelProfiles } from
7
7
  import { abortReason, isWorkflowPauseError, linkAbortSignal, throwIfAborted } from "./cancellation.ts";
8
8
  import { createBudget } from "./budget.ts";
9
9
  import { runAgent, type AgentExecutionOptions, type RunContext } from "./agent-runner.ts";
10
+ import { runClassifier } from "./classify.ts";
10
11
  import { ProgressTracker } from "./progress.ts";
11
12
  import { createPerfRecorder, formatPerfSummary, type PerfSink } from "./perf.ts";
12
13
  import { createWorkflowUsageRecorder, type WorkflowUsageSink } from "./usage.ts";
@@ -177,6 +178,8 @@ export async function runResolvedWorkflow(
177
178
  budget,
178
179
  journal,
179
180
  worktrees,
181
+ hostTools: resolvedOptions.hostTools,
182
+ classifierRoutes: new Map(),
180
183
  };
181
184
 
182
185
  // perf.total_ms wraps the whole tree: sub-workflows run inside this span via api.workflow().
@@ -401,6 +404,7 @@ export async function runWorkflowWithContext(
401
404
 
402
405
  const api: WorkflowApi = {
403
406
  agent,
407
+ classify: (context, classifyOpts) => runClassifier(rc, context, { ...classifyOpts, phase: scope.agentOptions({ phase: classifyOpts?.phase }).phase }),
404
408
  workflow,
405
409
  parallel: bindParallel({
406
410
  signal: rc.signal,
@@ -0,0 +1,76 @@
1
+ import type { AgentToolCallOutcome } from "@earendil-works/pi-agent-core";
2
+ import { defineTool, type ExtensionToolContext, type ToolDefinition, type ToolInfo } from "@earendil-works/pi-coding-agent";
3
+
4
+ /** pi names MCP tools `mcp__<server>__<tool>`; its MCP extension also registers these resource tools. */
5
+ const MCP_TOOL_PREFIX = "mcp__";
6
+ const MCP_RESOURCE_TOOLS = new Set(["list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"]);
7
+
8
+ /** Mirrors pi's own `isMcpToolName`, which the package does not export. */
9
+ export function isMcpToolName(name: string): boolean {
10
+ return name.startsWith(MCP_TOOL_PREFIX) || MCP_RESOURCE_TOOLS.has(name);
11
+ }
12
+
13
+ /** An agent named MCP tools it cannot reach. Raised before prompting so it never runs without them. */
14
+ export class WorkflowHostToolUnavailableError extends Error {
15
+ readonly code = "WORKFLOW_HOST_TOOL_UNAVAILABLE";
16
+ readonly tools: readonly string[];
17
+
18
+ constructor(tools: readonly string[], bridged: boolean) {
19
+ super(
20
+ bridged
21
+ ? `Host MCP tools not available: ${tools.join(", ")}`
22
+ : `MCP tools reach subagents only in runs started by the workflow tool; unavailable: ${tools.join(", ")}`,
23
+ );
24
+ this.name = "WorkflowHostToolUnavailableError";
25
+ this.tools = [...tools];
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Host-only tools a subagent can reach through the host session. Subagents are SDK sessions, which
31
+ * load none of pi's built-in extensions, so they connect no MCP servers of their own; proxies call the
32
+ * host's tools over its existing connections and through its `tool_call` permission handlers. They act
33
+ * in the host's context, so a local MCP tool sees the main checkout even for an isolated worktree agent.
34
+ */
35
+ export interface HostToolBridge {
36
+ /** Callable host MCP tools, read as each agent starts so servers that connected mid-run are included. */
37
+ tools(): readonly ToolInfo[];
38
+ execute(name: string, args: unknown, signal: AbortSignal | undefined): Promise<AgentToolCallOutcome>;
39
+ }
40
+
41
+ /**
42
+ * `executeTool` exists only while the host's tool call runs, so only a synchronous `workflow` tool run
43
+ * gets a bridge; `/workflow` commands and background runs that outlive the call do not.
44
+ */
45
+ export function createHostToolBridge(
46
+ registeredTools: () => readonly ToolInfo[],
47
+ ctx: Pick<ExtensionToolContext, "tools" | "executeTool">,
48
+ ): HostToolBridge {
49
+ return {
50
+ tools() {
51
+ const callable = new Set(ctx.tools.map((tool) => tool.name));
52
+ return registeredTools().filter((tool) => isMcpToolName(tool.name) && callable.has(tool.name));
53
+ },
54
+ execute: (name, args, signal) => ctx.executeTool(name, args, { signal }),
55
+ };
56
+ }
57
+
58
+ /**
59
+ * A subagent tool that runs the host tool. It reports no usage: pi already adds nested calls' usage to
60
+ * the host's `workflow` tool result.
61
+ */
62
+ export function hostToolProxy(tool: ToolInfo, bridge: HostToolBridge): ToolDefinition {
63
+ return defineTool({
64
+ name: tool.name,
65
+ label: tool.name,
66
+ description: tool.description,
67
+ parameters: tool.parameters,
68
+ promptGuidelines: tool.promptGuidelines,
69
+ namespace: tool.namespace,
70
+ annotations: tool.annotations,
71
+ async execute(_toolCallId, params, signal) {
72
+ const outcome = await bridge.execute(tool.name, params, signal);
73
+ return { content: outcome.result.content, details: undefined, ...(outcome.isError ? { isError: true } : {}) };
74
+ },
75
+ });
76
+ }
@@ -0,0 +1,19 @@
1
+ /** Parse a model ref: a bare id is Anthropic shorthand, otherwise "provider/id". Malformed refs throw. */
2
+ export function parseAgentModelRef(modelRef: string): { readonly provider: string; readonly id: string } {
3
+ const normalized = modelRef.trim();
4
+ if (normalized.length === 0) {
5
+ throw new Error('Invalid agent model ref: expected a bare model id or "provider/id".');
6
+ }
7
+ if (normalized !== modelRef) {
8
+ throw new Error(`Invalid agent model ref "${modelRef}": remove leading or trailing whitespace.`);
9
+ }
10
+
11
+ const slash = modelRef.indexOf("/");
12
+ if (slash === -1) return { provider: "anthropic", id: modelRef };
13
+ const provider = modelRef.slice(0, slash);
14
+ const id = modelRef.slice(slash + 1);
15
+ if (provider.length === 0 || id.length === 0 || id.startsWith("/")) {
16
+ throw new Error(`Invalid agent model ref "${modelRef}": expected "provider/id".`);
17
+ }
18
+ return { provider, id };
19
+ }
@@ -1,6 +1,6 @@
1
- const MINIMUM_PI_VERSION = "0.80.10";
1
+ const MINIMUM_PI_VERSION = "1.0.0";
2
2
 
3
- const MINIMUM_PI_CORE = [0, 80, 10] as const;
3
+ const MINIMUM_PI_CORE = [1, 0, 0] as const;
4
4
 
5
5
  /** Fail during extension registration instead of failing later in a workflow. */
6
6
  export function assertSupportedPiVersion(version: string): void {
@@ -155,8 +155,10 @@ const UNAVAILABLE_RESEARCH = {
155
155
  nextStep: "Run `/workflow research <question>` and include any source, date, or geography constraints in the arguments.",
156
156
  },
157
157
  "missing-capability": {
158
- message: "Research could not start because pi exposed no installed external web-search or URL-extraction tool.",
159
- nextStep: "Install or enable a pi tool that can search the web or extract HTTP(S) pages, then rerun the workflow.",
158
+ message: "Research could not start because its agents could reach no external web-search or URL-extraction tool.",
159
+ nextStep:
160
+ "Install or enable a pi tool that can search the web or extract HTTP(S) pages, then rerun the workflow. " +
161
+ "An MCP web search server counts only when the host agent runs research through the workflow tool, for example after `dynamax`.",
160
162
  },
161
163
  "lanes-failed": {
162
164
  message: "Research could not gather direct-page evidence because one or more research lanes failed.",
@@ -1,4 +1,5 @@
1
1
  import type { ToolInfo } from "@earendil-works/pi-coding-agent";
2
+ import { isMcpToolName } from "./host-tools.ts";
2
3
  import type { AgentToolHint } from "./types.ts";
3
4
 
4
5
  /** Raised before prompting when required semantic tool capabilities are unavailable. */
@@ -6,8 +7,12 @@ export class WorkflowToolHintUnavailableError extends Error {
6
7
  readonly code = "WORKFLOW_TOOL_HINT_UNAVAILABLE";
7
8
  readonly hints: readonly AgentToolHint[];
8
9
 
9
- constructor(hints: readonly AgentToolHint[]) {
10
- super(`Required workflow tool capability unavailable: ${hints.join(", ")}`);
10
+ constructor(hints: readonly AgentToolHint[], options: { readonly hostToolsReachable?: boolean } = {}) {
11
+ // Host MCP tools only count in runs that can reach them; say so rather than imply none are installed.
12
+ const mcpNote = hints.includes("external-search") && options.hostToolsReachable === false
13
+ ? " (MCP tools reach subagents only in runs started by the workflow tool)"
14
+ : "";
15
+ super(`Required workflow tool capability unavailable: ${hints.join(", ")}${mcpNote}`);
11
16
  this.name = "WorkflowToolHintUnavailableError";
12
17
  this.hints = [...hints];
13
18
  }
@@ -18,7 +23,9 @@ export function matchesAgentToolHint(tool: ToolInfo, hint: AgentToolHint): boole
18
23
  }
19
24
 
20
25
  function isExternalSearchLikeTool(tool: ToolInfo): boolean {
21
- if (isMutationLikeToolName(tool.name) || tool.sourceInfo.source === "builtin") return false;
26
+ if (isMutationLikeToolName(tool.name) || isPiLocalTool(tool)) return false;
27
+ // MCP servers declare these hints: a tool that changes its environment or stays in a closed domain is not web research.
28
+ if (tool.annotations?.readOnlyHint === false || tool.annotations?.openWorldHint === false) return false;
22
29
  const name = tool.name.toLowerCase();
23
30
  const description = [tool.description, ...(tool.promptGuidelines ?? [])].join(" ").toLowerCase();
24
31
  const text = `${name} ${description}`;
@@ -40,6 +47,11 @@ function isSearchLikeTool(tool: ToolInfo): boolean {
40
47
  return /\b(?:grep|find|search|ripgrep|rg|structural search|code search)\b/.test(tool.description.toLowerCase());
41
48
  }
42
49
 
50
+ /** pi's own tools, such as read and bash. MCP tools also come from a built-in extension but reach outside it. */
51
+ function isPiLocalTool(tool: ToolInfo): boolean {
52
+ return tool.sourceInfo.source === "builtin" && !isMcpToolName(tool.name);
53
+ }
54
+
43
55
  function isMutationLikeToolName(name: string): boolean {
44
56
  return /(?:edit|write|replace|patch|apply|delete|remove|move|rename|create|commit|push)/.test(name.toLowerCase());
45
57
  }
@@ -1,4 +1,5 @@
1
1
  import type { Static, TSchema } from "typebox";
2
+ import type { ClassifierAnswer, ClassifierContext } from "@earendil-works/pi-ai";
2
3
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
3
4
  import type { WorkflowBudget } from "./budget.ts";
4
5
  import type { Pipeline, WorkflowParallel } from "./concurrency.ts";
@@ -10,6 +11,7 @@ import type { WorkflowLaneItemStatus, WorkflowProgressSnapshot } from "./progres
10
11
  import type { WorkflowUsageSnapshot } from "./usage.ts";
11
12
  import type { WorktreeBaseline } from "./worktree.ts";
12
13
  import type { WorkflowModelProfileName } from "./model-profiles.ts";
14
+ import type { HostToolBridge } from "./host-tools.ts";
13
15
  import type { WorkflowRunState } from "./workflow-run-record.ts";
14
16
 
15
17
  export type { WorkflowLaneItemStatus } from "./progress-types.ts";
@@ -79,6 +81,8 @@ export interface WorkflowRunOptions {
79
81
  signal?: AbortSignal;
80
82
  /** Internal recorder override for command/tool invocation timing. */
81
83
  perfRecorder?: PerfSink;
84
+ /** Internal bridge to the host session's MCP tools, available while a `workflow` tool call runs. */
85
+ hostTools?: HostToolBridge;
82
86
  /** Resolve a sub-workflow reference to a module, enabling `api.workflow()`. When omitted, `api.workflow()` throws. */
83
87
  resolveWorkflow?: (ref: WorkflowRef) => Promise<LoadedWorkflow>;
84
88
  /** Called with the final performance snapshot when perf is enabled. */
@@ -185,6 +189,19 @@ export interface AgentOptions<S extends TSchema = TSchema> {
185
189
  schema?: S;
186
190
  }
187
191
 
192
+ /** Options for `api.classify()`. */
193
+ export interface ClassifyOptions {
194
+ /**
195
+ * Classifier model as "provider/id", or refs in preference order of which the first with working
196
+ * credentials is used. Default: the first classifier model with working credentials.
197
+ */
198
+ model?: string | readonly string[];
199
+ /** Label for usage reporting. Default: `classify:<provider>/<id>`. */
200
+ label?: string;
201
+ /** Phase to attribute usage to. Default: the current phase. */
202
+ phase?: string;
203
+ }
204
+
188
205
  /**
189
206
  * The primitives injected into every workflow run. A workflow is any module that
190
207
  * exports `meta` plus a default `async (api: WorkflowApi) => result`.
@@ -201,6 +218,13 @@ export interface WorkflowApi {
201
218
  agent<S extends TSchema>(prompt: string, opts: AgentOptions<S> & { schema: S }): Promise<Static<S>>;
202
219
  /** Run a subagent and return its final assistant text. */
203
220
  agent(prompt: string, opts?: AgentOptions): Promise<string>;
221
+ /**
222
+ * Answer typed `choice`/`score`/`bool` questions about JSON `state` with a classifier model, without
223
+ * starting a chat session: a cheap gate for triage, deduplication, or routing between stages. Answers
224
+ * are keyed by question id. Rejects with a recoverable error when no classifier is configured or the
225
+ * call fails; results are not journaled, so a resumed run classifies again.
226
+ */
227
+ classify(context: ClassifierContext, opts?: ClassifyOptions): Promise<Record<string, ClassifierAnswer>>;
204
228
  /**
205
229
  * Run another registered workflow inline as a sub-step and return its result. The child shares
206
230
  * this run's concurrency cap, abort signal, and perf sink. Nests one level only: calling
@@ -1,3 +1,4 @@
1
+ import type { Usage } from "@earendil-works/pi-ai";
1
2
  import { isFiniteNumber, isRecord } from "./guards.ts";
2
3
  import { formatCount } from "./text.ts";
3
4
 
@@ -81,9 +82,14 @@ export class WorkflowUsageRecorder implements WorkflowUsageSink {
81
82
  const parsed = parseAssistantUsageMessage(message);
82
83
  return parsed ? [parsed] : [];
83
84
  });
84
- if (assistantMessages.length === 0) return;
85
+ // Tools that call models themselves, such as codemode running a classifier, report it on their result.
86
+ const toolUsage = input.messages.flatMap((message) => {
87
+ const parsed = parseToolResultUsage(message);
88
+ return parsed ? [parsed] : [];
89
+ });
90
+ if (assistantMessages.length === 0 && toolUsage.length === 0) return;
85
91
 
86
- const usage = sumTotals(assistantMessages.map((message) => message.usage));
92
+ const usage = sumTotals([...assistantMessages.map((message) => message.usage), ...toolUsage]);
87
93
  const latestMetadata = assistantMessages.findLast((message) => message.provider !== undefined || message.model !== undefined);
88
94
  this.agents.push({
89
95
  label: input.label,
@@ -127,6 +133,13 @@ export function hasWorkflowUsage(snapshot: unknown): snapshot is WorkflowUsageSn
127
133
  return snapshot.totals.totalTokens > 0 || snapshot.totals.cost.total > 0;
128
134
  }
129
135
 
136
+ /** The run's subagent totals as pi's `Usage`, so a tool result adds them to the session's cost. */
137
+ export function workflowUsageForPi(snapshot: unknown): Usage | undefined {
138
+ if (!hasWorkflowUsage(snapshot)) return undefined;
139
+ const { input, output, cacheRead, cacheWrite, totalTokens, cost } = snapshot.totals;
140
+ return { input, output, cacheRead, cacheWrite, totalTokens, cost: { ...cost } };
141
+ }
142
+
130
143
  export function formatWorkflowUsageLine(snapshot: unknown): string | undefined {
131
144
  if (!hasWorkflowUsage(snapshot)) return undefined;
132
145
  const parts = formatWorkflowUsageComponents(snapshot.totals);
@@ -217,6 +230,11 @@ function parseAssistantUsageMessage(message: unknown): AssistantUsageMessage | u
217
230
  };
218
231
  }
219
232
 
233
+ function parseToolResultUsage(message: unknown): WorkflowUsageTotals | undefined {
234
+ if (!isRecord(message) || message.role !== "toolResult") return undefined;
235
+ return parseUsageTotals(message.usage);
236
+ }
237
+
220
238
  function parseUsageTotals(value: unknown): WorkflowUsageTotals | undefined {
221
239
  if (!isRecord(value)) return undefined;
222
240
  const input = finiteNumber(value.input);
@@ -1,5 +1,8 @@
1
1
  import { AdvisoryCandidatesSchema, AdvisoryVerdictSchema, formatAdvisoryLocation, type AdvisoryCandidate, type AdvisoryFinding, type IdentifiedAdvisoryCandidate, type AdvisoryLocation, type AdvisoryReport, type AdvisoryVerdict } from "./advisory-schema.ts";
2
- import { AdvisorySynthesisSchema, SYNTHESIS_ID_INSTRUCTIONS, withAdvisoryCoverage, collectAdvisoryStage, dedupeCandidates, identifyCandidates, uniqueLocations, type AdvisoryStageCoverage, type AdvisorySynthesis } from "./advisory-evidence.ts";
2
+ import { AdvisorySynthesisSchema, SYNTHESIS_ID_INSTRUCTIONS, withAdvisoryCoverage, collectAdvisoryStage, dedupeCandidates, identifyCandidates, mergeCandidateInto, repoPathCandidates, uniqueLocations, type AdvisoryStageCoverage, type AdvisorySynthesis } from "./advisory-evidence.ts";
3
+ import { isFatalWorkflowError } from "./cancellation.ts";
4
+ import { WorkflowClassifierUnavailableError } from "./classify.ts";
5
+ import { unknownErrorMessage } from "./unknown-error.ts";
3
6
  import type { AgentOptions, WorkflowApi, WorkflowProgressEvent, WorkflowRunStats } from "./types.ts";
4
7
 
5
8
 
@@ -49,7 +52,7 @@ export function publishVerifiedKeptProgress(
49
52
  }
50
53
 
51
54
  export interface LensVerificationPipelineOptions {
52
- api: Pick<WorkflowApi, "agent" | "parallel" | "phase" | "progress" | "log">;
55
+ api: Pick<WorkflowApi, "agent" | "classify" | "parallel" | "phase" | "progress" | "log">;
53
56
  lenses: readonly AdvisoryLens[];
54
57
  perLens: number;
55
58
  finderPhase?: "Find" | "Hypothesize";
@@ -90,7 +93,7 @@ export async function runLensVerificationPipeline(
90
93
  }
91
94
  return candidates;
92
95
  } })), coverage);
93
- const candidates = dedupeCandidates(found.flat());
96
+ const candidates = await mergeEquivalentCandidates(api, dedupeCandidates(found.flat()));
94
97
  const dropped = rawCandidates - candidates.length;
95
98
  if (dropped > 0) {
96
99
  api.progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: dropped });
@@ -112,6 +115,106 @@ export async function runLensVerificationPipeline(
112
115
  return { verified, rawCandidates, dropped, coverage };
113
116
  }
114
117
 
118
+ /** TypeSafe Jev routes in preference order. Jev is the classifier the duplicate merge was measured with. */
119
+ export const DUPLICATE_CANDIDATE_CLASSIFIERS = [
120
+ "typesafe/jev-latest",
121
+ "openrouter/~typesafe/jev-latest",
122
+ "openrouter/typesafe/jev-1.13",
123
+ "cloudflare-workers-ai/typesafe/jev",
124
+ "vercel-ai-gateway/typesafe-ai/jev",
125
+ "opencode/jev-1.13",
126
+ "opencode/jev-1.13-free",
127
+ ] as const;
128
+
129
+ /** Probability above which two candidates count as one defect. */
130
+ const SAME_DEFECT_PROBABILITY = 0.8;
131
+
132
+ /**
133
+ * Merge same-file candidates that a classifier judges to be one defect, so each defect gets one verifier.
134
+ * Lenses often report the same bug in different words; on GPT-6.1 Sol code reviews this cut verifier
135
+ * agents and cost by about 40% without losing a finding. Runs without a reachable Jev classifier skip it.
136
+ */
137
+ export async function mergeEquivalentCandidates<T extends IdentifiedAdvisoryCandidate>(
138
+ api: Pick<WorkflowApi, "classify" | "parallel" | "log">,
139
+ candidates: readonly T[],
140
+ ): Promise<T[]> {
141
+ const files = candidates.map(candidateFile);
142
+ const pairs: Array<readonly [number, number]> = [];
143
+ for (let first = 0; first < candidates.length; first++) {
144
+ for (let second = first + 1; second < candidates.length; second++) {
145
+ if (files[first] === files[second]) pairs.push([first, second]);
146
+ }
147
+ }
148
+ if (pairs.length === 0) return [...candidates];
149
+ const sameDefect = async ([first, second]: readonly [number, number]): Promise<number> => {
150
+ const answers = await api.classify({
151
+ state: { first: candidateForClassifier(candidates[first]!), second: candidateForClassifier(candidates[second]!) },
152
+ questions: {
153
+ same: {
154
+ type: "bool",
155
+ instructions: "Two code-review candidates were raised against the same file. Do they describe the same underlying defect (same root cause and same fix), even if worded differently?",
156
+ criteria: { true: "Same underlying defect", false: "Different defects" },
157
+ },
158
+ },
159
+ }, { model: DUPLICATE_CANDIDATE_CLASSIFIERS, label: "dedup" });
160
+ return answers.same?.type === "bool" ? answers.same.probability : 0;
161
+ };
162
+ // The first call probes the classifier: if it fails for any reason (no Jev route, bad credentials, a rate
163
+ // limit), skip the merge rather than send every other pair into the same failure.
164
+ let firstProbability: number;
165
+ try {
166
+ firstProbability = await sameDefect(pairs[0]!);
167
+ } catch (error) {
168
+ if (isFatalWorkflowError(error, undefined)) throw error;
169
+ if (!(error instanceof WorkflowClassifierUnavailableError)) api.log(`Skipped merging duplicate candidates: ${unknownErrorMessage(error)}`);
170
+ return [...candidates];
171
+ }
172
+ if (pairs.length > 1) api.log(`Comparing ${pairs.length} same-file candidate pairs for duplicates`);
173
+ const rest = await api.parallel(pairs.slice(1).map((pair) => () => sameDefect(pair)));
174
+ const probability = new Map<string, number>();
175
+ pairs.forEach(([first, second], index) => probability.set(`${first}:${second}`, index === 0 ? firstProbability : rest[index - 1] ?? 0));
176
+
177
+ // Complete linkage: a candidate joins a group only when it matches every member, so one vague candidate
178
+ // that resembles two different defects cannot chain them together.
179
+ const groups: number[][] = [];
180
+ candidates.forEach((_, index) => {
181
+ const group = groups.find((members) =>
182
+ members.every((member) => (probability.get(`${member}:${index}`) ?? 0) >= SAME_DEFECT_PROBABILITY));
183
+ if (group) group.push(index);
184
+ else groups.push([index]);
185
+ });
186
+ const merged = groups.map(([keep, ...folded]) => {
187
+ const kept = { ...candidates[keep!]! };
188
+ for (const index of folded) {
189
+ const duplicate = candidates[index]!;
190
+ mergeCandidateInto(kept, duplicate);
191
+ // The kept summary leads; keep folded wording visible to the verifier in case the classifier was wrong.
192
+ if (duplicate.summary !== kept.summary) {
193
+ kept.discoveryEvidence = [...new Set([...(kept.discoveryEvidence ?? []), `Also reported as: ${duplicate.summary}`])];
194
+ }
195
+ }
196
+ return kept;
197
+ });
198
+ const folded = candidates.length - merged.length;
199
+ if (folded > 0) api.log(`Merged ${folded} candidate(s) describing the same defect before verification`);
200
+ return merged;
201
+ }
202
+
203
+ /** Pair by the changed line when there is one, with the repository-relative path exact dedup uses. */
204
+ function candidateFile(candidate: IdentifiedAdvisoryCandidate): string {
205
+ return repoPathCandidates((candidate.reviewAnchor ?? primaryLocation(candidate)).file)[1];
206
+ }
207
+
208
+ function candidateForClassifier(candidate: IdentifiedAdvisoryCandidate) {
209
+ return {
210
+ category: candidate.category,
211
+ summary: candidate.summary,
212
+ impact: candidate.impact,
213
+ location: formatLocation(candidate),
214
+ evidence: (candidate.discoveryEvidence ?? []).slice(0, 4),
215
+ };
216
+ }
217
+
115
218
  /** Stats for a lens review that stopped before discovery. */
116
219
  export const EMPTY_LENS_REVIEW_STATS: Readonly<WorkflowRunStats> = Object.freeze({ files: 0, candidates: 0, verified: 0, kept: 0, dropped: 0, refuted: 0 });
117
220
 
package/README.md CHANGED
@@ -15,7 +15,7 @@ one-off workflow for the task.
15
15
 
16
16
  ## Quick start
17
17
 
18
- Requires **pi 0.80.10 or newer**.
18
+ Requires **pi 1.0.0 or newer**.
19
19
 
20
20
  ```bash
21
21
  pi install npm:pi-workflow-engine
package/USAGE.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  `pi-workflow-engine` adds zero-dependency Dynamax workflows to pi: opt into live, parallel subagents for the tasks that need a custom investigation instead of one long prompt. Most users only need these surfaces:
4
4
 
5
- This release requires **pi 0.80.10 or newer**. pi remains host-provided rather
5
+ This release requires **pi 1.0.0 or newer**. pi remains host-provided rather
6
6
  than bundled by this package, and the extension rejects older runtimes at load
7
7
  time with an update message.
8
8
 
@@ -109,7 +109,9 @@ command instead of selecting another model.
109
109
  as external web search, browsing, or HTTP(S) page extraction. Local grep/find
110
110
  tools do not satisfy this check. The workflow does not bundle a scraper or a
111
111
  paid API; install or enable the web capability you prefer, then restart or
112
- reload pi so subagent sessions can see it.
112
+ reload pi so subagent sessions can see it. An MCP web search server also
113
+ qualifies when the host agent runs `research` through the `workflow` tool,
114
+ because only those runs can reach the host's MCP tools.
113
115
 
114
116
  The workflow bounds decomposition to four lanes and verification to twelve
115
117
  claims, while the engine's concurrency, total-agent, timeout, cancellation, and
@@ -146,7 +148,7 @@ Sticky mode and inspector shortcut:
146
148
 
147
149
  The workflow inspector and code-review results viewer are also registered as first-class shortcuts shown by `/hotkeys`. The defaults are `ctrl+shift+m` for the inspector and `ctrl+shift+r` for the latest review results. `ctrl+o` is intentionally not used because pi already uses it for tool-output expansion and tree filtering.
148
150
 
149
- When only the literal `dynamax` token is used, the opt-in is one-shot: the next agent run receives the workflow permission reminder, stays visibly active for that run, then clears after the run ends. When `/workflow:dynamax on` is used, the opt-in is sticky for the current pi session until `/workflow:dynamax off`. The footer status states the mode compactly: `◆ dynamax` while sticky, `◆ dynamax · next prompt` while a one-shot waits for its prompt, and `◆ dynamax · this turn` while that run is active; off mode clears it. A running workflow names itself in its own status next to it.
151
+ When only the literal `dynamax` token is used, the opt-in is one-shot: the next agent run receives a `<dynamax>` system prompt section that permits the workflow tool (pi appends it as a section patch, so the cached prompt head is kept), stays visibly active for that run, then clears after the run ends. When `/workflow:dynamax on` is used, the opt-in is sticky for the current pi session until `/workflow:dynamax off`. The footer status states the mode compactly: `◆ dynamax` while sticky, `◆ dynamax · next prompt` while a one-shot waits for its prompt, and `◆ dynamax · this turn` while that run is active; off mode clears it. A running workflow names itself in its own status next to it.
150
152
 
151
153
  ### Prompt editor cue
152
154
 
@@ -160,7 +162,7 @@ The default effect is `shine`. Set `PI_DYNAMAX_EFFECT=static` for a motionless
160
162
  cue or `PI_DYNAMAX_EFFECT=off` to disable it. A non-empty `NO_COLOR` also
161
163
  disables the default cue; an explicit `PI_DYNAMAX_EFFECT` value takes precedence.
162
164
 
163
- The implementation follows pi 0.80.10's [official `CustomEditor`
165
+ The implementation follows pi's [official `CustomEditor`
164
166
  pattern](https://pi.dev/docs/latest/extensions#custom-editor): it decorates only
165
167
  the editor's rendered lines, retaining the wrapped editor's input, cursor,
166
168
  deletion, paste, multiline, undo, completion, IME, and app-keybinding behavior.
@@ -423,6 +425,11 @@ overrides its profile.
423
425
 
424
426
  `tools` is a strict allowlist. If you set `tools: ["read", "bash"]`, extension tools such as `ast-grep`, `mgrep`, `fffind`, or `ffgrep` are hidden from that subagent. Add `toolHints: ["search"]` to dynamically expose installed grep/find/search-like tools while keeping the concrete base allowlist portable. Use `toolHints: ["external-search"]` for installed web search, browsing, and URL-extraction tools; `requireToolHints: true` fails before prompting unless each requested capability matched. The built-in advisory workflows use `tools: ["read", "bash", "grep", "find", "ls"]` plus `toolHints: ["search"]`.
425
427
 
428
+ Subagents are SDK sessions, so pi's built-in codemode and MCP extensions do not load in them. Two opt-ins cover that:
429
+
430
+ - **Codemode.** Add `"codemode"` to an agent's `tools` to let it batch or filter many tool calls in one script, for example `tools: ["read", "grep", "codemode"]`. Scripts can call only that agent's other allowed tools, and never `final_answer`. Classifier calls a script makes count toward the run's usage and budget.
431
+ - **Host MCP tools.** In runs started by the `workflow` tool, subagents reach the host's MCP tools over its existing connections and through its permission handlers. Name a tool in `tools` (for example `"mcp__linear__create_issue"`), or use `toolHints: ["external-search"]` to pick up MCP web research tools; servers that declare a tool not read-only or closed-world are excluded from that hint. Bridged tools act in the host session, so the local `search` hint never bridges them, and an isolated worktree agent that names a local MCP tool sees the main checkout through it, not its worktree. `/workflow` commands and background runs outlive the host tool call that makes this possible: an agent there that names an MCP tool fails before prompting rather than running without it.
432
+
426
433
  Subagents receive no skills by default. Opt in per agent with `skills: ["skill-name"]`; if `tools` is also restricted, the engine automatically keeps `read` available so the subagent can load the selected `SKILL.md`. When `skills` is omitted, clear prompt text such as `/skill:name`, `include skill name`, or `use the name skill` is also treated as an opt-in. Pass `skills: []` to suppress that inference.
427
434
 
428
435
  Set `model` only when a subagent should use a specific model. Bare ids keep the Anthropic shorthand; `provider/id` targets built-in, custom, or local providers. Explicit models override a requested profile; calls with neither inherit the host/session default. Malformed or unknown explicit refs fail fast.
@@ -461,6 +468,25 @@ export default async function run({ workflow }: WorkflowApi) {
461
468
 
462
469
  Nesting is one level only: calling `workflow()` from inside a sub-workflow rejects. Resolution throws on an unknown name. Inside `parallel()` or `pipeline()`, recoverable branch errors become `null` results, so filter nulls before synthesis; a genuine run abort still rejects.
463
470
 
471
+ ### Classify between stages
472
+
473
+ `classify(context, opts?)` answers typed `choice`, `score`, or `bool` questions about JSON `state` with one of pi's classifier models (for example TypeSafe Jev or Cloudflare Clef), using the host's credentials and no chat session. It suits cheap gates between stages, such as deduplicating findings or deciding which items need a follow-up agent.
474
+
475
+ ```ts
476
+ const answers = await api.classify(
477
+ {
478
+ state: { finding },
479
+ questions: {
480
+ followUp: { type: "bool", instructions: "Does this finding need verification?", criteria: { true: "Plausible but unproven", false: "Clearly settled" } },
481
+ },
482
+ },
483
+ { label: `triage:${finding.id}` },
484
+ );
485
+ if (answers.followUp?.type === "bool" && answers.followUp.probability > 0.5) { /* verify it */ }
486
+ ```
487
+
488
+ Without `opts.model` ("provider/id"), the first classifier with working credentials is used. Calls share the run's concurrency cap, count toward usage and the budget, and are not journaled, so a resumed run classifies again. When no classifier is configured the call rejects with a recoverable error, so guard it or let `parallel()` turn it into `null`.
489
+
464
490
  ### Where workflows live
465
491
 
466
492
  Guaranteed built-ins are statically registered:
@@ -575,6 +601,25 @@ fails, the result retains verified records and reports incomplete coverage.
575
601
  Synthesis selects or merges IDs; the workflow reconstructs evidence-bearing
576
602
  fields from those records.
577
603
 
604
+ ### Duplicate candidates
605
+
606
+ Lenses often report the same defect in different words. When a TypeSafe Jev
607
+ classifier is reachable (TypeSafe directly, OpenRouter, Cloudflare Workers AI,
608
+ Vercel AI Gateway, or OpenCode Zen), the four advisory workflows ask it whether
609
+ each pair of candidates in the same file describes one defect. Before
610
+ verification, a candidate joins a group only when the classifier is at least 80%
611
+ sure it matches every member, so each defect gets one verifier. Merged
612
+ candidates keep all of their source IDs, locations, impacts, and evidence, and
613
+ the verifier sees the other wordings as "Also reported as" evidence. If the
614
+ first comparison fails (no Jev credentials, a bad key, a rate limit), the merge
615
+ is skipped.
616
+
617
+ Measured on GPT-6.1 Sol code reviews with planted bugs, the merge cut verifier
618
+ agents and cost by about 40% (e.g. 29 → 18 agents, $0.60 → $0.37 list price on
619
+ a small diff; 22 → 14 agents, $1.74 → $0.89 on a 160 KB diff). In every
620
+ instrumented run, each planted bug that a finder proposed reached the final
621
+ report, with or without merging.
622
+
578
623
  ### Selective adversarial challenges
579
624
 
580
625
  Add `--challenge` to an advisory workflow to challenge up to three uncertain or
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-workflow-engine",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "description": "Zero-dependency Dynamax workflows for pi: live parallel subagents, typed handoffs, synthesized results.",
6
6
  "keywords": [