@parcha/agentrun-pi 0.1.0-beta.23 → 0.1.0-beta.27

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.
@@ -1,6 +1,6 @@
1
1
  export type { RecoveryBinding, RecoveryEffect, RecoveryJournal, RecoveryNote, RecoveryStore } from "@parcha/agentrun-dsl/recovery";
2
2
  export { documentStore, type DocumentStoreHost, type DocumentStoreOptions } from "./store.js";
3
- export { committedRoutes, durableDecisions, durableDoc, readJournal, type JournalView } from "./readers.js";
3
+ export { committedRoutes, durableDecisions, durableDoc, readJournal, type DurableReadOptions, type JournalView } from "./readers.js";
4
4
  export { recordCommits, recordingLines, snapshotLines, type RecordingLine, type RecordingSource } from "./recording.js";
5
5
  export { reuseReceipts, seedReceipts, ReceiptIndex, type ReceiptReuse, type ReuseReceiptsOptions, type ReusableTool } from "./receipts.js";
6
6
  export { effectResultOf } from "../effect-result.js";
@@ -12,6 +12,7 @@
12
12
  import { randomUUID } from "node:crypto";
13
13
  import { configure, defineDoc, defineExtension, GenerationTask, hook, section, } from "@earendil-works/pi-durable";
14
14
  import { canonicalSha256, runStopOf } from "@parcha/agentrun-dsl/recovery";
15
+ import { SUBMIT_TOOL } from "../host-tools.js";
15
16
  import { nodeThinking } from "../thinking.js";
16
17
  import { closeNodeAttempt, runNodeAttempt } from "./node.js";
17
18
  import { DELIVERY_ATTEMPTS, lintRecordRoot, submitFooter } from "./record.js";
@@ -78,7 +79,7 @@ async function underStop(signal, work) {
78
79
  }
79
80
  }
80
81
  const listed = (value) => Array.isArray(value);
81
- const isSubmit = (tool) => tool.name === "submit";
82
+ const isSubmit = (tool) => tool.name === SUBMIT_TOOL;
82
83
  /** The node runner over a host. Install `extension` in the registry before the Harness opens; `on` binds the runner
83
84
  * to where its conversations live (`taskScope`, `hostScope`). */
