@yycholla/pi-dynamic-workflows 3.3.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.
Files changed (179) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +325 -0
  3. package/assets/readme/hero.png +0 -0
  4. package/assets/readme/hero.svg +70 -0
  5. package/assets/readme/package-cover.png +0 -0
  6. package/assets/readme/package-cover.svg +66 -0
  7. package/assets/readme/workflow.png +0 -0
  8. package/assets/readme/workflow.svg +77 -0
  9. package/dist/accept-workflow-guidance.d.ts +14 -0
  10. package/dist/accept-workflow-guidance.js +53 -0
  11. package/dist/adversarial-review.d.ts +30 -0
  12. package/dist/adversarial-review.js +107 -0
  13. package/dist/agent-history.d.ts +20 -0
  14. package/dist/agent-history.js +122 -0
  15. package/dist/agent-registry.d.ts +83 -0
  16. package/dist/agent-registry.js +190 -0
  17. package/dist/agent.d.ts +364 -0
  18. package/dist/agent.js +714 -0
  19. package/dist/builtin-commands.d.ts +19 -0
  20. package/dist/builtin-commands.js +251 -0
  21. package/dist/builtin-workflows.d.ts +45 -0
  22. package/dist/builtin-workflows.js +121 -0
  23. package/dist/code-review.d.ts +26 -0
  24. package/dist/code-review.js +181 -0
  25. package/dist/config.d.ts +37 -0
  26. package/dist/config.js +44 -0
  27. package/dist/deep-research.d.ts +30 -0
  28. package/dist/deep-research.js +124 -0
  29. package/dist/display.d.ts +134 -0
  30. package/dist/display.js +248 -0
  31. package/dist/effort-command.d.ts +28 -0
  32. package/dist/effort-command.js +68 -0
  33. package/dist/enums.d.ts +69 -0
  34. package/dist/enums.js +78 -0
  35. package/dist/errors.d.ts +113 -0
  36. package/dist/errors.js +140 -0
  37. package/dist/extension-reload.d.ts +37 -0
  38. package/dist/extension-reload.js +78 -0
  39. package/dist/fs-persistence.d.ts +63 -0
  40. package/dist/fs-persistence.js +102 -0
  41. package/dist/index.d.ts +57 -0
  42. package/dist/index.js +35 -0
  43. package/dist/logger.d.ts +21 -0
  44. package/dist/logger.js +66 -0
  45. package/dist/model-routing.d.ts +30 -0
  46. package/dist/model-routing.js +50 -0
  47. package/dist/model-spec.d.ts +29 -0
  48. package/dist/model-spec.js +252 -0
  49. package/dist/model-tier-config.d.ts +133 -0
  50. package/dist/model-tier-config.js +249 -0
  51. package/dist/run-persistence.d.ts +180 -0
  52. package/dist/run-persistence.js +294 -0
  53. package/dist/saved-commands.d.ts +28 -0
  54. package/dist/saved-commands.js +100 -0
  55. package/dist/shared-store.d.ts +98 -0
  56. package/dist/shared-store.js +212 -0
  57. package/dist/structured-output.d.ts +19 -0
  58. package/dist/structured-output.js +30 -0
  59. package/dist/task-panel.d.ts +61 -0
  60. package/dist/task-panel.js +422 -0
  61. package/dist/usage-limit-scheduler.d.ts +145 -0
  62. package/dist/usage-limit-scheduler.js +368 -0
  63. package/dist/web-tools.d.ts +20 -0
  64. package/dist/web-tools.js +120 -0
  65. package/dist/workflow-authoring-coverage.d.ts +70 -0
  66. package/dist/workflow-authoring-coverage.js +421 -0
  67. package/dist/workflow-authoring-reference.d.ts +20 -0
  68. package/dist/workflow-authoring-reference.js +156 -0
  69. package/dist/workflow-capability-contract.d.ts +131 -0
  70. package/dist/workflow-capability-contract.js +604 -0
  71. package/dist/workflow-commands.d.ts +18 -0
  72. package/dist/workflow-commands.js +260 -0
  73. package/dist/workflow-comprehension.d.ts +133 -0
  74. package/dist/workflow-comprehension.js +1321 -0
  75. package/dist/workflow-context-measurement.d.ts +72 -0
  76. package/dist/workflow-context-measurement.js +213 -0
  77. package/dist/workflow-control-tool.d.ts +30 -0
  78. package/dist/workflow-control-tool.js +176 -0
  79. package/dist/workflow-delivery-choice.d.ts +20 -0
  80. package/dist/workflow-delivery-choice.js +48 -0
  81. package/dist/workflow-editor.d.ts +93 -0
  82. package/dist/workflow-editor.js +363 -0
  83. package/dist/workflow-manager.d.ts +492 -0
  84. package/dist/workflow-manager.js +1124 -0
  85. package/dist/workflow-paths.d.ts +22 -0
  86. package/dist/workflow-paths.js +46 -0
  87. package/dist/workflow-release-gate.d.ts +39 -0
  88. package/dist/workflow-release-gate.js +309 -0
  89. package/dist/workflow-saved.d.ts +38 -0
  90. package/dist/workflow-saved.js +126 -0
  91. package/dist/workflow-settings.d.ts +70 -0
  92. package/dist/workflow-settings.js +131 -0
  93. package/dist/workflow-tool.d.ts +71 -0
  94. package/dist/workflow-tool.js +367 -0
  95. package/dist/workflow-ui.d.ts +182 -0
  96. package/dist/workflow-ui.js +1587 -0
  97. package/dist/workflow.d.ts +333 -0
  98. package/dist/workflow.js +1151 -0
  99. package/dist/workflows-models-command.d.ts +31 -0
  100. package/dist/workflows-models-command.js +156 -0
  101. package/dist/worktree.d.ts +25 -0
  102. package/dist/worktree.js +61 -0
  103. package/extensions/workflow.ts +151 -0
  104. package/package.json +104 -0
  105. package/skills/workflow-authoring/SKILL.md +30 -0
  106. package/skills/workflow-authoring/examples/adversarial-verification.js +63 -0
  107. package/skills/workflow-authoring/examples/bounded-semantic-retry.js +56 -0
  108. package/skills/workflow-authoring/examples/classify-and-act.js +67 -0
  109. package/skills/workflow-authoring/examples/fan-out-and-synthesize.js +47 -0
  110. package/skills/workflow-authoring/examples/generate-and-filter.js +68 -0
  111. package/skills/workflow-authoring/examples/loop-until-done.js +68 -0
  112. package/skills/workflow-authoring/examples/phased-budgets.js +75 -0
  113. package/skills/workflow-authoring/examples/saved-nested-workflows.js +44 -0
  114. package/skills/workflow-authoring/examples/structured-output.js +37 -0
  115. package/skills/workflow-authoring/examples/tournament.js +81 -0
  116. package/skills/workflow-authoring/examples/validated-gate.js +63 -0
  117. package/skills/workflow-authoring/references/capabilities.md +41 -0
  118. package/skills/workflow-authoring/references/capability-details.md +357 -0
  119. package/skills/workflow-authoring/references/common-helpers.md +4 -0
  120. package/skills/workflow-authoring/references/debugging.md +27 -0
  121. package/skills/workflow-authoring/references/focused-recipes.md +11 -0
  122. package/skills/workflow-authoring/references/helpers.md +9 -0
  123. package/skills/workflow-authoring/references/lifecycle.md +33 -0
  124. package/skills/workflow-authoring/references/pattern-selection.md +15 -0
  125. package/skills/workflow-authoring/references/quality-helpers.md +8 -0
  126. package/skills/workflow-authoring/references/registry-ownership.md +15 -0
  127. package/skills/workflow-authoring/references/retry-helper.md +5 -0
  128. package/skills/workflow-authoring/references/review.md +43 -0
  129. package/skills/workflow-authoring/references/runtime.md +27 -0
  130. package/skills/workflow-authoring/references/specialized-helpers.md +19 -0
  131. package/skills/workflow-authoring/references/versions.md +13 -0
  132. package/skills/workflow-patterns/SKILL.md +51 -0
  133. package/src/accept-workflow-guidance.ts +71 -0
  134. package/src/adversarial-review.ts +120 -0
  135. package/src/agent-history.ts +157 -0
  136. package/src/agent-registry.ts +221 -0
  137. package/src/agent.ts +929 -0
  138. package/src/builtin-commands.ts +286 -0
  139. package/src/builtin-workflows.ts +155 -0
  140. package/src/code-review.ts +183 -0
  141. package/src/config.ts +55 -0
  142. package/src/deep-research.ts +135 -0
  143. package/src/display.ts +367 -0
  144. package/src/effort-command.ts +87 -0
  145. package/src/enums.ts +77 -0
  146. package/src/errors.ts +199 -0
  147. package/src/extension-reload.ts +100 -0
  148. package/src/fs-persistence.ts +124 -0
  149. package/src/index.ts +176 -0
  150. package/src/logger.ts +88 -0
  151. package/src/model-routing.ts +73 -0
  152. package/src/model-spec.ts +309 -0
  153. package/src/model-tier-config.ts +296 -0
  154. package/src/run-persistence.ts +484 -0
  155. package/src/saved-commands.ts +115 -0
  156. package/src/shared-store.ts +228 -0
  157. package/src/structured-output.ts +47 -0
  158. package/src/task-panel.ts +490 -0
  159. package/src/usage-limit-scheduler.ts +432 -0
  160. package/src/web-tools.ts +124 -0
  161. package/src/workflow-authoring-coverage.ts +486 -0
  162. package/src/workflow-authoring-reference.ts +186 -0
  163. package/src/workflow-capability-contract.ts +806 -0
  164. package/src/workflow-commands.ts +287 -0
  165. package/src/workflow-comprehension.ts +1673 -0
  166. package/src/workflow-context-measurement.ts +262 -0
  167. package/src/workflow-control-tool.ts +238 -0
  168. package/src/workflow-delivery-choice.ts +69 -0
  169. package/src/workflow-editor.ts +444 -0
  170. package/src/workflow-manager.ts +1405 -0
  171. package/src/workflow-paths.ts +63 -0
  172. package/src/workflow-release-gate.ts +529 -0
  173. package/src/workflow-saved.ts +180 -0
  174. package/src/workflow-settings.ts +194 -0
  175. package/src/workflow-tool.ts +464 -0
  176. package/src/workflow-ui.ts +1789 -0
  177. package/src/workflow.ts +1615 -0
  178. package/src/workflows-models-command.ts +211 -0
  179. package/src/worktree.ts +76 -0
