pi-extensible-workflows 3.3.0 → 3.4.1

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,28 +12,23 @@ 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("");
20
19
  }
20
+ function isEmptyAbortedAssistant(message) { return message?.stopReason === "aborted" && Array.isArray(message.content) && message.content.length === 0; }
21
21
  function hasToolCall(message) {
22
22
  return typeof message === "object" && message !== null && Array.isArray(message.content) && message.content.some((part) => typeof part === "object" && part !== null && part.type === "toolCall");
23
23
  }
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");
24
+ function latestAssistantHasToolCall(message) { return hasToolCall(message); }
25
+ function throwIfTerminalAssistantError(session, message) {
30
26
  if (message?.stopReason !== "error")
31
27
  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";
28
+ const state = session.getState();
29
+ const error = message.errorMessage ?? "Workflow agent session ended with a terminal provider error";
35
30
  const failure = new WorkflowError("AGENT_FAILED", error);
36
- Object.defineProperty(failure, "terminalProviderError", { value: { provider, model, error }, configurable: true });
31
+ Object.defineProperty(failure, "terminalProviderError", { value: { provider: state.model.provider, model: state.model.model, error }, configurable: true });
37
32
  throw failure;
38
33
  }
39
34
  function terminalProviderError(error) {
@@ -44,11 +39,11 @@ function terminalProviderError(error) {
44
39
  return typeof candidate.provider === "string" && typeof candidate.model === "string" && typeof candidate.error === "string" ? { provider: candidate.provider, model: candidate.model, error: candidate.error } : undefined;
45
40
  }
46
41
  const providerContinuationPrompt = "The provider error was transient. Continue the task from your current state.";
47
- async function recoverTerminalProviderError(session, fallbackModel, label, recovery, continuePrompt) {
42
+ async function recoverTerminalProviderError(session, label, recovery, continuePrompt, getAssistant) {
48
43
  let continued = false;
49
44
  for (;;) {
50
45
  try {
51
- throwIfTerminalAssistantError(session, fallbackModel);
46
+ throwIfTerminalAssistantError(session, getAssistant());
52
47
  return continued;
53
48
  }
54
49
  catch (error) {
@@ -91,7 +86,7 @@ function workflowSystemPromptPath(cwd, agentDir, projectTrusted) {
91
86
  const globalPaths = [join(homedir(), ".pi", WORKFLOW_DIRECTORY, "SYSTEM.md"), join(agentDir, WORKFLOW_DIRECTORY, "SYSTEM.md")];
92
87
  return globalPaths.find((path) => existsSync(path));
93
88
  }
94
- export async function createNativeAgentSession(input) {
89
+ export async function createLocalPiSession(input) {
95
90
  const agentDir = input.agentDir ?? getAgentDir();
96
91
  const systemPromptSource = workflowSystemPromptPath(input.cwd, agentDir, input.resourcePolicy?.projectTrusted ?? true);
97
92
  const systemPromptOptions = input.systemPrompt !== undefined ? { systemPromptOverride: () => input.systemPrompt } : systemPromptSource !== undefined ? { systemPrompt: systemPromptSource } : {};
@@ -150,6 +145,77 @@ export async function createNativeAgentSession(input) {
150
145
  getToolDefinitions: () => session.getAllTools().map(({ name, description, parameters, promptGuidelines }) => ({ name, description, parameters, ...(promptGuidelines ? { promptGuidelines } : {}) })),
151
146
  });
152
147
  }
148
+ 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; }
149
+ function latestUsableAssistant(messages) { for (let index = messages.length - 1; index >= 0; index -= 1) {
150
+ const candidate = workflowAgentMessage(messages[index]);
151
+ if (candidate?.role === "assistant" && !isEmptyAbortedAssistant(candidate))
152
+ return candidate;
153
+ } return undefined; }
154
+ 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 }; }
155
+ function workflowAgentState(native, prepared) {
156
+ const tools = native.agent?.state.tools.map(({ name }) => name) ?? prepared.tools;
157
+ 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 };
158
+ return { model, ...(model.thinking ? { thinking: model.thinking } : {}), tools: [...tools], ...(native.systemPrompt === undefined ? {} : { systemPrompt: native.systemPrompt }) };
159
+ }
160
+ function localSessionEvent(event) { return event; }
161
+ export async function createLocalWorkflowAgentSession(prepared, context) {
162
+ void context;
163
+ const input = {
164
+ cwd: prepared.cwd, model: { ...prepared.model }, tools: [...prepared.tools], sessionLabel: prepared.sessionLabel,
165
+ ...(prepared.agentDir ? { agentDir: prepared.agentDir } : {}), ...(prepared.customTools?.length ? { customTools: [...prepared.customTools] } : {}),
166
+ ...(prepared.resultTool ? { resultTool: prepared.resultTool } : {}), ...(prepared.systemPrompt === undefined ? {} : { systemPrompt: prepared.systemPrompt }),
167
+ ...(prepared.systemPromptAppend ? { systemPromptAppend: prepared.systemPromptAppend } : {}), ...(prepared.extensionFactories?.length ? { extensionFactories: [...prepared.extensionFactories] } : {}),
168
+ ...(prepared.additionalSkillPaths?.length ? { additionalSkillPaths: [...prepared.additionalSkillPaths] } : {}), ...(prepared.resourcePolicy ? { resourcePolicy: structuredClone(prepared.resourcePolicy) } : {}), ...(prepared.options ? { options: { ...prepared.options } } : {}),
169
+ };
170
+ const native = await createLocalPiSession(input);
171
+ let disposal;
172
+ let aborting;
173
+ let prompting;
174
+ let disposed = false;
175
+ const startAbort = () => aborting ??= Promise.resolve().then(() => native.abort?.()).then(() => undefined).finally(() => { aborting = undefined; });
176
+ const reference = { transport: "local", sessionId: native.sessionId, ...(native.sessionFile ? { locator: { sessionFile: native.sessionFile } } : {}) };
177
+ const session = {
178
+ reference,
179
+ getState: () => Object.freeze(workflowAgentState(native, prepared)),
180
+ getSessionStats: () => workflowAgentStats(native.getSessionStats()),
181
+ subscribe(listener) { listener({ type: "state_changed", state: workflowAgentState(native, prepared) }); return native.subscribe?.((event) => { listener(localSessionEvent(event)); }) ?? (() => undefined); },
182
+ getLastAssistant: () => latestUsableAssistant(native.messages),
183
+ async prompt(text) {
184
+ if (disposed)
185
+ throw new WorkflowError("INTERNAL_ERROR", "Local workflow session is disposed");
186
+ const prompt = Promise.resolve().then(async () => { await native.prompt(text); const assistant = latestUsableAssistant(native.messages); return assistant ? { assistant } : {}; });
187
+ prompting = prompt;
188
+ try {
189
+ return await prompt;
190
+ }
191
+ finally {
192
+ if (prompting === prompt)
193
+ prompting = undefined;
194
+ }
195
+ },
196
+ async steer(text) { if (!native.steer)
197
+ throw new WorkflowError("INTERNAL_ERROR", "Local workflow session does not support steering"); await native.steer(text); },
198
+ async abort() { if (disposed)
199
+ return; await startAbort(); },
200
+ async dispose() {
201
+ disposal ??= (async () => {
202
+ disposed = true;
203
+ try {
204
+ await startAbort();
205
+ }
206
+ catch { /* Abort failure must not prevent the prompt from settling. */ }
207
+ try {
208
+ await prompting;
209
+ }
210
+ catch { /* Prompt rejection is expected after abort during teardown. */ }
211
+ native.dispose();
212
+ })();
213
+ await disposal;
214
+ },
215
+ };
216
+ return session;
217
+ }
218
+ export const localAgentTransport = Object.freeze({ id: "local", createSession: createLocalWorkflowAgentSession });
153
219
  function changedOption(options, baseline, key) { return JSON.stringify(options[key]) !== JSON.stringify(baseline[key]); }
154
220
  function validThinking(value) { return typeof value === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value); }