84
85
  export function nodeRunner(host, options = {}) {
@@ -177,7 +178,7 @@ export function nodeRunner(host, options = {}) {
177
178
  const { agent, record } = await underStop(signal, async () => ({ agent: leveled(await host.agent(node), node), record: await host.record?.(node) ?? {} }));
178
179
  const label = record.label ?? `the ${node.label} record`;
179
180
  const offered = (listed(agent.tools) ? agent.tools : listed(agent.extensions) ? agent.extensions.flatMap((selected) => selected.tools ?? []) : [])
180
- .map((offer) => offer.name).filter((name) => name !== "submit");
181
+ .map((offer) => offer.name).filter((name) => name !== SUBMIT_TOOL);
181
182
  const system = [...node.system, host.stance?.(node, { tools: offered })].filter(Boolean).join("\n\n");
182
183
  const input = inputOf(node.user);
183
184
  const reviewers = [
@@ -11,11 +11,16 @@ export type JournalView = {
11
11
  notes: RecoveryNote[];
12
12
  effects: RecoveryEffect[];
13
13
  };
14
+ /** How a reader opens the store file. `immutable` opens it as SQLite's immutable file, for a store no process writes any
15
+ * more and with no write-ahead log beside it (an ended run's): nothing beside it is read or made. */
16
+ export type DurableReadOptions = {
17
+ immutable?: boolean;
18
+ };
14
19
  /** The run's store `file`, opened read-only for `read`; null when there is no such file. Every query of one `read` runs
15
20
  * in one read transaction, so what it returns is one commit's view, whatever a writer commits meanwhile. */
16
- export declare function withDurableFile<T>(file: string, read: (db: Database) => T): T | null;
21
+ export declare function withDurableFile<T>(file: string, read: (db: Database) => T, options?: DurableReadOptions): T | null;
17
22
  /** A Session document of the run's store `file`, read-only; undefined when the file or the document is absent. */
18
- export declare function durableDoc<T = Record<string, unknown>>(file: string, kind: string, key?: string): T | undefined;
23
+ export declare function durableDoc<T = Record<string, unknown>>(file: string, kind: string, key?: string, options?: DurableReadOptions): T | undefined;
19
24
  /** The decision rows (`agentrun.decisions`) of the run's store `file`, in commit order, in whichever form the store holds: the single
20
25
  * document's `receipts` list, or the keyed documents (one row each, in the order of their keys). A reader that folds decisions from the
21
26
  * store uses this and not `durableDoc(file, "agentrun.decisions").receipts`, which is nothing in the keyed form. */
@@ -25,7 +30,7 @@ export declare function durableDecisions(file: string): Array<Record<string, unk
25
30
  * admitted, in either layout: in the keyed one (`agentrun.frozen_run.v4`) the effects a map item owns, nested maps
26
31
  * included, are listed by that item's record, not the driver's, and are read from there. `state` is the driver's own
27
32
  * record: in the keyed layout what lies in each map item stays in the item's record. */
28
- export declare function readJournal(file: string, key: string): JournalView | undefined;
33
+ export declare function readJournal(file: string, key: string, options?: DurableReadOptions): JournalView | undefined;
29
34
  /** Every committed route decision a frozen snapshot holds (each judge's answer, whole), by execution path. Empty for a
30
35
  * state that is no snapshot. */
31
36
  export declare function committedRoutes(state: unknown): Record<string, Json>;
@@ -4,16 +4,17 @@
4
4
  // readers take the store file itself (pi-durable's SQLite file, wherever the host keeps it), never a directory.
5
5
  import fs from "node:fs";
6
6
  import { createRequire } from "node:module";
7
+ import { pathToFileURL } from "node:url";
7
8
  import { apply } from "@earendil-works/chord/delta";
8
9
  import { decisionKey } from "./decisions.js";
9
10
  import { effectKey } from "./store.js";
10
11
  /** The run's store `file`, opened read-only for `read`; null when there is no such file. Every query of one `read` runs
11
12
  * in one read transaction, so what it returns is one commit's view, whatever a writer commits meanwhile. */
12
- export function withDurableFile(file, read) {
13
+ export function withDurableFile(file, read, options = {}) {
13
14
  if (!fs.existsSync(file))
14
15
  return null;
15
16
  const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite");
16
- const db = new DatabaseSync(file, { readOnly: true, timeout: 5000 });
17
+ const db = new DatabaseSync(options.immutable ? `${pathToFileURL(file).href}?immutable=1` : file, { readOnly: true, timeout: 5000 });
17
18
  try {
18
19
  db.exec("BEGIN");
19
20
  return read(db);
@@ -67,8 +68,8 @@ function itemEffects(db, key, table, into) {
67
68
  }
68
69
  }
69
70
  /** A Session document of the run's store `file`, read-only; undefined when the file or the document is absent. */
70
- export function durableDoc(file, kind, key) {
71
- return (withDurableFile(file, (db) => sessionDoc(db, kind, key)) ?? undefined);
71
+ export function durableDoc(file, kind, key, options = {}) {
72
+ return (withDurableFile(file, (db) => sessionDoc(db, kind, key), options) ?? undefined);
72
73
  }
73
74
  /** The decision rows (`agentrun.decisions`) of the run's store `file`, in commit order, in whichever form the store holds: the single
74
75
  * document's `receipts` list, or the keyed documents (one row each, in the order of their keys). A reader that folds decisions from the
@@ -94,7 +95,7 @@ export function durableDecisions(file) {
94
95
  * admitted, in either layout: in the keyed one (`agentrun.frozen_run.v4`) the effects a map item owns, nested maps
95
96
  * included, are listed by that item's record, not the driver's, and are read from there. `state` is the driver's own
96
97
  * record: in the keyed layout what lies in each map item stays in the item's record. */
97
- export function readJournal(file, key) {
98
+ export function readJournal(file, key, options = {}) {
98
99
  return withDurableFile(file, (db) => {
99
100
  const doc = sessionDoc(db, "agentrun.driver", key);
100
101
  if (!doc || doc.binding === null)
@@ -113,7 +114,7 @@ export function readJournal(file, key) {
113
114
  return { id, name: e?.name ?? "", argsHash: e?.argsHash ?? "", status: e?.status ?? "unknown", session: e?.session ?? null, result: e?.result ?? null, ...(e?.intent ? { intent: e.intent } : {}), ...(e?.args !== undefined ? { args: e.args } : {}), ...(Array.isArray(e?.calls) ? { calls: e.calls } : {}) };
114
115
  });
115
116
  return { binding: doc.binding, inputs: doc.inputs, generation: doc.generation, revision: doc.revision, state: doc.state, notes: doc.notes, effects };
116
- }) ?? undefined;
117
+ }, options) ?? undefined;
117
118
  }
118
119
  /** Every committed route decision a frozen snapshot holds (each judge's answer, whole), by execution path. Empty for a
119
120
  * state that is no snapshot. */
@@ -1,5 +1,6 @@
1
1
  import { defineDoc, defineTool, GenerationTask, hook, } from "@earendil-works/pi-durable";
2
2
  import { DELIVERY_ATTEMPTS, deliver, deliveryTerminates, deliveryText, fileSubmission, mixedFileSubmission, nudgeText, repairRecord, VIOLATIONS_SHOWN, } from "./record.js";
3
+ import { SUBMIT_TOOL } from "../host-tools.js";
3
4
  import { toolResult } from "./pi-versions.js";
4
5
  /** The record a conversation delivered, or null while it has not, and the delivery attempts it has spent. */
5
6
  export const RecordDoc = defineDoc({
@@ -76,7 +77,7 @@ export function recordTool(options) {
76
77
  ...(deliveryTerminates(delivery) ? { control: { terminate: true } } : {}),
77
78
  });
78
79
  return defineTool({
79
- name: "submit",
80
+ name: SUBMIT_TOOL,
80
81
  description: shown.description,
81
82
  parameters: shown.parameters,
82
83
  // Every call's arguments are carried to `execute`; one record's are repaired here, before the Harness validates.
@@ -8,7 +8,7 @@ import { Type } from "typebox";
8
8
  import { inspectWorkflow, WORKFLOW_NODE_KINDS } from "@parcha/agentrun-dsl";
9
9
  import { canonicalHash } from "@parcha/agentrun-dsl/recovery";
10
10
  import { extensionStructuralLimits, WorkflowExtensionService } from "../extension-service.js";
11
- import { checkHostTools } from "../host-tools.js";
11
+ import { assertHostTools, SUBMIT_TOOL } from "../host-tools.js";
12
12
  import { modelJson } from "../presentation.js";
13
13
  import { WorkflowStore } from "../workflow-store.js";
14
14
  import { progressLines, WorkflowObservation } from "../workflow-view.js";
@@ -38,7 +38,8 @@ const answer = (value) => toolResult([{ type: "text", text: modelJson(value) }],
38
38
  * agent definition. `workflow` reads and stops runs from the host's side. */
39
39
  export function createWorkflowTool(host) {
40
40
  // The host's tools pass the check the coding-agent extension's do; the tool's own name and the node runner's submit are reserved.
41
- checkHostTools(host.tools ?? [], { reserved: [host.name ?? "agentrun", "submit"], executable: false });
41
+ const name = host.name ?? "agentrun";
42
+ assertHostTools(host.tools ?? [], { reserved: [name, SUBMIT_TOOL], executable: false });
42
43
  const names = (host.tools ?? []).map((tool) => tool.name);
43
44
  const checker = () => new WorkflowExtensionService({ allowedTools: names });
44
45
  /** Inspect's checks, after naming any tool the workflow uses (a call's, or an agent node's) that the host did not
@@ -86,7 +87,7 @@ export function createWorkflowTool(host) {
86
87
  },
87
88
  });
88
89
  const tool = defineTool({
89
- name: host.name ?? "agentrun",
90
+ name,
90
91
  description: "Compose and run AgentRun workflows in this session. Describe lists the tools a workflow may use, as a call's tool or an agent node's tools, and the language; a workflow uses only the tools describe lists, which the host registers. Inspect validates a complete workflow and keeps it as this conversation's draft. Dry-run checks the draft on synthetic values (or the given input) with no model, Jev or tool call. Run starts the draft as a durable task of this conversation and answers with its outcome. Running the same draft on the same input again, after a restart too, reaches the same run and makes no effect twice; again: true starts a fresh run of them, which a later run then reaches. Save, load and list keep named definition revisions.",
91
92
  parameters: Type.Object({ ...workflowToolProperties(), again: Type.Optional(Type.Boolean({ description: "Run only: start a fresh run of the same draft and input instead of reaching the one already run." })) }),
92
93
  executionMode: "sequential",
package/dist/extension.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { Compile } from 'typebox/compile';
4
- import { checkHostTools } from './host-tools.js';
4
+ import { assertHostTools, SUBMIT_TOOL } from './host-tools.js';
5
5
  import { Text } from '@earendil-works/pi-tui';
6
6
  import { createReadToolDefinition, createBashToolDefinition, createEditToolDefinition, createWriteToolDefinition, createGrepToolDefinition, createFindToolDefinition, createLsToolDefinition, VERSION as PI_VERSION, } from '@earendil-works/pi-coding-agent';
7
7
  import { formatWorkflowTree, inspectWorkflow, AUTHOR_SKILL_NAME } from '@parcha/agentrun-dsl';
@@ -24,11 +24,14 @@ import { showWorkflowInspector } from './workflow-inspector.js';
24
24
  import { authoringReferences, describeTools, dryRunDraft, formatDryRun, inspectCandidate, workflowToolParameters } from './workflow-tool.js';
25
25
  import { supportTriageWorkflow, supportTriageInputs, supportTriageTools, scriptedSupportTriageDeps } from './triage-demo.js';
26
26
  import { restoreWorkflowSession, workflowRunHistory } from './workflow-session.js';
27
+ import { OperatorError } from './operator-error.js';
27
28
  const constructors = {
28
29
  read: createReadToolDefinition, bash: createBashToolDefinition, edit: createEditToolDefinition,
29
30
  write: createWriteToolDefinition, grep: createGrepToolDefinition, find: createFindToolDefinition, ls: createLsToolDefinition,
30
31
  };
31
32
  const mutatingTools = new Set(['bash', 'edit', 'write']);
33
+ /** The extension's own tool's name, which a host tool may not take. */
34
+ const toolName = 'agentrun';
32
35
  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 · Dry run: /agentrun dry-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';
33
36
  const textResult = (text, details = {}) => ({ content: [{ type: 'text', text: safe(text) }], details });
34
37
  function singleTextInput(workflow) {
@@ -79,7 +82,7 @@ function registerExtension(pi, configuration) {
79
82
  const toolSet = (ctx, trusted = false) => {
80
83
  if (configuration.hostTools) {
81
84
  const supplied = configuration.hostTools(ctx);
82
- checkHostTools(supplied, { reserved: ['agentrun', 'submit'], executable: true });
85
+ assertHostTools(supplied, { reserved: [toolName, SUBMIT_TOOL], executable: true });
83
86
  return supplied.map(tool => ({ ...tool, parameters: structuredClone(tool.parameters), ...(tool.resultSchema ? { resultSchema: structuredClone(tool.resultSchema) } : {}) }));
84
87
  }
85
88
  const active = new Set(pi.getActiveTools());
@@ -153,7 +156,8 @@ function registerExtension(pi, configuration) {
153
156
  pi.sendMessage({ customType: 'agentrun', content: safe(content), display: true, details });
154
157
  };
155
158
  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|SOP|The SOP|Use \/agentrun sop)/.test(error.message))
159
+ // What may reach the operator is decided by the error's type, never by its text.
160
+ const message = error instanceof Error && (error instanceof WorkflowStoreError || error.name === 'ExtensionServiceError' || error instanceof OperatorError)
157
161
  ? 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
162
  show(s, message);
159
163
  };
@@ -164,10 +168,10 @@ function registerExtension(pi, configuration) {
164
168
  };
165
169
  const inspect = (ctx, s, workflow, input) => {
166
170
  if (s.busy && (workflow !== undefined || input !== undefined))
167
- throw new Error('A workflow is running. Stop it before changing its definition or input.');
171
+ throw new OperatorError('A workflow is running. Stop it before changing its definition or input.');
168
172
  const candidate = workflow === undefined ? s.draft : workflow;
169
173
  if (candidate === undefined)
170
- throw new Error('No workflow yet. Describe a task with /agentrun <task>.');
174
+ throw new OperatorError('No workflow yet. Describe a task with /agentrun <task>.');
171
175
  // Include active tools available only with operator trust, without granting that trust.
172
176
  // Execution rechecks the actual per-run permissions and captures implementations.
173
177
  const checker = new WorkflowExtensionService({ allowedTools: toolSet(ctx, true).map(tool => tool.name), limits: serviceLimits });
@@ -206,7 +210,7 @@ function registerExtension(pi, configuration) {
206
210
  signal.throwIfAborted();
207
211
  toolCalls++;
208
212
  if (limits.toolAttempts !== null && toolCalls > limits.toolAttempts)
209
- throw new Error('Workflow tool-call limit reached');
213
+ throw new OperatorError('Workflow tool-call limit reached');
210
214
  configuration.onToolAttempt?.();
211
215
  signal.throwIfAborted();
212
216
  };
@@ -224,7 +228,7 @@ function registerExtension(pi, configuration) {
224
228
  signal.throwIfAborted();
225
229
  modelCalls++;
226
230
  if (limits.modelRequests !== null && modelCalls > limits.modelRequests)
227
- throw new Error('Workflow model-request limit reached');
231
+ throw new OperatorError('Workflow model-request limit reached');
228
232
  return ctx.modelRegistry.streamSimple(model, transcript, { ...options, reasoning: options?.reasoning === 'off' ? undefined : options?.reasoning, signal: options?.signal ? AbortSignal.any([signal, options.signal]) : signal });
229
233
  },
230
234
  },
@@ -247,26 +251,26 @@ function registerExtension(pi, configuration) {
247
251
  judge = createJevRunner({ signal, maxAttempts: 1 });
248
252
  }
249
253
  catch {
250
- throw new Error('AgentRun requires TypeSafe configuration for this workflow. Set TYPESAFE_API_KEY and, for a gateway, TYPESAFE_BASE_URL before running.');
254
+ throw new OperatorError('AgentRun requires TypeSafe configuration for this workflow. Set TYPESAFE_API_KEY and, for a gateway, TYPESAFE_BASE_URL before running.');
251
255
  }
252
256
  }
253
257
  }
254
258
  return {
255
259
  runNode: request => {
256
260
  if (!runner)
257
- throw new Error('Workflow agent adapter was not admitted');
261
+ throw new OperatorError('Workflow agent adapter was not admitted');
258
262
  return runner(request);
259
263
  },
260
264
  runJudge: request => {
261
265
  if (!judge)
262
- throw new Error('Workflow Jev adapter was not admitted');
266
+ throw new OperatorError('Workflow Jev adapter was not admitted');
263
267
  return judge(request);
264
268
  },
265
269
  runEffect: async ({ node, input, signal: effectSignal }) => {
266
270
  countTool();
267
271
  const tool = tools.find(tool => tool.name === node.tool);
268
272
  if (!tool || node.via !== 'tool')
269
- throw new Error('Workflow requested an unavailable tool');
273
+ throw new OperatorError('Workflow requested an unavailable tool');
270
274
  let result;
271
275
  try {
272
276
  result = await tool.execute('agentrun-effect', input, effectSignal, undefined, workflowToolContext(ctx));
@@ -292,21 +296,21 @@ function registerExtension(pi, configuration) {
292
296
  else if (s.sop) {
293
297
  const read = await readSopFile(ctx.cwd, s.sop.file);
294
298
  if (read.sha256 !== s.sop.sha256)
295
- 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.`);
299
+ throw new OperatorError(`SOP file ${s.sop.file} changed since it was supplied. Run /agentrun sop ${s.sop.file} again to use the new text.`);
296
300
  sop = read;
297
301
  }
298
302
  else
299
- throw new Error(`Workflow requires SOP text for ${needed.map(name => `"${name}"`).join(', ')}. Supply it with /agentrun sop <file>.`);
303
+ throw new OperatorError(`Workflow requires SOP text for ${needed.map(name => `"${name}"`).join(', ')}. Supply it with /agentrun sop <file>.`);
300
304
  const missing = missingSopSections(sop.text, needed);
301
305
  if (missing.length)
302
- throw new Error(`SOP text has no "## <section>" heading for ${missing.map(name => `"${name}"`).join(', ')}.`);
306
+ throw new OperatorError(`SOP text has no "## <section>" heading for ${missing.map(name => `"${name}"`).join(', ')}.`);
303
307
  return sop;
304
308
  };
305
309
  const execute = async (ctx, s, input, options = {}) => {
306
310
  if (s.busy)
307
- throw new Error('A workflow is already running. Use /agentrun stop first.');
311
+ throw new OperatorError('A workflow is already running. Use /agentrun stop first.');
308
312
  if (!s.draft)
309
- throw new Error('Inspect a workflow first. Try /agentrun demo, or describe a task with /agentrun <task>.');
313
+ throw new OperatorError('Inspect a workflow first. Try /agentrun demo, or describe a task with /agentrun <task>.');
310
314
  const scripted = s.demo !== undefined;
311
315
  s.busy = true;
312
316
  s.controller = new AbortController();
@@ -425,7 +429,7 @@ function registerExtension(pi, configuration) {
425
429
  * only when the user's command trusts this dry run. */
426
430
  const dryRun = async (ctx, s, input, trusted, signal) => {
427
431
  if (!s.draft)
428
- throw new Error('Inspect a workflow first. Try /agentrun demo, or describe a task with /agentrun <task>.');
432
+ throw new OperatorError('Inspect a workflow first. Try /agentrun demo, or describe a task with /agentrun <task>.');
429
433
  const draft = s.draft;
430
434
  // /agentrun stop, the tool call's own abort and the session's end each stop it, from before its first await on.
431
435
  const controller = new AbortController();
@@ -452,7 +456,7 @@ function registerExtension(pi, configuration) {
452
456
  };
453
457
  const launch = async (ctx, s, input, trusted = false) => {
454
458
  if (s.busy)
455
- throw new Error('A workflow is already running. Use /agentrun stop first.');
459
+ throw new OperatorError('A workflow is already running. Use /agentrun stop first.');
456
460
  // Capture host selection and permission before yielding the interactive command loop.
457
461
  const runContext = { ...ctx, model: ctx.model, thinkingLevel: ctx.thinkingLevel ?? pi.getThinkingLevel() };
458
462
  const pending = execute(runContext, s, structuredClone(input), { trusted })
@@ -467,10 +471,10 @@ function registerExtension(pi, configuration) {
467
471
  await pending;
468
472
  };
469
473
  pi.registerTool({
470
- name: 'agentrun', label: 'AgentRun workflow',
474
+ name: toolName, label: 'AgentRun workflow',
471
475
  description: `Use the ${AUTHOR_SKILL_NAME} skill to compose workflows.` + ' Describe lists available tools and their schemas. Inspect or run an AgentRun DSL workflow in this Pi session. First inspect to show its graph. Dry-run checks the inspected workflow on synthetic values (or the given input) with no model, Jev or tool call; code runs only with the user command /agentrun dry-run --trusted. Agent nodes capture the active Pi model when execution starts. Code needs the user command /agentrun run --trusted for each run. ' + (configuration.hostTools
472
476
  ? 'Only the explicit host-configured tools are available, including during trusted runs. Outer permission hooks are not inherited. '
473
- : 'Enabled read-only Pi built-ins are available to declared steps. Shell/write/edit need the trusted command for each run; custom extension tools and permission hooks are not inherited. The search and support_demo tools use fictional supplied sources only. ') + 'Save stores a named procedure revision, not its input or permission. Load preflights current host capabilities. Run always restarts; there is no checkpoint resume. Scripted demos stay scripted on rerun.',
477
+ : 'Enabled read-only Pi built-ins are available to declared steps. Shell/write/edit need the trusted command for each run; custom extension tools and permission hooks are not inherited. The search and support_demo tools use fictional supplied sources only. ') + 'Save stores a named procedure revision, not its input or permission. Load preflights current host capabilities. A run this session left interrupted or failed resumes from its journal when its draft, input, SOP and tools are unchanged; any other run starts new. Scripted demos stay scripted on rerun.',
474
478
  parameters: workflowToolParameters(),
475
479
  executionMode: 'sequential',
476
480
  renderResult(result, options) {
@@ -487,9 +491,9 @@ function registerExtension(pi, configuration) {
487
491
  const store = new WorkflowStore(ctx.cwd);
488
492
  if (args.action === 'save') {
489
493
  if (s.busy)
490
- throw new Error('A workflow is running. Save after it stops.');
494
+ throw new OperatorError('A workflow is running. Save after it stops.');
491
495
  if (!s.draft)
492
- throw new Error('No workflow to save. Inspect a workflow first.');
496
+ throw new OperatorError('No workflow to save. Inspect a workflow first.');
493
497
  const saved = await store.save(args.name ?? '', s.draft);
494
498
  s.savedName = saved.name;
495
499
  persist(s);
@@ -583,9 +587,9 @@ function registerExtension(pi, configuration) {
583
587
  }
584
588
  else if (/^save(?:\s|$)/.test(command)) {
585
589
  if (s.busy)
586
- throw new Error('A workflow is running. Save after it stops.');
590
+ throw new OperatorError('A workflow is running. Save after it stops.');
587
591
  if (!s.draft)
588
- throw new Error('No workflow to save. Inspect a workflow first.');
592
+ throw new OperatorError('No workflow to save. Inspect a workflow first.');
589
593
  const saved = await new WorkflowStore(ctx.cwd).save(command.slice(4).trim(), s.draft);
590
594
  s.savedName = saved.name;
591
595
  persist(s);
@@ -639,23 +643,23 @@ function registerExtension(pi, configuration) {
639
643
  return;
640
644
  }
641
645
  if (s.busy)
642
- throw new Error('A workflow is running. Stop it before changing its SOP.');
646
+ throw new OperatorError('A workflow is running. Stop it before changing its SOP.');
643
647
  if (configuration.sop)
644
- throw new Error('SOP text comes from the host; /agentrun sop is not used.');
648
+ throw new OperatorError('SOP text comes from the host; /agentrun sop is not used.');
645
649
  const read = await readSopFile(ctx.cwd, file);
646
650
  const needed = s.draft ? inspectWorkflow(s.draft).requires.sopSections : [];
647
651
  const missing = missingSopSections(read.text, needed);
648
652
  if (missing.length)
649
- throw new Error(`SOP text has no "## <section>" heading for ${missing.map(name => `"${name}"`).join(', ')}. Nothing was changed.`);
653
+ throw new OperatorError(`SOP text has no "## <section>" heading for ${missing.map(name => `"${name}"`).join(', ')}. Nothing was changed.`);
650
654
  s.sop = { file: read.file, sha256: read.sha256 };
651
655
  persist(s);
652
656
  show(s, `SOP set: ${read.file} (${read.text.length} characters). ${needed.length ? `Covers ${needed.map(name => `"${name}"`).join(', ')}.` : 'The current workflow names no section.'}`);
653
657
  }
654
658
  else if (/^input(?:\s|$)/.test(command)) {
655
659
  if (!s.draft)
656
- throw new Error('No workflow yet. Load or describe a procedure first.');
660
+ throw new OperatorError('No workflow yet. Load or describe a procedure first.');
657
661
  if (s.busy)
658
- throw new Error('A workflow is running. Stop it before changing its input.');
662
+ throw new OperatorError('A workflow is running. Stop it before changing its input.');
659
663
  let supplied = raw.slice(5).trim();
660
664
  if (!supplied && ctx.hasUI)
661
665
  supplied = await ctx.ui.input('New workflow input', 'Describe the new case in plain language, or paste a JSON object.') ?? '';
@@ -669,10 +673,10 @@ function registerExtension(pi, configuration) {
669
673
  input = JSON.parse(supplied);
670
674
  }
671
675
  catch {
672
- throw new Error('Workflow input must be valid JSON, or describe it in plain language.');
676
+ throw new OperatorError('Workflow input must be valid JSON, or describe it in plain language.');
673
677
  }
674
678
  if (!input || typeof input !== 'object' || Array.isArray(input))
675
- throw new Error('Workflow input must be an object.');
679
+ throw new OperatorError('Workflow input must be an object.');
676
680
  inspect(ctx, s, undefined, input);
677
681
  show(s, `Input updated. Nothing was run.\n\n${formatWorkflowView(view(ctx, s))}`);
678
682
  }
@@ -738,12 +742,12 @@ function registerExtension(pi, configuration) {
738
742
  }
739
743
  else {
740
744
  if (s.busy)
741
- throw new Error('A workflow is running. Stop it before starting another.');
745
+ throw new OperatorError('A workflow is running. Stop it before starting another.');
742
746
  if (!pi.getCommands().some(item => item.name === `skill:${AUTHOR_SKILL_NAME}` && item.source === 'skill')) {
743
- throw new Error('AgentRun authoring skill is unavailable. Enable package skills, then /reload. Run /agentrun status to check discovery.');
747
+ throw new OperatorError('AgentRun authoring skill is unavailable. Enable package skills, then /reload. Run /agentrun status to check discovery.');
744
748
  }
745
749
  if (!readiness(ctx, s).pi)
746
- throw new Error(PI_MODEL_SETUP_MESSAGE);
750
+ throw new OperatorError(PI_MODEL_SETUP_MESSAGE);
747
751
  pi.sendUserMessage(`/skill:${AUTHOR_SKILL_NAME} ${raw}`, { expandPromptTemplates: true });
748
752
  }
749
753
  }
@@ -1,16 +1,18 @@
1
1
  import { Agent } from '@earendil-works/pi-agent-core';
2
+ import { SUBMIT_TOOL } from './host-tools.js';
2
3
  import { createPiRunner } from './runner.js';
3
4
  import { workflowToolContext } from './tool-context.js';
5
+ import { OperatorError } from './operator-error.js';
4
6
  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.';
5
7
  function selectedModel(context) {
6
8
  const model = context.model;
7
9
  if (!model)
8
- throw new Error(PI_MODEL_SETUP_MESSAGE);
10
+ throw new OperatorError(PI_MODEL_SETUP_MESSAGE);
9
11
  if (!context.modelRegistry || typeof context.modelRegistry.streamSimple !== 'function' || typeof context.modelRegistry.getAll !== 'function') {
10
- throw new Error('AgentRun requires the host public model registry');
12
+ throw new OperatorError('AgentRun requires the host public model registry');
11
13
  }
12
14
  if (!context.modelRegistry.getAll().some(known => known.id === model.id && known.provider === model.provider && known.api === model.api)) {
13
- throw new Error(PI_MODEL_SETUP_MESSAGE);
15
+ throw new OperatorError(PI_MODEL_SETUP_MESSAGE);
14
16
  }
15
17
  return model;
16
18
  }
@@ -28,7 +30,7 @@ export function createPiHostSessionFactory(context) {
28
30
  parameters: tool.parameters,
29
31
  async execute(id, args, signal, onUpdate) {
30
32
  const result = await tool.execute(id, args, signal, onUpdate, toolContext);
31
- if (tool.name === 'submit' && result.details?.accepted === true)
33
+ if (tool.name === SUBMIT_TOOL && result.details?.accepted === true)
32
34
  delivered = true;
33
35
  return result;
34
36
  },
@@ -1,9 +1,12 @@
1
+ /** The tool a workflow node delivers its result with (the runner's and the durable record tool's), so no host tool may
2
+ * take its name. */
3
+ export declare const SUBMIT_TOOL = "submit";
1
4
  /**
2
5
  * Refuses a host's tool list unless it is an array of at most 100 tools, each with a name of 1 to 200 characters that
3
6
  * is not blank, not in `reserved` and not repeated, and an object parameter schema (and result schema, when given) the
4
7
  * compiler takes. With `executable`, each tool also needs an `execute` function. Throws on the first problem.
5
8
  */
6
- export declare function checkHostTools(tools: unknown, options: {
9
+ export declare function assertHostTools(tools: unknown, options: {
7
10
  reserved: readonly string[];
8
11
  executable: boolean;
9
12
  }): void;
@@ -3,12 +3,15 @@
3
3
  import { Compile } from 'typebox/compile';
4
4
  /** The most tools a host may register. */
5
5
  const MAX_HOST_TOOLS = 100;
6
+ /** The tool a workflow node delivers its result with (the runner's and the durable record tool's), so no host tool may
7
+ * take its name. */
8
+ export const SUBMIT_TOOL = 'submit';
6
9
  /**
7
10
  * Refuses a host's tool list unless it is an array of at most 100 tools, each with a name of 1 to 200 characters that
8
11
  * is not blank, not in `reserved` and not repeated, and an object parameter schema (and result schema, when given) the
9
12
  * compiler takes. With `executable`, each tool also needs an `execute` function. Throws on the first problem.
10
13
  */
11
- export function checkHostTools(tools, options) {
14
+ export function assertHostTools(tools, options) {
12
15
  if (!Array.isArray(tools) || tools.length > MAX_HOST_TOOLS)
13
16
  throw new Error(`Host must supply at most ${MAX_HOST_TOOLS} explicit tool definitions`);
14
17
  const names = new Set();
@@ -0,0 +1,4 @@
1
+ /** An error whose message is written for the operator, so the extension shows it as it is. Any other error reaches the
2
+ * operator as a generic message, since its text may carry what an operator should not see. Its name stays `Error`. */
3
+ export declare class OperatorError extends Error {
4
+ }
@@ -0,0 +1,4 @@
1
+ /** An error whose message is written for the operator, so the extension shows it as it is. Any other error reaches the
2
+ * operator as a generic message, since its text may carry what an operator should not see. Its name stays `Error`. */
3
+ export class OperatorError extends Error {
4
+ }
package/dist/runner.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Compile } from "typebox/compile";
2
2
  import { createAgentSession, DefaultResourceLoader, SessionManager, SettingsManager, ModelRuntime, } from "@earendil-works/pi-coding-agent";
3
+ import { SUBMIT_TOOL } from "./host-tools.js";
3
4
  import { PiRunError } from "./run-error.js";
4
5
  import { nodeThinking } from "./thinking.js";
5
6
  export { PiRunError } from "./run-error.js";
@@ -49,7 +50,7 @@ export function createPiRunner(options) {
49
50
  const timeoutMs = options.timeoutMs === null ? null : positive(options.timeoutMs ?? 120_000, "timeoutMs");
50
51
  const names = new Set();
51
52
  for (const tool of options.tools ?? []) {
52
- if (tool.name === "submit" || names.has(tool.name))
53
+ if (tool.name === SUBMIT_TOOL || names.has(tool.name))
53
54
  throw new Error(`Reserved or duplicate Pi tool: ${tool.name}`);
54
55
  names.add(tool.name);
55
56
  }
@@ -96,7 +97,7 @@ export function createPiRunner(options) {
96
97
  let unsubscribe;
97
98
  const result = (text, ok) => ({ content: [{ type: "text", text }], details: { accepted: ok } });
98
99
  const submit = {
99
- name: "submit", label: "Submit result", description: "Deliver a result matching the required JSON Schema. Rejection feedback must be corrected in this session.",
100
+ name: SUBMIT_TOOL, label: "Submit result", description: "Deliver a result matching the required JSON Schema. Rejection feedback must be corrected in this session.",
100
101
  parameters,
101
102
  executionMode: "sequential",
102
103
  async execute(id, args) {
@@ -162,7 +163,7 @@ export function createPiRunner(options) {
162
163
  cwd, model: options.model, modelRuntime: options.modelRuntime,
163
164
  thinkingLevel: nodeThinking(request.thinking, options.thinkingLevel), maxTurns: maxTurns ?? undefined, resourceLoader, settingsManager,
164
165
  sessionManager,
165
- tools: [...requested, "submit"],
166
+ tools: [...requested, SUBMIT_TOOL],
166
167
  customTools: [...(options.tools ?? []).filter(tool => requested.includes(tool.name)).map(tool => ({
167
168
  ...tool,
168
169
  executionMode: "sequential",
@@ -192,7 +193,7 @@ export function createPiRunner(options) {
192
193
  unsubscribe = session.subscribe(event => {
193
194
  // Native events carry correlation IDs, including SDK-rejected calls. Custom
194
195
  // sessions without IDs use execute's counter so one attempt is not counted twice.
195
- if (event.type === "tool_execution_start" && event.toolName === "submit" && event.toolCallId !== undefined && !accepted && !stopped && !fatal) {
196
+ if (event.type === "tool_execution_start" && event.toolName === SUBMIT_TOOL && event.toolCallId !== undefined && !accepted && !stopped && !fatal) {
196
197
  submissions++;
197
198
  pendingSubmissions.set(event.toolCallId, {
198
199
  ...(Object.hasOwn(event, "args") ? { args: structuredClone(event.args) } : {}), executed: false,
@@ -200,7 +201,7 @@ export function createPiRunner(options) {
200
201
  if (maxSubmissions !== null && submissions > maxSubmissions)
201
202
  stop("submission_limit");
202
203
  }
203
- if (event.type === "tool_execution_end" && event.toolName === "submit") {
204
+ if (event.type === "tool_execution_end" && event.toolName === SUBMIT_TOOL) {
204
205
  if (event.toolCallId !== undefined)
205
206
  pendingSubmissions.delete(event.toolCallId);
206
207
  if (!accepted && !stopped && !fatal && maxSubmissions !== null && submissions >= maxSubmissions)
@@ -228,7 +229,8 @@ export function createPiRunner(options) {
228
229
  throw new PiRunError(stopped, turns, submissions);
229
230
  if (accepted)
230
231
  return submission;
231
- throw new PiRunError(maxTurns !== null && turns >= maxTurns ? "turn_limit" : "no_submission", turns, submissions);
232
+ // The loop ends only on one of the three above or at the turn cap, so what is left is the cap.
233
+ throw new PiRunError("turn_limit", turns, submissions);
232
234
  }
233
235
  catch (error) {
234
236
  if (fatal)
package/dist/sop.js CHANGED
@@ -1,25 +1,26 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { lstat, readFile, realpath } from 'node:fs/promises';
3
3
  import { isAbsolute, relative, resolve, sep } from 'node:path';
4
+ import { OperatorError } from './operator-error.js';
4
5
  export const MAX_SOP_BYTES = 512 * 1024;
5
6
  export const sopDigest = (text) => createHash('sha256').update(text).digest('hex');
6
7
  /** The SOP text of a regular file inside `cwd`; a path that resolves outside it, or a file over the bound, is refused. */
7
8
  export async function readSopFile(cwd, file) {
8
9
  if (!file.trim() || file.includes('\0'))
9
- throw new Error('Use /agentrun sop <file>, a path inside this project.');
10
+ throw new OperatorError('Use /agentrun sop <file>, a path inside this project.');
10
11
  const root = await realpath(cwd);
11
- const target = await realpath(resolve(cwd, file)).catch(() => { throw new Error(`SOP file not found: ${file}`); });
12
+ const target = await realpath(resolve(cwd, file)).catch(() => { throw new OperatorError(`SOP file not found: ${file}`); });
12
13
  const inside = relative(root, target);
13
14
  if (!inside || inside === '..' || inside.startsWith(`..${sep}`) || isAbsolute(inside))
14
- throw new Error('The SOP file must be inside this project.');
15
+ throw new OperatorError('The SOP file must be inside this project.');
15
16
  const info = await lstat(target);
16
17
  if (!info.isFile())
17
- throw new Error('The SOP must be a regular file.');
18
+ throw new OperatorError('The SOP must be a regular file.');
18
19
  if (info.size > MAX_SOP_BYTES)
19
- throw new Error(`The SOP file is larger than ${MAX_SOP_BYTES / 1024} KiB.`);
20
+ throw new OperatorError(`The SOP file is larger than ${MAX_SOP_BYTES / 1024} KiB.`);
20
21
  const text = (await readFile(target, 'utf8')).replace(/^/, '');
21
22
  if (!text.trim())
22
- throw new Error('The SOP file is empty.');
23
+ throw new OperatorError('The SOP file is empty.');
23
24
  return { file: inside.split(sep).join('/'), text, sha256: sopDigest(text) };
24
25
  }
25
26
  /** The sections a workflow names that the SOP has no `## <section>` heading line for. */
@@ -1,6 +1,6 @@
1
1
  import { Type } from 'typebox';
2
2
  import { type AuthorHostAddendum, type DryRunResult, type WorkflowDeps } from '@parcha/agentrun-dsl';
3
- import type { WorkflowExtensionService } from './extension-service.js';
3
+ import { type WorkflowExtensionService } from './extension-service.js';
4
4
  export declare const WORKFLOW_TOOL_ACTIONS: readonly ["describe", "inspect", "dry-run", "run", "save", "load", "list"];
5
5
  /** The tool's fields: one action, and the fields an action reads. A host's tool may add its own. */
6
6
  export declare const workflowToolProperties: () => {
@@ -2,7 +2,9 @@
2
2
  // and the dry run. Nothing here imports pi's coding agent, its agent core or its TUI, so a pi-durable host can use it.
3
3
  import { Type } from 'typebox';
4
4
  import { compileTransform, dryRunWorkflow, formatWorkflowTree, inspectWorkflow, loadAuthorReference, renderAuthorHostAddendum, } from '@parcha/agentrun-dsl';
5
+ import { ExtensionServiceError } from './extension-service.js';
5
6
  import { PI_HOST_ADDENDUM } from './host-addendum.js';
7
+ import { OperatorError } from './operator-error.js';
6
8
  export const WORKFLOW_TOOL_ACTIONS = ['describe', 'inspect', 'dry-run', 'run', 'save', 'load', 'list'];
7
9
  /** The tool's fields: one action, and the fields an action reads. A host's tool may add its own. */
8
10
  export const workflowToolProperties = () => ({ action: Type.Union(WORKFLOW_TOOL_ACTIONS.map(value => Type.Literal(value))),
@@ -28,14 +30,16 @@ export const authoringReferences = (host = PI_HOST_ADDENDUM) => ({
28
30
  * refusal throws, naming why. */
29
31
  export function inspectCandidate(checker, candidate, input) {
30
32
  if (candidate === null || typeof candidate !== 'object' || Array.isArray(candidate)) {
31
- throw new Error('Cannot inspect workflow: workflow must be a JSON object, not a JSON-encoded string or array. Pass the complete object directly.');
33
+ throw new OperatorError('Cannot inspect workflow: workflow must be a JSON object, not a JSON-encoded string or array. Pass the complete object directly.');
32
34
  }
33
35
  let inspection;
34
36
  try {
35
37
  inspection = checker.preflight(candidate, input);
36
38
  }
37
39
  catch (error) {
38
- throw new Error(`Cannot inspect workflow: ${error instanceof Error ? error.message : 'Invalid workflow'}`);
40
+ const message = `Cannot inspect workflow: ${error instanceof Error ? error.message : 'Invalid workflow'}`;
41
+ // The service's refusal keeps its type and code, so a host branches on it as it does for a run; either keeps its cause.
42
+ throw error instanceof ExtensionServiceError ? Object.assign(new ExtensionServiceError(error.code, message), { cause: error }) : new OperatorError(message, { cause: error });
39
43
  }
40
44
  return { inspection, tree: formatWorkflowTree(inspection) };
41
45
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parcha/agentrun-pi",
3
- "version": "0.1.0-beta.23",
3
+ "version": "0.1.0-beta.27",
4
4
  "description": "Pi extension for building, running and evaluating AgentRun workflows",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -50,12 +50,12 @@
50
50
  "@earendil-works/pi-coding-agent": "1.1.0",
51
51
  "@earendil-works/pi-durable": "1.1.0",
52
52
  "@earendil-works/pi-tui": "1.1.0",
53
- "@parcha/pi-durable-disk": "0.1.0-beta.23",
53
+ "@parcha/pi-durable-disk": "0.1.0-beta.27",
54
54
  "typebox": "1.3.7"
55
55
  },
56
56
  "dependencies": {
57
- "@parcha/agentrun-dsl": "0.1.0-beta.23",
58
- "@parcha/agentrun-jev": "0.1.0-beta.23"
57
+ "@parcha/agentrun-dsl": "0.1.0-beta.27",
58
+ "@parcha/agentrun-jev": "0.1.0-beta.27"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@earendil-works/pi-agent-core": "*",