package/dist/agent.js ADDED
@@ -0,0 +1,714 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { unlinkSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { createAgentSession, createCodingTools, DefaultResourceLoader, getAgentDir, ModelRegistry, ModelRuntime, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
5
+ import { Check, Convert } from "typebox/value";
6
+ import { compactAgentHistory } from "./agent-history.js";
7
+ import { applyToolPolicy } from "./agent-registry.js";
8
+ import { classifyProviderLimit, WorkflowError, WorkflowErrorCode } from "./errors.js";
9
+ import { canonicalModelSpec, resolveModelSpecWithThinking } from "./model-spec.js";
10
+ import { formatTierFallbackNotice, loadModelTierConfig, resolveTierModel, } from "./model-tier-config.js";
11
+ import { createStructuredOutputTool } from "./structured-output.js";
12
+ /**
13
+ * Find a JSON object/array in free-form text: a fenced ```json block if present,
14
+ * else the first balanced {...} or [...]. Best-effort (the schema check is the
15
+ * real gate). Returns the raw JSON string, or undefined when none is found.
16
+ */
17
+ function findJsonBlock(text) {
18
+ const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
19
+ if (fence?.[1])
20
+ return fence[1].trim();
21
+ const start = text.search(/[{[]/);
22
+ if (start === -1)
23
+ return undefined;
24
+ const open = text[start];
25
+ const close = open === "{" ? "}" : "]";
26
+ let depth = 0;
27
+ for (let i = start; i < text.length; i++) {
28
+ if (text[i] === open)
29
+ depth++;
30
+ else if (text[i] === close && --depth === 0)
31
+ return text.slice(start, i + 1);
32
+ }
33
+ return undefined;
34
+ }
35
+ /**
36
+ * Last-resort structured-output recovery: extract a JSON block from prose, coerce
37
+ * it toward the schema, and accept it only if it then validates. Never fabricates
38
+ * — returns undefined unless the parsed value genuinely satisfies the schema.
39
+ */
40
+ export function extractValidated(text, schema) {
41
+ const json = findJsonBlock(text);
42
+ if (json === undefined)
43
+ return undefined;
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(json);
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ try {
52
+ const converted = Convert(schema, parsed);
53
+ if (Check(schema, converted))
54
+ return converted;
55
+ }
56
+ catch {
57
+ // typebox can throw on exotic schemas; treat as no match.
58
+ }
59
+ return undefined;
60
+ }
61
+ /**
62
+ * The last assistant message's terminal metadata (stopReason/errorMessage). The pi
63
+ * SDK does NOT throw provider usage/quota limits — it records them as an assistant
64
+ * message with stopReason "error" and an errorMessage. This is the only place that
65
+ * metadata is observable to the workflow layer.
66
+ */
67
+ export function lastAssistantError(messages) {
68
+ for (let i = messages.length - 1; i >= 0; i--) {
69
+ const message = messages[i];
70
+ if (message?.role !== "assistant")
71
+ continue;
72
+ return { stopReason: message.stopReason, errorMessage: message.errorMessage };
73
+ }
74
+ return undefined;
75
+ }
76
+ /**
77
+ * If the subagent's turn ended in a provider usage/quota/rate-limit error, throw a
78
+ * PROVIDER_USAGE_LIMIT WorkflowError carrying the real provider message + reset hint.
79
+ * Gated on stopReason === "error" so a successful turn whose text merely mentions
80
+ * "rate limit" is never misclassified. recoverable:false so the run checkpoints
81
+ * (paused) rather than being retried into the same wall or collapsed to a silent null.
82
+ */
83
+ export function throwIfProviderLimit(messages, label) {
84
+ const err = lastAssistantError(messages);
85
+ if (err?.stopReason !== "error")
86
+ return;
87
+ const { matched, resetHint } = classifyProviderLimit(err.errorMessage);
88
+ if (!matched)
89
+ return;
90
+ throw new WorkflowError(err.errorMessage ?? "Provider usage/quota limit reached", WorkflowErrorCode.PROVIDER_USAGE_LIMIT, { recoverable: false, agentLabel: label, resetHint });
91
+ }
92
+ /**
93
+ * Resolve a schema agent's result. If the tool was called, return the captured
94
+ * value. Otherwise re-prompt up to maxSchemaRetries (tools restricted to
95
+ * structured_output), then try strict schema-validated prose extraction, else
96
+ * throw SCHEMA_NONCOMPLIANCE (non-recoverable — surfaced, never a silent null).
97
+ * Module-level with an injected `lastText` so it is unit-testable.
98
+ */
99
+ export async function resolveStructuredOutput(session, capture, schema, options, lastText) {
100
+ if (capture.called)
101
+ return capture.value;
102
+ const maxRetries = Math.max(0, options.maxSchemaRetries ?? 2);
103
+ // Restrict to the schema tool so the only useful next action is calling it
104
+ // (takes effect on the next prompt turn). Best-effort.
105
+ try {
106
+ session.setActiveToolsByName?.(["structured_output"]);
107
+ }
108
+ catch {
109
+ // ignore — the re-prompt alone still drives most models to comply
110
+ }
111
+ for (let attempt = 0; attempt < maxRetries && !capture.called; attempt++) {
112
+ if (options.signal?.aborted)
113
+ throw new Error("Subagent was aborted");
114
+ await session.prompt("You did not call the structured_output tool. Call structured_output now as your only action, with the required fields filled in. Do not write a prose answer.");
115
+ }
116
+ if (capture.called)
117
+ return capture.value;
118
+ const extracted = extractValidated(lastText(session.messages), schema);
119
+ if (extracted !== undefined) {
120
+ console.warn("[workflow] structured_output recovered from prose extraction (the model never called the tool); prefer a tool-reliable model");
121
+ return extracted;
122
+ }
123
+ // A repair re-prompt can itself hit the provider limit. Surface that as the real
124
+ // (recoverable) cause instead of the misleading non-recoverable SCHEMA_NONCOMPLIANCE.
125
+ throwIfProviderLimit(session.messages, options.label);
126
+ throw new WorkflowError("Subagent did not produce valid structured_output after repair attempts", WorkflowErrorCode.SCHEMA_NONCOMPLIANCE, { recoverable: false, agentLabel: options.label });
127
+ }
128
+ /**
129
+ * Resolve which concrete model spec a subagent should use. Precedence, most
130
+ * specific first:
131
+ * 1. options.model — an explicit per-agent model (also carries agentType /
132
+ * phase model, which the workflow layer folds into options.model).
133
+ * 2. options.tier — resolved via the model-tiers config, falling back to the
134
+ * session's main model when the tier has no configured entry.
135
+ * 3. DEFAULT TIER — when neither is set but the user has a model-tiers config,
136
+ * untagged agents default to the "medium" tier so a configured tier set
137
+ * actually affects the whole workflow (not just agents the script tagged).
138
+ * Fresh-install medium == the session model, so this is a no-op until the
139
+ * user customizes tiers via /workflows-models.
140
+ * Returns undefined when nothing applies, so the session default is used.
141
+ *
142
+ * `loadConfig` is injectable for testing; it defaults to reading from disk.
143
+ */
144
+ export function resolveAgentModelSpec(options, mainModel, loadConfig = loadModelTierConfig, onTierWithoutConfig) {
145
+ if (options.model)
146
+ return options.model;
147
+ const config = loadConfig();
148
+ if (options.tier) {
149
+ // Tier requested but unconfigured → it silently falls back to mainModel.
150
+ // Let the caller surface that (once) so the no-op is discoverable.
151
+ if (!config)
152
+ onTierWithoutConfig?.(options.tier);
153
+ return (config ? resolveTierModel(options.tier, config) : undefined) ?? mainModel;
154
+ }
155
+ // Untagged agent: default to the configured medium tier when one exists.
156
+ if (config) {
157
+ const medium = resolveTierModel("medium", config);
158
+ if (medium)
159
+ return medium;
160
+ }
161
+ return undefined;
162
+ }
163
+ // pi >= 0.80.8: ModelRegistry is a sync facade over an async-created ModelRuntime
164
+ // (AuthStorage/ModelRegistry.create are gone). The disk-backed fallback is built
165
+ // lazily; sync callers see [] until it resolves and real specs on later reads.
166
+ let fallbackRuntimePromise;
167
+ let fallbackRegistry;
168
+ function ensureFallbackRegistry() {
169
+ if (!fallbackRuntimePromise) {
170
+ const dir = getAgentDir();
171
+ // Same auth.json/models.json createAgentSession uses by default, so a model
172
+ // resolved here carries valid credentials.
173
+ fallbackRuntimePromise = (async () => {
174
+ const runtime = await ModelRuntime.create({
175
+ authPath: join(dir, "auth.json"),
176
+ modelsPath: join(dir, "models.json"),
177
+ });
178
+ // Warm the availability snapshot so the facade's sync getAvailable() is
179
+ // populated immediately after this promise resolves.
180
+ await runtime.getAvailable().catch(() => { });
181
+ return runtime;
182
+ })();
183
+ // Don't cache a rejection: a transient failure (e.g. auth.json lock) would
184
+ // otherwise wedge the fallback for the rest of the process.
185
+ fallbackRuntimePromise.catch(() => {
186
+ fallbackRuntimePromise = undefined;
187
+ });
188
+ }
189
+ return fallbackRuntimePromise.then((runtime) => {
190
+ fallbackRegistry ??= new ModelRegistry(runtime);
191
+ return fallbackRegistry;
192
+ });
193
+ }
194
+ let warnedNoRuntime = false;
195
+ /**
196
+ * The ModelRuntime behind a registry facade. pi's ModelRegistry does not expose
197
+ * its runtime publicly, so reach into the private field (stable since 0.80.8);
198
+ * subagent sessions need it to share the host session's exact catalog and auth
199
+ * (createAgentSession takes modelRuntime, not a registry, since 0.80.8).
200
+ *
201
+ * Exported so the test suite can pin this pi-internals contract: the cast means
202
+ * neither tsc nor mock-based tests would notice pi renaming the field, and the
203
+ * runtime consequence is silent (subagents fall back to a default runtime and
204
+ * extension-registered providers vanish from routing).
205
+ */
206
+ export function runtimeOf(registry) {
207
+ const runtime = registry.runtime;
208
+ if (!runtime && !warnedNoRuntime) {
209
+ warnedNoRuntime = true;
210
+ console.warn("[workflow] ModelRegistry no longer carries a private `runtime` field (pi internals changed); subagents fall back to a default-built runtime and may miss extension-registered providers");
211
+ }
212
+ return runtime;
213
+ }
214
+ /**
215
+ * List the user's currently available models (those with auth configured) with
216
+ * the minimal fields tier ranking needs: canonical spec, output price, and
217
+ * context window. This is the single place the SDK `Model` is projected into
218
+ * the SDK-agnostic `RankableModel`. Best-effort: returns [] if the registry
219
+ * can't be built (or while the disk-backed fallback is still initializing).
220
+ */
221
+ export function listAvailableModels(registry) {
222
+ try {
223
+ const modelRegistry = registry ?? fallbackRegistry;
224
+ if (!modelRegistry) {
225
+ // Kick off the async fallback build; this call reports [] and later
226
+ // calls (e.g. the tool's lazy promptGuidelines re-reads) see real specs.
227
+ void ensureFallbackRegistry().catch(() => { });
228
+ return [];
229
+ }
230
+ return modelRegistry.getAvailable().map((model) => ({
231
+ spec: canonicalModelSpec(model),
232
+ costOutput: model.cost?.output,
233
+ contextWindow: model.contextWindow,
234
+ }));
235
+ }
236
+ catch {
237
+ return [];
238
+ }
239
+ }
240
+ /**
241
+ * List the user's currently available models as `provider/modelId` specs. Used
242
+ * to tell the workflow author which models it may route agents to. Best-effort:
243
+ * returns [] if the registry can't be built.
244
+ */
245
+ export function listAvailableModelSpecs(registry) {
246
+ return listAvailableModels(registry).map((model) => model.spec);
247
+ }
248
+ /**
249
+ * Emitted at most once per process: when an agent asks for a tier but no
250
+ * model-tiers.json exists, the tier silently falls back to the session model.
251
+ * Surface that once (with the mapping the user would get by configuring) so the
252
+ * no-op is discoverable. Diagnostics only — never lets a failure break a run.
253
+ */
254
+ let warnedTierUnconfigured = false;
255
+ function warnTierUnconfiguredOnce(mainModel, registry) {
256
+ if (warnedTierUnconfigured)
257
+ return;
258
+ warnedTierUnconfigured = true;
259
+ try {
260
+ console.warn(formatTierFallbackNotice(mainModel, listAvailableModels(registry)));
261
+ }
262
+ catch {
263
+ // best-effort diagnostic
264
+ }
265
+ }
266
+ /**
267
+ * Emitted at most once per process when persistAgentSessions is enabled and a
268
+ * session is actually persisted: full subagent transcripts (which may include
269
+ * secrets or other sensitive context) are being written to disk. Surface the
270
+ * privacy trade-off at run time, not only in the docs.
271
+ */
272
+ let warnedPersistSecrets = false;
273
+ function warnPersistSecretsOnce(sessionDir) {
274
+ if (warnedPersistSecrets)
275
+ return;
276
+ warnedPersistSecrets = true;
277
+ console.warn(`[workflow] persistAgentSessions is ON: full subagent transcripts (which may include secrets or other sensitive context) are being written to disk under ${sessionDir}. Disable persistAgentSessions if that isn't intended.`);
278
+ }
279
+ /**
280
+ * Map session stats to an AgentUsage, or undefined when the provider reported
281
+ * no usage at all (all-zero stats). Returning undefined — instead of a zero
282
+ * breakdown — lets displays fall back to their scalar token count, so setups
283
+ * on non-reporting providers render the same as before the split existed.
284
+ */
285
+ export function usageFromStats(stats) {
286
+ const { tokens, cost } = stats;
287
+ if (tokens.total <= 0 && cost <= 0)
288
+ return undefined;
289
+ return {
290
+ input: tokens.input,
291
+ output: tokens.output,
292
+ cacheRead: tokens.cacheRead,
293
+ cacheWrite: tokens.cacheWrite,
294
+ total: tokens.total,
295
+ cost,
296
+ };
297
+ }
298
+ /**
299
+ * Orchestration tools ALWAYS denied to workflow subagents. The `workflow` and
300
+ * `workflow_control` tools are registered globally by the extension, so — unless
301
+ * excluded — a subagent's session sees them and can start its own independent
302
+ * background workflows. Those nested runs recursively fan out and are NOT bounded
303
+ * by the parent run's maxAgents / concurrency / progress / accounting, and can
304
+ * drain a shared provider quota and pile up paused runs (#107). Callers may deny
305
+ * additional tool names via WorkflowAgentOptions.excludeTools.
306
+ */
307
+ export const DEFAULT_EXCLUDED_SUBAGENT_TOOLS = ["workflow", "workflow_control"];
308
+ /**
309
+ * The full subagent tool denylist: the always-on defaults plus any names the
310
+ * caller added (via WorkflowAgentOptions.excludeTools) or set on the injected
311
+ * session options. Extracted so the merge — and its order — is unit-testable;
312
+ * a spread-order regression that dropped the defaults would slip past a test
313
+ * that only asserts the constant. The SDK dedupes, so overlap is harmless.
314
+ */
315
+ export function subagentExcludedTools(extra, sessionExclude) {
316
+ return [...DEFAULT_EXCLUDED_SUBAGENT_TOOLS, ...(sessionExclude ?? []), ...(extra ?? [])];
317
+ }
318
+ export class WorkflowAgent {
319
+ cwd;
320
+ baseTools;
321
+ /** Extra subagent tool-name denylist, merged with the always-on defaults. */
322
+ excludeTools;
323
+ sessionOptions;
324
+ persistAgentSessions;
325
+ instructions;
326
+ mainModel;
327
+ /** Shared registry from the host session, when provided. */
328
+ sharedRegistry;
329
+ /** Lazily built once; shares the SDK's agentDir/auth so resolved models are authed. */
330
+ registry;
331
+ /**
332
+ * Memoized model-tiers.json snapshot, boxed so a legitimately-null config
333
+ * (file absent/invalid) is distinguishable from "not loaded yet". See
334
+ * loadTierConfig() below for why this is scoped per-instance.
335
+ */
336
+ tierConfigBox;
337
+ /**
338
+ * Shared resource loader for every subagent of this run, built once. See
339
+ * getSharedResourceLoader — this is the #109 memory mitigation.
340
+ */
341
+ sharedResourceLoaderPromise;
342
+ constructor(options = {}) {
343
+ this.cwd = options.cwd ?? process.cwd();
344
+ this.baseTools = options.tools ?? createCodingTools(this.cwd);
345
+ this.excludeTools = options.excludeTools ?? [];
346
+ this.sessionOptions = options.session ?? {};
347
+ this.persistAgentSessions = options.persistAgentSessions ?? false;
348
+ this.instructions = options.instructions;
349
+ this.mainModel = options.mainModel;
350
+ this.sharedRegistry = options.modelRegistry;
351
+ }
352
+ /**
353
+ * A resource loader shared by every subagent of this run, built once (#109).
354
+ *
355
+ * Without a resourceLoader, createAgentSession() builds a fresh
356
+ * DefaultResourceLoader per subagent and reloads it — re-running EVERY installed
357
+ * extension factory each time (verified: N subagents → N factory runs). Each
358
+ * such factory that arms a load-time timer/listener then roots its subagent
359
+ * session forever, because AgentSession.dispose() emits no session_shutdown to
360
+ * run the cleanup — the dominant #109 leak, and one our own extension
361
+ * (UsageLimitScheduler) can trigger.
362
+ *
363
+ * `noExtensions: true` skips loading host extensions; skills, prompts, and
364
+ * AGENTS.md context still load. The subagent keeps the tools this workflow
365
+ * hands it via `customTools` (coding tools + any toolset like web-research) —
366
+ * those are unaffected. What it loses is HOST EXTENSION-REGISTERED tools (MCP
367
+ * bridges, browser tools, anything a host extension added via ctx.registerTool):
368
+ * pre-change a subagent session inherited those from the full host extension
369
+ * set, now it does not, so an agentType `tools` allowlist naming one matches
370
+ * nothing. This is a deliberate trade-off — it also structurally kills recursive
371
+ * orchestration in subagents (no extension runtime at all), beyond the name-level
372
+ * #107 denylist — and must be release-noted. `createAgentSession` with a shared
373
+ * resourceLoader is a supported embedding pattern. runWorkflow builds one
374
+ * WorkflowAgent per run, so this loader's lifetime is exactly one run: built
375
+ * once, reused by all its subagents, then dropped with the agent.
376
+ */
377
+ getSharedResourceLoader(agentDir) {
378
+ if (!this.sharedResourceLoaderPromise) {
379
+ this.sharedResourceLoaderPromise = (async () => {
380
+ const loader = new DefaultResourceLoader({
381
+ cwd: this.cwd,
382
+ agentDir,
383
+ settingsManager: SettingsManager.create(this.cwd, agentDir),
384
+ noExtensions: true,
385
+ });
386
+ await loader.reload();
387
+ return loader;
388
+ })().catch((err) => {
389
+ // Don't let a transient build failure (e.g. EMFILE during reload's disk
390
+ // I/O) poison every subagent AND every retry of this run — clear the memo
391
+ // so the next caller rebuilds instead of replaying the same rejection.
392
+ this.sharedResourceLoaderPromise = undefined;
393
+ throw err;
394
+ });
395
+ }
396
+ return this.sharedResourceLoaderPromise;
397
+ }
398
+ /**
399
+ * Resolve the registry for a run: an explicit per-run registry wins, then the
400
+ * constructor's shared registry, then a lazily-built disk registry (shared
401
+ * across calls once built). Async because pi >= 0.80.8 builds registries from
402
+ * an async-created ModelRuntime.
403
+ */
404
+ async getRegistry(perRunRegistry) {
405
+ if (perRunRegistry) {
406
+ return perRunRegistry;
407
+ }
408
+ if (this.sharedRegistry) {
409
+ return this.sharedRegistry;
410
+ }
411
+ if (!this.registry) {
412
+ this.registry = await ensureFallbackRegistry();
413
+ }
414
+ return this.registry;
415
+ }
416
+ /**
417
+ * Read+parse ~/.pi/workflows/model-tiers.json at most once for this
418
+ * instance's lifetime, instead of on every run() call. `resolveAgentModelSpec`
419
+ * previously received `loadModelTierConfig` directly (sync existsSync +
420
+ * readFileSync + JSON.parse from disk), which it calls unconditionally for
421
+ * any agent without an explicit options.model — so a large fan-out did N
422
+ * redundant synchronous disk reads that blocked the event loop and stalled
423
+ * concurrent agents' I/O.
424
+ *
425
+ * `runWorkflow()` constructs a fresh `WorkflowAgent` per run (see
426
+ * `new WorkflowAgent(options)` in workflow.ts, unless a caller injects its
427
+ * own `options.agent` runner — a test-only escape hatch per
428
+ * WorkflowManagerOptions.agent's doc comment), so a WorkflowAgent instance's
429
+ * lifetime is one run in production. Memoizing on `this` therefore has the
430
+ * same scope and lifetime as the agentRegistry snapshot workflow.ts already
431
+ * takes once per run "for determinism" — the config file isn't expected to
432
+ * change mid-run, and two different runs (= two different WorkflowAgent
433
+ * instances) each get their own fresh read of whatever is on disk at the
434
+ * time, so this does not leak stale config across runs or break tests that
435
+ * construct fresh agents with different configs.
436
+ *
437
+ * `loader` is injectable for tests (defaults to the real disk read); it is
438
+ * only ever consulted once, on the first call, regardless of what is passed
439
+ * on later calls.
440
+ */
441
+ loadTierConfig(loader = loadModelTierConfig) {
442
+ if (!this.tierConfigBox) {
443
+ this.tierConfigBox = { value: loader() };
444
+ }
445
+ return this.tierConfigBox.value;
446
+ }
447
+ /**
448
+ * Session manager for one subagent run. File-backed (persisted under the
449
+ * standard sessions dir, keyed by the runner's project cwd — never a
450
+ * per-call worktree cwd) when persistAgentSessions is on; in-memory otherwise.
451
+ *
452
+ * SessionManager.create() only creates the session directory — the SDK writes
453
+ * the session file lazily (synchronous fs calls, uncaught) on the first
454
+ * assistant message, deep inside session.prompt(). A failure there would
455
+ * otherwise throw mid-run and abort this subagent. Probe writability up front
456
+ * so any create/write failure (permissions, disk full) degrades this single
457
+ * agent to an in-memory session instead — the run continues, just without a
458
+ * persisted transcript.
459
+ */
460
+ createSessionManager() {
461
+ if (!this.persistAgentSessions)
462
+ return SessionManager.inMemory();
463
+ try {
464
+ const manager = SessionManager.create(this.cwd);
465
+ this.assertSessionDirWritable(manager.getSessionDir());
466
+ warnPersistSecretsOnce(manager.getSessionDir());
467
+ return manager;
468
+ }
469
+ catch (error) {
470
+ console.warn(`[workflow] persistAgentSessions: could not persist this agent's session (${error instanceof Error ? error.message : String(error)}); continuing with an in-memory session`);
471
+ return SessionManager.inMemory();
472
+ }
473
+ }
474
+ /** Best-effort write probe: throws if the session directory isn't actually writable. */
475
+ assertSessionDirWritable(dir) {
476
+ const probePath = join(dir, `.write-probe-${randomUUID()}`);
477
+ writeFileSync(probePath, "");
478
+ unlinkSync(probePath);
479
+ }
480
+ async run(prompt, options = {}) {
481
+ const capture = { called: false, value: undefined };
482
+ // Per-call cwd (e.g. a worktree) needs coding tools bound to that directory,
483
+ // since tools capture their cwd at construction and can't be relocated.
484
+ const runCwd = options.cwd ?? this.cwd;
485
+ const baseTools = runCwd === this.cwd ? this.baseTools : createCodingTools(runCwd);
486
+ // Apply the agentType tool policy BEFORE adding structured_output, so a
487
+ // restrictive allowlist never strips the schema tool.
488
+ const customTools = applyToolPolicy([...baseTools, ...(options.tools ?? [])], options.toolNames, options.disallowedToolNames);
489
+ // System tools bypass the allowlist/denylist filter (e.g. shared-store tools).
490
+ if (options.systemTools?.length) {
491
+ customTools.push(...options.systemTools);
492
+ }
493
+ if (options.schema) {
494
+ // Strict OpenAI-compatible providers (e.g. DeepSeek) reject a tool whose top-level
495
+ // parameters schema isn't a JSON object with a transport-level 400, before any of
496
+ // this file's SCHEMA_NONCOMPLIANCE/empty-output classification ever runs. Fail fast
497
+ // here instead, so a script's non-object opts.schema surfaces a clear workflow error.
498
+ const schemaType = options.schema.type;
499
+ if (schemaType !== "object") {
500
+ throw new WorkflowError(`agent() opts.schema must be a top-level JSON object schema (type: "object") — got type: ${schemaType ?? "undefined"}; wrap array/primitive results in an object, e.g. { type: "object", properties: { items: <your schema> } }`, WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, { recoverable: false });
501
+ }
502
+ customTools.push(createStructuredOutputTool({ schema: options.schema, capture }));
503
+ }
504
+ // Per-run modelRegistry wins over the constructor's shared registry, then
505
+ // the lazily-built disk fallback. Used for tier diagnostics, model
506
+ // resolution, and the subagent session's runtime below.
507
+ const modelRegistry = await this.getRegistry(options.modelRegistry);
508
+ // Resolve the model spec (explicit model > tier > session default). This
509
+ // composes with phase-based routing in workflow.ts, which only supplies
510
+ // options.model when a phase pattern matches — so an explicit model wins.
511
+ const modelSpec = resolveAgentModelSpec(options, this.mainModel, () => this.loadTierConfig(), () => warnTierUnconfiguredOnce(this.mainModel, modelRegistry));
512
+ // Resolve a requested model spec to a Model object. Specs use Pi CLI-style
513
+ // parsing, including an optional :thinking suffix such as gpt-5.5:xhigh.
514
+ // A given-but-unresolved spec falls back to the session default (with a
515
+ // warning) rather than failing.
516
+ let resolvedModel;
517
+ let resolvedThinkingLevel;
518
+ if (modelSpec) {
519
+ const resolved = resolveModelSpecWithThinking(modelSpec, modelRegistry);
520
+ if (resolved.warning)
521
+ console.warn(`[workflow] ${resolved.warning}`);
522
+ if (resolved.model) {
523
+ resolvedModel = resolved.model;
524
+ resolvedThinkingLevel = resolved.thinkingLevel;
525
+ options.onModelResolved?.(resolved.resolvedSpec ?? canonicalModelSpec(resolved.model));
526
+ }
527
+ else {
528
+ console.warn(`[workflow] model "${modelSpec}" not found; using session default`);
529
+ options.onModelFallback?.(modelSpec);
530
+ }
531
+ }
532
+ const agentDir = getAgentDir();
533
+ // The runtime behind the resolved registry, handed to the subagent session
534
+ // below so it shares the host session's exact catalog and auth.
535
+ const modelRuntime = runtimeOf(modelRegistry);
536
+ // Key persisted sessions by the runner's project cwd (this.cwd), NOT the
537
+ // per-call runCwd: agents working in short-lived git worktrees should still
538
+ // group under the project's session dir instead of scattering across
539
+ // temporary worktree paths.
540
+ const sessionManager = this.createSessionManager();
541
+ const { session } = await createAgentSession({
542
+ cwd: runCwd,
543
+ agentDir,
544
+ sessionManager,
545
+ // Use real SettingsManager to inherit user's default provider/model settings.
546
+ // SettingsManager.inMemory() doesn't load ~/.pi/settings.json, so subagents
547
+ // would fall back to the first available model (e.g. openai-codex) which may
548
+ // not have valid auth, causing silent empty responses.
549
+ settingsManager: SettingsManager.create(this.cwd, agentDir),
550
+ customTools,
551
+ // Shared per-run loader with no host extensions (#109) — see
552
+ // getSharedResourceLoader. An injected resourceLoader (tests / embedders)
553
+ // wins and skips the shared build entirely; the ...this.sessionOptions
554
+ // spread below re-applies the same injected value harmlessly.
555
+ resourceLoader: this.sessionOptions.resourceLoader ?? (await this.getSharedResourceLoader(agentDir)),
556
+ // Share the resolved registry's ModelRuntime (catalog + auth, including
557
+ // extension-registered providers) with the subagent session. pi >= 0.80.8
558
+ // takes modelRuntime here; the old modelRegistry option is gone.
559
+ ...(modelRuntime ? { modelRuntime } : {}),
560
+ ...this.sessionOptions,
561
+ // Per-call model/thinking wins over any sessionOptions defaults.
562
+ ...(resolvedModel ? { model: resolvedModel } : {}),
563
+ ...(resolvedThinkingLevel ? { thinkingLevel: resolvedThinkingLevel } : {}),
564
+ // Deny recursive-orchestration tools in the subagent (#107). Placed after
565
+ // the sessionOptions spread so it always applies; folds in any denylist
566
+ // the caller set on sessionOptions rather than dropping it.
567
+ excludeTools: subagentExcludedTools(this.excludeTools, this.sessionOptions.excludeTools),
568
+ });
569
+ // Name the persisted session so it's identifiable in session pickers.
570
+ // Skip when an injected session.sessionManager override won (tests/embedders).
571
+ if (this.persistAgentSessions && !this.sessionOptions.sessionManager && options.sessionName) {
572
+ try {
573
+ sessionManager.appendSessionInfo(options.sessionName);
574
+ }
575
+ catch {
576
+ // Naming is best-effort; never fail the run over it.
577
+ }
578
+ }
579
+ let removeAbortListener;
580
+ let removeHistoryListener;
581
+ let lastHistoryEmit = 0;
582
+ const emitHistory = () => options.onHistory?.(compactAgentHistory(session.messages));
583
+ const maybeEmitHistory = () => {
584
+ if (!options.onHistory)
585
+ return;
586
+ const now = Date.now();
587
+ if (now - lastHistoryEmit < 250)
588
+ return;
589
+ lastHistoryEmit = now;
590
+ emitHistory();
591
+ };
592
+ try {
593
+ if (options.signal?.aborted)
594
+ throw new Error("Subagent was aborted");
595
+ if (options.signal) {
596
+ const onAbort = () => void session.abort();
597
+ options.signal.addEventListener("abort", onAbort, { once: true });
598
+ removeAbortListener = () => options.signal?.removeEventListener("abort", onAbort);
599
+ }
600
+ if (options.onHistory) {
601
+ removeHistoryListener = session.subscribe(() => maybeEmitHistory());
602
+ }
603
+ await session.prompt(this.buildPrompt(prompt, options, Boolean(options.schema)));
604
+ if (options.signal?.aborted)
605
+ throw new Error("Subagent was aborted");
606
+ // The SDK buries a provider usage/quota limit in the assistant message rather
607
+ // than throwing; detect it here (before the schema/empty-text branches) so it
608
+ // is classified as a recoverable checkpoint, not a SCHEMA_NONCOMPLIANCE failure
609
+ // (schema path) or a silent empty-output null (non-schema path).
610
+ throwIfProviderLimit(session.messages, options.label);
611
+ if (options.schema) {
612
+ return (await resolveStructuredOutput(session, capture, options.schema, options, (m) => this.lastAssistantText(m)));
613
+ }
614
+ // Unstructured result: require assistant text AFTER the last tool result.
615
+ // Text emitted before it is stale progress (the agent's last real action was
616
+ // a tool call) — accepting it would report an incomplete run as successful
617
+ // and suppress the AGENT_EMPTY_OUTPUT retry (#111).
618
+ const text = this.finalAssistantText(session.messages);
619
+ if (!text.trim()) {
620
+ throw new WorkflowError("Subagent produced no assistant output", WorkflowErrorCode.AGENT_EMPTY_OUTPUT, {
621
+ recoverable: true,
622
+ agentLabel: options.label,
623
+ });
624
+ }
625
+ return text;
626
+ }
627
+ finally {
628
+ removeAbortListener?.();
629
+ removeHistoryListener?.();
630
+ try {
631
+ emitHistory();
632
+ }
633
+ catch {
634
+ // History is diagnostic only; never let it mask the real result/error.
635
+ }
636
+ // Read real usage before disposing — dispose tears down the session state.
637
+ if (options.onUsage) {
638
+ try {
639
+ const usage = usageFromStats(session.getSessionStats());
640
+ if (usage)
641
+ options.onUsage(usage);
642
+ }
643
+ catch {
644
+ // Usage is best-effort; never let stats failure mask the real result/error.
645
+ }
646
+ }
647
+ session.dispose();
648
+ }
649
+ }
650
+ buildPrompt(prompt, options, structured) {
651
+ const parts = [
652
+ this.instructions,
653
+ options.instructions,
654
+ options.label ? `Task label: ${options.label}` : undefined,
655
+ prompt,
656
+ ].filter(Boolean);
657
+ if (structured) {
658
+ parts.push([
659
+ "Final output contract:",
660
+ "- Your final action MUST be a structured_output tool call.",
661
+ "- The structured_output arguments are the return value of this subagent.",
662
+ "- Do not emit a prose final answer instead of structured_output.",
663
+ "- If you need to inspect files or run commands first, do so, then call structured_output exactly once.",
664
+ ].join("\n"));
665
+ }
666
+ return parts.join("\n\n");
667
+ }
668
+ lastAssistantText(messages) {
669
+ for (let i = messages.length - 1; i >= 0; i--) {
670
+ const message = messages[i];
671
+ if (message?.role !== "assistant" || !Array.isArray(message.content))
672
+ continue;
673
+ const text = message.content
674
+ .filter((part) => part.type === "text")
675
+ .map((part) => part.text)
676
+ .join("");
677
+ if (text.trim())
678
+ return text;
679
+ }
680
+ return "";
681
+ }
682
+ /**
683
+ * The unstructured agent's FINAL answer: assistant text that appears after the
684
+ * last tool result. Text before the final tool result is stale progress (the
685
+ * agent's last real action was a tool call, not answering), so returning it
686
+ * would mask an incomplete run and suppress AGENT_EMPTY_OUTPUT retries (#111).
687
+ *
688
+ * Distinct from lastAssistantText(), which stays deliberately lenient — the
689
+ * schema path's prose-JSON recovery (resolveStructuredOutput) may need to read
690
+ * the structured payload out of any assistant message, not only the terminal one.
691
+ */
692
+ finalAssistantText(messages) {
693
+ // Locate the last tool result; only assistant text strictly after it counts.
694
+ let lastToolResult = -1;
695
+ for (let i = messages.length - 1; i >= 0; i--) {
696
+ if (messages[i]?.role === "toolResult") {
697
+ lastToolResult = i;
698
+ break;
699
+ }
700
+ }
701
+ for (let i = messages.length - 1; i > lastToolResult; i--) {
702
+ const message = messages[i];
703
+ if (message?.role !== "assistant" || !Array.isArray(message.content))
704
+ continue;
705
+ const text = message.content
706
+ .filter((part) => part.type === "text")
707
+ .map((part) => part.text)
708
+ .join("");
709
+ if (text.trim())
710
+ return text;
711
+ }
712
+ return "";
713
+ }
714
+ }