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