155
221
  function isChildAgentToolParams(value) {
@@ -179,51 +245,94 @@ function fallbackSetupContext(root, options, signal) {
179
245
  function resourcePolicySummary(policy) {
180
246
  return { skills: [...policy.effective.skills], extensions: [...policy.effective.extensions], excludedSkills: [...(policy.excludedSkills ?? [])], excludedExtensions: [...(policy.excludedExtensions ?? [])], unmatchedSkills: [...policy.unmatchedSkills], unmatchedExtensions: [...policy.unmatchedExtensions] };
181
247
  }
182
- async function prepareAgentSetup(root, createSession, task, options, resolved, cwd, attempt, signal, customTools, resultTool) {
248
+ function resourcePolicyWidened(ceiling, candidate) {
249
+ if (!ceiling)
250
+ return false;
251
+ if (!candidate)
252
+ return true;
253
+ if (!ceiling.projectTrusted && candidate.projectTrusted)
254
+ return true;
255
+ return ceiling.effective.skills.some((pattern) => !candidate.effective.skills.includes(pattern)) || ceiling.effective.extensions.some((pattern) => !candidate.effective.extensions.includes(pattern));
256
+ }
257
+ function preparedAgentSession(input) {
258
+ const prepared = {
259
+ cwd: input.cwd, model: Object.freeze({ ...input.model }), tools: Object.freeze([...input.tools]), sessionLabel: input.sessionLabel,
260
+ ...(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)) } : {}),
261
+ ...(input.systemPrompt === undefined ? {} : { systemPrompt: input.systemPrompt }), ...(input.systemPromptAppend ? { systemPromptAppend: input.systemPromptAppend } : {}),
262
+ ...(input.extensionFactories?.length ? { extensionFactories: Object.freeze([...input.extensionFactories]) } : {}), ...(input.additionalSkillPaths?.length ? { additionalSkillPaths: Object.freeze([...input.additionalSkillPaths]) } : {}),
263
+ ...(input.resourcePolicy ? { resourcePolicy: Object.freeze(structuredClone(input.resourcePolicy)) } : {}),
264
+ };
265
+ return deepFreeze(prepared);
266
+ }
267
+ function agentSetupSummary(setup, hookNames) {
268
+ const model = setup.sessionInput.model;
269
+ 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) } : {}) };
270
+ }
271
+ async function prepareAgentSetup(root, transport, task, options, resolved, cwd, attempt, signal, customTools, resultTool) {
183
272
  const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
184
273
  const baselineOptions = structuredClone(options.agentOptions ?? {});
185
274
  const baseResourcePolicy = await root.agentResourcePolicy?.();
186
275
  const roleExclusions = options.role ? root.agentDefinitions?.[options.role]?.disabledAgentResources : undefined;
187
276
  const resourcePolicy = baseResourcePolicy && roleExclusions ? { ...baseResourcePolicy, effective: mergeAgentResourceExclusions(baseResourcePolicy.effective, roleExclusions) } : baseResourcePolicy;
277
+ const resourcePolicyCeiling = resourcePolicy ? structuredClone(resourcePolicy) : undefined;
188
278
  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 };
