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.
- package/.pi/extensions/pi-workflow-engine/index.ts +47 -26
- package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +9 -6
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +7 -2
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +41 -20
- package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +3 -0
- package/.pi/extensions/pi-workflow-engine/src/classify.ts +107 -0
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +19 -19
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +4 -0
- package/.pi/extensions/pi-workflow-engine/src/host-tools.ts +76 -0
- package/.pi/extensions/pi-workflow-engine/src/model-ref.ts +19 -0
- package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +2 -2
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +4 -2
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +15 -3
- package/.pi/extensions/pi-workflow-engine/src/types.ts +24 -0
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +20 -2
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +106 -3
- package/README.md +1 -1
- package/USAGE.md +49 -4
- package/package.json +1 -1
|
@@ -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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
|
242
|
-
if (
|
|
239
|
+
const options = event.systemPromptOptions;
|
|
240
|
+
if (!applyDynamaxPromptSection(options.sections, runtime.state)) return undefined;
|
|
243
241
|
updateDynamaxSurfaces(ctx, runtime);
|
|
244
|
-
|
|
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.
|
|
1
|
+
const MINIMUM_PI_VERSION = "1.0.0";
|
|
2
2
|
|
|
3
|
-
const MINIMUM_PI_CORE = [
|
|
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
|
|
159
|
-
nextStep:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
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.
|
|
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
|
|
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
|
|
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
|