@parcha/agentrun-pi 0.1.0-beta.11 → 0.1.0-beta.13

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 (47) hide show
  1. package/README.md +20 -8
  2. package/dist/cli.js +10 -1
  3. package/dist/demo-cli.d.ts +2 -0
  4. package/dist/demo-cli.js +25 -0
  5. package/dist/durable/decision-recovery.d.ts +23 -0
  6. package/dist/durable/decision-recovery.js +57 -0
  7. package/dist/durable/decisions.d.ts +113 -0
  8. package/dist/durable/decisions.js +71 -0
  9. package/dist/durable/index.d.ts +19 -0
  10. package/dist/durable/index.js +13 -0
  11. package/dist/durable/node-runner.d.ts +100 -0
  12. package/dist/durable/node-runner.js +243 -0
  13. package/dist/durable/node.d.ts +88 -0
  14. package/dist/durable/node.js +81 -0
  15. package/dist/durable/pi-versions.d.ts +34 -0
  16. package/dist/durable/pi-versions.js +66 -0
  17. package/dist/durable/readers.d.ts +25 -0
  18. package/dist/durable/readers.js +64 -0
  19. package/dist/durable/receipts.d.ts +52 -0
  20. package/dist/durable/receipts.js +91 -0
  21. package/dist/durable/record-file.d.ts +10 -0
  22. package/dist/durable/record-file.js +56 -0
  23. package/dist/durable/record-tool.d.ts +76 -0
  24. package/dist/durable/record-tool.js +123 -0
  25. package/dist/durable/record.d.ts +157 -0
  26. package/dist/durable/record.js +402 -0
  27. package/dist/durable/recording.d.ts +90 -0
  28. package/dist/durable/recording.js +292 -0
  29. package/dist/durable/store.d.ts +23 -0
  30. package/dist/durable/store.js +192 -0
  31. package/dist/durable/task.d.ts +129 -0
  32. package/dist/durable/task.js +395 -0
  33. package/dist/extension-service.js +14 -1
  34. package/dist/extension.d.ts +2 -0
  35. package/dist/extension.js +98 -13
  36. package/dist/host-session.js +3 -1
  37. package/dist/run-error.d.ts +6 -0
  38. package/dist/run-error.js +13 -0
  39. package/dist/runner.d.ts +1 -6
  40. package/dist/runner.js +2 -12
  41. package/dist/sop.d.ts +12 -0
  42. package/dist/sop.js +29 -0
  43. package/dist/tool-context.d.ts +21 -0
  44. package/dist/tool-context.js +13 -0
  45. package/dist/workflow-session.d.ts +4 -0
  46. package/dist/workflow-session.js +3 -2
  47. package/package.json +43 -8