279
+ const setup = { prompt: task, options: sessionInput.options ?? {}, sessionInput, prepared: preparedAgentSession(sessionInput), transport };
190
280
  const base = fallbackSetupContext(root, options, setupSignal);
191
281
  const context = Object.freeze({ run: base.run, identity: base.identity, attempt, signal: setupSignal });
192
282
  const hookNames = [];
193
283
  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
284
  if (setupSignal.aborted)
195
- throw new WorkflowError("CANCELLED", "Agent cancelled");
285
+ return { setup, summary: agentSetupSummary(setup, hookNames), failure: { error: new WorkflowError("CANCELLED", "Agent cancelled") } };
196
286
  try {
197
287
  await hook.setup(setup, context);
198
288
  }
199
289
  catch (error) {
200
- if (setupSignal.reason !== undefined)
201
- throw new WorkflowError("CANCELLED", "Agent cancelled");
202
- throw error;
290
+ setup.prepared = preparedAgentSession(setup.sessionInput);
291
+ return { setup, summary: agentSetupSummary(setup, hookNames), failure: { error: setupSignal.reason !== undefined ? new WorkflowError("CANCELLED", "Agent cancelled") : error } };
203
292
  }
293
+ setup.prepared = preparedAgentSession(setup.sessionInput);
204
294
  hookNames.push(hook.name);
205
295
  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 };
