@signalridge/pi-workflows 1.4.0 → 1.5.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/CHANGELOG.md +135 -0
- package/README.md +54 -12
- package/package.json +3 -3
- package/skills/workflow-authoring/SKILL.md +11 -6
- package/skills/workflow-authoring/references/capabilities.md +3 -3
- package/skills/workflow-authoring/references/capability-details.md +7 -5
- package/skills/workflow-authoring/references/debugging.md +2 -2
- package/skills/workflow-authoring/references/registry-ownership.md +6 -4
- package/skills/workflow-authoring/references/review.md +5 -2
- package/skills/workflow-authoring/references/runtime.md +3 -3
- package/skills/workflow-authoring/references/versions.md +1 -1
- package/skills/workflow-patterns/SKILL.md +1 -1
- package/skills/workflow-review/SKILL.md +1 -1
- package/src/builtins.ts +23 -23
- package/src/capabilities.ts +11 -4
- package/src/engine.ts +238 -58
- package/src/index.ts +292 -150
- package/src/journal.ts +292 -253
- package/src/rpc-client.ts +37 -48
- package/src/runtime.ts +176 -44
- package/src/ui.ts +29 -4
package/src/rpc-client.ts
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type {
|
|
3
|
+
ManagedRoutingPolicy,
|
|
3
4
|
ManagedSpawnResponse as ProtocolManagedSpawnResponse,
|
|
4
5
|
ManagedTerminalSnapshot as ProtocolManagedTerminalSnapshot,
|
|
5
|
-
WorkflowTier,
|
|
6
6
|
} from "@signalridge/pi-subagents-protocol";
|
|
7
7
|
import {
|
|
8
8
|
PROTOCOL_VERSION,
|
|
9
9
|
parseManagedSpawnResponse,
|
|
10
10
|
parseProtocolPing,
|
|
11
11
|
requiredCapabilitiesMatch,
|
|
12
|
-
workflowTierCapabilityMatch,
|
|
13
12
|
} from "@signalridge/pi-subagents-protocol";
|
|
14
13
|
import type { WorkflowOwner } from "./journal.js";
|
|
15
14
|
|
|
@@ -18,11 +17,8 @@ export interface DispatchTask {
|
|
|
18
17
|
subagent_type: string;
|
|
19
18
|
prompt: string;
|
|
20
19
|
description: string;
|
|
21
|
-
tier
|
|
22
|
-
|
|
23
|
-
model?: string;
|
|
24
|
-
/** Optional thinking suffix/override resolved by pi-subagents. */
|
|
25
|
-
thinking?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "off";
|
|
20
|
+
/** Agent-tier key, from the host's own catalogue. The only model policy here. */
|
|
21
|
+
tier?: string;
|
|
26
22
|
/** Per-call worktree request owned by pi-subagents. */
|
|
27
23
|
isolation?: "worktree";
|
|
28
24
|
/** Forwarded toolset hint (e.g. "web-research") for pi-subagents to augment tools. */
|
|
@@ -105,9 +101,11 @@ function callRpc<T>(
|
|
|
105
101
|
|
|
106
102
|
export const REQUIRED_SUBAGENTS_PROTOCOL = PROTOCOL_VERSION;
|
|
107
103
|
export const PROTOCOL_DIAGNOSTIC =
|
|
108
|
-
|
|
104
|
+
`@signalridge/pi-workflows requires @signalridge/pi-subagents protocol v${PROTOCOL_VERSION} with managedSpawn, lifecycleOwner, ownedStop, childContext, ownedQuiescence, agentTiers, and managedPolicy, plus its Agent-tier routing policy. ` +
|
|
109
105
|
"Install @signalridge/pi-subagents and @signalridge/pi-workflows exactly once; Pi loads both from their configured pi.extensions manifests.";
|
|
110
106
|
|
|
107
|
+
const CAPABILITY_DIAGNOSTIC = `subagents protocol v${PROTOCOL_VERSION} is required, with every capability and an Agent-tier routing policy`;
|
|
108
|
+
|
|
111
109
|
export const CHILD_CONTEXT_QUERY_TIMEOUT_MS = 250;
|
|
112
110
|
|
|
113
111
|
/**
|
|
@@ -169,7 +167,17 @@ export function queryChildSessionContextImmediate(events: WorkflowEventBus): boo
|
|
|
169
167
|
}
|
|
170
168
|
|
|
171
169
|
/** Verify the separately configured pi-subagents package before workflow execution. */
|
|
172
|
-
export
|
|
170
|
+
export interface ManagedProtocolCheck {
|
|
171
|
+
/** The host's Agent-tier catalogue, as of this check. */
|
|
172
|
+
routingPolicy: ManagedRoutingPolicy;
|
|
173
|
+
/** Whole-catalogue identity; used only where a per-tier identity cannot be. */
|
|
174
|
+
routingPolicyFingerprint: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function checkManagedSpawnProtocol(
|
|
178
|
+
events: WorkflowEventBus,
|
|
179
|
+
signal?: AbortSignal,
|
|
180
|
+
): Promise<ManagedProtocolCheck> {
|
|
173
181
|
const id = requestId();
|
|
174
182
|
const reply = await new Promise<unknown>((resolve, reject) => {
|
|
175
183
|
const replyChannel = `subagents:rpc:ping:reply:${id}`;
|
|
@@ -228,57 +236,40 @@ export async function checkManagedSpawnProtocol(events: WorkflowEventBus, signal
|
|
|
228
236
|
let ping: ReturnType<typeof parseProtocolPing>;
|
|
229
237
|
try {
|
|
230
238
|
ping = parseProtocolPing(reply);
|
|
231
|
-
} catch {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
reply.capabilities.lifecycleOwner !== true ||
|
|
239
|
-
reply.capabilities.ownedStop !== true ||
|
|
240
|
-
reply.capabilities.ownedQuiescence !== true ||
|
|
241
|
-
reply.capabilities.managedPolicy !== true
|
|
242
|
-
) {
|
|
243
|
-
throw new Error(
|
|
244
|
-
`subagents protocol v${REQUIRED_SUBAGENTS_PROTOCOL} with managed spawning, owned stop, owned quiescence, and managed policy capabilities is required`,
|
|
245
|
-
);
|
|
239
|
+
} catch (error: unknown) {
|
|
240
|
+
// Anything shaped like a ping is a peer that is present but not v4-complete,
|
|
241
|
+
// and the actionable answer is the same in every such case: install a
|
|
242
|
+
// matching pair. A reply that is not a ping at all gets its own message,
|
|
243
|
+
// because "upgrade the peer" would be the wrong thing to go do.
|
|
244
|
+
if (!isRecord(reply) || typeof reply.version !== "number") {
|
|
245
|
+
throw new Error("subagents protocol ping did not return a valid version/capability envelope");
|
|
246
246
|
}
|
|
247
|
-
throw new Error(
|
|
247
|
+
throw new Error(`${CAPABILITY_DIAGNOSTIC} (${error instanceof Error ? error.message : String(error)})`);
|
|
248
248
|
}
|
|
249
|
+
// Every v4 capability is required, so one check covers them all. The parser
|
|
250
|
+
// has already rejected a ping without a well-formed routing policy.
|
|
249
251
|
if (ping.version < REQUIRED_SUBAGENTS_PROTOCOL || !requiredCapabilitiesMatch(ping.capabilities)) {
|
|
250
|
-
throw new Error(
|
|
251
|
-
`subagents protocol v${REQUIRED_SUBAGENTS_PROTOCOL} with managed spawning, owned stop, and owned quiescence capability is required`,
|
|
252
|
-
);
|
|
253
|
-
}
|
|
254
|
-
if (!workflowTierCapabilityMatch(ping.capabilities)) {
|
|
255
|
-
throw new Error("subagents workflow tier capability is required before tiered managed spawning");
|
|
256
|
-
}
|
|
257
|
-
if (ping.capabilities.managedPolicy !== true) {
|
|
258
|
-
throw new Error("subagents managed policy capability is required before policy-bearing managed spawning");
|
|
252
|
+
throw new Error(CAPABILITY_DIAGNOSTIC);
|
|
259
253
|
}
|
|
254
|
+
return {
|
|
255
|
+
routingPolicy: ping.routingPolicy.policy,
|
|
256
|
+
routingPolicyFingerprint: ping.routingPolicy.fingerprint,
|
|
257
|
+
};
|
|
260
258
|
}
|
|
261
259
|
|
|
262
260
|
export type ManagedTerminalSnapshot = ProtocolManagedTerminalSnapshot;
|
|
263
261
|
export type ManagedSpawnResponse = ProtocolManagedSpawnResponse;
|
|
264
262
|
|
|
265
263
|
export interface ManagedSpawnClient {
|
|
266
|
-
/**
|
|
267
|
-
/**
|
|
268
|
-
* The fourth argument is the current attempt for protocol-v3 clients. The
|
|
269
|
-
* optional fifth argument is retained only for old in-process test/fixture
|
|
270
|
-
* clients that used the former context, attemptId shape; it is never a
|
|
271
|
-
* runtime context and is not sent over RPC.
|
|
272
|
-
*/
|
|
264
|
+
/** Managed spawn returns a structured response. */
|
|
273
265
|
spawn(
|
|
274
266
|
task: DispatchTask,
|
|
275
267
|
runId: string,
|
|
276
268
|
nodeId: string,
|
|
277
269
|
attemptId?: string,
|
|
278
|
-
legacyAttemptId?: string,
|
|
279
270
|
signal?: AbortSignal,
|
|
280
271
|
): Promise<ManagedSpawnResponse | string>;
|
|
281
|
-
/** Unscoped
|
|
272
|
+
/** Unscoped stop for non-managed callers. */
|
|
282
273
|
stop(agentId: string): Promise<void>;
|
|
283
274
|
/** Owner-scoped v3 stop used by workflow lifecycle operations. */
|
|
284
275
|
stopOwned?(agentId: string, owner: WorkflowOwner): Promise<void>;
|
|
@@ -290,8 +281,8 @@ export interface ManagedSpawnClient {
|
|
|
290
281
|
timeoutMs?: number,
|
|
291
282
|
owners?: WorkflowOwner[],
|
|
292
283
|
): Promise<{ settled: boolean; pending: string[] }>;
|
|
293
|
-
/**
|
|
294
|
-
checkProtocol?: () => Promise<
|
|
284
|
+
/** Fresh capability and routing-policy check; workflow execution remains event-bus-only. */
|
|
285
|
+
checkProtocol?: () => Promise<ManagedProtocolCheck>;
|
|
295
286
|
}
|
|
296
287
|
|
|
297
288
|
/** Event-bus-only client for the additive pi-subagents managed protocol. */
|
|
@@ -320,7 +311,7 @@ export function createManagedSpawnClient(
|
|
|
320
311
|
* error for the same key with a different fingerprint. Same key + same
|
|
321
312
|
* fingerprint still reuses the persisted tombstone (per-call cache).
|
|
322
313
|
*/
|
|
323
|
-
async spawn(task, runId, nodeId, attemptId,
|
|
314
|
+
async spawn(task, runId, nodeId, attemptId, signal) {
|
|
324
315
|
const effectiveAttemptId = attemptId ?? `attempt-1`;
|
|
325
316
|
const spawnKey = `${runId}/${nodeId}/${effectiveAttemptId}`;
|
|
326
317
|
const requestSignal =
|
|
@@ -339,8 +330,6 @@ export function createManagedSpawnClient(
|
|
|
339
330
|
...(task.toolset === undefined ? {} : { toolset: task.toolset }),
|
|
340
331
|
...(task.thread === undefined ? {} : { thread: task.thread }),
|
|
341
332
|
...(task.excludeTools === undefined ? {} : { excludeTools: task.excludeTools }),
|
|
342
|
-
...(task.model === undefined ? {} : { model: task.model }),
|
|
343
|
-
...(task.thinking === undefined ? {} : { thinking: task.thinking }),
|
|
344
333
|
...(task.isolation === undefined ? {} : { isolation: task.isolation }),
|
|
345
334
|
owner: { extension: "pi-workflows", runId, nodeId, attemptId: effectiveAttemptId },
|
|
346
335
|
},
|
package/src/runtime.ts
CHANGED
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
22
22
|
import { createHash } from "node:crypto";
|
|
23
23
|
import vm from "node:vm";
|
|
24
|
+
import type { ManagedRoutingPolicy } from "@signalridge/pi-subagents-protocol";
|
|
25
|
+
import { agentTierPolicyIdentity, isManagedAgentTier } from "@signalridge/pi-subagents-protocol";
|
|
24
26
|
import { parse } from "acorn";
|
|
25
27
|
import { checkRuntimeBindings } from "./capabilities.js";
|
|
26
28
|
import { WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
|
|
@@ -37,24 +39,12 @@ import { SharedStore } from "./shared-store.js";
|
|
|
37
39
|
export interface WorkflowMetaPhase {
|
|
38
40
|
title: string;
|
|
39
41
|
detail?: string;
|
|
40
|
-
/**
|
|
41
|
-
* Phase-level model preference — currently validated but not routed.
|
|
42
|
-
* Per-call model routing requires a protocol extension (see README
|
|
43
|
-
* "Intentional divergences"); use agent({ tier }) instead.
|
|
44
|
-
*/
|
|
45
|
-
model?: string;
|
|
46
42
|
}
|
|
47
43
|
|
|
48
44
|
export interface WorkflowMeta {
|
|
49
45
|
name: string;
|
|
50
46
|
description: string;
|
|
51
47
|
phases?: WorkflowMetaPhase[];
|
|
52
|
-
/**
|
|
53
|
-
* Run-level model preference — currently validated but not routed.
|
|
54
|
-
* Per-call model routing requires a protocol extension (see README
|
|
55
|
-
* "Intentional divergences"); use agent({ tier }) instead.
|
|
56
|
-
*/
|
|
57
|
-
model?: string;
|
|
58
48
|
}
|
|
59
49
|
|
|
60
50
|
/** A resolved, journaled agent() call. */
|
|
@@ -65,6 +55,8 @@ export interface JournalEntry {
|
|
|
65
55
|
hash: string;
|
|
66
56
|
result: unknown;
|
|
67
57
|
storeDelta?: Record<string, unknown>;
|
|
58
|
+
/** Agent tier this call resolved to, as the host reported it. */
|
|
59
|
+
tier?: string;
|
|
68
60
|
/** Generation for a nested workflow replay boundary. */
|
|
69
61
|
generation?: number;
|
|
70
62
|
/** Logical agents represented by a nested workflow replay boundary. */
|
|
@@ -108,7 +100,6 @@ export interface AgentRunOptions {
|
|
|
108
100
|
schema?: unknown;
|
|
109
101
|
signal?: AbortSignal;
|
|
110
102
|
instructions?: string;
|
|
111
|
-
model?: string;
|
|
112
103
|
tier?: string;
|
|
113
104
|
cwd?: string;
|
|
114
105
|
/** Named subagent type; resolved by pi-subagents at dispatch. */
|
|
@@ -129,7 +120,14 @@ export interface AgentRunOptions {
|
|
|
129
120
|
/** Additional tool names denied for this call. */
|
|
130
121
|
excludeTools?: string[];
|
|
131
122
|
onModelResolved?: (id: string) => void;
|
|
132
|
-
|
|
123
|
+
/**
|
|
124
|
+
* The tier the host actually selected, which may be one this call did not
|
|
125
|
+
* name — an agent's own tier, or the host's default. A callback rather than a
|
|
126
|
+
* mutation of `tier`: the dispatcher does not own this object, and reading a
|
|
127
|
+
* reply back out of an input field would only work while every layer happened
|
|
128
|
+
* to preserve its identity.
|
|
129
|
+
*/
|
|
130
|
+
onTierResolved?: (tier: string) => void;
|
|
133
131
|
onUsage?: (usage: AgentUsage) => void;
|
|
134
132
|
onHistory?: (history: unknown[]) => void;
|
|
135
133
|
}
|
|
@@ -138,7 +136,12 @@ export interface AgentOptions {
|
|
|
138
136
|
label?: string;
|
|
139
137
|
phase?: string;
|
|
140
138
|
schema?: unknown;
|
|
141
|
-
|
|
139
|
+
/**
|
|
140
|
+
* Agent tier: a key from the host's `agentTiers` catalogue. This is the only
|
|
141
|
+
* model control a workflow has — there is no per-call model or thinking, so
|
|
142
|
+
* a script cannot pin a vendor the host did not choose. Omitting it uses the
|
|
143
|
+
* agent's own tier, else the host's configured default.
|
|
144
|
+
*/
|
|
142
145
|
tier?: string;
|
|
143
146
|
isolation?: "worktree";
|
|
144
147
|
agentType?: string;
|
|
@@ -167,6 +170,27 @@ export interface WorkflowRunOptions {
|
|
|
167
170
|
agent?: WorkflowAgentRunner;
|
|
168
171
|
/** The session's main model (provider/id), shown for default agents. */
|
|
169
172
|
mainModel?: string;
|
|
173
|
+
/** The host's Agent-tier catalogue, captured fresh for this start/resume. */
|
|
174
|
+
routingPolicy?: ManagedRoutingPolicy;
|
|
175
|
+
/**
|
|
176
|
+
* This script ships with the package rather than being written here.
|
|
177
|
+
*
|
|
178
|
+
* The tier catalogue belongs to the user, names included. A script we ship
|
|
179
|
+
* cannot assert that `low` exists on someone whose tiers are called
|
|
180
|
+
* `cheap`/`deep`, so its tier is a preference: an undefined one falls back to
|
|
181
|
+
* the host's default with a warning. A script the user wrote names tiers from
|
|
182
|
+
* the user's own catalogue, so an undefined one there is a typo and fails
|
|
183
|
+
* closed rather than quietly running a different model.
|
|
184
|
+
*/
|
|
185
|
+
shippedScript?: boolean;
|
|
186
|
+
/**
|
|
187
|
+
* Whole-catalogue identity, used wherever a per-tier one would be dishonest:
|
|
188
|
+
* a nested `workflow()` boundary, where the parent caches one value for a
|
|
189
|
+
* child whose per-call tiers it cannot see, and an `agent()` call that names
|
|
190
|
+
* no tier, whose real tier may come from the agent's frontmatter. A call that
|
|
191
|
+
* names its tier keys on that tier's policy instead.
|
|
192
|
+
*/
|
|
193
|
+
routingPolicyFingerprint?: string;
|
|
170
194
|
/** Engine-owned execution nonce used to isolate stale nested managed calls. */
|
|
171
195
|
executionNonce?: string;
|
|
172
196
|
/** Runtime frame depth; siblings inherit the same depth and may run concurrently. */
|
|
@@ -190,16 +214,29 @@ export interface WorkflowRunOptions {
|
|
|
190
214
|
sharedRuntime?: SharedRuntime;
|
|
191
215
|
initialTokenUsage?: AgentUsage;
|
|
192
216
|
sharedStore?: SharedStore;
|
|
193
|
-
|
|
217
|
+
/**
|
|
218
|
+
* Resolve a nested `workflow(name)` reference, reporting whether the script
|
|
219
|
+
* it returned ships with this package. Shipped-ness travels with the script,
|
|
220
|
+
* not with the frame that called it — see `shippedScript`.
|
|
221
|
+
*/
|
|
222
|
+
loadSavedWorkflow?: (name: string) => { script: string; shippedScript?: boolean } | undefined;
|
|
194
223
|
confirm?: (promptText: string, options: CheckpointOptions) => Promise<unknown>;
|
|
195
224
|
onLog?: (message: string) => void;
|
|
196
225
|
onPhase?: (title: string) => void;
|
|
197
226
|
onRuntimeEvent?: (event: WorkflowRuntimeEvent) => void;
|
|
198
|
-
onAgentStart?: (event: {
|
|
227
|
+
onAgentStart?: (event: {
|
|
228
|
+
id: string;
|
|
229
|
+
label: string;
|
|
230
|
+
phase?: string;
|
|
231
|
+
prompt: string;
|
|
232
|
+
model?: string;
|
|
233
|
+
tier?: string;
|
|
234
|
+
}) => void;
|
|
199
235
|
onAgentEnd?: (event: {
|
|
200
236
|
id: string;
|
|
201
237
|
label: string;
|
|
202
238
|
phase?: string;
|
|
239
|
+
tier?: string;
|
|
203
240
|
result: unknown;
|
|
204
241
|
tokens?: number;
|
|
205
242
|
tokenUsage?: AgentUsage;
|
|
@@ -473,26 +510,29 @@ function propertyKey(node: unknown, path: string): string {
|
|
|
473
510
|
|
|
474
511
|
function validateMeta(meta: unknown): asserts meta is WorkflowMeta {
|
|
475
512
|
if (!meta || typeof meta !== "object") throw new Error("meta must be an object");
|
|
476
|
-
const value = meta as WorkflowMeta
|
|
513
|
+
const value = meta as WorkflowMeta & Record<string, unknown>;
|
|
477
514
|
if (typeof value.name !== "string" || !value.name.trim() || value.name.length > 512)
|
|
478
515
|
throw new Error("meta.name must be a non-empty string of at most 512 characters");
|
|
479
516
|
if (typeof value.description !== "string" || !value.description.trim() || value.description.length > 100_000)
|
|
480
517
|
throw new Error("meta.description must be a non-empty string of at most 100000 characters");
|
|
481
|
-
if (
|
|
518
|
+
if (Object.hasOwn(value, "model") || Object.hasOwn(value, "thinking")) {
|
|
519
|
+
throw new Error("workflow meta model/thinking fields are unsupported; use agent({ tier })");
|
|
520
|
+
}
|
|
482
521
|
if (value.phases !== undefined) {
|
|
483
522
|
if (!Array.isArray(value.phases)) throw new Error("meta.phases must be an array");
|
|
484
523
|
for (const phase of value.phases) {
|
|
485
524
|
if (
|
|
486
525
|
!phase ||
|
|
487
526
|
typeof phase !== "object" ||
|
|
488
|
-
typeof phase.title !== "string" ||
|
|
489
|
-
!phase.title.trim() ||
|
|
490
|
-
phase.title.length > 512
|
|
527
|
+
typeof (phase as { title?: unknown }).title !== "string" ||
|
|
528
|
+
!(phase as { title: string }).title.trim() ||
|
|
529
|
+
(phase as { title: string }).title.length > 512
|
|
491
530
|
) {
|
|
492
531
|
throw new Error("each meta phase must have a title string");
|
|
493
532
|
}
|
|
494
|
-
|
|
495
|
-
|
|
533
|
+
const phaseRecord = phase as unknown as Record<string, unknown>;
|
|
534
|
+
if (Object.hasOwn(phaseRecord, "model") || Object.hasOwn(phaseRecord, "thinking")) {
|
|
535
|
+
throw new Error("workflow phase model/thinking fields are unsupported; use agent({ tier })");
|
|
496
536
|
}
|
|
497
537
|
}
|
|
498
538
|
}
|
|
@@ -527,24 +567,42 @@ function hashIdentity(value: unknown): string {
|
|
|
527
567
|
/**
|
|
528
568
|
* Stable identity hash for an agent() call. Must cover every input that can
|
|
529
569
|
* change the outcome so a cache-miss is never served on resume. The agent
|
|
530
|
-
* definition is represented by the agentType name
|
|
531
|
-
* in pi-subagents
|
|
570
|
+
* definition is represented by the agentType name — the definition itself lives
|
|
571
|
+
* in pi-subagents, so a name-only key is the honest observable, and there is no
|
|
572
|
+
* second agent-definition field to hash beside it.
|
|
532
573
|
*/
|
|
533
574
|
function hashAgentCall(
|
|
534
575
|
prompt: string,
|
|
535
|
-
model: string | undefined,
|
|
536
576
|
phase: string | undefined,
|
|
537
577
|
options: AgentOptions,
|
|
538
|
-
|
|
578
|
+
routingPolicy: ManagedRoutingPolicy | undefined,
|
|
579
|
+
routingPolicyFingerprint: string | undefined,
|
|
539
580
|
): string {
|
|
581
|
+
// A call that names its tier is keyed on that tier's policy alone, so
|
|
582
|
+
// defining or editing an unrelated tier cannot invalidate it.
|
|
583
|
+
//
|
|
584
|
+
// A call that names none cannot be keyed that way, and keying it on
|
|
585
|
+
// `defaultTier` would be worse than coarse — it would be wrong. The host
|
|
586
|
+
// resolves an unnamed tier against the agent's own frontmatter tier first and
|
|
587
|
+
// only then the default, and frontmatter is invisible here: the agent
|
|
588
|
+
// definition lives in pi-subagents and reaches this side as a name. Keyed on
|
|
589
|
+
// the default, editing the tier the agent actually declares would replay
|
|
590
|
+
// stale work, while editing the default would invalidate work that never
|
|
591
|
+
// touched it. The whole-catalogue fingerprint is the coarse answer that is at
|
|
592
|
+
// least sound, and it is stable across the original run and its resume, which
|
|
593
|
+
// the tier the host selected is not — that one is only known after dispatch,
|
|
594
|
+
// and this hash is computed before it.
|
|
595
|
+
const tierPolicy =
|
|
596
|
+
options.tier === undefined
|
|
597
|
+
? (routingPolicyFingerprint ?? null)
|
|
598
|
+
: agentTierPolicyIdentity(routingPolicy, options.tier);
|
|
540
599
|
return hashIdentity({
|
|
541
600
|
prompt,
|
|
542
|
-
model: model ?? null,
|
|
543
601
|
tier: options.tier ?? null,
|
|
602
|
+
tierPolicy,
|
|
544
603
|
phase: phase ?? null,
|
|
545
604
|
agentType: options.agentType ?? null,
|
|
546
605
|
thread: options.thread ?? null,
|
|
547
|
-
agentDef: agentDefKey,
|
|
548
606
|
schema: options.schema ?? null,
|
|
549
607
|
isolation: options.isolation ?? null,
|
|
550
608
|
toolset: options.toolset ?? null,
|
|
@@ -930,6 +988,11 @@ export async function runWorkflow<T = unknown>(
|
|
|
930
988
|
const agentTimeoutMs = normalizeAgentTimeout(options.agentTimeoutMs);
|
|
931
989
|
const runId = options.runId ?? `run-${started.toString(36)}`;
|
|
932
990
|
const baseCwd = options.cwd ?? process.cwd();
|
|
991
|
+
// The host's catalogue, known before the first call. A tier name that is not
|
|
992
|
+
// in it is a typo, and catching it here beats discovering it on dispatch.
|
|
993
|
+
// Undefined when no policy was supplied (in-process tests, nested frames that
|
|
994
|
+
// inherit the parent's checks) — then only the key shape is enforced.
|
|
995
|
+
const knownTiers = options.routingPolicy ? new Set(Object.keys(options.routingPolicy.profiles)) : undefined;
|
|
933
996
|
|
|
934
997
|
const state: RuntimeState = {
|
|
935
998
|
logs: [],
|
|
@@ -1090,16 +1153,52 @@ export async function runWorkflow<T = unknown>(
|
|
|
1090
1153
|
log(`agentType "${agentOptions.agentType}" resolves in pi-subagents at dispatch`);
|
|
1091
1154
|
}
|
|
1092
1155
|
|
|
1093
|
-
//
|
|
1094
|
-
//
|
|
1095
|
-
//
|
|
1096
|
-
// the
|
|
1097
|
-
|
|
1098
|
-
|
|
1156
|
+
// A tier is a key in the host's catalogue, so the only shape check this
|
|
1157
|
+
// side can make is the key shape. An unknown key is the host's call to
|
|
1158
|
+
// refuse — it is the one that knows what is defined, and its error names
|
|
1159
|
+
// the available tiers.
|
|
1160
|
+
if (agentOptions.tier !== undefined && !isManagedAgentTier(agentOptions.tier)) {
|
|
1161
|
+
throw new WorkflowError(
|
|
1162
|
+
`agent tier must be a non-empty, whitespace-free key (received ${JSON.stringify(agentOptions.tier)})`,
|
|
1163
|
+
WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
|
|
1164
|
+
{ recoverable: false },
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
let effectiveTier = agentOptions.tier;
|
|
1168
|
+
if (effectiveTier !== undefined && knownTiers && !knownTiers.has(effectiveTier)) {
|
|
1169
|
+
if (options.shippedScript) {
|
|
1170
|
+
// Our own script naming a tier this host does not define. Drop it and
|
|
1171
|
+
// let the host's default decide, which is the user's policy either way.
|
|
1172
|
+
log(`agent tier ${JSON.stringify(effectiveTier)} is not defined here; using the host default`);
|
|
1173
|
+
effectiveTier = undefined;
|
|
1174
|
+
} else {
|
|
1175
|
+
// A typo would otherwise surface only when this call reaches dispatch,
|
|
1176
|
+
// possibly deep into a run. The catalogue is known at start; check now.
|
|
1177
|
+
throw new WorkflowError(
|
|
1178
|
+
`unknown agent tier ${JSON.stringify(effectiveTier)}; this host defines: ${
|
|
1179
|
+
[...knownTiers].sort().join(", ") || "(none)"
|
|
1180
|
+
}`,
|
|
1181
|
+
WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
|
|
1182
|
+
{ recoverable: false },
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
// Everything downstream — the resume hash, the dispatch, the journal —
|
|
1187
|
+
// works from the tier that will actually be requested, not the one the
|
|
1188
|
+
// script wrote, so a dropped name cannot make a cached call look different
|
|
1189
|
+
// from the live one.
|
|
1190
|
+
const tieredOptions: AgentOptions =
|
|
1191
|
+
effectiveTier === agentOptions.tier ? agentOptions : { ...agentOptions, tier: effectiveTier };
|
|
1099
1192
|
|
|
1100
1193
|
// Deterministic resume key: assigned at lexical call time, before the limiter.
|
|
1101
1194
|
const callIndex = nextCallIndex();
|
|
1102
|
-
const callHash = hashAgentCall(
|
|
1195
|
+
const callHash = hashAgentCall(
|
|
1196
|
+
promptText,
|
|
1197
|
+
assignedPhase,
|
|
1198
|
+
tieredOptions,
|
|
1199
|
+
options.routingPolicy,
|
|
1200
|
+
options.routingPolicyFingerprint,
|
|
1201
|
+
);
|
|
1103
1202
|
// Store delta key: runId-namespaced so a nested workflow() call's
|
|
1104
1203
|
// callIndex-0 can never collide with the parent's (see SharedStore doc).
|
|
1105
1204
|
const deltaKey = `${runId}:${callIndex}`;
|
|
@@ -1117,11 +1216,22 @@ export async function runWorkflow<T = unknown>(
|
|
|
1117
1216
|
if (hashMatches && !cachedEmptyOutput && callIndex < state.firstMiss) {
|
|
1118
1217
|
shared.agentCount++;
|
|
1119
1218
|
frameAgentCount++;
|
|
1120
|
-
|
|
1219
|
+
// The journal holds the tier the host actually selected, which may be a
|
|
1220
|
+
// default the script never named. Replay reports that durable identity
|
|
1221
|
+
// rather than re-deriving one from the script.
|
|
1222
|
+
const replayTier = cached.tier ?? effectiveTier;
|
|
1223
|
+
options.onAgentStart?.({
|
|
1224
|
+
id: deltaKey,
|
|
1225
|
+
label,
|
|
1226
|
+
phase: assignedPhase,
|
|
1227
|
+
prompt: promptText,
|
|
1228
|
+
...(replayTier === undefined ? {} : { tier: replayTier }),
|
|
1229
|
+
});
|
|
1121
1230
|
options.onAgentEnd?.({
|
|
1122
1231
|
id: deltaKey,
|
|
1123
1232
|
label,
|
|
1124
1233
|
phase: assignedPhase,
|
|
1234
|
+
tier: replayTier,
|
|
1125
1235
|
result: cached.result,
|
|
1126
1236
|
tokens: 0,
|
|
1127
1237
|
});
|
|
@@ -1166,7 +1276,13 @@ export async function runWorkflow<T = unknown>(
|
|
|
1166
1276
|
const retryAttempts = normalizeAgentRetries(agentOptions.retries ?? options.agentRetries ?? 0);
|
|
1167
1277
|
const maxAttempts = retryAttempts + 1;
|
|
1168
1278
|
|
|
1169
|
-
options.onAgentStart?.({
|
|
1279
|
+
options.onAgentStart?.({
|
|
1280
|
+
id: deltaKey,
|
|
1281
|
+
label,
|
|
1282
|
+
phase: assignedPhase,
|
|
1283
|
+
prompt: promptText,
|
|
1284
|
+
...(effectiveTier === undefined ? {} : { tier: effectiveTier }),
|
|
1285
|
+
});
|
|
1170
1286
|
|
|
1171
1287
|
// Worktree creation and cleanup remain owned by pi-subagents; the request
|
|
1172
1288
|
// carries the explicit per-call policy without touching the host filesystem here.
|
|
@@ -1206,6 +1322,9 @@ export async function runWorkflow<T = unknown>(
|
|
|
1206
1322
|
const externalSignal = options.signal;
|
|
1207
1323
|
let onExternalAbort: (() => void) | undefined;
|
|
1208
1324
|
let onRunFatal: (() => void) | undefined;
|
|
1325
|
+
// Filled in from the host's reply, which may name a tier this call
|
|
1326
|
+
// did not: an agent's own tier, or the configured default.
|
|
1327
|
+
let resolvedTier = effectiveTier;
|
|
1209
1328
|
try {
|
|
1210
1329
|
throwIfAborted();
|
|
1211
1330
|
if (batch?.cancelled) throw agentLimitError();
|
|
@@ -1238,8 +1357,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
1238
1357
|
schema,
|
|
1239
1358
|
signal: agentController.signal,
|
|
1240
1359
|
instructions: buildAgentInstructions(assignedPhase, agentOptions, resolvedIsolation),
|
|
1241
|
-
|
|
1242
|
-
tier: agentOptions.tier,
|
|
1360
|
+
tier: effectiveTier,
|
|
1243
1361
|
agentType: agentOptions.agentType,
|
|
1244
1362
|
isolation: resolvedIsolation,
|
|
1245
1363
|
toolset: options.toolset ?? agentOptions.toolset,
|
|
@@ -1252,6 +1370,9 @@ export async function runWorkflow<T = unknown>(
|
|
|
1252
1370
|
onModelResolved: (id: string) => {
|
|
1253
1371
|
void id; // display model tracking is engine-side
|
|
1254
1372
|
},
|
|
1373
|
+
onTierResolved: (tier: string) => {
|
|
1374
|
+
resolvedTier = tier;
|
|
1375
|
+
},
|
|
1255
1376
|
onUsage: (u: AgentUsage) => {
|
|
1256
1377
|
usage = u;
|
|
1257
1378
|
},
|
|
@@ -1313,6 +1434,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
1313
1434
|
runId,
|
|
1314
1435
|
hash: callHash,
|
|
1315
1436
|
result,
|
|
1437
|
+
...(resolvedTier === undefined ? {} : { tier: resolvedTier }),
|
|
1316
1438
|
storeDelta: store.commitDelta(deltaKey),
|
|
1317
1439
|
});
|
|
1318
1440
|
options.onAgentEnd?.({
|
|
@@ -1320,9 +1442,9 @@ export async function runWorkflow<T = unknown>(
|
|
|
1320
1442
|
label,
|
|
1321
1443
|
phase: assignedPhase,
|
|
1322
1444
|
result,
|
|
1445
|
+
tier: resolvedTier,
|
|
1323
1446
|
tokens,
|
|
1324
1447
|
tokenUsage: usage,
|
|
1325
|
-
model: modelSpec,
|
|
1326
1448
|
});
|
|
1327
1449
|
return result;
|
|
1328
1450
|
} catch (error) {
|
|
@@ -1350,9 +1472,9 @@ export async function runWorkflow<T = unknown>(
|
|
|
1350
1472
|
label,
|
|
1351
1473
|
phase: assignedPhase,
|
|
1352
1474
|
result: null,
|
|
1475
|
+
tier: resolvedTier,
|
|
1353
1476
|
tokens,
|
|
1354
1477
|
tokenUsage: usage,
|
|
1355
|
-
model: modelSpec,
|
|
1356
1478
|
error: workflowError.message,
|
|
1357
1479
|
errorCode: workflowError.code,
|
|
1358
1480
|
recoverable: workflowError.recoverable,
|
|
@@ -1512,7 +1634,12 @@ export async function runWorkflow<T = unknown>(
|
|
|
1512
1634
|
}
|
|
1513
1635
|
const callIndex = nextCallIndex();
|
|
1514
1636
|
const resolved = options.loadSavedWorkflow?.(String(nameOrScript));
|
|
1515
|
-
const childScript = resolved ?? String(nameOrScript);
|
|
1637
|
+
const childScript = resolved?.script ?? String(nameOrScript);
|
|
1638
|
+
// A named script carries its own shipped-ness; a raw script literal is the
|
|
1639
|
+
// caller's own text and shares the caller's. Inheriting the frame's flag for
|
|
1640
|
+
// a resolved script would let a user script run a built-in under the strict
|
|
1641
|
+
// tier check — a hard failure on any host that renamed the shipped tiers.
|
|
1642
|
+
const childShippedScript = resolved ? resolved.shippedScript === true : options.shippedScript === true;
|
|
1516
1643
|
const workflowName = String(nameOrScript);
|
|
1517
1644
|
const argsPresent = childArgs !== undefined;
|
|
1518
1645
|
const callHash = hashIdentity({
|
|
@@ -1521,6 +1648,10 @@ export async function runWorkflow<T = unknown>(
|
|
|
1521
1648
|
script: childScript,
|
|
1522
1649
|
argsPresent,
|
|
1523
1650
|
args: argsPresent ? childArgs : null,
|
|
1651
|
+
// Part of the identity because it decides how the child treats a tier
|
|
1652
|
+
// name the host does not define: dropped, or fatal.
|
|
1653
|
+
shippedScript: childShippedScript,
|
|
1654
|
+
routingPolicyFingerprint: options.routingPolicyFingerprint ?? null,
|
|
1524
1655
|
});
|
|
1525
1656
|
const cached = options.resumeJournal?.get(`${runId}:${callIndex}`);
|
|
1526
1657
|
if (cached != null && cached.hash === callHash && callIndex < state.firstMiss) {
|
|
@@ -1540,6 +1671,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
1540
1671
|
const child = await runWorkflow(childScript, {
|
|
1541
1672
|
...options,
|
|
1542
1673
|
args: childArgs,
|
|
1674
|
+
shippedScript: childShippedScript,
|
|
1543
1675
|
sharedRuntime: shared,
|
|
1544
1676
|
sharedStore: store,
|
|
1545
1677
|
workflowDepth: workflowDepth + 1,
|
package/src/ui.ts
CHANGED
|
@@ -172,12 +172,30 @@ async function selectList<T>(
|
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
/** Interactive `/workflows` navigator. TUI-only by design. */
|
|
175
|
-
export async function showWorkflowNavigator(
|
|
175
|
+
export async function showWorkflowNavigator(
|
|
176
|
+
ctx: ExtensionCommandContext,
|
|
177
|
+
engineSource: WorkflowEngine | (() => WorkflowEngine),
|
|
178
|
+
): Promise<void> {
|
|
176
179
|
if (ctx.mode !== "tui") {
|
|
177
180
|
ctx.ui.notify("/workflows requires TUI mode; use workflow_control for machine-readable access.", "warning");
|
|
178
181
|
return;
|
|
179
182
|
}
|
|
183
|
+
const resolveEngine = typeof engineSource === "function" ? engineSource : () => engineSource;
|
|
184
|
+
let inactiveNotified = false;
|
|
185
|
+
const currentEngine = (): WorkflowEngine | undefined => {
|
|
186
|
+
try {
|
|
187
|
+
return resolveEngine();
|
|
188
|
+
} catch (error: unknown) {
|
|
189
|
+
if (!inactiveNotified) {
|
|
190
|
+
inactiveNotified = true;
|
|
191
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
|
|
192
|
+
}
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
180
196
|
while (true) {
|
|
197
|
+
const engine = currentEngine();
|
|
198
|
+
if (!engine) return;
|
|
181
199
|
const states = engine
|
|
182
200
|
.list()
|
|
183
201
|
.map((summary) => engine.getRun(String(summary.runId)))
|
|
@@ -198,7 +216,9 @@ export async function showWorkflowNavigator(ctx: ExtensionCommandContext, engine
|
|
|
198
216
|
);
|
|
199
217
|
if (!selected) return;
|
|
200
218
|
while (true) {
|
|
201
|
-
const
|
|
219
|
+
const latest = currentEngine();
|
|
220
|
+
if (!latest) return;
|
|
221
|
+
const run = latest.getRun(selected);
|
|
202
222
|
if (!run) break;
|
|
203
223
|
const name = sanitizeDisplayText(run.meta.name, MAX_NAME_CHARS);
|
|
204
224
|
const action = await selectList<WorkflowAction>(
|
|
@@ -208,7 +228,10 @@ export async function showWorkflowNavigator(ctx: ExtensionCommandContext, engine
|
|
|
208
228
|
value: item.value,
|
|
209
229
|
label: item.label,
|
|
210
230
|
})),
|
|
211
|
-
() =>
|
|
231
|
+
() => {
|
|
232
|
+
const current = currentEngine();
|
|
233
|
+
return current ? formatWorkflowDetail(current, selected) : "Workflow surface is no longer active.";
|
|
234
|
+
},
|
|
212
235
|
);
|
|
213
236
|
if (action === undefined || action === "back") break;
|
|
214
237
|
if (action === "refresh") continue;
|
|
@@ -217,7 +240,9 @@ export async function showWorkflowNavigator(ctx: ExtensionCommandContext, engine
|
|
|
217
240
|
if (!confirmed) continue;
|
|
218
241
|
}
|
|
219
242
|
try {
|
|
220
|
-
|
|
243
|
+
const current = currentEngine();
|
|
244
|
+
if (!current) return;
|
|
245
|
+
await current.control(action, selected);
|
|
221
246
|
ctx.ui.notify(`Workflow ${action} requested.`, "info");
|
|
222
247
|
} catch (error: unknown) {
|
|
223
248
|
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|