package/dist/extension.js CHANGED
@@ -8,10 +8,15 @@ import { formatWorkflowTree, inspectWorkflow, AUTHOR_SKILL_NAME, loadAuthorRefer
8
8
  import { createJevRunner } from '@parcha/agentrun-jev';
9
9
  import { createPiHostRunner, PI_MODEL_SETUP_MESSAGE } from './host-session.js';
10
10
  import { PI_HOST_ADDENDUM } from './host-addendum.js';
11
+ import { workflowToolContext } from './tool-context.js';
11
12
  import { WorkflowExtensionService, ExtensionServiceError, extensionStructuralLimits } from './extension-service.js';
12
13
  import { demoInput, demoSearchTool, demoWorkflow, scriptedDemoDeps } from './demo.js';
13
14
  import { cleanText as safe, formatRunReport, modelJson } from './presentation.js';
14
15
  import { ToolInputValidationError, toolInputProblems } from './tool-input-error.js';
16
+ import { mkdir, readFile } from 'node:fs/promises';
17
+ import { join } from 'node:path';
18
+ import { RecoveryError, fileStore, openRecovery, withRecovery } from '@parcha/agentrun-dsl/recovery';
19
+ import { readSopFile, missingSopSections, sopDigest } from './sop.js';
15
20
  import { WorkflowStore, WorkflowStoreError } from './workflow-store.js';
16
21
  import { WorkflowObservation, workflowView, formatWorkflowView, progressLines, readable } from './workflow-view.js';
17
22
  import { showWorkflowInspector } from './workflow-inspector.js';
@@ -22,7 +27,8 @@ const constructors = {
22
27
  write: createWriteToolDefinition, grep: createGrepToolDefinition, find: createFindToolDefinition, ls: createLsToolDefinition,
23
28
  };
24
29
  const mutatingTools = new Set(['bash', 'edit', 'write']);
25
- const help = 'Describe a task: /agentrun <what you want done>\nTry support triage: /agentrun triage (fictional, no model calls)\nInspect: /agentrun · Change input: /agentrun input\nSave: /agentrun save <name> · Load: /agentrun load <name>\nLibrary: /agentrun list · History: /agentrun history\nRun: /agentrun run · Setup: /agentrun status\nStop: /agentrun stop (closing the inspector does not stop a run)\nOther examples: /agentrun demo | demo empty | demo live';
30
+ const TESTED_PI_VERSION = '1.1.0';
31
+ const help = 'Describe a task: /agentrun <what you want done>\nTry support triage: /agentrun triage (fictional, no model calls)\nInspect: /agentrun · Change input: /agentrun input\nSave: /agentrun save <name> · Load: /agentrun load <name>\nLibrary: /agentrun list · History: /agentrun history\nRun: /agentrun run · SOP text: /agentrun sop <file> · Setup: /agentrun status\nStop: /agentrun stop (closing the inspector does not stop a run)\nOther examples: /agentrun demo | demo empty | demo live';
26
32
  const textResult = (text, details = {}) => ({ content: [{ type: 'text', text: safe(text) }], details });
27
33
  function singleTextInput(workflow) {
28
34
  const source = workflow;
@@ -45,13 +51,15 @@ export function createAgentRunExtension(options = {}) {
45
51
  throw new Error('onToolAttempt must be a host callback');
46
52
  if (options.onWorkflowEvent !== undefined && typeof options.onWorkflowEvent !== 'function')
47
53
  throw new Error('onWorkflowEvent must be a host callback');
54
+ if (options.sop !== undefined && (typeof options.sop !== 'string' || !options.sop.trim()))
55
+ throw new Error('sop must be nonempty text');
48
56
  const limits = { ...nativeLimits, ...options.runtimeLimits };
49
57
  for (const [key, value] of Object.entries(limits)) {
50
58
  if (!(key in nativeLimits) || value !== null && (!Number.isSafeInteger(value) || value < 1 || value > nativeLimits[key])) {
51
59
  throw new Error('runtimeLimits must use known fields with positive bounded integers or explicit null');
52
60
  }
53
61
  }
54
- const configured = { hostTools: options.hostTools, createJudge: options.createJudge, onToolAttempt: options.onToolAttempt, onWorkflowEvent: options.onWorkflowEvent,
62
+ const configured = { hostTools: options.hostTools, createJudge: options.createJudge, onToolAttempt: options.onToolAttempt, onWorkflowEvent: options.onWorkflowEvent, sop: options.sop,
55
63
  runtimeLimits: Object.freeze(limits) };
56
64
  return pi => registerExtension(pi, configured);
57
65
  }
@@ -116,6 +124,7 @@ function registerExtension(pi, configuration) {
116
124
  current.report = restored.report;
117
125
  current.observation = restored.observation;
118
126
  current.runId = restored.runId;
127
+ current.sop = restored.sop;
119
128
  }
120
129
  return current;
121
130
  };
@@ -126,7 +135,7 @@ function registerExtension(pi, configuration) {
126
135
  input: s.input, running, createdAt: new Date().toISOString(),
127
136
  ...(s.savedName ? { savedName: s.savedName } : {}), ...(s.demo ? { demo: s.demo } : {}),
128
137
  ...(s.runId ? { runId: s.runId } : {}), ...(s.report ? { report: s.report } : {}),
129
- ...(s.observation ? { observation: s.observation } : {}), };
138
+ ...(s.observation ? { observation: s.observation } : {}), ...(s.sop ? { sop: s.sop } : {}), };
130
139
  try {
131
140
  const data = structuredClone(record);
132
141
  // Never advertise history that this release cannot restore (including
@@ -144,7 +153,7 @@ function registerExtension(pi, configuration) {
144
153
  const readiness = (ctx, s) => ({
145
154
  skill: pi.getCommands().some(item => item.name === `skill:${AUTHOR_SKILL_NAME}` && item.source === 'skill'),
146
155
  pi: !!ctx.model && ctx.modelRegistry.getAll().some(model => model.id === ctx.model?.id && model.provider === ctx.model?.provider && model.api === ctx.model?.api),
147
- jev: !!configuration.createJudge || !!process.env.TYPESAFE_API_KEY?.trim(), sop: false, running: s.busy, workflow: !!s.draft,
156
+ jev: !!configuration.createJudge || !!process.env.TYPESAFE_API_KEY?.trim(), sop: !!configuration.sop || !!s.sop, running: s.busy, workflow: !!s.draft,
148
157
  mode: s.draft ? s.demo ? 'scripted' : 'live' : undefined,
149
158
  });
150
159
  const show = (s, content, details = {}) => {
@@ -153,7 +162,7 @@ function registerExtension(pi, configuration) {
153
162
  pi.sendMessage({ customType: 'agentrun', content: safe(content), display: true, details });
154
163
  };
155
164
  const commandError = (s, error) => {
156
- const message = error instanceof Error && (error instanceof WorkflowStoreError || error.name === 'ExtensionServiceError' || /^(No workflow|Inspect a workflow|A workflow|Cannot inspect|Workflow|Code|Executable|Unsupported|Invalid workflow|AgentRun requires|AgentRun authoring|The active Pi|Saved workflow)/.test(error.message))
165
+ const message = error instanceof Error && (error instanceof WorkflowStoreError || error.name === 'ExtensionServiceError' || /^(No workflow|Inspect a workflow|A workflow|Cannot inspect|Workflow|Code|Executable|Unsupported|Invalid workflow|AgentRun requires|AgentRun authoring|The active Pi|Saved workflow|SOP|The SOP|Use \/agentrun sop)/.test(error.message))
157
166
  ? error.message.slice(0, 2000) : 'AgentRun could not complete this operation. Use /agentrun status to check setup and /agentrun to inspect the workflow. No fallback was selected.';
158
167
  show(s, message);
159
168
  };
@@ -177,8 +186,6 @@ function registerExtension(pi, configuration) {
177
186
  let inspection;
178
187
  try {
179
188
  inspection = checker.preflight(candidate, input);
180
- if (inspection.requires.sopSections.length)
181
- throw new Error('Native Pi does not supply SOP text in V1; use a configured SDK host. Keep the required sections intact.');
182
189
  }
183
190
  catch (error) {
184
191
  throw new Error(`Cannot inspect workflow: ${error instanceof Error ? error.message : 'Invalid workflow'}`);
@@ -285,7 +292,7 @@ function registerExtension(pi, configuration) {
285
292
  throw new Error('Workflow requested an unavailable tool');
286
293
  let result;
287
294
  try {
288
- result = await tool.execute('agentrun-effect', input, effectSignal, undefined, ctx);
295
+ result = await tool.execute('agentrun-effect', input, effectSignal, undefined, workflowToolContext(ctx));
289
296
  }
290
297
  catch (error) {
291
298
  if (error instanceof ToolInputValidationError)
@@ -296,6 +303,26 @@ function registerExtension(pi, configuration) {
296
303
  },
297
304
  };
298
305
  };
306
+ /** The SOP text a run of a workflow naming `needed` sections uses: the host's, else the file the operator named. */
307
+ const sopFor = async (ctx, s, needed) => {
308
+ if (!needed.length)
309
+ return undefined;
310
+ let sop;
311
+ if (configuration.sop)
312
+ sop = { text: configuration.sop, sha256: sopDigest(configuration.sop) };
313
+ else if (s.sop) {
314
+ const read = await readSopFile(ctx.cwd, s.sop.file);
315
+ if (read.sha256 !== s.sop.sha256)
316
+ throw new Error(`SOP file ${s.sop.file} changed since it was supplied. Run /agentrun sop ${s.sop.file} again to use the new text.`);
317
+ sop = read;
318
+ }
319
+ else
320
+ throw new Error(`Workflow requires SOP text for ${needed.map(name => `"${name}"`).join(', ')}. Supply it with /agentrun sop <file>.`);
321
+ const missing = missingSopSections(sop.text, needed);
322
+ if (missing.length)
323
+ throw new Error(`SOP text has no "## <section>" heading for ${missing.map(name => `"${name}"`).join(', ')}.`);
324
+ return sop;
325
+ };
299
326
  const execute = async (ctx, s, input, options = {}) => {
300
327
  if (s.busy)
301
328
  throw new Error('A workflow is already running. Use /agentrun stop first.');
@@ -306,9 +333,9 @@ function registerExtension(pi, configuration) {
306
333
  s.controller = new AbortController();
307
334
  const signals = [s.controller.signal, options.signal, ctx.signal].filter((v) => !!v);
308
335
  const signal = AbortSignal.any(signals);
336
+ let driver;
309
337
  try {
310
- if (inspectWorkflow(s.draft).requires.sopSections.length)
311
- throw new Error('Workflow requires SOP text. Native Pi does not supply SOP text in V1; use a configured SDK host. Keep the required sections intact.');
338
+ const sop = await sopFor(ctx, s, inspectWorkflow(s.draft).requires.sopSections);
312
339
  signal.throwIfAborted();
313
340
  const available = toolSet(ctx, options.trusted);
314
341
  if (available.map(t => t.name).join(',') !== s.toolNames.join(',')) {
@@ -333,14 +360,41 @@ function registerExtension(pi, configuration) {
333
360
  options.update?.(textResult(`${label}\n\n${tree}`));
334
361
  stage(ctx, s, 'agent.run() · starting');
335
362
  const observation = new WorkflowObservation();
363
+ // A run this session left interrupted or failed, over the same draft and input, continues from its journal;
364
+ // anything else starts a new run.
365
+ const runRoot = join(ctx.cwd, '.pi', 'agentrun', 'runs');
366
+ const resumable = !scripted && s.runId !== undefined && ['interrupted', 'failed'].includes(s.report?.status ?? '') && existsSync(join(runRoot, s.runId));
367
+ let runId = resumable ? s.runId : randomUUID();
368
+ if (!scripted) {
369
+ await mkdir(runRoot, { recursive: true });
370
+ const bind = { input: s.input, tools: [...s.toolNames].sort(), ...(sop ? { sop: sop.sha256 } : {}) };
371
+ const open = (id) => openRecovery(fileStore(join(runRoot, id)), s.draft, { key: id, view: prepared.workflow, bind });
372
+ try {
373
+ try {
374
+ driver = await open(runId);
375
+ }
376
+ catch (error) {
377
+ // A run bound to other input, SOP text or tools is a different run: it starts anew, and the old journal stays.
378
+ if (!resumable || !(error instanceof RecoveryError) || error.code !== 'RUN_STORE_BINDING_MISMATCH')
379
+ throw error;
380
+ runId = randomUUID();
381
+ driver = await open(runId);
382
+ }
383
+ }
384
+ catch (error) {
385
+ throw new Error(`Cannot ${resumable ? 'resume' : 'start'} run ${runId}: ${error instanceof Error ? error.message : 'recovery store refused'}`);
386
+ }
387
+ s.pause = () => driver.stop({ action: 'pause', source: 'operator' });
388
+ }
336
389
  s.observation = observation.data;
337
390
  s.report = undefined;
338
- s.runId = randomUUID();
391
+ s.runId = runId;
339
392
  persist(s, true);
340
393
  const ordinal = ++runOrdinal;
341
394
  let sequence = 0;
342
395
  const report = await s.service.run(s.input, {
343
- deps: observation.observe(deps), signal,
396
+ deps: observation.observe(driver ? withRecovery(driver, sop ? { ...deps, sop: sop.text } : deps) : sop ? { ...deps, sop: sop.text } : deps),
397
+ signal: driver ? AbortSignal.any([signal, driver.signal]) : signal,
344
398
  ...(configuration.onWorkflowEvent ? { onTraceEvent: (event) => configuration.onWorkflowEvent({ workflowDigest: prepared.digest, runOrdinal: ordinal, sequence: ++sequence, event }) } : {}),
345
399
  onEvent: event => {
346
400
  observation.record(event);
@@ -353,6 +407,14 @@ function registerExtension(pi, configuration) {
353
407
  options.update?.(textResult(`${label}\n\n${progress.join('\n')}`));
354
408
  },
355
409
  });
410
+ if (driver && report.error?.code === 'FROZEN_EFFECT_UNKNOWN') {
411
+ // The journal names what the driver refused to repeat. Read it once the driver has let go of it.
412
+ await driver.close().catch(() => { });
413
+ const journal = await readFile(join(runRoot, runId, 'journal.json'), 'utf8').then(text => JSON.parse(text), () => undefined);
414
+ const unknown = (journal?.effects ?? []).filter(effect => effect.status === 'unknown').slice(0, 100);
415
+ if (unknown.length)
416
+ report.uncertainEffects = unknown.map(effect => ({ idempotencyKey: effect.id, outcome: 'unknown' }));
417
+ }
356
418
  s.report = report;
357
419
  persist(s);
358
420
  const summary = formatRunReport(report, scripted);
@@ -372,6 +434,8 @@ function registerExtension(pi, configuration) {
372
434
  finally {
373
435
  s.busy = false;
374
436
  s.controller = undefined;
437
+ s.pause = undefined;
438
+ await driver?.close().catch(() => { });
375
439
  stage(ctx, s);
376
440
  if (current === s && !s.closed && ctx.hasUI)
377
441
  ctx.ui.setWidget('agentrun', undefined);
@@ -563,6 +627,25 @@ function registerExtension(pi, configuration) {
563
627
  else
564
628
  show(s, formatWorkflowView(receipt), { run });
565
629
  }
630
+ else if (/^sop(?:\s|$)/.test(command)) {
631
+ const file = raw.trim().slice(3).trim();
632
+ if (!file) {
633
+ show(s, configuration.sop ? 'SOP text comes from the host.' : s.sop ? `SOP: ${s.sop.file} (${s.sop.sha256.slice(0, 12)}). Supply another with /agentrun sop <file>.` : 'No SOP supplied. Use /agentrun sop <file> for a workflow that names sopSection.');
634
+ return;
635
+ }
636
+ if (s.busy)
637
+ throw new Error('A workflow is running. Stop it before changing its SOP.');
638
+ if (configuration.sop)
639
+ throw new Error('SOP text comes from the host; /agentrun sop is not used.');
640
+ const read = await readSopFile(ctx.cwd, file);
641
+ const needed = s.draft ? inspectWorkflow(s.draft).requires.sopSections : [];
642
+ const missing = missingSopSections(read.text, needed);
643
+ if (missing.length)
644
+ throw new Error(`SOP text has no "## <section>" heading for ${missing.map(name => `"${name}"`).join(', ')}. Nothing was changed.`);
645
+ s.sop = { file: read.file, sha256: read.sha256 };
646
+ persist(s);
647
+ show(s, `SOP set: ${read.file} (${read.text.length} characters). ${needed.length ? `Covers ${needed.map(name => `"${name}"`).join(', ')}.` : 'The current workflow names no section.'}`);
648
+ }
566
649
  else if (/^input(?:\s|$)/.test(command)) {
567
650
  if (!s.draft)
568
651
  throw new Error('No workflow yet. Load or describe a procedure first.');
@@ -616,9 +699,10 @@ function registerExtension(pi, configuration) {
616
699
  }
617
700
  else if (command === 'status') {
618
701
  const ready = readiness(ctx, s);
619
- show(s, `agent.run() · ${s.busy ? 'running' : 'idle'}\nDemo: ready — /agentrun demo (no keys or model calls)\nWorkflow: ${s.draft ? `${modelJson(inspectWorkflow(s.draft).name)} · ${s.demo ? 'scripted' : 'live adapters'}` : 'none — load a saved procedure, run the demo, or describe a task'}\n\nFor your own workflows:\nPi host: ${PI_VERSION}${PI_VERSION === '0.87.0' ? '' : ' (tested on 0.87.0; use the bundled ./node_modules/.bin/pi)'}\nSkill: ${ready.skill ? 'loaded — /agentrun <task>' : 'missing — enable package skills, then /reload'}\nPi: ${ready.pi ? 'selected model available (connection not tested)' : 'no usable active model — /login to connect a provider, then /model to select it'}\nJev: ${ready.jev ? 'configuration present (connection not tested)' : 'not configured — only needed for system one decisions; set TYPESAFE_API_KEY before starting Pi'}`, ready);
702
+ show(s, `agent.run() · ${s.busy ? 'running' : 'idle'}\nDemo: ready — /agentrun demo (no keys or model calls)\nWorkflow: ${s.draft ? `${modelJson(inspectWorkflow(s.draft).name)} · ${s.demo ? 'scripted' : 'live adapters'}` : 'none — load a saved procedure, run the demo, or describe a task'}\n\nFor your own workflows:\nPi host: ${PI_VERSION}${PI_VERSION === TESTED_PI_VERSION ? '' : ` (tested on ${TESTED_PI_VERSION}; install that host: npm install -g @earendil-works/pi-coding-agent@${TESTED_PI_VERSION})`}\nSkill: ${ready.skill ? 'loaded — /agentrun <task>' : 'missing — enable package skills, then /reload'}\nPi: ${ready.pi ? 'selected model available (connection not tested)' : 'no usable active model — /login to connect a provider, then /model to select it'}\nJev: ${ready.jev ? 'configuration present (connection not tested)' : 'not configured — only needed for system one decisions; set TYPESAFE_API_KEY before starting Pi'}`, ready);
620
703
  }
621
704
  else if (command === 'stop') {
705
+ s.pause?.();
622
706
  s.controller?.abort();
623
707
  s.service.stop();
624
708
  show(s, s.busy ? 'Stop requested. Already-started tool effects may still finish.' : 'Nothing is running.');
@@ -657,6 +741,7 @@ function registerExtension(pi, configuration) {
657
741
  const old = current, wasRunning = old.busy;
658
742
  old.clearStatus();
659
743
  old.closed = true;
744
+ old.pause?.();
660
745
  old.controller?.abort();
661
746
  await old.service.dispose();
662
747
  await old.inFlight;
@@ -1,5 +1,6 @@
1
1
  import { Agent } from '@earendil-works/pi-agent-core';
2
2
  import { createPiRunner } from './runner.js';
3
+ import { workflowToolContext } from './tool-context.js';
3
4
  export const PI_MODEL_SETUP_MESSAGE = 'AgentRun requires an active Pi model. Use /login to connect a provider, then /model to select it. Try /agentrun demo without model access.';
4
5
  function selectedModel(context) {
5
6
  const model = context.model;
@@ -16,6 +17,7 @@ function selectedModel(context) {
16
17
  export function createPiHostSessionFactory(context) {
17
18
  const model = selectedModel(context);
18
19
  const stream = context.modelRegistry.streamSimple.bind(context.modelRegistry);
20
+ const toolContext = workflowToolContext(context);
19
21
  return async (options) => {
20
22
  if (options.model !== model)
21
23
  throw new Error('The child session must use the captured active Pi model');
@@ -25,7 +27,7 @@ export function createPiHostSessionFactory(context) {
25
27
  name: tool.name, label: tool.label, description: tool.description,
26
28
  parameters: tool.parameters,
27
29
  async execute(id, args, signal, onUpdate) {
28
- const result = await tool.execute(id, args, signal, onUpdate, context);
30
+ const result = await tool.execute(id, args, signal, onUpdate, toolContext);
29
31
  if (tool.name === 'submit' && result.details?.accepted === true)
30
32
  delivered = true;
31
33
  return result;
@@ -0,0 +1,6 @@
1
+ export declare class PiRunError extends Error {
2
+ readonly reason: "aborted" | "timeout" | "turn_limit" | "submission_limit" | "no_submission" | "model_error";
3
+ readonly turns: number;
4
+ readonly submissions: number;
5
+ constructor(reason: "aborted" | "timeout" | "turn_limit" | "submission_limit" | "no_submission" | "model_error", turns: number, submissions: number);
6
+ }
@@ -0,0 +1,13 @@
1
+ // Kept apart from runner.ts so the workflow service loads without a pi package installed.
2
+ export class PiRunError extends Error {
3
+ reason;
4
+ turns;
5
+ submissions;
6
+ constructor(reason, turns, submissions) {
7
+ super(`Pi node did not deliver an accepted submission: ${reason} (${turns} turns, ${submissions} submissions)`);
8
+ this.reason = reason;
9
+ this.turns = turns;
10
+ this.submissions = submissions;
11
+ this.name = "PiRunError";
12
+ }
13
+ }
package/dist/runner.d.ts CHANGED
@@ -3,10 +3,5 @@ import type { PiRunnerOptions } from "./types.js";
3
3
  export type { PiRunnerOptions, PiSession, PiSessionFactory } from "./types.js";
4
4
  export type PiRunner = NonNullable<WorkflowDeps["runNode"]>;
5
5
  export type PiNodeRequest = Parameters<PiRunner>[0];
6
- export declare class PiRunError extends Error {
7
- readonly reason: "aborted" | "timeout" | "turn_limit" | "submission_limit" | "no_submission" | "model_error";
8
- readonly turns: number;
9
- readonly submissions: number;
10
- constructor(reason: "aborted" | "timeout" | "turn_limit" | "submission_limit" | "no_submission" | "model_error", turns: number, submissions: number);
11
- }
6
+ export { PiRunError } from "./run-error.js";
12
7
  export declare function createPiRunner(options: PiRunnerOptions): PiRunner;
package/dist/runner.js CHANGED
@@ -1,17 +1,7 @@
1
1
  import { Compile } from "typebox/compile";
2
2
  import { createAgentSession, DefaultResourceLoader, SessionManager, SettingsManager, ModelRuntime, } from "@earendil-works/pi-coding-agent";
3
- export class PiRunError extends Error {
4
- reason;
5
- turns;
6
- submissions;
7
- constructor(reason, turns, submissions) {
8
- super(`Pi node did not deliver an accepted submission: ${reason} (${turns} turns, ${submissions} submissions)`);
9
- this.reason = reason;
10
- this.turns = turns;
11
- this.submissions = submissions;
12
- this.name = "PiRunError";
13
- }
14
- }
3
+ import { PiRunError } from "./run-error.js";
4
+ export { PiRunError } from "./run-error.js";
15
5
  function positive(value, name) {
16
6
  if (!Number.isSafeInteger(value) || value < 1)
17
7
  throw new Error(`${name} must be a positive integer`);
package/dist/sop.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ export declare const MAX_SOP_BYTES: number;
2
+ export interface SopText {
3
+ text: string;
4
+ sha256: string;
5
+ }
6
+ export declare const sopDigest: (text: string) => string;
7
+ /** The SOP text of a regular file inside `cwd`; a path that resolves outside it, or a file over the bound, is refused. */
8
+ export declare function readSopFile(cwd: string, file: string): Promise<SopText & {
9
+ file: string;
10
+ }>;
11
+ /** The sections a workflow names that the SOP has no `## <section>` heading line for. */
12
+ export declare function missingSopSections(text: string, sections: readonly string[]): string[];
package/dist/sop.js ADDED
@@ -0,0 +1,29 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat, readFile, realpath } from 'node:fs/promises';
3
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
4
+ export const MAX_SOP_BYTES = 512 * 1024;
5
+ export const sopDigest = (text) => createHash('sha256').update(text).digest('hex');
6
+ /** The SOP text of a regular file inside `cwd`; a path that resolves outside it, or a file over the bound, is refused. */
7
+ export async function readSopFile(cwd, file) {
8
+ if (!file.trim() || file.includes('\0'))
9
+ throw new Error('Use /agentrun sop <file>, a path inside this project.');
10
+ const root = await realpath(cwd);
11
+ const target = await realpath(resolve(cwd, file)).catch(() => { throw new Error(`SOP file not found: ${file}`); });
12
+ const inside = relative(root, target);
13
+ if (!inside || inside === '..' || inside.startsWith(`..${sep}`) || isAbsolute(inside))
14
+ throw new Error('The SOP file must be inside this project.');
15
+ const info = await lstat(target);
16
+ if (!info.isFile())
17
+ throw new Error('The SOP must be a regular file.');
18
+ if (info.size > MAX_SOP_BYTES)
19
+ throw new Error(`The SOP file is larger than ${MAX_SOP_BYTES / 1024} KiB.`);
20
+ const text = (await readFile(target, 'utf8')).replace(/^/, '');
21
+ if (!text.trim())
22
+ throw new Error('The SOP file is empty.');
23
+ return { file: inside.split(sep).join('/'), text, sha256: sopDigest(text) };
24
+ }
25
+ /** The sections a workflow names that the SOP has no `## <section>` heading line for. */
26
+ export function missingSopSections(text, sections) {
27
+ const headings = new Set(text.split(/\r?\n/).filter(line => line.startsWith('## ')).map(line => line.trimEnd().slice(3)));
28
+ return sections.filter(section => !headings.has(section));
29
+ }
@@ -0,0 +1,21 @@
1
+ export interface NestedToolOutcome {
2
+ toolCall: {
3
+ type: 'toolCall';
4
+ id: string;
5
+ name: string;
6
+ arguments: Record<string, unknown>;
7
+ };
8
+ result: {
9
+ content: Array<{
10
+ type: 'text';
11
+ text: string;
12
+ }>;
13
+ details: undefined;
14
+ };
15
+ isError: true;
16
+ }
17
+ export type WorkflowToolContext<C extends object> = C & {
18
+ readonly tools: readonly never[];
19
+ executeTool(name: string, args: unknown): Promise<NestedToolOutcome>;
20
+ };
21
+ export declare function workflowToolContext<C extends object>(context: C): WorkflowToolContext<C>;
@@ -0,0 +1,13 @@
1
+ export function workflowToolContext(context) {
2
+ return Object.create(context, {
3
+ tools: { value: Object.freeze([]), enumerable: true },
4
+ executeTool: {
5
+ enumerable: true,
6
+ async value(name, args) {
7
+ const text = `A workflow node cannot call the tool ${name}: nested tool calls are not available here`;
8
+ const toolCall = { type: 'toolCall', id: 'agentrun-nested', name, arguments: (args && typeof args === 'object' ? args : {}) };
9
+ return { toolCall, result: { content: [{ type: 'text', text }], details: undefined }, isError: true };
10
+ },
11
+ },
12
+ });
13
+ }
@@ -11,6 +11,10 @@ export interface WorkflowSessionSnapshot {
11
11
  observation?: RunObservation;
12
12
  running?: boolean;
13
13
  runId?: string;
14
+ sop?: {
15
+ file: string;
16
+ sha256: string;
17
+ };
14
18
  createdAt: string;
15
19
  }
16
20
  /** Caller supplies Pi's active branch, not the complete session tree. */
@@ -96,12 +96,13 @@ function snapshotEntry(entry) {
96
96
  if (descriptors.type?.value !== 'custom' || descriptors.customType?.value !== 'agentrun:snapshot' || !('value' in (descriptors.data ?? {})))
97
97
  return;
98
98
  const s = jsonSnapshot(descriptors.data.value);
99
- if (!object(s) || !only(s, ['version', 'workflow', 'input', 'savedName', 'demo', 'report', 'observation', 'running', 'runId', 'createdAt'])
99
+ if (!object(s) || !only(s, ['version', 'workflow', 'input', 'savedName', 'demo', 'report', 'observation', 'running', 'runId', 'sop', 'createdAt'])
100
100
  || s.version !== 1 || !object(s.input) || typeof s.createdAt !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(s.createdAt)
101
101
  || !Number.isFinite(Date.parse(s.createdAt)) || new Date(s.createdAt).toISOString() !== s.createdAt
102
102
  || s.savedName !== undefined && (typeof s.savedName !== 'string' || !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(s.savedName))
103
103
  || s.runId !== undefined && (typeof s.runId !== 'string' || !s.runId.length || s.runId.length > 200)
104
- || s.running !== undefined && typeof s.running !== 'boolean')
104
+ || s.running !== undefined && typeof s.running !== 'boolean'
105
+ || s.sop !== undefined && (!object(s.sop) || !only(s.sop, ['file', 'sha256']) || !textFields(s.sop, ['file', 'sha256']) || !/^[0-9a-f]{64}$/.test(s.sop.sha256) || s.sop.file.length > 1024))
105
106
  return;
106
107
  const inspection = inspectWorkflow(s.workflow);
107
108
  // Input remains an exact draft, including incomplete form values; run preflight owns it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parcha/agentrun-pi",
3
- "version": "0.1.0-beta.11",
3
+ "version": "0.1.0-beta.13",
4
4
  "description": "Pi extension for building, running and evaluating AgentRun workflows",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -15,6 +15,10 @@
15
15
  "./extension": {
16
16
  "types": "./dist/extension.d.ts",
17
17
  "import": "./dist/extension.js"
18
+ },
19
+ "./durable": {
20
+ "types": "./dist/durable/index.d.ts",
21
+ "import": "./dist/durable/index.js"
18
22
  }
19
23
  },
20
24
  "bin": {
@@ -32,15 +36,46 @@
32
36
  "test": "node --test test/*.test.mjs"
33
37
  },
34
38
  "devDependencies": {
35
- "@earendil-works/pi-ai": "0.87.0"
39
+ "@earendil-works/chord": "1.1.0",
40
+ "@earendil-works/pi-agent-core": "1.1.0",
41
+ "@earendil-works/pi-ai": "1.1.0",
42
+ "@earendil-works/pi-coding-agent": "1.1.0",
43
+ "@earendil-works/pi-durable": "1.1.0",
44
+ "@earendil-works/pi-tui": "1.1.0",
45
+ "@parcha/pi-durable-disk": "0.1.0-beta.13",
46
+ "typebox": "1.3.7"
36
47
  },
37
48
  "dependencies": {
38
- "@parcha/agentrun-dsl": "0.1.0-beta.11",
39
- "@parcha/agentrun-jev": "0.1.0-beta.11",
40
- "@earendil-works/pi-agent-core": "0.87.0",
41
- "@earendil-works/pi-coding-agent": "0.87.0",
42
- "@earendil-works/pi-tui": "0.87.0",
43
- "typebox": "1.3.7"
49
+ "@parcha/agentrun-dsl": "0.1.0-beta.13",
50
+ "@parcha/agentrun-jev": "0.1.0-beta.13"
51
+ },
52
+ "peerDependencies": {
53
+ "@earendil-works/pi-agent-core": "*",
54
+ "@earendil-works/pi-coding-agent": "*",
55
+ "@earendil-works/pi-tui": "*",
56
+ "typebox": "*",
57
+ "@earendil-works/pi-durable": ">=1.0.4 <1.3.0",
58
+ "@earendil-works/chord": ">=1.0.4 <1.3.0"
59
+ },
60
+ "peerDependenciesMeta": {
61
+ "@earendil-works/pi-agent-core": {
62
+ "optional": true
63
+ },
64
+ "@earendil-works/pi-coding-agent": {
65
+ "optional": true
66
+ },
67
+ "@earendil-works/pi-tui": {
68
+ "optional": true
69
+ },
70
+ "typebox": {
71
+ "optional": true
72
+ },
73
+ "@earendil-works/pi-durable": {
74
+ "optional": true
75
+ },
76
+ "@earendil-works/chord": {
77
+ "optional": true
78
+ }
44
79
  },
45
80
  "repository": {
46
81
  "type": "git",