296
+ return { setup, summary: agentSetupSummary(setup, hookNames), failure: { error: new WorkflowError("CANCELLED", "Agent cancelled") } };
297
+ }
298
+ try {
299
+ if (resourcePolicyWidened(resourcePolicyCeiling, setup.sessionInput.resourcePolicy))
300
+ throw new WorkflowError("INVALID_METADATA", "Agent setup widened the prepared resource policy");
301
+ setup.sessionInput.options = setup.options;
302
+ if (changedOption(setup.options, baselineOptions, "model") && typeof setup.options.model === "string")
303
+ 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);
304
+ if (changedOption(setup.options, baselineOptions, "thinking") && validThinking(setup.options.thinking))
305
+ setup.sessionInput.model = { ...setup.sessionInput.model, thinking: setup.options.thinking };
306
+ if (changedOption(setup.options, baselineOptions, "tools") && Array.isArray(setup.options.tools) && setup.options.tools.every((tool) => typeof tool === "string"))
307
+ setup.sessionInput.tools = [...setup.options.tools];
308
+ if (changedOption(setup.options, baselineOptions, "cwd") && typeof setup.options.cwd === "string")
309
+ setup.sessionInput.cwd = setup.options.cwd;
310
+ const customToolNames = new Set([...(setup.sessionInput.customTools ?? []).map(({ name }) => name), ...(setup.sessionInput.resultTool ? [setup.sessionInput.resultTool.name] : [])]);
311
+ const widened = setup.sessionInput.tools.find((tool) => !resolved.tools.includes(tool) && !customToolNames.has(tool));
312
+ const outsideTool = widened ?? setup.sessionInput.tools.find((tool) => !root.tools.has(tool) && !customToolNames.has(tool));
313
+ if (outsideTool)
314
+ throw new WorkflowError("UNKNOWN_TOOL", `Tool is outside the prepared agent policy: ${outsideTool}`);
315
+ }
316
+ catch (error) {
317
+ setup.prepared = preparedAgentSession(setup.sessionInput);
318
+ return { setup, summary: agentSetupSummary(setup, hookNames), failure: { error } };
319
+ }
320
+ setup.prepared = preparedAgentSession(setup.sessionInput);
321
+ return { setup, summary: agentSetupSummary(setup, hookNames) };
322
+ }
323
+ function attemptRecord(transport, attempt, session, setup, stats, result, error) {
324
+ return { attempt, transport, session: session.reference, ...(result === undefined ? {} : { result }), ...(error ? { error } : {}), accounting: stats, setup };
325
+ }
326
+ function errorWithAttempts(error, attempts) {
327
+ const typed = error instanceof Error ? error : new Error(typeof error === "string" ? error : String(error));
328
+ return Object.assign(typed, { attempts });
220
329
  }
