pi-extensible-workflows 3.3.0 → 3.4.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.
@@ -4,7 +4,7 @@ import { join, resolve } from "node:path";
4
4
  import { Type } from "@earendil-works/pi-ai";
5
5
  import { Value } from "typebox/value";
6
6
  import { createAgentSession, DefaultPackageManager, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
7
- import { jsonObject, disabledResources, mergeAgentResourceExclusions, modelAliasName, modelCapability, resolveModelReference, unmatchedResourcePatterns } from "./utils.js";
7
+ import { deepFreeze, jsonObject, disabledResources, mergeAgentResourceExclusions, modelAliasName, modelCapability, resolveModelReference, unmatchedResourcePatterns } from "./utils.js";
8
8
  import { WorkflowError } from "./types.js";
9
9
  function parseModel(value, fallback, thinking, aliases = {}, knownModels, settingsPath) {
10
10
  if (!value)
@@ -12,8 +12,7 @@ function parseModel(value, fallback, thinking, aliases = {}, knownModels, settin
12
12
  const parsed = resolveModelReference(value, aliases, knownModels, settingsPath);
13
13
  return { ...parsed, ...(thinking ? { thinking } : !parsed.thinking && fallback.thinking ? { thinking: fallback.thinking } : {}) };
14
14
  }
15
- function text(messages) {
16
- const message = [...messages].reverse().find((item) => item.role === "assistant");
15
+ function text(message) {
17
16
  if (!message || !Array.isArray(message.content))
18
17
  return "";
19
18
  return message.content.filter((part) => typeof part === "object" && part !== null && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string").map((part) => part.text).join("");
@@ -21,19 +20,14 @@ function text(messages) {
21
20
  function hasToolCall(message) {
22
21
  return typeof message === "object" && message !== null && Array.isArray(message.content) && message.content.some((part) => typeof part === "object" && part !== null && part.type === "toolCall");
23
22
  }
24
- function latestAssistantHasToolCall(messages) {
25
- const message = [...messages].reverse().find((item) => item.role === "assistant");
26
- return hasToolCall(message);
27
- }
28
- function throwIfTerminalAssistantError(session, fallbackModel) {
29
- const message = [...session.messages].reverse().find((item) => item.role === "assistant");
23
+ function latestAssistantHasToolCall(message) { return hasToolCall(message); }
24
+ function throwIfTerminalAssistantError(session, message) {
30
25
  if (message?.stopReason !== "error")
31
26
  return;
32
- const provider = session.model?.provider ?? fallbackModel.provider;
33
- const model = session.model?.model ?? session.model?.id ?? fallbackModel.model;
34
- const error = message.errorMessage ?? "Native Pi assistant ended with a terminal provider error";
27
+ const state = session.getState();
28
+ const error = message.errorMessage ?? "Workflow agent session ended with a terminal provider error";
35
29
  const failure = new WorkflowError("AGENT_FAILED", error);
36
- Object.defineProperty(failure, "terminalProviderError", { value: { provider, model, error }, configurable: true });
30
+ Object.defineProperty(failure, "terminalProviderError", { value: { provider: state.model.provider, model: state.model.model, error }, configurable: true });
37
31
  throw failure;
38
32
  }
39
33
  function terminalProviderError(error) {
@@ -44,11 +38,11 @@ function terminalProviderError(error) {
44
38
  return typeof candidate.provider === "string" && typeof candidate.model === "string" && typeof candidate.error === "string" ? { provider: candidate.provider, model: candidate.model, error: candidate.error } : undefined;
45
39
  }
46
40
  const providerContinuationPrompt = "The provider error was transient. Continue the task from your current state.";
47
- async function recoverTerminalProviderError(session, fallbackModel, label, recovery, continuePrompt) {
41
+ async function recoverTerminalProviderError(session, label, recovery, continuePrompt, getAssistant) {
48
42
  let continued = false;
49
43
  for (;;) {
50
44
  try {
51
- throwIfTerminalAssistantError(session, fallbackModel);
45
+ throwIfTerminalAssistantError(session, getAssistant());
52
46
  return continued;
53
47
  }
54
48
  catch (error) {
@@ -91,7 +85,7 @@ function workflowSystemPromptPath(cwd, agentDir, projectTrusted) {
91
85
  const globalPaths = [join(homedir(), ".pi", WORKFLOW_DIRECTORY, "SYSTEM.md"), join(agentDir, WORKFLOW_DIRECTORY, "SYSTEM.md")];
92
86
  return globalPaths.find((path) => existsSync(path));
93
87
  }
94
- export async function createNativeAgentSession(input) {
88
+ export async function createLocalPiSession(input) {
95
89
  const agentDir = input.agentDir ?? getAgentDir();
96
90
  const systemPromptSource = workflowSystemPromptPath(input.cwd, agentDir, input.resourcePolicy?.projectTrusted ?? true);
97
91
  const systemPromptOptions = input.systemPrompt !== undefined ? { systemPromptOverride: () => input.systemPrompt } : systemPromptSource !== undefined ? { systemPrompt: systemPromptSource } : {};
@@ -150,6 +144,72 @@ export async function createNativeAgentSession(input) {
150
144
  getToolDefinitions: () => session.getAllTools().map(({ name, description, parameters, promptGuidelines }) => ({ name, description, parameters, ...(promptGuidelines ? { promptGuidelines } : {}) })),
151
145
  });
152
146
  }
147
+ function workflowAgentMessage(message) { return message ? { role: message.role, ...(message.content === undefined ? {} : { content: message.content }), ...(message.stopReason === undefined ? {} : { stopReason: message.stopReason }), ...(message.errorMessage === undefined ? {} : { errorMessage: message.errorMessage }), ...(message.usage === undefined ? {} : { usage: message.usage }) } : undefined; }
148
+ function workflowAgentStats(stats) { return { tokens: { input: stats.tokens.input, output: stats.tokens.output, cacheRead: stats.tokens.cacheRead, cacheWrite: stats.tokens.cacheWrite, total: stats.tokens.total }, cost: stats.cost }; }
149
+ function workflowAgentState(native, prepared) {
150
+ const tools = native.agent?.state.tools.map(({ name }) => name) ?? prepared.tools;
151
+ const model = native.model?.provider && (native.model.model ?? native.model.id) ? { provider: native.model.provider, model: native.model.model ?? native.model.id ?? prepared.model.model, ...(prepared.model.thinking ? { thinking: prepared.model.thinking } : {}) } : { ...prepared.model };
152
+ return { model, ...(model.thinking ? { thinking: model.thinking } : {}), tools: [...tools], ...(native.systemPrompt === undefined ? {} : { systemPrompt: native.systemPrompt }) };
153
+ }
154
+ function localSessionEvent(event) { return event; }
155
+ export async function createLocalWorkflowAgentSession(prepared, context) {
156
+ void context;
157
+ const input = {
158
+ cwd: prepared.cwd, model: { ...prepared.model }, tools: [...prepared.tools], sessionLabel: prepared.sessionLabel,
159
+ ...(prepared.agentDir ? { agentDir: prepared.agentDir } : {}), ...(prepared.customTools?.length ? { customTools: [...prepared.customTools] } : {}),
160
+ ...(prepared.resultTool ? { resultTool: prepared.resultTool } : {}), ...(prepared.systemPrompt === undefined ? {} : { systemPrompt: prepared.systemPrompt }),
161
+ ...(prepared.systemPromptAppend ? { systemPromptAppend: prepared.systemPromptAppend } : {}), ...(prepared.extensionFactories?.length ? { extensionFactories: [...prepared.extensionFactories] } : {}),
162
+ ...(prepared.additionalSkillPaths?.length ? { additionalSkillPaths: [...prepared.additionalSkillPaths] } : {}), ...(prepared.resourcePolicy ? { resourcePolicy: structuredClone(prepared.resourcePolicy) } : {}), ...(prepared.options ? { options: { ...prepared.options } } : {}),
163
+ };
164
+ const native = await createLocalPiSession(input);
165
+ let disposal;
166
+ let aborting;
167
+ let prompting;
168
+ let disposed = false;
169
+ const startAbort = () => aborting ??= Promise.resolve().then(() => native.abort?.()).then(() => undefined).finally(() => { aborting = undefined; });
170
+ const reference = { transport: "local", sessionId: native.sessionId, ...(native.sessionFile ? { locator: { sessionFile: native.sessionFile } } : {}) };
171
+ const session = {
172
+ reference,
173
+ getState: () => Object.freeze(workflowAgentState(native, prepared)),
174
+ getSessionStats: () => workflowAgentStats(native.getSessionStats()),
175
+ subscribe(listener) { listener({ type: "state_changed", state: workflowAgentState(native, prepared) }); return native.subscribe?.((event) => { listener(localSessionEvent(event)); }) ?? (() => undefined); },
176
+ getLastAssistant: () => workflowAgentMessage([...native.messages].reverse().find((message) => message.role === "assistant")),
177
+ async prompt(text) {
178
+ if (disposed)
179
+ throw new WorkflowError("INTERNAL_ERROR", "Local workflow session is disposed");
180
+ const prompt = Promise.resolve().then(async () => { await native.prompt(text); const assistant = workflowAgentMessage([...native.messages].reverse().find((message) => message.role === "assistant")); return assistant ? { assistant } : {}; });
181
+ prompting = prompt;
182
+ try {
183
+ return await prompt;
184
+ }
185
+ finally {
186
+ if (prompting === prompt)
187
+ prompting = undefined;
188
+ }
189
+ },
190
+ async steer(text) { if (!native.steer)
191
+ throw new WorkflowError("INTERNAL_ERROR", "Local workflow session does not support steering"); await native.steer(text); },
192
+ async abort() { if (disposed)
193
+ return; await startAbort(); },
194
+ async dispose() {
195
+ disposal ??= (async () => {
196
+ disposed = true;
197
+ try {
198
+ await startAbort();
199
+ }
200
+ catch { /* Abort failure must not prevent the prompt from settling. */ }
201
+ try {
202
+ await prompting;
203
+ }
204
+ catch { /* Prompt rejection is expected after abort during teardown. */ }
205
+ native.dispose();
206
+ })();
207
+ await disposal;
208
+ },
209
+ };
210
+ return session;
211
+ }
212
+ export const localAgentTransport = Object.freeze({ id: "local", createSession: createLocalWorkflowAgentSession });
153
213
  function changedOption(options, baseline, key) { return JSON.stringify(options[key]) !== JSON.stringify(baseline[key]); }
154
214
  function validThinking(value) { return typeof value === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value); }
155
215
  function isChildAgentToolParams(value) {
@@ -179,51 +239,94 @@ function fallbackSetupContext(root, options, signal) {
179
239
  function resourcePolicySummary(policy) {
180
240
  return { skills: [...policy.effective.skills], extensions: [...policy.effective.extensions], excludedSkills: [...(policy.excludedSkills ?? [])], excludedExtensions: [...(policy.excludedExtensions ?? [])], unmatchedSkills: [...policy.unmatchedSkills], unmatchedExtensions: [...policy.unmatchedExtensions] };
181
241
  }
182
- async function prepareAgentSetup(root, createSession, task, options, resolved, cwd, attempt, signal, customTools, resultTool) {
242
+ function resourcePolicyWidened(ceiling, candidate) {
243
+ if (!ceiling)
244
+ return false;
245
+ if (!candidate)
246
+ return true;
247
+ if (!ceiling.projectTrusted && candidate.projectTrusted)
248
+ return true;
249
+ return ceiling.effective.skills.some((pattern) => !candidate.effective.skills.includes(pattern)) || ceiling.effective.extensions.some((pattern) => !candidate.effective.extensions.includes(pattern));
250
+ }
251
+ function preparedAgentSession(input) {
252
+ const prepared = {
253
+ cwd: input.cwd, model: Object.freeze({ ...input.model }), tools: Object.freeze([...input.tools]), sessionLabel: input.sessionLabel,
254
+ ...(input.agentDir ? { agentDir: input.agentDir } : {}), ...(input.customTools?.length ? { customTools: Object.freeze([...input.customTools]) } : {}), ...(input.resultTool ? { resultTool: input.resultTool } : {}), ...(input.options ? { options: Object.freeze(structuredClone(input.options)) } : {}),
255
+ ...(input.systemPrompt === undefined ? {} : { systemPrompt: input.systemPrompt }), ...(input.systemPromptAppend ? { systemPromptAppend: input.systemPromptAppend } : {}),
256
+ ...(input.extensionFactories?.length ? { extensionFactories: Object.freeze([...input.extensionFactories]) } : {}), ...(input.additionalSkillPaths?.length ? { additionalSkillPaths: Object.freeze([...input.additionalSkillPaths]) } : {}),
257
+ ...(input.resourcePolicy ? { resourcePolicy: Object.freeze(structuredClone(input.resourcePolicy)) } : {}),
258
+ };
259
+ return deepFreeze(prepared);
260
+ }
261
+ function agentSetupSummary(setup, hookNames) {
262
+ const model = setup.sessionInput.model;
263
+ return { hookNames: [...hookNames], model: { provider: model.provider, model: model.model, ...(model.thinking ? { thinking: model.thinking } : {}) }, tools: [...setup.sessionInput.tools], cwd: setup.sessionInput.cwd, ...(setup.sessionInput.resourcePolicy ? { disabledAgentResources: resourcePolicySummary(setup.sessionInput.resourcePolicy) } : {}) };
264
+ }
265
+ async function prepareAgentSetup(root, transport, task, options, resolved, cwd, attempt, signal, customTools, resultTool) {
183
266
  const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
184
267
  const baselineOptions = structuredClone(options.agentOptions ?? {});
185
268
  const baseResourcePolicy = await root.agentResourcePolicy?.();
186
269
  const roleExclusions = options.role ? root.agentDefinitions?.[options.role]?.disabledAgentResources : undefined;
187
270
  const resourcePolicy = baseResourcePolicy && roleExclusions ? { ...baseResourcePolicy, effective: mergeAgentResourceExclusions(baseResourcePolicy.effective, roleExclusions) } : baseResourcePolicy;
271
+ const resourcePolicyCeiling = resourcePolicy ? structuredClone(resourcePolicy) : undefined;
188
272
  const sessionInput = { cwd, model: { ...resolved.model }, tools: [...resolved.tools], sessionLabel: `${options.workflowName}:${options.label}:attempt-${String(attempt)}`, ...(root.agentDir ? { agentDir: root.agentDir } : {}), ...(root.additionalSkillPaths?.length ? { additionalSkillPaths: [...root.additionalSkillPaths] } : {}), ...(customTools.length ? { customTools: [...customTools] } : {}), ...(resultTool ? { resultTool } : {}), ...(resolved.systemPrompt !== undefined ? { systemPrompt: resolved.systemPrompt } : {}), systemPromptAppend: resolved.systemPromptAppend, ...(resourcePolicy ? { resourcePolicy } : {}), options: structuredClone(baselineOptions) };
189
- const setup = { prompt: task, options: sessionInput.options ?? {}, sessionInput, createSession };
273
+ const setup = { prompt: task, options: sessionInput.options ?? {}, sessionInput, prepared: preparedAgentSession(sessionInput), transport };
190
274
  const base = fallbackSetupContext(root, options, setupSignal);
191
275
  const context = Object.freeze({ run: base.run, identity: base.identity, attempt, signal: setupSignal });
192
276
  const hookNames = [];
193
277
  for (const hook of [...(root.agentSetupHooks ?? [])].sort((left, right) => left.priority - right.priority || (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))) {
194
278
  if (setupSignal.aborted)
195
- throw new WorkflowError("CANCELLED", "Agent cancelled");
279
+ return { setup, summary: agentSetupSummary(setup, hookNames), failure: { error: new WorkflowError("CANCELLED", "Agent cancelled") } };
196
280
  try {
197
281
  await hook.setup(setup, context);
198
282
  }
199
283
  catch (error) {
200
- if (setupSignal.reason !== undefined)
201
- throw new WorkflowError("CANCELLED", "Agent cancelled");
202
- throw error;
284
+ setup.prepared = preparedAgentSession(setup.sessionInput);
285
+ return { setup, summary: agentSetupSummary(setup, hookNames), failure: { error: setupSignal.reason !== undefined ? new WorkflowError("CANCELLED", "Agent cancelled") : error } };
203
286
  }
287
+ setup.prepared = preparedAgentSession(setup.sessionInput);
204
288
  hookNames.push(hook.name);
205
289
  if (setupSignal.reason !== undefined)
206
- throw new WorkflowError("CANCELLED", "Agent cancelled");
207
- }
208
- setup.sessionInput.options = setup.options;
209
- if (changedOption(setup.options, baselineOptions, "model") && typeof setup.options.model === "string")
210
- setup.sessionInput.model = parseModel(setup.options.model, setup.sessionInput.model, changedOption(setup.options, baselineOptions, "thinking") && validThinking(setup.options.thinking) ? setup.options.thinking : undefined, root.modelAliases, root.knownModels ?? root.availableModels, root.settingsPath);
211
- if (changedOption(setup.options, baselineOptions, "thinking") && validThinking(setup.options.thinking))
212
- setup.sessionInput.model = { ...setup.sessionInput.model, thinking: setup.options.thinking };
213
- if (changedOption(setup.options, baselineOptions, "tools") && Array.isArray(setup.options.tools) && setup.options.tools.every((tool) => typeof tool === "string"))
214
- setup.sessionInput.tools = [...setup.options.tools];
215
- if (changedOption(setup.options, baselineOptions, "cwd") && typeof setup.options.cwd === "string")
216
- setup.sessionInput.cwd = setup.options.cwd;
217
- const model = setup.sessionInput.model;
218
- const summary = { hookNames: [...hookNames], model: { provider: model.provider, model: model.model, ...(model.thinking ? { thinking: model.thinking } : {}) }, tools: [...setup.sessionInput.tools], cwd: setup.sessionInput.cwd, ...(setup.sessionInput.resourcePolicy ? { disabledAgentResources: resourcePolicySummary(setup.sessionInput.resourcePolicy) } : {}) };
219
- return { setup, summary };
290
+ return { setup, summary: agentSetupSummary(setup, hookNames), failure: { error: new WorkflowError("CANCELLED", "Agent cancelled") } };
291
+ }
292
+ try {
293
+ if (resourcePolicyWidened(resourcePolicyCeiling, setup.sessionInput.resourcePolicy))
294
+ throw new WorkflowError("INVALID_METADATA", "Agent setup widened the prepared resource policy");
295
+ setup.sessionInput.options = setup.options;
296
+ if (changedOption(setup.options, baselineOptions, "model") && typeof setup.options.model === "string")
297
+ setup.sessionInput.model = parseModel(setup.options.model, setup.sessionInput.model, changedOption(setup.options, baselineOptions, "thinking") && validThinking(setup.options.thinking) ? setup.options.thinking : undefined, root.modelAliases, root.knownModels ?? root.availableModels, root.settingsPath);
298
+ if (changedOption(setup.options, baselineOptions, "thinking") && validThinking(setup.options.thinking))
299
+ setup.sessionInput.model = { ...setup.sessionInput.model, thinking: setup.options.thinking };
300
+ if (changedOption(setup.options, baselineOptions, "tools") && Array.isArray(setup.options.tools) && setup.options.tools.every((tool) => typeof tool === "string"))
301
+ setup.sessionInput.tools = [...setup.options.tools];
302
+ if (changedOption(setup.options, baselineOptions, "cwd") && typeof setup.options.cwd === "string")
303
+ setup.sessionInput.cwd = setup.options.cwd;
304
+ const customToolNames = new Set([...(setup.sessionInput.customTools ?? []).map(({ name }) => name), ...(setup.sessionInput.resultTool ? [setup.sessionInput.resultTool.name] : [])]);
305
+ const widened = setup.sessionInput.tools.find((tool) => !resolved.tools.includes(tool) && !customToolNames.has(tool));
306
+ const outsideTool = widened ?? setup.sessionInput.tools.find((tool) => !root.tools.has(tool) && !customToolNames.has(tool));
307
+ if (outsideTool)
308
+ throw new WorkflowError("UNKNOWN_TOOL", `Tool is outside the prepared agent policy: ${outsideTool}`);
309
+ }
310
+ catch (error) {
311
+ setup.prepared = preparedAgentSession(setup.sessionInput);
312
+ return { setup, summary: agentSetupSummary(setup, hookNames), failure: { error } };
313
+ }
314
+ setup.prepared = preparedAgentSession(setup.sessionInput);
315
+ return { setup, summary: agentSetupSummary(setup, hookNames) };
316
+ }
317
+ function attemptRecord(transport, attempt, session, setup, stats, result, error) {
318
+ return { attempt, transport, session: session.reference, ...(result === undefined ? {} : { result }), ...(error ? { error } : {}), accounting: stats, setup };
319
+ }
320
+ function errorWithAttempts(error, attempts) {
321
+ const typed = error instanceof Error ? error : new Error(typeof error === "string" ? error : String(error));
322
+ return Object.assign(typed, { attempts });
220
323
  }
221
324
  export class WorkflowAgentExecutor {
222
325
  root;
223
- createSession;
224
- constructor(root, createSession = createNativeAgentSession) {
326
+ transport;
327
+ constructor(root, transport = localAgentTransport) {
225
328
  this.root = root;
226
- this.createSession = createSession;
329
+ this.transport = transport;
227
330
  }
228
331
  setRunContext(runContext) { this.root.runContext = runContext; }
229
332
  resolve(options, inheritedTools) {
@@ -291,16 +394,16 @@ export class WorkflowAgentExecutor {
291
394
  const attempts = [];
292
395
  let maxAttempts = (options.retries ?? 0) + 1;
293
396
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
397
+ const attemptSignal = executionSignal ?? new AbortController().signal;
294
398
  if (recoveryModel)
295
399
  resolved = this.resolve({ ...options, modelOverride: recoveryModel });
296
400
  options.budget?.beforeAttempt();
297
401
  let schemaResult;
298
402
  let session;
299
403
  let setup;
300
- let setupSummary;
404
+ let setupSummary = { hookNames: [], model: { ...resolved.model }, tools: [...resolved.tools], cwd };
301
405
  let setupFailed = false;
302
406
  let budgetError;
303
- let turnStarted = false;
304
407
  const hasSchemaResult = () => schemaResult !== undefined;
305
408
  const resultTool = options.schema ? {
306
409
  name: "workflow_result", label: "Workflow Result", description: "Submit the terminal structured workflow result", parameters: Type.Unsafe(options.schema),
@@ -310,7 +413,9 @@ export class WorkflowAgentExecutor {
310
413
  if (schemaResult !== undefined)
311
414
  return { content: [{ type: "text", text: "Result has already been accepted." }], details: {}, isError: true };
312
415
  schemaResult = structuredClone(value);
313
- void session?.abort?.();
416
+ const currentSession = session;
417
+ if (currentSession)
418
+ void currentSession.abort();
314
419
  return { content: [{ type: "text", text: "Result accepted." }], details: {} };
315
420
  },
316
421
  } : undefined;
@@ -323,6 +428,8 @@ export class WorkflowAgentExecutor {
323
428
  let systemPromptTurn = 0;
324
429
  let systemPromptWrite = Promise.resolve();
325
430
  let systemPromptWriteError;
431
+ let turnStarted = false;
432
+ let completedAttempt;
326
433
  const flushSystemPrompts = async () => {
327
434
  await systemPromptWrite;
328
435
  if (systemPromptWriteError)
@@ -331,7 +438,7 @@ export class WorkflowAgentExecutor {
331
438
  const report = (persist) => {
332
439
  if (!session || !options.onProgress)
333
440
  return;
334
- const update = { accounting: accounting(session.getSessionStats()), toolCalls: [...toolCalls.values()], ...(activity ? { activity } : {}), ...(lastEventAt === undefined ? {} : { lastEventAt }), persist };
441
+ const update = { accounting: accounting(session.getSessionStats()), toolCalls: [...toolCalls.values()], state: session.getState(), ...(activity ? { activity } : {}), ...(lastEventAt === undefined ? {} : { lastEventAt }), persist };
335
442
  if (lastEventAt !== undefined)
336
443
  lastReportedEventAt = lastEventAt;
337
444
  progress = progress.then(() => options.onProgress?.(update)).then(() => undefined);
@@ -344,34 +451,48 @@ export class WorkflowAgentExecutor {
344
451
  const activityChanged = (previous) => previous?.kind !== activity?.kind || previous?.text !== activity?.text;
345
452
  try {
346
453
  setupFailed = true;
347
- const prepared = await prepareAgentSetup(this.root, this.createSession, task, options, resolved, cwd, attempt, executionSignal, customTools, resultTool);
454
+ const prepared = await prepareAgentSetup(this.root, this.transport, task, options, resolved, cwd, attempt, attemptSignal, customTools, resultTool);
348
455
  setup = prepared.setup;
349
456
  setupSummary = prepared.summary;
457
+ if (prepared.failure)
458
+ throw prepared.failure.error;
350
459
  setupFailed = false;
351
- if (executionSignal?.aborted)
460
+ if (attemptSignal.aborted)
352
461
  throw new WorkflowError("CANCELLED", "Agent cancelled");
353
462
  const started = Date.now();
354
- session = await setup.createSession(setup.sessionInput);
463
+ const transportSignal = attemptSignal;
464
+ await options.onAttempt?.({ attempt, transport: setup.transport.id, accounting: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }, setup: setupSummary });
465
+ const transportBase = fallbackSetupContext(this.root, options, transportSignal);
466
+ const createdSession = await setup.transport.createSession(setup.prepared, Object.freeze({ run: transportBase.run, identity: transportBase.identity, attempt, signal: transportSignal }));
467
+ if (createdSession.reference.transport !== setup.transport.id) {
468
+ await createdSession.dispose();
469
+ throw new WorkflowError("INTERNAL_ERROR", `Agent transport ${setup.transport.id} created a session for ${createdSession.reference.transport}`);
470
+ }
471
+ session = createdSession;
472
+ await options.onAttempt?.({ attempt, transport: setup.transport.id, session: session.reference, liveSession: session, accounting: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }, setup: setupSummary });
473
+ const preparedTools = new Set([...setup.prepared.tools, ...(setup.prepared.customTools ?? []).map(({ name }) => name), ...(setup.prepared.resultTool ? [setup.prepared.resultTool.name] : [])]);
474
+ if (session.getState().tools.some((tool) => !preparedTools.has(tool)))
475
+ throw new WorkflowError("INTERNAL_ERROR", `Agent transport ${setup.transport.id} widened the prepared tool policy`);
355
476
  if (setup.sessionInput.resourcePolicy)
356
477
  setupSummary = { ...setupSummary, disabledAgentResources: resourcePolicySummary(setup.sessionInput.resourcePolicy) };
357
- const includeAttemptSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
358
- await options.onAttempt?.({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), ...(includeAttemptSetup ? { setup: setupSummary } : {}) });
359
478
  const activeSession = session;
360
- const sessionModel = setup.sessionInput.model;
361
- const recoverTerminal = () => recoverTerminalProviderError(activeSession, sessionModel, options.label, options.providerErrorRecovery, async () => { try {
362
- await promptWithProviderPause(activeSession, providerContinuationPrompt, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
479
+ let lastAssistant;
480
+ const recoverTerminal = () => recoverTerminalProviderError(activeSession, options.label, options.providerErrorRecovery, async () => { try {
481
+ lastAssistant = (await promptWithProviderPause(activeSession, providerContinuationPrompt, remaining(options.timeoutMs, started), attemptSignal, this.root.providerPause)).assistant;
363
482
  }
364
483
  catch (error) {
484
+ lastAssistant = activeSession.getLastAssistant?.() ?? lastAssistant;
365
485
  if (!hasSchemaResult())
366
486
  throw error;
367
- } });
487
+ } }, () => lastAssistant);
368
488
  const promptAndRecover = async (prompt) => {
369
489
  let promptFailed = false;
370
490
  let promptError;
371
491
  try {
372
- await promptWithProviderPause(activeSession, prompt, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
492
+ lastAssistant = (await promptWithProviderPause(activeSession, prompt, remaining(options.timeoutMs, started), attemptSignal, this.root.providerPause)).assistant;
373
493
  }
374
494
  catch (error) {
495
+ lastAssistant = activeSession.getLastAssistant?.() ?? lastAssistant;
375
496
  promptFailed = true;
376
497
  promptError = error;
377
498
  }
@@ -379,19 +500,35 @@ export class WorkflowAgentExecutor {
379
500
  if (promptFailed && !hasSchemaResult() && !recovered)
380
501
  throw promptError;
381
502
  };
382
- unsubscribe = activeSession.subscribe?.((event) => {
503
+ unsubscribe = activeSession.subscribe((event) => {
383
504
  lastEventAt = Date.now();
384
505
  let persist = false;
385
506
  let shouldReport = false;
386
507
  let removeToolCallId;
387
- if (event.type === "agent_start" && session?.systemPrompt !== undefined) {
508
+ if (event.type === "agent_start" && session?.getState().systemPrompt !== undefined) {
388
509
  if (this.root.runStore) {
389
510
  systemPromptTurn += 1;
390
- const entry = { sessionId: session.sessionId, attempt, turn: systemPromptTurn, prompt: session.systemPrompt };
511
+ const entry = { sessionId: session.reference.sessionId, attempt, turn: systemPromptTurn, prompt: session.getState().systemPrompt ?? "" };
391
512
  systemPromptWrite = systemPromptWrite.then(() => this.root.runStore?.recordSystemPrompt(entry)).then(() => undefined).catch((error) => { systemPromptWriteError ??= error; });
392
513
  }
393
514
  }
394
- if (event.type === "message_start" && event.message.role === "assistant") {
515
+ if (event.type === "state_changed") {
516
+ shouldReport = true;
517
+ persist = true;
518
+ }
519
+ if (event.type === "turnStarted" || event.type === "turn_started") {
520
+ if (!turnStarted) {
521
+ try {
522
+ options.budget?.beforeTurn();
523
+ turnStarted = true;
524
+ }
525
+ catch (error) {
526
+ budgetError ??= error instanceof WorkflowError ? error : new WorkflowError("BUDGET_EXHAUSTED", error instanceof Error ? error.message : String(error));
527
+ void activeSession.abort();
528
+ }
529
+ }
530
+ }
531
+ if (event.type === "message_start" && event.message?.role === "assistant") {
395
532
  if (!turnStarted) {
396
533
  try {
397
534
  options.budget?.beforeTurn();
@@ -399,7 +536,7 @@ export class WorkflowAgentExecutor {
399
536
  }
400
537
  catch (error) {
401
538
  budgetError ??= error instanceof WorkflowError ? error : new WorkflowError("BUDGET_EXHAUSTED", error instanceof Error ? error.message : String(error));
402
- void session?.abort?.();
539
+ void activeSession.abort();
403
540
  }
404
541
  }
405
542
  activity = { kind: "text", text: "responding" };
@@ -407,9 +544,10 @@ export class WorkflowAgentExecutor {
407
544
  }
408
545
  if (event.type === "message_update") {
409
546
  const previousActivity = activity;
410
- if (["thinking_start", "thinking_delta", "thinking_end"].includes(event.assistantMessageEvent.type))
547
+ const updateType = event.assistantMessageEvent?.type;
548
+ if (updateType && ["thinking_start", "thinking_delta", "thinking_end"].includes(updateType))
411
549
  activity = { kind: "reasoning", text: "reasoning" };
412
- else if (["text_start", "text_delta", "text_end", "toolcall_start", "toolcall_delta", "toolcall_end"].includes(event.assistantMessageEvent.type))
550
+ else if (updateType && ["text_start", "text_delta", "text_end", "toolcall_start", "toolcall_delta", "toolcall_end"].includes(updateType))
413
551
  activity = { kind: "text", text: "responding" };
414
552
  shouldReport = activityChanged(previousActivity);
415
553
  }
@@ -417,7 +555,8 @@ export class WorkflowAgentExecutor {
417
555
  const previousActivity = activity;
418
556
  activity = undefined;
419
557
  shouldReport = activityChanged(previousActivity);
420
- if (event.message.role === "assistant") {
558
+ if (event.message?.role === "assistant") {
559
+ lastAssistant = event.message;
421
560
  const needsMoreWork = hasToolCall(event.message);
422
561
  const final = !needsMoreWork || (options.schema !== undefined && hasSchemaResult());
423
562
  if (!budgetError) {
@@ -426,29 +565,29 @@ export class WorkflowAgentExecutor {
426
565
  if (!final) {
427
566
  const instruction = options.budget?.instruction();
428
567
  if (instruction)
429
- void session?.steer?.(instruction);
568
+ void activeSession.steer(instruction);
430
569
  }
431
570
  }
432
571
  catch (error) {
433
572
  budgetError ??= error instanceof WorkflowError ? error : new WorkflowError("BUDGET_EXHAUSTED", error instanceof Error ? error.message : String(error));
434
- void session?.abort?.();
573
+ void activeSession.abort();
435
574
  }
436
575
  }
437
576
  turnStarted = false;
438
577
  persist = true;
439
578
  }
440
579
  }
441
- if (event.type === "tool_execution_start") {
580
+ if (event.type === "tool_execution_start" && event.toolCallId && event.toolName) {
442
581
  toolCalls.set(event.toolCallId, { id: event.toolCallId, name: event.toolName, state: "running" });
443
582
  activity = { kind: "tool", text: event.toolName };
444
583
  shouldReport = true;
445
584
  }
446
- if (event.type === "tool_execution_update") {
585
+ if (event.type === "tool_execution_update" && event.toolName) {
447
586
  const previousActivity = activity;
448
587
  activity = { kind: "tool", text: event.toolName };
449
588
  shouldReport = activityChanged(previousActivity);
450
589
  }
451
- if (event.type === "tool_execution_end") {
590
+ if (event.type === "tool_execution_end" && event.toolCallId && event.toolName) {
452
591
  toolCalls.set(event.toolCallId, { id: event.toolCallId, name: event.toolName, state: event.isError ? "failed" : "completed" });
453
592
  if (activity?.kind === "tool" && activity.text === event.toolName)
454
593
  activity = undefined;
@@ -464,9 +603,7 @@ export class WorkflowAgentExecutor {
464
603
  });
465
604
  report(false);
466
605
  if (setSteer) {
467
- if (!session.steer)
468
- throw new WorkflowError("INTERNAL_ERROR", "Native Pi session does not support steering");
469
- setSteer((message) => session?.steer?.(message));
606
+ setSteer((message) => activeSession.steer(message));
470
607
  }
471
608
  const context = [`Workflow: ${options.workflowName}`, `Agent: ${options.label}`, options.phase ? `Phase: ${options.phase}` : "", options.parent ? `Parent: ${options.parent}` : "", "You own this task and any direct child agents you create. Return child results to your parent; do not leave descendants running.", attempt > 1 ? `Retry attempt ${String(attempt)}. Previous state: ${options.retryState ?? attempts.at(-1)?.error?.message ?? "failed attempt"}` : ""].filter(Boolean).join("\n");
472
609
  const instruction = options.budget?.instruction();
@@ -476,7 +613,7 @@ export class WorkflowAgentExecutor {
476
613
  await promptAndRecover(promptText);
477
614
  {
478
615
  const completedAccounting = accounting(session.getSessionStats());
479
- options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? hasSchemaResult() : !latestAssistantHasToolCall(session.messages));
616
+ options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? hasSchemaResult() : !latestAssistantHasToolCall(lastAssistant));
480
617
  turnStarted = false;
481
618
  }
482
619
  if (budgetError)
@@ -505,24 +642,41 @@ export class WorkflowAgentExecutor {
505
642
  if (schemaResult === undefined)
506
643
  throw new WorkflowError("RESULT_INVALID", "Agent did not submit a valid workflow_result after one repair");
507
644
  }
508
- const value = options.schema ? schemaResult : text(session.messages);
645
+ const value = options.schema ? schemaResult : text(lastAssistant);
509
646
  if (options.worktreeOwner)
510
647
  await this.root.runStore?.snapshotWorktree(options.worktreeOwner);
511
648
  report(true);
512
649
  await progress;
513
650
  await flushSystemPrompts();
514
- unsubscribe?.();
651
+ unsubscribe();
515
652
  const attemptAccounting = accounting(session.getSessionStats());
516
- const includeCompletedSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
517
- attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), result: value, accounting: attemptAccounting, ...(includeCompletedSetup ? { setup: setupSummary } : {}) });
518
- session.dispose();
653
+ completedAttempt = attemptRecord(setup.transport.id, attempt, session, setupSummary, attemptAccounting, value);
654
+ attempts.push(completedAttempt);
655
+ try {
656
+ await options.onAttempt?.(completedAttempt);
657
+ }
658
+ finally {
659
+ await session.dispose();
660
+ }
519
661
  return { value, attempts, cwd: setupSummary.cwd };
520
662
  }
521
663
  catch (error) {
522
- const typed = budgetError ?? (error instanceof WorkflowError ? error : new WorkflowError(executionSignal?.aborted && setupFailed ? "CANCELLED" : "AGENT_FAILED", error instanceof Error ? error.message : String(error)));
664
+ if (completedAttempt)
665
+ throw errorWithAttempts(error, attempts);
666
+ const typed = budgetError ?? (error instanceof WorkflowError ? error : new WorkflowError(attemptSignal.aborted && setupFailed ? "CANCELLED" : "AGENT_FAILED", error instanceof Error ? error.message : String(error)));
667
+ if (!session) {
668
+ const failedAttempt = { attempt, transport: setup?.transport.id ?? this.transport.id, accounting: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }, error: { code: typed.code, message: typed.message }, setup: setupSummary };
669
+ attempts.push(failedAttempt);
670
+ try {
671
+ await options.onAttempt?.(failedAttempt);
672
+ }
673
+ catch (persistenceError) {
674
+ throw errorWithAttempts(persistenceError, attempts);
675
+ }
676
+ }
523
677
  if (session) {
524
678
  report(true);
525
- await progress;
679
+ await progress.catch(() => undefined);
526
680
  try {
527
681
  await flushSystemPrompts();
528
682
  }
@@ -537,9 +691,19 @@ export class WorkflowAgentExecutor {
537
691
  budgetError ??= budgetFailure instanceof WorkflowError ? budgetFailure : new WorkflowError("BUDGET_EXHAUSTED", budgetFailure instanceof Error ? budgetFailure.message : String(budgetFailure));
538
692
  }
539
693
  }
540
- const includeFailedSetup = Boolean(this.root.agentSetupHooks?.length || setup?.sessionInput.resourcePolicy);
541
- attempts.push({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), error: { code: typed.code, message: typed.message }, accounting: attemptAccounting, ...(includeFailedSetup && setupSummary ? { setup: setupSummary } : {}) });
542
- session.dispose();
694
+ const failedAttempt = attemptRecord(setup?.transport.id ?? this.transport.id, attempt, session, setupSummary, attemptAccounting, undefined, { code: typed.code, message: typed.message });
695
+ attempts.push(failedAttempt);
696
+ try {
697
+ try {
698
+ await options.onAttempt?.(failedAttempt);
699
+ }
700
+ finally {
701
+ await session.dispose();
702
+ }
703
+ }
704
+ catch (persistenceError) {
705
+ throw errorWithAttempts(persistenceError, attempts);
706
+ }
543
707
  }
544
708
  if (options.worktreeOwner && typed.code !== "WORKTREE_FAILED")
545
709
  await this.root.runStore?.snapshotWorktree(options.worktreeOwner).catch(() => undefined);
@@ -627,7 +791,7 @@ export class FairAgentScheduler {
627
791
  const id = `${runId}:${String(++this.#nextId)}`;
628
792
  let resolveResult = () => undefined;
629
793
  const promise = new Promise((resolve) => { resolveResult = resolve; });
630
- const node = { id, runId, ...(parentId ? { parentId } : {}), options: effective, children: new Set(), collected: false, state: "queued", controller: new AbortController(), promise, resolve: resolveResult, task: async () => undefined, restored: false };
794
+ const node = { id, runId, ...(parentId ? { parentId } : {}), prompt, options: effective, children: new Set(), collected: false, state: "queued", controller: new AbortController(), promise, resolve: resolveResult, task: async () => undefined, restored: false };
631
795
  node.task = async () => {
632
796
  if (node.controller.signal.aborted) {
633
797
  this.#release(node.runId);
@@ -766,7 +930,7 @@ export class FairAgentScheduler {
766
930
  return [agentTool, resultTool, steerTool];
767
931
  }
768
932
  snapshot() {
769
- return [...this.#nodes.values()].map(({ id, parentId, options, state }) => ({ id, ...(parentId ? { parentId } : {}), label: options.label, state, options }));
933
+ return [...this.#nodes.values()].map(({ id, parentId, prompt, options, state }) => ({ id, ...(parentId ? { parentId } : {}), ...(prompt === undefined ? {} : { prompt }), label: options.label, state, options }));
770
934
  }
771
935
  restoreRun(runId, limit, ownership, beforeLaunch) {
772
936
  this.addRun(runId, limit, beforeLaunch);
@@ -776,7 +940,7 @@ export class FairAgentScheduler {
776
940
  throw new WorkflowError("RESUME_INCOMPATIBLE", `Persisted agent belongs to another run: ${record.id}`);
777
941
  let resolveResult = () => undefined;
778
942
  const promise = new Promise((resolve) => { resolveResult = resolve; });
779
- const node = { id: record.id, runId, ...(record.parentId ? { parentId: record.parentId } : {}), options: this.#inherit(undefined, record.options), children: new Set(), collected: false, state: record.state, controller: new AbortController(), promise, resolve: resolveResult, task: async () => undefined, restored: true };
943
+ const node = { id: record.id, runId, ...(record.parentId ? { parentId: record.parentId } : {}), ...(record.prompt === undefined ? {} : { prompt: record.prompt }), options: this.#inherit(undefined, record.options), children: new Set(), collected: false, state: record.state, controller: new AbortController(), promise, resolve: resolveResult, task: async () => undefined, restored: true };
780
944
  this.#nodes.set(node.id, node);
781
945
  run.logical += 1;
782
946
  this.#nextId = Math.max(this.#nextId, Number(node.id.slice(node.id.lastIndexOf(":") + 1)) || 0);
@@ -887,11 +1051,6 @@ export class FairAgentScheduler {
887
1051
  }
888
1052
  }
889
1053
  function resolvePath(path) { return path.replace(/[\\/]+$/, ""); }
890
- function requiredFile(file) {
891
- if (!file)
892
- throw new WorkflowError("INTERNAL_ERROR", "Workflow agents require persisted native Pi sessions");
893
- return file;
894
- }
895
1054
  function remaining(timeoutMs, started) {
896
1055
  return timeoutMs === null || timeoutMs === undefined ? timeoutMs : Math.max(1, timeoutMs - (Date.now() - started));
897
1056
  }
@@ -904,8 +1063,7 @@ function providerLimited(error) {
904
1063
  async function promptWithProviderPause(session, text, timeoutMs, signal, pause) {
905
1064
  for (;;) {
906
1065
  try {
907
- await withTimeout(session.prompt(text), timeoutMs, signal, session);
908
- return;
1066
+ return await withTimeout(session.prompt(text), timeoutMs, signal, session);
909
1067
  }
910
1068
  catch (error) {
911
1069
  if (!pause || !providerLimited(error))
@@ -923,7 +1081,7 @@ async function withTimeout(work, timeoutMs, signal, session) {
923
1081
  const timeout = timeoutMs ? new Promise((_, reject) => { timer = setTimeout(() => { state.interrupted = true; reject(new WorkflowError("AGENT_TIMEOUT", "Agent attempt timed out")); }, timeoutMs); }) : new Promise(() => { });
924
1082
  const cancelled = signal ? new Promise((_, reject) => { abort = () => { state.interrupted = true; reject(new WorkflowError("CANCELLED", "Agent cancelled")); }; signal.addEventListener("abort", abort, { once: true }); }) : new Promise(() => { });
925
1083
  try {
926
- await Promise.race([work, timeout, cancelled]);
1084
+ return await Promise.race([work, timeout, cancelled]);
927
1085
  }
928
1086
  finally {
929
1087
  if (timer)
@@ -931,6 +1089,6 @@ async function withTimeout(work, timeoutMs, signal, session) {
931
1089
  if (abort)
932
1090
  signal?.removeEventListener("abort", abort);
933
1091
  if (state.interrupted)
934
- await session.abort?.();
1092
+ await session.abort();
935
1093
  }
936
1094
  }