221
330
  export class WorkflowAgentExecutor {
222
331
  root;
223
- createSession;
224
- constructor(root, createSession = createNativeAgentSession) {
332
+ transport;
333
+ constructor(root, transport = localAgentTransport) {
225
334
  this.root = root;
226
- this.createSession = createSession;
335
+ this.transport = transport;
227
336
  }
228
337
  setRunContext(runContext) { this.root.runContext = runContext; }
229
338
  resolve(options, inheritedTools) {
@@ -291,16 +400,16 @@ export class WorkflowAgentExecutor {
291
400
  const attempts = [];
292
401
  let maxAttempts = (options.retries ?? 0) + 1;
293
402
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
403
+ const attemptSignal = executionSignal ?? new AbortController().signal;
294
404
  if (recoveryModel)
295
405
  resolved = this.resolve({ ...options, modelOverride: recoveryModel });
296
406
  options.budget?.beforeAttempt();
297
407
  let schemaResult;
298
408
  let session;
299
409
  let setup;
300
- let setupSummary;
410
+ let setupSummary = { hookNames: [], model: { ...resolved.model }, tools: [...resolved.tools], cwd };
301
411
  let setupFailed = false;
302
412
  let budgetError;
303
- let turnStarted = false;
304
413
  const hasSchemaResult = () => schemaResult !== undefined;
305
414
  const resultTool = options.schema ? {
306
415
  name: "workflow_result", label: "Workflow Result", description: "Submit the terminal structured workflow result", parameters: Type.Unsafe(options.schema),
@@ -310,7 +419,9 @@ export class WorkflowAgentExecutor {
310
419
  if (schemaResult !== undefined)
311
420
  return { content: [{ type: "text", text: "Result has already been accepted." }], details: {}, isError: true };
312
421
  schemaResult = structuredClone(value);
313
- void session?.abort?.();
422
+ const currentSession = session;
423
+ if (currentSession)
424
+ void currentSession.abort();
314
425
  return { content: [{ type: "text", text: "Result accepted." }], details: {} };
315
426
  },
316
427
  } : undefined;
@@ -323,6 +434,8 @@ export class WorkflowAgentExecutor {
323
434
  let systemPromptTurn = 0;
324
435
  let systemPromptWrite = Promise.resolve();
325
436
  let systemPromptWriteError;
437
+ let turnStarted = false;
438
+ let completedAttempt;
326
439
  const flushSystemPrompts = async () => {
327
440
  await systemPromptWrite;
328
441
  if (systemPromptWriteError)
@@ -331,7 +444,7 @@ export class WorkflowAgentExecutor {
331
444
  const report = (persist) => {
332
445
  if (!session || !options.onProgress)
333
446
  return;
334
- const update = { accounting: accounting(session.getSessionStats()), toolCalls: [...toolCalls.values()], ...(activity ? { activity } : {}), ...(lastEventAt === undefined ? {} : { lastEventAt }), persist };
447
+ const update = { accounting: accounting(session.getSessionStats()), toolCalls: [...toolCalls.values()], state: session.getState(), ...(activity ? { activity } : {}), ...(lastEventAt === undefined ? {} : { lastEventAt }), persist };
335
448
  if (lastEventAt !== undefined)
336
449
  lastReportedEventAt = lastEventAt;
337
450
  progress = progress.then(() => options.onProgress?.(update)).then(() => undefined);
@@ -344,34 +457,57 @@ export class WorkflowAgentExecutor {
344
457
  const activityChanged = (previous) => previous?.kind !== activity?.kind || previous?.text !== activity?.text;
345
458
  try {
346
459
  setupFailed = true;
347
- const prepared = await prepareAgentSetup(this.root, this.createSession, task, options, resolved, cwd, attempt, executionSignal, customTools, resultTool);
460
+ const prepared = await prepareAgentSetup(this.root, this.transport, task, options, resolved, cwd, attempt, attemptSignal, customTools, resultTool);
348
461
  setup = prepared.setup;
349
462
  setupSummary = prepared.summary;
463
+ if (prepared.failure)
464
+ throw prepared.failure.error;
350
465
  setupFailed = false;
351
- if (executionSignal?.aborted)
466
+ if (attemptSignal.aborted)
352
467
  throw new WorkflowError("CANCELLED", "Agent cancelled");
353
468
  const started = Date.now();
354
- session = await setup.createSession(setup.sessionInput);
469
+ const transportSignal = attemptSignal;
470
+ await options.onAttempt?.({ attempt, transport: setup.transport.id, accounting: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }, setup: setupSummary });
471
+ const transportBase = fallbackSetupContext(this.root, options, transportSignal);
472
+ const createdSession = await setup.transport.createSession(setup.prepared, Object.freeze({ run: transportBase.run, identity: transportBase.identity, attempt, signal: transportSignal }));
473
+ if (createdSession.reference.transport !== setup.transport.id) {
474
+ await createdSession.dispose();
475
+ throw new WorkflowError("INTERNAL_ERROR", `Agent transport ${setup.transport.id} created a session for ${createdSession.reference.transport}`);
476
+ }
477
+ session = createdSession;
478
+ 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 });
479
+ const preparedTools = new Set([...setup.prepared.tools, ...(setup.prepared.customTools ?? []).map(({ name }) => name), ...(setup.prepared.resultTool ? [setup.prepared.resultTool.name] : [])]);
480
+ if (session.getState().tools.some((tool) => !preparedTools.has(tool)))
481
+ throw new WorkflowError("INTERNAL_ERROR", `Agent transport ${setup.transport.id} widened the prepared tool policy`);
355
482
  if (setup.sessionInput.resourcePolicy)
356
483
  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
484
  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);
485
+ let lastAssistant;
486
+ const acceptAssistant = (candidate) => {
487
+ if (isEmptyAbortedAssistant(candidate)) {
488
+ const previous = activeSession.getLastAssistant?.();
489
+ if (previous && !isEmptyAbortedAssistant(previous))
490
+ lastAssistant = previous;
491
+ }
492
+ else if (candidate)
493
+ lastAssistant = candidate;
494
+ };
495
+ const recoverTerminal = () => recoverTerminalProviderError(activeSession, options.label, options.providerErrorRecovery, async () => { try {
496
+ acceptAssistant((await promptWithProviderPause(activeSession, providerContinuationPrompt, remaining(options.timeoutMs, started), attemptSignal, this.root.providerPause)).assistant);
363
497
  }
364
498
  catch (error) {
499
+ acceptAssistant(activeSession.getLastAssistant?.() ?? lastAssistant);
365
500
  if (!hasSchemaResult())
366
501
  throw error;
367
- } });
502
+ } }, () => lastAssistant);
368
503
  const promptAndRecover = async (prompt) => {
369
504
  let promptFailed = false;
370
505
  let promptError;
371
506
  try {
372
- await promptWithProviderPause(activeSession, prompt, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause);
507
+ acceptAssistant((await promptWithProviderPause(activeSession, prompt, remaining(options.timeoutMs, started), attemptSignal, this.root.providerPause)).assistant);
373
508
  }
374
509
  catch (error) {
510
+ acceptAssistant(activeSession.getLastAssistant?.() ?? lastAssistant);
375
511
  promptFailed = true;
376
512
  promptError = error;
377
513
  }
@@ -379,19 +515,35 @@ export class WorkflowAgentExecutor {
379
515
  if (promptFailed && !hasSchemaResult() && !recovered)
380
516
  throw promptError;
381
517
  };
382
- unsubscribe = activeSession.subscribe?.((event) => {
518
+ unsubscribe = activeSession.subscribe((event) => {
383
519
  lastEventAt = Date.now();
384
520
  let persist = false;
385
521
  let shouldReport = false;
386
522
  let removeToolCallId;
387
- if (event.type === "agent_start" && session?.systemPrompt !== undefined) {
523
+ if (event.type === "agent_start" && session?.getState().systemPrompt !== undefined) {
388
524
  if (this.root.runStore) {
389
525
  systemPromptTurn += 1;
390
- const entry = { sessionId: session.sessionId, attempt, turn: systemPromptTurn, prompt: session.systemPrompt };
526
+ const entry = { sessionId: session.reference.sessionId, attempt, turn: systemPromptTurn, prompt: session.getState().systemPrompt ?? "" };
391
527
  systemPromptWrite = systemPromptWrite.then(() => this.root.runStore?.recordSystemPrompt(entry)).then(() => undefined).catch((error) => { systemPromptWriteError ??= error; });
392
528
  }
393
529
  }
394
- if (event.type === "message_start" && event.message.role === "assistant") {
530
+ if (event.type === "state_changed") {
531
+ shouldReport = true;
532
+ persist = true;
533
+ }
534
+ if (event.type === "turnStarted" || event.type === "turn_started") {
535
+ if (!turnStarted) {
536
+ try {
537
+ options.budget?.beforeTurn();
538
+ turnStarted = true;
539
+ }
540
+ catch (error) {
541
+ budgetError ??= error instanceof WorkflowError ? error : new WorkflowError("BUDGET_EXHAUSTED", error instanceof Error ? error.message : String(error));
542
+ void activeSession.abort();
543
+ }
544
+ }
545
+ }
546
+ if (event.type === "message_start" && event.message?.role === "assistant") {
395
547
  if (!turnStarted) {
396
548
  try {
397
549
  options.budget?.beforeTurn();
@@ -399,7 +551,7 @@ export class WorkflowAgentExecutor {
399
551
  }
400
552
  catch (error) {
401
553
  budgetError ??= error instanceof WorkflowError ? error : new WorkflowError("BUDGET_EXHAUSTED", error instanceof Error ? error.message : String(error));
402
- void session?.abort?.();
554
+ void activeSession.abort();
403
555
  }
404
556
  }
405
557
  activity = { kind: "text", text: "responding" };
@@ -407,9 +559,10 @@ export class WorkflowAgentExecutor {
407
559
  }
408
560
  if (event.type === "message_update") {
409
561
  const previousActivity = activity;
410
- if (["thinking_start", "thinking_delta", "thinking_end"].includes(event.assistantMessageEvent.type))
562
+ const updateType = event.assistantMessageEvent?.type;
563
+ if (updateType && ["thinking_start", "thinking_delta", "thinking_end"].includes(updateType))
411
564
  activity = { kind: "reasoning", text: "reasoning" };
412
- else if (["text_start", "text_delta", "text_end", "toolcall_start", "toolcall_delta", "toolcall_end"].includes(event.assistantMessageEvent.type))
565
+ else if (updateType && ["text_start", "text_delta", "text_end", "toolcall_start", "toolcall_delta", "toolcall_end"].includes(updateType))
413
566
  activity = { kind: "text", text: "responding" };
414
567
  shouldReport = activityChanged(previousActivity);
415
568
  }
@@ -417,7 +570,8 @@ export class WorkflowAgentExecutor {
417
570
  const previousActivity = activity;
418
571
  activity = undefined;
419
572
  shouldReport = activityChanged(previousActivity);
420
- if (event.message.role === "assistant") {
573
+ if (event.message?.role === "assistant") {
574
+ acceptAssistant(event.message);
421
575
  const needsMoreWork = hasToolCall(event.message);
422
576
  const final = !needsMoreWork || (options.schema !== undefined && hasSchemaResult());
423
577
  if (!budgetError) {
@@ -426,29 +580,29 @@ export class WorkflowAgentExecutor {
426
580
  if (!final) {
427
581
  const instruction = options.budget?.instruction();
428
582
  if (instruction)
429
- void session?.steer?.(instruction);
583
+ void activeSession.steer(instruction);
430
584
  }
431
585
  }
432
586
  catch (error) {
433
587
  budgetError ??= error instanceof WorkflowError ? error : new WorkflowError("BUDGET_EXHAUSTED", error instanceof Error ? error.message : String(error));
434
- void session?.abort?.();
588
+ void activeSession.abort();
435
589
  }
436
590
  }
437
591
  turnStarted = false;
438
592
  persist = true;
439
593
  }
440
594
  }
441
- if (event.type === "tool_execution_start") {
595
+ if (event.type === "tool_execution_start" && event.toolCallId && event.toolName) {
442
596
  toolCalls.set(event.toolCallId, { id: event.toolCallId, name: event.toolName, state: "running" });
443
597
  activity = { kind: "tool", text: event.toolName };
444
598
  shouldReport = true;
445
599
  }
446
- if (event.type === "tool_execution_update") {
600
+ if (event.type === "tool_execution_update" && event.toolName) {
447
601
  const previousActivity = activity;
448
602
  activity = { kind: "tool", text: event.toolName };
449
603
  shouldReport = activityChanged(previousActivity);
450
604
  }
451
- if (event.type === "tool_execution_end") {
605
+ if (event.type === "tool_execution_end" && event.toolCallId && event.toolName) {
452
606
  toolCalls.set(event.toolCallId, { id: event.toolCallId, name: event.toolName, state: event.isError ? "failed" : "completed" });
453
607
  if (activity?.kind === "tool" && activity.text === event.toolName)
454
608
  activity = undefined;
@@ -464,9 +618,7 @@ export class WorkflowAgentExecutor {
464
618
  });
465
619
  report(false);
466
620
  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));
621
+ setSteer((message) => activeSession.steer(message));
470
622
  }
471
623
  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
624
  const instruction = options.budget?.instruction();
@@ -476,7 +628,7 @@ export class WorkflowAgentExecutor {
476
628
  await promptAndRecover(promptText);
477
629
  {
478
630
  const completedAccounting = accounting(session.getSessionStats());
479
- options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? hasSchemaResult() : !latestAssistantHasToolCall(session.messages));
631
+ options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? hasSchemaResult() : !latestAssistantHasToolCall(lastAssistant));
480
632
  turnStarted = false;
481
633
  }
482
634
  if (budgetError)
@@ -505,24 +657,41 @@ export class WorkflowAgentExecutor {
505
657
  if (schemaResult === undefined)
506
658
  throw new WorkflowError("RESULT_INVALID", "Agent did not submit a valid workflow_result after one repair");
507
659
  }
508
- const value = options.schema ? schemaResult : text(session.messages);
660
+ const value = options.schema ? schemaResult : text(lastAssistant);
509
661
  if (options.worktreeOwner)
510
662
  await this.root.runStore?.snapshotWorktree(options.worktreeOwner);
511
663
  report(true);
512
664
  await progress;
513
665
  await flushSystemPrompts();
514
- unsubscribe?.();
666
+ unsubscribe();
515
667
  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();
668
+ completedAttempt = attemptRecord(setup.transport.id, attempt, session, setupSummary, attemptAccounting, value);
669
+ attempts.push(completedAttempt);
670
+ try {
671
+ await options.onAttempt?.(completedAttempt);
672
+ }
673
+ finally {
674
+ await session.dispose();
675
+ }
519
676
  return { value, attempts, cwd: setupSummary.cwd };
520
677
  }
521
678
  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)));
679
+ if (completedAttempt)
680
+ throw errorWithAttempts(error, attempts);
681
+ const typed = budgetError ?? (error instanceof WorkflowError ? error : new WorkflowError(attemptSignal.aborted && setupFailed ? "CANCELLED" : "AGENT_FAILED", error instanceof Error ? error.message : String(error)));
682
+ if (!session) {
683
+ 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 };
684
+ attempts.push(failedAttempt);
685
+ try {
686
+ await options.onAttempt?.(failedAttempt);
687
+ }
688
+ catch (persistenceError) {
689
+ throw errorWithAttempts(persistenceError, attempts);
690
+ }
691
+ }
523
692
  if (session) {
524
693
  report(true);
525
- await progress;
694
+ await progress.catch(() => undefined);
526
695
  try {
527
696
  await flushSystemPrompts();
528
697
  }
@@ -537,9 +706,19 @@ export class WorkflowAgentExecutor {
537
706
  budgetError ??= budgetFailure instanceof WorkflowError ? budgetFailure : new WorkflowError("BUDGET_EXHAUSTED", budgetFailure instanceof Error ? budgetFailure.message : String(budgetFailure));
538
707
  }
539
708
  }
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();
709
+ const failedAttempt = attemptRecord(setup?.transport.id ?? this.transport.id, attempt, session, setupSummary, attemptAccounting, undefined, { code: typed.code, message: typed.message });
710
+ attempts.push(failedAttempt);
711
+ try {
712
+ try {
713
+ await options.onAttempt?.(failedAttempt);
714
+ }
715
+ finally {
716
+ await session.dispose();
717
+ }
718
+ }
719
+ catch (persistenceError) {
720
+ throw errorWithAttempts(persistenceError, attempts);
721
+ }
543
722
  }
544
723
  if (options.worktreeOwner && typed.code !== "WORKTREE_FAILED")
545
724
  await this.root.runStore?.snapshotWorktree(options.worktreeOwner).catch(() => undefined);
@@ -627,7 +806,7 @@ export class FairAgentScheduler {
627
806
  const id = `${runId}:${String(++this.#nextId)}`;
628
807
  let resolveResult = () => undefined;
629
808
  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 };
809
+ 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
810
  node.task = async () => {
632
811
  if (node.controller.signal.aborted) {
633
812
  this.#release(node.runId);
@@ -766,7 +945,7 @@ export class FairAgentScheduler {
766
945
  return [agentTool, resultTool, steerTool];
767
946
  }
768
947
  snapshot() {
769
- return [...this.#nodes.values()].map(({ id, parentId, options, state }) => ({ id, ...(parentId ? { parentId } : {}), label: options.label, state, options }));
948
+ return [...this.#nodes.values()].map(({ id, parentId, prompt, options, state }) => ({ id, ...(parentId ? { parentId } : {}), ...(prompt === undefined ? {} : { prompt }), label: options.label, state, options }));
770
949
  }
771
950
  restoreRun(runId, limit, ownership, beforeLaunch) {
772
951
  this.addRun(runId, limit, beforeLaunch);
@@ -776,7 +955,7 @@ export class FairAgentScheduler {
776
955
  throw new WorkflowError("RESUME_INCOMPATIBLE", `Persisted agent belongs to another run: ${record.id}`);
777
956
  let resolveResult = () => undefined;
778
957
  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 };
958
+ 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
959
  this.#nodes.set(node.id, node);
781
960
  run.logical += 1;
782
961
  this.#nextId = Math.max(this.#nextId, Number(node.id.slice(node.id.lastIndexOf(":") + 1)) || 0);
@@ -887,11 +1066,6 @@ export class FairAgentScheduler {
887
1066
  }
888
1067
  }
889
1068
  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
1069
  function remaining(timeoutMs, started) {
896
1070
  return timeoutMs === null || timeoutMs === undefined ? timeoutMs : Math.max(1, timeoutMs - (Date.now() - started));
897
1071
  }
@@ -904,8 +1078,7 @@ function providerLimited(error) {
904
1078
  async function promptWithProviderPause(session, text, timeoutMs, signal, pause) {
905
1079
  for (;;) {
906
1080
  try {
907
- await withTimeout(session.prompt(text), timeoutMs, signal, session);
908
- return;
1081
+ return await withTimeout(session.prompt(text), timeoutMs, signal, session);
909
1082
  }
910
1083
  catch (error) {
911
1084
  if (!pause || !providerLimited(error))
@@ -923,7 +1096,7 @@ async function withTimeout(work, timeoutMs, signal, session) {
923
1096
  const timeout = timeoutMs ? new Promise((_, reject) => { timer = setTimeout(() => { state.interrupted = true; reject(new WorkflowError("AGENT_TIMEOUT", "Agent attempt timed out")); }, timeoutMs); }) : new Promise(() => { });
924
1097
  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
1098
  try {
926
- await Promise.race([work, timeout, cancelled]);
1099
+ return await Promise.race([work, timeout, cancelled]);
927
1100
  }
928
1101
  finally {
929
1102
  if (timer)
@@ -931,6 +1104,6 @@ async function withTimeout(work, timeoutMs, signal, session) {
931
1104
  if (abort)
932
1105
  signal?.removeEventListener("abort", abort);
933
1106
  if (state.interrupted)
934
- await session.abort?.();
1107
+ await session.abort();
935
1108
  }
936
1109
  }