@parcha/agentrun-pi 0.1.0-beta.1 → 0.1.0-beta.2

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.
package/README.md CHANGED
@@ -2,24 +2,24 @@
2
2
 
3
3
  Describe a task in Pi. AgentRun turns it into a workflow you can inspect and runs its steps using your current Pi model. Requires Node 22.19+ and Pi 0.87.0.
4
4
 
5
- ## Install from source
5
+ ## Install
6
6
 
7
- Build the extension and use the included Pi CLI. From a new terminal:
7
+ If you do not have Pi, install the tested host version first:
8
8
 
9
9
  ```sh
10
- git clone --branch main --single-branch https://github.com/Parcha-ai/agentrun.git
11
- cd agentrun
12
- npm ci --ignore-scripts
13
- npm run build
14
- ./node_modules/.bin/pi install ./packages/pi -l
15
- ./node_modules/.bin/pi --offline
10
+ npm install -g @earendil-works/pi-coding-agent@0.87.0
16
11
  ```
17
12
 
18
- `-l` writes project settings in `.pi/settings.json`. Omit it to install for all your Pi sessions. `--offline` skips startup downloads; it does not disable model calls.
13
+ Then, in your project:
19
14
 
20
- On first launch, Pi asks whether you trust the project before loading its extension. Review the checkout before accepting.
15
+ ```sh
16
+ pi install npm:@parcha/agentrun-pi@0.1.0-beta.2 -l
17
+ pi --offline
18
+ ```
19
+
20
+ `-l` writes project settings in `.pi/settings.json`. Omit it to install for all your Pi sessions. `--offline` skips startup downloads; it does not disable model calls. No AgentRun checkout or build is needed.
21
21
 
22
- Already built this checkout? Start at the `pi install` line. The included CLI is the tested Pi 0.87.0 version; a global Pi installation is unnecessary. If Pi 0.87.0 is already open, use `/reload`. Keep this checkout: Pi loads the extension and skill from it. Rebuild after source changes, then reload. `/reload` clears the current workflow definition; run the demo or describe your task again to create one.
22
+ On first launch, Pi asks whether you trust the project before loading its extension. If Pi is already open, use `/reload`. Reloading clears the current workflow definition; run the demo or describe your task again to create one.
23
23
 
24
24
  For live tasks, configure model access in Pi first. AgentRun uses Pi's current model and authentication; it needs no separate model configuration. The scripted demo below works without provider access.
25
25
 
@@ -31,7 +31,7 @@ Try the bundled example inside Pi:
31
31
 
32
32
  The demo shows the workflow graph, progress, and a completed result with call counts. It uses scripted responses over fictional sources. It makes no model calls and does not test model quality. Run `/agentrun status` to check the loaded skill and Pi host version. The tested version is 0.87.0; status flags a different host without blocking it. Missing Pi or Jev access does not prevent the scripted demo. To repeat it, run `/agentrun run` or `/agentrun demo`. A scripted demo stays scripted on rerun, including its missing-evidence path. Use `/agentrun demo live` to switch to real Pi and Jev calls. Workflows authored by Pi use real adapters.
33
33
 
34
- For an offline Pi startup, use `./node_modules/.bin/pi --offline`. Without that flag, Pi may download optional command-line tools on first launch. This is separate from the scripted demo, which makes no network calls.
34
+ For an offline Pi startup, use `pi --offline`. Without that flag, Pi may download optional command-line tools on first launch. This is separate from the scripted demo, which makes no network calls.
35
35
 
36
36
  ## Describe, inspect, run
37
37
 
@@ -64,7 +64,7 @@ One workflow runs at a time. Definitions and run state belong to the current Pi
64
64
  | `/agentrun demo empty` | Scripted insufficient-evidence path, without model calls. |
65
65
  | `/agentrun demo live` | Real Pi agent steps and Jev system one decisions over the same fictional sources. |
66
66
 
67
- Live mode uses your active Pi model and requires configured TypeSafe access for Jev. It still does not search the web or prove accuracy on real research. See the [Jev adapter configuration](../jev/README.md). Live calls can incur usage charges; scripted mode needs no provider access.
67
+ Live mode uses your active Pi model and requires configured TypeSafe access for Jev. It still does not search the web or prove accuracy on real research. See the [Jev adapter configuration](https://github.com/Parcha-ai/agentrun/tree/main/packages/jev#readme). Live calls can incur usage charges; scripted mode needs no provider access.
68
68
 
69
69
  ## Tools and execution
70
70
 
@@ -196,6 +196,21 @@ With `rubricSections`, every supplied section must appear on every generated LLM
196
196
 
197
197
  The standalone CLI remains available as `node packages/pi/dist/cli.js --help` from source. It imports a trusted caller-owned `--config` module for `author` and `run`; importing that module executes its code. `validate` can evaluate code probes. Review executable workflows before using it.
198
198
 
199
+ ## Develop from source
200
+
201
+ To change the extension itself, clone and build the repository:
202
+
203
+ ```sh
204
+ git clone --branch main --single-branch https://github.com/Parcha-ai/agentrun.git
205
+ cd agentrun
206
+ npm ci --ignore-scripts
207
+ npm run build
208
+ ./node_modules/.bin/pi install ./packages/pi -l
209
+ ./node_modules/.bin/pi --offline
210
+ ```
211
+
212
+ Use a separate project for this source installation. Pi loads the extension and skill from the checkout, so keep it in place. After source changes, rebuild and run `/reload`.
213
+
199
214
  ## Dependencies and retained data
200
215
 
201
216
  Pi is optional for `@parcha/agentrun-dsl`. This package brings the Pi coding-agent SDK, its provider dependencies, and the Jev adapter; its dependency footprint is larger than the core. The release inventory records the locked versions.
@@ -0,0 +1,88 @@
1
+ export type BenchmarkSplit = 'development' | 'heldout';
2
+ export type BenchmarkFinding = {
3
+ topicId: string;
4
+ status: 'supported' | 'conflicting' | 'insufficient';
5
+ summary: string;
6
+ sourceIds: string[];
7
+ };
8
+ export type BenchmarkCase = {
9
+ id: string;
10
+ split: BenchmarkSplit;
11
+ question: string;
12
+ topics: {
13
+ id: string;
14
+ question: string;
15
+ }[];
16
+ sources: {
17
+ id: string;
18
+ text: string;
19
+ }[];
20
+ expected: Omit<BenchmarkFinding, 'summary'>[];
21
+ };
22
+ export declare const benchmarkHash: (value: unknown) => string;
23
+ export declare const benchmarkInstructions = "Answer using only the fictional source corpus. Produce exactly one finding for each supplied topic. Status supported means concrete evidence establishes a finding, including a negative or qualified answer. Conflicting means incompatible accounts. Insufficient means no concrete information answers the question. Preserve conditions and contrary evidence; cite every material source by ID. Do not invent facts or citations. Submit the final structured report.";
24
+ export declare const benchmarkReportSchema: {
25
+ type: string;
26
+ additionalProperties: boolean;
27
+ required: string[];
28
+ properties: {
29
+ findings: {
30
+ type: string;
31
+ minItems: number;
32
+ items: {
33
+ type: string;
34
+ additionalProperties: boolean;
35
+ required: string[];
36
+ properties: {
37
+ topicId: {
38
+ type: string;
39
+ minLength: number;
40
+ };
41
+ status: {
42
+ enum: string[];
43
+ };
44
+ summary: {
45
+ type: string;
46
+ minLength: number;
47
+ };
48
+ sourceIds: {
49
+ type: string;
50
+ uniqueItems: boolean;
51
+ items: {
52
+ type: string;
53
+ minLength: number;
54
+ };
55
+ };
56
+ };
57
+ };
58
+ };
59
+ };
60
+ };
61
+ export declare const benchmarkEvidenceSchema: {
62
+ type: string;
63
+ additionalProperties: boolean;
64
+ required: string[];
65
+ properties: {
66
+ relevant: {
67
+ type: string;
68
+ description: string;
69
+ criteria: {
70
+ true: string;
71
+ false: string;
72
+ };
73
+ };
74
+ };
75
+ };
76
+ export declare function benchmarkCases(split: BenchmarkSplit): BenchmarkCase[];
77
+ /** Frozen independent checks. Never return these results to an authoring agent. */
78
+ export declare function evaluatePiBenchmark(caseId: string, output: unknown): {
79
+ passed: boolean;
80
+ issues: string[];
81
+ };
82
+ export declare function benchmarkContract(): {
83
+ version: number;
84
+ fixturesSha256: string;
85
+ verifierSha256: string;
86
+ outputSchemaSha256: string;
87
+ instructionsSha256: string;
88
+ };
@@ -0,0 +1,50 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import { Compile } from 'typebox/compile';
4
+ const cases = [
5
+ { id: 'docs-editing', split: 'development', question: 'Can contributors edit our docs in the browser?', topics: [{ id: 'editing', question: 'Did contributors edit a page in the browser during our trial?' }], sources: [{ id: 'd1', text: 'All three contributors edited a page in the browser during the trial.' }, { id: 'd2', text: 'The documentation product has a new logo.' }], expected: [{ topicId: 'editing', status: 'supported', sourceIds: ['d1'] }] },
6
+ { id: 'migration', split: 'development', question: 'What changes with a repository-based wiki?', topics: [{ id: 'review', question: 'How are changes reviewed?' }, { id: 'editing', question: 'Could non-developers edit documentation?' }, { id: 'search', question: 'What did the search trial find?' }], sources: [{ id: 'm1', text: 'Documentation changes require an assigned reviewer to approve the pull request.' }, { id: 'm2', text: 'Non-developers edited in the browser but two needed help with merge conflicts.' }, { id: 'm3', text: 'Search found exact words but missed synonyms during our trial.' }, { id: 'm4', text: 'A revolutionary documentation experience.' }], expected: [{ topicId: 'review', status: 'supported', sourceIds: ['m1'] }, { topicId: 'editing', status: 'supported', sourceIds: ['m2'] }, { topicId: 'search', status: 'supported', sourceIds: ['m3'] }] },
7
+ { id: 'missing-conflicting', split: 'development', question: 'Can we rely on restricted editing and synonym search?', topics: [{ id: 'permissions', question: 'Who can edit restricted pages?' }, { id: 'search', question: 'Did synonym search work in this trial?' }], sources: [{ id: 'c1', text: 'Restricted-page permissions: details pending.' }, { id: 'c2', text: 'The trial report says all ten synonym searches found their target pages.' }, { id: 'c3', text: 'An audit of the same trial says none of those ten searches found their target pages.' }], expected: [{ topicId: 'permissions', status: 'insufficient', sourceIds: [] }, { topicId: 'search', status: 'conflicting', sourceIds: ['c2', 'c3'] }] },
8
+ { id: 'backup', split: 'heldout', question: 'Did the restore drill recover the archive?', topics: [{ id: 'restore', question: 'Was the archive recovered during the drill?' }], sources: [{ id: 'h1', text: 'The drill restored the archive and every file matched its recorded checksum.' }, { id: 'h2', text: 'Backups that give you peace of mind.' }], expected: [{ topicId: 'restore', status: 'supported', sourceIds: ['h1'] }] },
9
+ { id: 'queue-adoption', split: 'heldout', question: 'What should we know before adopting this task queue?', topics: [{ id: 'retries', question: 'What happens to jobs after worker failure?' }, { id: 'ordering', question: 'Is task order guaranteed?' }, { id: 'visibility', question: 'Can operators inspect stuck jobs?' }], sources: [{ id: 'q1', text: 'In the failure test, an unfinished job returned to the queue when its lease expired.' }, { id: 'q2', text: 'Tasks from different workers completed out of submission order in the trial.' }, { id: 'q3', text: 'Operators used the dashboard to inspect job age and the last worker error.' }, { id: 'q4', text: 'Supercharge your asynchronous future.' }], expected: [{ topicId: 'retries', status: 'supported', sourceIds: ['q1'] }, { topicId: 'ordering', status: 'supported', sourceIds: ['q2'] }, { topicId: 'visibility', status: 'supported', sourceIds: ['q3'] }] },
10
+ { id: 'cache-evidence', split: 'heldout', question: 'What do the cache trials tell us about deletion and encryption?', topics: [{ id: 'deletion', question: 'Did deleted entries disappear immediately in the trial?' }, { id: 'encryption', question: 'Which encryption protects cached entries?' }], sources: [{ id: 'k1', text: 'The trial log says every deleted entry disappeared immediately.' }, { id: 'k2', text: 'A second observer of that same trial recorded deleted entries remaining readable for a full minute.' }, { id: 'k3', text: 'Encryption: documentation forthcoming.' }], expected: [{ topicId: 'deletion', status: 'conflicting', sourceIds: ['k1', 'k2'] }, { topicId: 'encryption', status: 'insufficient', sourceIds: [] }] },
11
+ ];
12
+ export const benchmarkHash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
13
+ export const benchmarkInstructions = 'Answer using only the fictional source corpus. Produce exactly one finding for each supplied topic. Status supported means concrete evidence establishes a finding, including a negative or qualified answer. Conflicting means incompatible accounts. Insufficient means no concrete information answers the question. Preserve conditions and contrary evidence; cite every material source by ID. Do not invent facts or citations. Submit the final structured report.';
14
+ const str = { type: 'string', minLength: 1 };
15
+ export const benchmarkReportSchema = { type: 'object', additionalProperties: false, required: ['findings'], properties: { findings: { type: 'array', minItems: 1, items: { type: 'object', additionalProperties: false, required: ['topicId', 'status', 'summary', 'sourceIds'], properties: { topicId: str, status: { enum: ['supported', 'conflicting', 'insufficient'] }, summary: str, sourceIds: { type: 'array', uniqueItems: true, items: str } } } } } };
16
+ export const benchmarkEvidenceSchema = { type: 'object', additionalProperties: false, required: ['relevant'], properties: { relevant: { type: 'boolean', description: 'Does the passage contain a concrete fact, condition, limitation or contrary evidence bearing on the question?', criteria: { true: 'Concrete evidence relevant to the question, including qualified or contradictory answers.', false: 'Only topic mentions, marketing promises, missing details, or unrelated information.' } } } };
17
+ const validator = Compile(benchmarkReportSchema);
18
+ export function benchmarkCases(split) {
19
+ if (split !== 'development' && split !== 'heldout')
20
+ throw new Error('Choose development or heldout explicitly');
21
+ return structuredClone(cases.filter(row => row.split === split));
22
+ }
23
+ /** Frozen independent checks. Never return these results to an authoring agent. */
24
+ export function evaluatePiBenchmark(caseId, output) {
25
+ const item = cases.find(row => row.id === caseId);
26
+ if (!item)
27
+ throw new Error('Unknown benchmark case');
28
+ if (!validator.Check(output))
29
+ return { passed: false, issues: ['schema'] };
30
+ const findings = output.findings;
31
+ const issues = [];
32
+ if (findings.length !== item.expected.length || new Set(findings.map(row => row.topicId)).size !== findings.length)
33
+ issues.push('coverage');
34
+ for (const expected of item.expected) {
35
+ const actual = findings.find(row => row.topicId === expected.topicId);
36
+ if (actual?.status !== expected.status)
37
+ issues.push(`${expected.topicId}:status`);
38
+ if (!actual || JSON.stringify([...actual.sourceIds].sort()) !== JSON.stringify([...expected.sourceIds].sort()))
39
+ issues.push(`${expected.topicId}:citations`);
40
+ }
41
+ return { passed: issues.length === 0, issues };
42
+ }
43
+ export function benchmarkContract() {
44
+ return {
45
+ version: 1, fixturesSha256: benchmarkHash(cases),
46
+ verifierSha256: createHash('sha256').update(readFileSync(new URL('./benchmark-contract.js', import.meta.url))).digest('hex'),
47
+ outputSchemaSha256: benchmarkHash(benchmarkReportSchema),
48
+ instructionsSha256: benchmarkHash(benchmarkInstructions),
49
+ };
50
+ }
@@ -0,0 +1,9 @@
1
+ /** Frozen before any live sample. Changing these pins requires a new benchmark
2
+ * version and a fresh baseline; never update them to improve a skill's score. */
3
+ export declare const frozenBenchmarkContract: {
4
+ readonly version: 1;
5
+ readonly fixturesSha256: "1a8f9ff022d5cdaf2455e327e59ae4593aeffcc8ae4c510a870b36ea0d9e7de2";
6
+ readonly verifierSha256: "f21d0c8b790186f571003907a5a6c7a6360423d77d1125c4398994cb1ae11f9a";
7
+ readonly outputSchemaSha256: "38a0d7ccd8590ad8a486a5b7b7edd36c0b8bd32e642ead5345a3f93d12f3e574";
8
+ readonly instructionsSha256: "d8d3fc6721ed99bb163ff3dbd93dffc15a1a92ab400f11f781bb25e9f8a61535";
9
+ };
@@ -0,0 +1,9 @@
1
+ /** Frozen before any live sample. Changing these pins requires a new benchmark
2
+ * version and a fresh baseline; never update them to improve a skill's score. */
3
+ export const frozenBenchmarkContract = {
4
+ version: 1,
5
+ fixturesSha256: '1a8f9ff022d5cdaf2455e327e59ae4593aeffcc8ae4c510a870b36ea0d9e7de2',
6
+ verifierSha256: 'f21d0c8b790186f571003907a5a6c7a6360423d77d1125c4398994cb1ae11f9a',
7
+ outputSchemaSha256: '38a0d7ccd8590ad8a486a5b7b7edd36c0b8bd32e642ead5345a3f93d12f3e574',
8
+ instructionsSha256: 'd8d3fc6721ed99bb163ff3dbd93dffc15a1a92ab400f11f781bb25e9f8a61535',
9
+ };
@@ -0,0 +1,127 @@
1
+ import { type WorkflowDeps } from '@parcha/agentrun-dsl';
2
+ import type { PiHostContext } from './types.js';
3
+ import { evaluatePiBenchmark, type BenchmarkSplit } from './benchmark-contract.js';
4
+ export interface PiBenchmarkBudgets {
5
+ timeoutMs: number;
6
+ maxModelRequests: number;
7
+ maxToolCalls: number;
8
+ maxTurns: number;
9
+ maxSubmissions: number;
10
+ }
11
+ export interface PiSkillBenchmarkOptions {
12
+ host: PiHostContext;
13
+ runJudge: NonNullable<WorkflowDeps['runJudge']>;
14
+ /** Exact native skill plus its loaded references, supplied by the caller. No hidden authoring contract is added. */
15
+ skillText: string;
16
+ split: BenchmarkSplit;
17
+ mode: 'live' | 'scripted-transport';
18
+ signal?: AbortSignal;
19
+ budgets?: Partial<PiBenchmarkBudgets>;
20
+ onProgress?: (event: {
21
+ caseId: string;
22
+ arm: 'plain-pi' | 'skill-dsl';
23
+ stage: 'arm' | 'outer' | 'workflow';
24
+ event: 'start' | 'end';
25
+ status?: string;
26
+ }) => unknown;
27
+ }
28
+ type Metrics = {
29
+ pi: number;
30
+ jev: number;
31
+ tools: number;
32
+ submissions: number;
33
+ tokens: number;
34
+ usageSamples: number;
35
+ knownCostUsd: number;
36
+ costSamples: number;
37
+ };
38
+ type Arm = 'plain-pi' | 'skill-dsl';
39
+ export interface PiBenchmarkArm {
40
+ arm: Arm;
41
+ status: 'passed' | 'rejected' | 'failed' | 'cancelled';
42
+ elapsedMs: number;
43
+ total: Metrics;
44
+ stages: {
45
+ outer: Metrics;
46
+ workflow: Metrics;
47
+ };
48
+ costComplete: boolean;
49
+ usageComplete: boolean;
50
+ candidates: {
51
+ digest?: string;
52
+ accepted: boolean;
53
+ workflow?: unknown;
54
+ }[];
55
+ workflowRuns: {
56
+ status: string;
57
+ digest: string;
58
+ output?: unknown;
59
+ }[];
60
+ output?: unknown;
61
+ evaluation?: ReturnType<typeof evaluatePiBenchmark>;
62
+ failure?: string;
63
+ }
64
+ /** End-to-end comparison: DSL authoring, repair, execution and delivery all count.
65
+ * Uses the selected host model, never a model selector or alternate provider. */
66
+ export declare function runPiSkillBenchmark(options: PiSkillBenchmarkOptions): Promise<{
67
+ mode: "live" | "scripted-transport";
68
+ split: BenchmarkSplit;
69
+ status: "complete" | "cancelled";
70
+ passed: boolean;
71
+ contract: {
72
+ version: number;
73
+ fixturesSha256: string;
74
+ verifierSha256: string;
75
+ outputSchemaSha256: string;
76
+ instructionsSha256: string;
77
+ };
78
+ skillSha256: string;
79
+ budgets: {
80
+ timeoutMs: number;
81
+ maxModelRequests: number;
82
+ maxToolCalls: number;
83
+ maxTurns: number;
84
+ maxSubmissions: number;
85
+ };
86
+ runtime: {
87
+ model: string;
88
+ provider: string;
89
+ api: string;
90
+ thinking: import("./types.js").PiThinkingLevel;
91
+ };
92
+ runs: {
93
+ caseId: string;
94
+ inputSha256: string;
95
+ order: Arm[];
96
+ arms: PiBenchmarkArm[];
97
+ }[];
98
+ limitations: string[];
99
+ }>;
100
+ export type PiSkillBenchmarkReport = Awaited<ReturnType<typeof runPiSkillBenchmark>>;
101
+ /** A development comparison receipt, not automatic promotion. Heldout is a final
102
+ * confirmation set, never the optimization target for this helper. */
103
+ export declare function comparePiSkillBenchmarks(baseline: PiSkillBenchmarkReport, candidate: PiSkillBenchmarkReport): {
104
+ mode: "live" | "scripted-transport";
105
+ baselineSkillSha256: string;
106
+ candidateSkillSha256: string;
107
+ regressions: string[];
108
+ previous: {
109
+ passed: number;
110
+ requests: number;
111
+ tokens: number;
112
+ usageComplete: boolean;
113
+ knownCostUsd: number;
114
+ costComplete: boolean;
115
+ };
116
+ current: {
117
+ passed: number;
118
+ requests: number;
119
+ tokens: number;
120
+ usageComplete: boolean;
121
+ knownCostUsd: number;
122
+ costComplete: boolean;
123
+ };
124
+ improved: boolean;
125
+ eligibleForLiveHeldout: boolean;
126
+ };
127
+ export {};
@@ -0,0 +1,260 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import { frozenBenchmarkContract } from './benchmark-freeze.js';
3
+ import { Compile } from 'typebox/compile';
4
+ import { formatWorkflowTree, runWorkflowSlice } from '@parcha/agentrun-dsl';
5
+ import { createPiHostSessionFactory } from './host-session.js';
6
+ import { createPiRunner, PiRunError } from './runner.js';
7
+ import { WorkflowExtensionService, ExtensionServiceError } from './extension-service.js';
8
+ import { benchmarkCases, benchmarkContract, benchmarkHash, benchmarkInstructions, benchmarkReportSchema, benchmarkEvidenceSchema, evaluatePiBenchmark } from './benchmark-contract.js';
9
+ const emptyMetrics = () => ({ pi: 0, jev: 0, tools: 0, submissions: 0, tokens: 0, usageSamples: 0, knownCostUsd: 0, costSamples: 0 });
10
+ const ceilings = { timeoutMs: 600_000, maxModelRequests: 24, maxToolCalls: 48, maxTurns: 24, maxSubmissions: 4 };
11
+ class BenchmarkStop extends Error {
12
+ code;
13
+ constructor(code) {
14
+ super(code);
15
+ this.code = code;
16
+ }
17
+ }
18
+ const textResult = (value) => ({ content: [{ type: 'text', text: JSON.stringify(value) }], details: value });
19
+ const selectionWorkflow = { v: 2, name: 'benchmark-evidence', schemas: { Evidence: benchmarkEvidenceSchema, Result: { type: 'object' } }, output: { schemaId: 'Result', path: 'evidence' }, root: { node: 'sift', label: 'screen', itemsPath: 'sources', state: { question: '{question}' }, out: 'Evidence', as: 'evidence', keep: { path: 'relevant', gte: 0.8 } } };
20
+ /** End-to-end comparison: DSL authoring, repair, execution and delivery all count.
21
+ * Uses the selected host model, never a model selector or alternate provider. */
22
+ export async function runPiSkillBenchmark(options) {
23
+ if (options.mode !== 'live' && options.mode !== 'scripted-transport')
24
+ throw new Error('Choose live or scripted-transport explicitly');
25
+ if (typeof options.skillText !== 'string' || !options.skillText.trim() || Buffer.byteLength(options.skillText) > 128 * 1024)
26
+ throw new Error('Provide the exact native skill bundle, at most 128 KiB');
27
+ if (typeof options.runJudge !== 'function')
28
+ throw new Error('Both arms require the same Jev capability');
29
+ const suite = benchmarkCases(options.split);
30
+ const contract = benchmarkContract();
31
+ if (!isDeepStrictEqual(contract, frozenBenchmarkContract))
32
+ throw new Error('Frozen benchmark contract changed; establish a new version and baseline');
33
+ const skillText = options.skillText;
34
+ const budgets = { ...ceilings, ...options.budgets };
35
+ for (const key of Object.keys(ceilings))
36
+ if (!Number.isSafeInteger(budgets[key]) || budgets[key] < 1 || budgets[key] > ceilings[key])
37
+ throw new Error(`Invalid benchmark budget: ${key}`);
38
+ const captured = { ...options.host };
39
+ createPiHostSessionFactory(captured);
40
+ const model = captured.model;
41
+ const runs = [];
42
+ for (const [index, item] of suite.entries()) {
43
+ if (options.signal?.aborted)
44
+ break;
45
+ const order = index % 2 ? ['skill-dsl', 'plain-pi'] : ['plain-pi', 'skill-dsl'];
46
+ const arms = [];
47
+ for (const arm of order) {
48
+ if (options.signal?.aborted)
49
+ break;
50
+ arms.push(await runArm(item, arm));
51
+ }
52
+ runs.push({ caseId: item.id, inputSha256: benchmarkHash({ question: item.question, topics: item.topics, sources: item.sources }), order: arms.map(row => row.arm), arms });
53
+ }
54
+ if (!isDeepStrictEqual(contract, benchmarkContract()))
55
+ throw new Error('Benchmark verifier changed during execution');
56
+ return {
57
+ mode: options.mode, split: options.split, status: options.signal?.aborted ? 'cancelled' : 'complete',
58
+ passed: runs.length === suite.length && runs.every(row => row.arms.length === 2 && row.arms.every(arm => arm.status === 'passed')),
59
+ contract, skillSha256: benchmarkHash(skillText), budgets,
60
+ runtime: { model: model.id, provider: model.provider, api: model.api, thinking: captured.thinkingLevel ?? 'low' }, runs,
61
+ limitations: ['Three fictional cases per split are not a statistical or real-world benchmark.', 'Gold checks cover statuses and citation sets; summary entailment remains unscored.', 'All outer-agent authoring, repair, final delivery, child execution and failed attempts count; initial package/skill development does not.', 'Request caps count adapter invocations, not provider-internal HTTP retries. There is no token or spending cap.', 'Known cost uses returned provider/SDK accounting only; missing samples are unknown, including failed requests.', 'Host caches are not reset; execution order alternates. Heldout cases must not be used to tune the skill.', 'The DSL arm uses the native bridge and service with a constrained benchmark tool surface; this is not a test of interactive skill discovery.'],
62
+ };
63
+ async function runArm(item, arm) {
64
+ const started = performance.now();
65
+ const notify = (stage, event, status) => {
66
+ try {
67
+ void Promise.resolve(options.onProgress?.({ caseId: item.id, arm, stage, event, ...(status ? { status } : {}) })).catch(() => { });
68
+ }
69
+ catch { /* UI hooks cannot change the measurement. */ }
70
+ };
71
+ notify('arm', 'start');
72
+ notify('outer', 'start');
73
+ const controller = new AbortController();
74
+ const signal = controller.signal;
75
+ const stop = (code) => { if (!signal.aborted)
76
+ controller.abort(new BenchmarkStop(code)); };
77
+ const externalAbort = () => stop('cancelled');
78
+ options.signal?.addEventListener('abort', externalAbort, { once: true });
79
+ if (options.signal?.aborted)
80
+ externalAbort();
81
+ const timer = setTimeout(() => stop('deadline'), budgets.timeoutMs);
82
+ const interrupted = new Promise((_resolve, reject) => {
83
+ signal.addEventListener('abort', () => reject(signal.reason), { once: true });
84
+ if (signal.aborted)
85
+ reject(signal.reason);
86
+ });
87
+ void interrupted.catch(() => { });
88
+ const bounded = (promise) => Promise.race([promise, interrupted]);
89
+ let stage = 'outer';
90
+ const stages = { outer: emptyMetrics(), workflow: emptyMetrics() };
91
+ const candidates = [], workflowRuns = [];
92
+ const total = () => Object.fromEntries(Object.keys(stages.outer).map(key => [key, stages.outer[key] + stages.workflow[key]]));
93
+ const admit = (kind) => {
94
+ signal.throwIfAborted();
95
+ const counts = total();
96
+ if (kind === 'tools' ? counts.tools >= budgets.maxToolCalls : counts.pi + counts.jev >= budgets.maxModelRequests)
97
+ stop(kind === 'tools' ? 'tool_limit' : 'model_limit');
98
+ signal.throwIfAborted();
99
+ stages[stage][kind]++;
100
+ };
101
+ const usage = (bucket, tokens, cost) => {
102
+ if (typeof tokens === 'number' && Number.isFinite(tokens) && tokens >= 0) {
103
+ bucket.tokens += tokens;
104
+ bucket.usageSamples++;
105
+ }
106
+ if (typeof cost === 'number' && Number.isFinite(cost) && cost >= 0) {
107
+ bucket.knownCostUsd += cost;
108
+ bucket.costSamples++;
109
+ }
110
+ };
111
+ const host = { ...captured, modelRegistry: {
112
+ getAll: () => captured.modelRegistry.getAll(),
113
+ streamSimple(selected, transcript, opts) {
114
+ admit('pi');
115
+ const bucket = stages[stage];
116
+ const stream = captured.modelRegistry.streamSimple(selected, transcript, opts);
117
+ void stream.result().then(result => {
118
+ const value = result;
119
+ usage(bucket, value.usage?.totalTokens, value.usage?.cost?.total);
120
+ }).catch(() => { });
121
+ return stream;
122
+ },
123
+ } };
124
+ const runJudge = async (request) => {
125
+ request.signal?.throwIfAborted();
126
+ admit('jev');
127
+ const bucket = stages[stage];
128
+ const result = await bounded(options.runJudge({ ...request, signal: request.signal ? AbortSignal.any([signal, request.signal]) : signal }));
129
+ usage(bucket, result.usage ? result.usage.input_tokens + result.usage.output_tokens : undefined, result.cost_usd);
130
+ return result;
131
+ };
132
+ const domainTools = [
133
+ { name: 'read_sources', label: 'Read sources', description: 'Return {sources:[{id,text}]} for this fictional task. Direct workflow calls return this same structured value. This is not web search.', parameters: { type: 'object', properties: {}, additionalProperties: false }, async execute() { return textResult({ sources: structuredClone(item.sources) }); } },
134
+ { name: 'screen_evidence', label: 'Screen evidence', description: 'Jev System One decisions select concrete evidence including limitations and contradictions at threshold 0.8.', parameters: { type: 'object', additionalProperties: false, required: ['topicId', 'sourceIds'], properties: { topicId: { type: 'string' }, sourceIds: { type: 'array', uniqueItems: true, items: { type: 'string' } } } },
135
+ async execute(_id, value, toolSignal) {
136
+ const args = value;
137
+ const topic = item.topics.find(row => row.id === args.topicId);
138
+ if (!topic || args.sourceIds.some(id => !item.sources.some(source => source.id === id)))
139
+ throw new Error('Unknown topic or source');
140
+ const result = await bounded(runWorkflowSlice(selectionWorkflow, { question: topic.question, sources: item.sources.filter(source => args.sourceIds.includes(source.id)) }, { from: 'screen' }, { runJudge: request => runJudge({ ...request, signal: toolSignal }) }));
141
+ if (result.status !== 'complete')
142
+ throw new Error('Screening did not complete');
143
+ return textResult(result.state.evidence);
144
+ } },
145
+ ];
146
+ const service = new WorkflowExtensionService({ allowedTools: domainTools.map(tool => tool.name), limits: { deadlineMs: budgets.timeoutMs } });
147
+ const input = { question: item.question, topics: structuredClone(item.topics) };
148
+ let lastOutput;
149
+ let workflowInput = input;
150
+ try {
151
+ const factory = createPiHostSessionFactory(host);
152
+ const makeRunner = (tools) => createPiRunner({ model, thinkingLevel: captured.thinkingLevel, cwd: captured.cwd, signal, tools,
153
+ maxTurns: budgets.maxTurns, maxSubmissions: budgets.maxSubmissions, timeoutMs: budgets.timeoutMs,
154
+ sessionFactory: opts => factory({ ...opts, customTools: opts.customTools.map(tool => ({ ...tool, async execute(...args) {
155
+ args[2]?.throwIfAborted();
156
+ admit('tools');
157
+ if (tool.name === 'submit')
158
+ stages[stage].submissions++;
159
+ const validator = Compile(tool.parameters);
160
+ if (!validator.Check(args[1]))
161
+ throw new Error('Tool input does not match its schema');
162
+ return tool.execute(...args);
163
+ } })) }),
164
+ });
165
+ const child = makeRunner(domainTools);
166
+ const agentrun = { name: 'agentrun', label: 'AgentRun workflow', description: 'Describe available tools, inspect a workflow, then run the inspected workflow. Never run an inline candidate. No shell, code, file access or model selection.', parameters: { type: 'object', additionalProperties: false, required: ['action'], properties: { action: { enum: ['describe', 'inspect', 'run'] }, workflow: {}, input: { type: 'object' } } }, async execute(_id, value) {
167
+ const args = value;
168
+ if (args.action === 'inspect' && args.input !== undefined)
169
+ workflowInput = args.input;
170
+ if (args.action === 'describe')
171
+ return textResult({ tools: domainTools.map(tool => ({ name: tool.name, description: tool.description, parameters: tool.parameters })), skill: true, pi: true, jev: true, running: false, workflow: !!service.inspect().current });
172
+ if (args.action === 'inspect') {
173
+ try {
174
+ if (args.workflow === undefined) {
175
+ const current = service.inspect().current;
176
+ if (!current)
177
+ throw new Error('Inspect a workflow first');
178
+ return textResult({ digest: current.digest, tree: formatWorkflowTree(current.inspection) });
179
+ }
180
+ const prepared = service.prepare(args.workflow);
181
+ candidates.push({ digest: prepared.digest, accepted: true, workflow: prepared.workflow });
182
+ return textResult({ digest: prepared.digest, tree: formatWorkflowTree(prepared.inspection) });
183
+ }
184
+ catch (error) {
185
+ candidates.push({ accepted: false, workflow: structuredClone(args.workflow) });
186
+ throw error;
187
+ }
188
+ }
189
+ if (args.action !== 'run' || args.workflow !== undefined)
190
+ throw new Error('Inspect the workflow first; run without a workflow argument');
191
+ notify('outer', 'end');
192
+ stage = 'workflow';
193
+ notify('workflow', 'start');
194
+ try {
195
+ const report = await bounded(service.run(args.input ?? workflowInput, { signal, deps: {
196
+ // A candidate cannot change the captured host thinking setting.
197
+ runNode: request => child({ ...request, thinking: undefined }), runJudge,
198
+ runEffect: async (request) => {
199
+ request.signal.throwIfAborted();
200
+ admit('tools');
201
+ const tool = domainTools.find(tool => tool.name === request.node.tool);
202
+ if (!tool || !Compile(tool.parameters).Check(request.input))
203
+ throw new Error('Unsupported tool input');
204
+ return (await bounded(tool.execute('benchmark-effect', request.input, request.signal, undefined, host))).details;
205
+ },
206
+ } }));
207
+ workflowRuns.push({ status: report.status, digest: report.digest, ...(report.output === undefined ? {} : { output: report.output }) });
208
+ lastOutput = report.status === 'complete' ? report.output : undefined;
209
+ return textResult(report);
210
+ }
211
+ finally {
212
+ notify('workflow', 'end');
213
+ stage = 'outer';
214
+ notify('outer', 'start');
215
+ }
216
+ } };
217
+ const outer = makeRunner(arm === 'skill-dsl' ? [...domainTools, agentrun] : domainTools);
218
+ const output = await bounded(outer({ kind: 'agent', label: 'benchmark-outer',
219
+ system: [benchmarkInstructions, ...(arm === 'skill-dsl' ? [skillText, 'Use agentrun to inspect and execute your workflow. Deliver its completed output unchanged with submit. The benchmark forbids code, shell, files and changing models. Available task tools and their contracts are returned by agentrun describe.'] : [])],
220
+ user: JSON.stringify(input), schema: benchmarkReportSchema }));
221
+ signal.throwIfAborted();
222
+ if (arm === 'skill-dsl' && (lastOutput === undefined || !isDeepStrictEqual(lastOutput, output)))
223
+ return finish({ status: 'rejected', output, failure: 'workflow_delivery_missing_or_changed' });
224
+ const evaluation = evaluatePiBenchmark(item.id, output);
225
+ return finish({ status: evaluation.passed ? 'passed' : 'rejected', output, evaluation });
226
+ }
227
+ catch (error) {
228
+ return finish({ status: options.signal?.aborted ? 'cancelled' : 'failed', failure: signal.reason instanceof BenchmarkStop ? signal.reason.code : error instanceof PiRunError ? `pi_${error.reason}` : error instanceof ExtensionServiceError ? error.code : 'execution_failed' });
229
+ }
230
+ finally {
231
+ clearTimeout(timer);
232
+ options.signal?.removeEventListener('abort', externalAbort);
233
+ stop('arm_finished');
234
+ void service.dispose();
235
+ }
236
+ function finish(result) {
237
+ const counts = total();
238
+ notify('outer', 'end', result.status);
239
+ notify('arm', 'end', result.status);
240
+ return structuredClone({ arm, ...result, elapsedMs: Math.round(performance.now() - started), total: counts, stages, candidates, workflowRuns, costComplete: counts.costSamples === counts.pi + counts.jev, usageComplete: counts.usageSamples === counts.pi + counts.jev });
241
+ }
242
+ }
243
+ }
244
+ /** A development comparison receipt, not automatic promotion. Heldout is a final
245
+ * confirmation set, never the optimization target for this helper. */
246
+ export function comparePiSkillBenchmarks(baseline, candidate) {
247
+ if (baseline.split !== 'development' || candidate.split !== 'development')
248
+ throw new Error('Hillclimb comparisons use development only');
249
+ if (baseline.status !== 'complete' || candidate.status !== 'complete' || baseline.mode !== candidate.mode || !isDeepStrictEqual(baseline.contract, candidate.contract) || !isDeepStrictEqual(baseline.budgets, candidate.budgets) || !isDeepStrictEqual(baseline.runtime, candidate.runtime))
250
+ throw new Error('Incomparable benchmark receipts');
251
+ const select = (report) => report.runs.map(run => ({ id: run.caseId, input: run.inputSha256, arm: run.arms.find(arm => arm.arm === 'skill-dsl') }));
252
+ const before = select(baseline), after = select(candidate);
253
+ if (before.length !== 3 || after.length !== 3 || !isDeepStrictEqual(before.map(row => [row.id, row.input]), after.map(row => [row.id, row.input])) || [...before, ...after].some(row => !row.arm))
254
+ throw new Error('Incomplete benchmark receipts');
255
+ const regressions = before.filter((row, i) => row.arm.status === 'passed' && after[i].arm.status !== 'passed').map(row => row.id);
256
+ const sums = (rows) => ({ passed: rows.filter(row => row.arm.status === 'passed').length, requests: rows.reduce((sum, row) => sum + row.arm.total.pi + row.arm.total.jev, 0), tokens: rows.reduce((sum, row) => sum + row.arm.total.tokens, 0), usageComplete: rows.every(row => row.arm.usageComplete), knownCostUsd: rows.reduce((sum, row) => sum + row.arm.total.knownCostUsd, 0), costComplete: rows.every(row => row.arm.costComplete) });
257
+ const previous = sums(before), current = sums(after);
258
+ const improved = !regressions.length && (current.passed > previous.passed || current.passed === previous.passed && current.passed === 3 && previous.usageComplete && current.usageComplete && current.tokens < previous.tokens);
259
+ return { mode: candidate.mode, baselineSkillSha256: baseline.skillSha256, candidateSkillSha256: candidate.skillSha256, regressions, previous, current, improved, eligibleForLiveHeldout: candidate.mode === 'live' && improved && current.passed === 3 };
260
+ }
package/dist/cli.js CHANGED
File without changes
@@ -0,0 +1,69 @@
1
+ import { type WorkflowDeps } from '@parcha/agentrun-dsl';
2
+ import type { PiHostContext } from './types.js';
3
+ export declare function evaluatePiComparison(caseId: string, output: unknown): {
4
+ passed: boolean;
5
+ issues: string[];
6
+ };
7
+ export interface PiComparisonBudgets {
8
+ timeoutMs: number;
9
+ maxModelRequests: number;
10
+ maxToolCalls: number;
11
+ maxTurns: number;
12
+ maxSubmissions: number;
13
+ }
14
+ export interface PiComparisonOptions {
15
+ host: PiHostContext;
16
+ runJudge: NonNullable<WorkflowDeps['runJudge']>;
17
+ signal?: AbortSignal;
18
+ mode: 'live' | 'scripted-transport';
19
+ budgets?: Partial<PiComparisonBudgets>;
20
+ }
21
+ export interface PiComparisonArm {
22
+ arm: 'plain-pi' | 'workflow';
23
+ status: 'passed' | 'rejected' | 'failed' | 'cancelled';
24
+ elapsedMs: number;
25
+ requests: {
26
+ pi: number;
27
+ jev: number;
28
+ total: number;
29
+ };
30
+ toolCalls: number;
31
+ submissions: number;
32
+ tokens: {
33
+ pi: number;
34
+ jev: number;
35
+ piUsageSamples: number;
36
+ jevUsageSamples: number;
37
+ };
38
+ output?: unknown;
39
+ evaluation?: ReturnType<typeof evaluatePiComparison>;
40
+ failure?: string;
41
+ }
42
+ export declare function runPiComparison(options: PiComparisonOptions): Promise<{
43
+ mode: "live" | "scripted-transport";
44
+ status: "complete" | "cancelled";
45
+ passed: boolean;
46
+ runtime: {
47
+ provider: string;
48
+ model: string;
49
+ api: string;
50
+ thinking: import("./types.js").PiThinkingLevel;
51
+ };
52
+ budgets: {
53
+ timeoutMs: number;
54
+ maxModelRequests: number;
55
+ maxToolCalls: number;
56
+ maxTurns: number;
57
+ maxSubmissions: number;
58
+ };
59
+ workflowSha256: string;
60
+ suiteSha256: string;
61
+ runs: {
62
+ caseId: string;
63
+ inputSha256: string;
64
+ expectedSha256: string;
65
+ executionOrder: string[];
66
+ arms: PiComparisonArm[];
67
+ }[];
68
+ limits: string[];
69
+ }>;
@@ -0,0 +1,229 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { Compile } from 'typebox/compile';
3
+ import { runWorkflow, runWorkflowSlice, workflowSha256 } from '@parcha/agentrun-dsl';
4
+ import { createPiHostSessionFactory } from './host-session.js';
5
+ import { createPiRunner, PiRunError } from './runner.js';
6
+ import { demoData } from './demo-data.js';
7
+ const cases = [
8
+ { id: 'single-fact', question: 'Can our contributors edit documentation in a browser?',
9
+ topics: [{ id: 'editing', question: 'Can contributors edit documentation in a browser?' }],
10
+ sources: [{ id: 'browser-trial', text: 'In our trial, all three contributors successfully edited a documentation page in the browser.' }, { id: 'advert', text: 'A beautiful new logo for documentation.' }],
11
+ expected: [{ topicId: 'editing', status: 'supported', sourceIds: ['browser-trial'] }] },
12
+ { id: 'parallel-research', question: 'What would change if our team moved its wiki into the code repository?',
13
+ topics: [{ id: 'reviews', question: demoData.subquestions[0] }, { id: 'editing', question: demoData.subquestions[1] }, { id: 'search', question: demoData.subquestions[2] }],
14
+ sources: structuredClone(demoData.sources),
15
+ expected: [{ topicId: 'reviews', status: 'supported', sourceIds: ['review-guide'] }, { topicId: 'editing', status: 'supported', sourceIds: ['editor-trial'] }, { topicId: 'search', status: 'supported', sourceIds: ['search-trial'] }] },
16
+ { id: 'gaps-and-conflicts', question: 'Does our repository trial support restricted editing and synonym search?',
17
+ topics: [{ id: 'permissions', question: 'Who can edit restricted documentation pages in our trial?' }, { id: 'search', question: 'Did synonym search work in our trial?' }],
18
+ sources: [{ id: 'permissions-heading', text: 'Restricted documentation permissions: details to be supplied.' }, { id: 'search-positive', text: 'The trial report says all ten synonym searches found the intended pages.' }, { id: 'search-negative', text: 'The audit of the same trial says none of those ten synonym searches found the intended pages.' }],
19
+ expected: [{ topicId: 'permissions', status: 'insufficient', sourceIds: [] }, { topicId: 'search', status: 'conflicting', sourceIds: ['search-positive', 'search-negative'] }] },
20
+ ];
21
+ const str = { type: 'string', minLength: 1 };
22
+ const findingSchema = { type: 'object', additionalProperties: false, required: ['topicId', 'status', 'summary', 'sourceIds'], properties: {
23
+ topicId: str, status: { enum: ['supported', 'conflicting', 'insufficient'] }, summary: str, sourceIds: { type: 'array', uniqueItems: true, items: str },
24
+ } };
25
+ const reportSchema = { type: 'object', additionalProperties: false, required: ['answer', 'findings'], properties: {
26
+ answer: str, findings: { type: 'array', minItems: 1, items: findingSchema },
27
+ } };
28
+ const sourcesSchema = { type: 'object', required: ['sources'], additionalProperties: false, properties: { sources: { type: 'array', items: { type: 'object', required: ['id', 'text'], additionalProperties: false, properties: { id: str, text: str } } } } };
29
+ const evidenceSchema = demoData.workflow.root.steps[1].body.workflow.schemas.EvidenceDecision;
30
+ const instructions = 'Use only the supplied fictional corpus. For each topic preserve all material sources, including contrary evidence, conditions and uncertainty. Status supported means the sources establish a finding, not necessarily a positive answer; conflicting means incompatible accounts; insufficient means no concrete information answers the topic. Cite source IDs. Do not turn qualified evidence into unconditional claims. You may read_sources or screen_evidence with Jev System One decisions. Submit the requested structured output.';
31
+ const screen = { node: 'sift', label: 'screen-evidence', itemsPath: 'search.sources', state: { subquestion: '{item.question}' }, out: 'EvidenceDecision', as: 'evidence', keep: { path: 'answersQuestion', gte: 0.8 } };
32
+ const workflow = {
33
+ v: 2, name: 'comparison-research', schemas: { Input: { type: 'object' }, Sources: sourcesSchema, Finding: findingSchema, Report: reportSchema, EvidenceDecision: evidenceSchema },
34
+ input: { schemaId: 'Input' }, output: { schemaId: 'Report', path: 'report' },
35
+ root: { node: 'chain', steps: [
36
+ { node: 'map', label: 'research-topics', itemsPath: 'topics', maxConcurrency: 3, resultPath: 'finding', as: 'findings', body: { node: 'chain', steps: [
37
+ { node: 'call', label: 'read-sources', via: 'tool', tool: 'read_sources', args: {}, out: 'Sources', as: 'search', deadline_s: 15 }, screen,
38
+ { node: 'agent', label: 'finding', instructions, state: { topic: '{item}', sources: '{evidence.items}' }, out: 'Finding', as: 'finding' },
39
+ ] } },
40
+ { node: 'agent', label: 'report', instructions, state: { question: '{question}', topics: '{topics}', findings: '{findings}' }, out: 'Report', as: 'report' },
41
+ ] },
42
+ };
43
+ const selector = { ...workflow, name: 'comparison-screen', root: { node: 'chain', steps: [screen] } };
44
+ const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
45
+ const validator = Compile(reportSchema);
46
+ export function evaluatePiComparison(caseId, output) {
47
+ const item = cases.find(row => row.id === caseId);
48
+ if (!item)
49
+ throw new Error('Unknown comparison case');
50
+ if (!validator.Check(output))
51
+ return { passed: false, issues: ['output_schema'] };
52
+ const findings = output.findings;
53
+ const issues = [];
54
+ if (findings.length !== item.expected.length || new Set(findings.map(row => row.topicId)).size !== findings.length)
55
+ issues.push('topic_coverage');
56
+ for (const expected of item.expected) {
57
+ const actual = findings.find(row => row.topicId === expected.topicId);
58
+ if (!actual || actual.status !== expected.status)
59
+ issues.push(`${expected.topicId}:status`);
60
+ if (!actual || JSON.stringify([...actual.sourceIds].sort()) !== JSON.stringify([...expected.sourceIds].sort()))
61
+ issues.push(`${expected.topicId}:citations`);
62
+ }
63
+ return { passed: issues.length === 0, issues };
64
+ }
65
+ class ComparisonStop extends Error {
66
+ code;
67
+ constructor(code) {
68
+ super(code);
69
+ this.code = code;
70
+ }
71
+ }
72
+ const defaults = { timeoutMs: 120_000, maxModelRequests: 12, maxToolCalls: 24, maxTurns: 12, maxSubmissions: 3 };
73
+ export async function runPiComparison(options) {
74
+ if (options.mode !== 'live' && options.mode !== 'scripted-transport')
75
+ throw new Error('Comparison requires an explicit execution mode');
76
+ if (typeof options.runJudge !== 'function')
77
+ throw new Error('Both arms require the same Jev capability');
78
+ const budgets = { ...defaults, ...options.budgets };
79
+ for (const key of Object.keys(defaults)) {
80
+ if (!Number.isSafeInteger(budgets[key]) || budgets[key] < 1 || budgets[key] > defaults[key])
81
+ throw new Error(`Comparison budget ${key} must be between 1 and ${defaults[key]}`);
82
+ }
83
+ const originalRegistry = options.host.modelRegistry;
84
+ const capturedHost = { ...options.host, modelRegistry: originalRegistry };
85
+ createPiHostSessionFactory(capturedHost);
86
+ const model = capturedHost.model;
87
+ const runs = [];
88
+ for (const [index, item] of cases.entries()) {
89
+ if (options.signal?.aborted)
90
+ break;
91
+ const order = index % 2 ? ['workflow', 'plain-pi'] : ['plain-pi', 'workflow'];
92
+ const arms = [];
93
+ for (const arm of order) {
94
+ if (options.signal?.aborted)
95
+ break;
96
+ arms.push(await runArm(item, arm));
97
+ }
98
+ runs.push({ caseId: item.id, inputSha256: hash({ question: item.question, topics: item.topics, sources: item.sources }), expectedSha256: hash(item.expected), executionOrder: arms.map(arm => arm.arm), arms });
99
+ }
100
+ return {
101
+ mode: options.mode, status: options.signal?.aborted ? 'cancelled' : 'complete',
102
+ passed: runs.length === cases.length && runs.every(run => run.arms.length === 2 && run.arms.every(arm => arm.status === 'passed')),
103
+ runtime: { provider: model.provider, model: model.id, api: model.api, thinking: capturedHost.thinkingLevel ?? 'low' },
104
+ budgets, workflowSha256: workflowSha256(workflow), suiteSha256: hash(cases), runs,
105
+ limits: ['Three fictional cases, one run per arm; not a statistical benchmark.', 'Workflow authoring and setup costs excluded; both arms receive identical prewritten topics.', 'Both arms can use the same corpus reader and Jev rubric; the plain loop chooses whether to call them.', 'Evaluation checks topic status and citation sets, not arbitrary prose entailment.', 'Token totals cover reported usage only; compare usage sample counts with request counts.', 'Host caches are not reset; execution order alternates.'],
106
+ };
107
+ async function runArm(item, arm) {
108
+ const started = performance.now();
109
+ const controller = new AbortController();
110
+ const signal = controller.signal;
111
+ const abort = (code) => { if (!signal.aborted)
112
+ controller.abort(new ComparisonStop(code)); };
113
+ const externalAbort = () => abort('cancelled');
114
+ options.signal?.addEventListener('abort', externalAbort, { once: true });
115
+ if (options.signal?.aborted)
116
+ externalAbort();
117
+ const timer = setTimeout(() => abort('deadline'), budgets.timeoutMs);
118
+ let rejectAbort;
119
+ const interrupted = new Promise((_resolve, reject) => { rejectAbort = reject; });
120
+ const onAbort = () => rejectAbort(signal.reason);
121
+ signal.addEventListener('abort', onAbort, { once: true });
122
+ if (signal.aborted)
123
+ onAbort();
124
+ void interrupted.catch(() => { });
125
+ const bounded = (work) => Promise.race([work, interrupted]);
126
+ const requests = { pi: 0, jev: 0, total: 0 };
127
+ const tokens = { pi: 0, jev: 0, piUsageSamples: 0, jevUsageSamples: 0 };
128
+ let toolCalls = 0, submissions = 0;
129
+ const admit = (kind) => {
130
+ signal.throwIfAborted();
131
+ if (kind === 'tool') {
132
+ if (toolCalls >= budgets.maxToolCalls)
133
+ abort('tool_call_limit');
134
+ signal.throwIfAborted();
135
+ toolCalls++;
136
+ }
137
+ else {
138
+ if (requests.total >= budgets.maxModelRequests)
139
+ abort('model_request_limit');
140
+ signal.throwIfAborted();
141
+ requests.total++;
142
+ requests[kind]++;
143
+ }
144
+ };
145
+ const host = { ...capturedHost, modelRegistry: {
146
+ getAll: () => originalRegistry.getAll(),
147
+ streamSimple(selected, transcript, streamOptions) {
148
+ admit('pi');
149
+ const stream = originalRegistry.streamSimple(selected, transcript, streamOptions);
150
+ void stream.result().then(result => {
151
+ const usage = result?.usage;
152
+ if (Number.isFinite(usage?.totalTokens) && usage.totalTokens >= 0) {
153
+ tokens.pi += usage.totalTokens;
154
+ tokens.piUsageSamples++;
155
+ }
156
+ }).catch(() => { });
157
+ return stream;
158
+ },
159
+ } };
160
+ const runJudge = async (request) => {
161
+ request.signal?.throwIfAborted();
162
+ admit('jev');
163
+ const result = await bounded(options.runJudge({ ...request, signal: request.signal ? AbortSignal.any([signal, request.signal]) : signal }));
164
+ if (result.usage && Number.isFinite(result.usage.input_tokens) && Number.isFinite(result.usage.output_tokens) && result.usage.input_tokens >= 0 && result.usage.output_tokens >= 0) {
165
+ tokens.jev += result.usage.input_tokens + result.usage.output_tokens;
166
+ tokens.jevUsageSamples++;
167
+ }
168
+ return result;
169
+ };
170
+ const read = () => ({ sources: structuredClone(item.sources) });
171
+ const tools = [
172
+ { name: 'read_sources', label: 'Read sources', description: 'Read the complete fictional corpus for this task.', parameters: { type: 'object', properties: {}, additionalProperties: false },
173
+ async execute() { const value = read(); return { content: [{ type: 'text', text: JSON.stringify(value) }], details: value }; } },
174
+ { name: 'screen_evidence', label: 'Screen evidence', description: 'Use Jev System One decisions to retain concrete evidence for a topic, including qualified and contrary evidence. Same rubric and 0.8 threshold as the workflow.',
175
+ parameters: { type: 'object', properties: { topicId: str, sourceIds: { type: 'array', uniqueItems: true, items: str } }, required: ['topicId', 'sourceIds'], additionalProperties: false },
176
+ async execute(_id, value, toolSignal) {
177
+ const args = value;
178
+ const topic = item.topics.find(row => row.id === args.topicId);
179
+ if (!topic || args.sourceIds.some(id => !item.sources.some(source => source.id === id)))
180
+ throw new Error('Unknown topic or source');
181
+ const selected = item.sources.filter(source => args.sourceIds.includes(source.id));
182
+ const result = await bounded(runWorkflowSlice(selector, {}, { from: 'screen-evidence', seed: { item: topic, search: { sources: selected } } }, { runJudge: request => runJudge({ ...request, signal: toolSignal }) }));
183
+ if (result.status !== 'complete')
184
+ throw new Error('Evidence screening did not complete');
185
+ const evidence = result.state.evidence;
186
+ return { content: [{ type: 'text', text: JSON.stringify(evidence) }], details: evidence };
187
+ } },
188
+ ];
189
+ try {
190
+ const factory = createPiHostSessionFactory(host);
191
+ const runNode = createPiRunner({ model, thinkingLevel: capturedHost.thinkingLevel, cwd: capturedHost.cwd, signal,
192
+ maxTurns: budgets.maxTurns, maxSubmissions: budgets.maxSubmissions, timeoutMs: budgets.timeoutMs, tools,
193
+ sessionFactory: opts => factory({ ...opts, customTools: opts.customTools.map(tool => ({ ...tool,
194
+ async execute(...args) { args[2]?.throwIfAborted(); admit('tool'); if (tool.name === 'submit')
195
+ submissions++; return tool.execute(...args); },
196
+ })) }),
197
+ });
198
+ const input = { question: item.question, topics: structuredClone(item.topics) };
199
+ const output = await bounded((async () => {
200
+ if (arm === 'plain-pi')
201
+ return runNode({ kind: 'agent', label: 'plain-pi', system: [instructions], user: JSON.stringify(input), schema: reportSchema });
202
+ const result = await runWorkflow(workflow, input, { runNode, runJudge, runEffect: async ({ node }) => {
203
+ if (node.tool !== 'read_sources')
204
+ throw new Error('Unknown comparison tool');
205
+ admit('tool');
206
+ return read();
207
+ } });
208
+ if (result.status !== 'complete')
209
+ throw new Error('Workflow did not complete');
210
+ return result.output;
211
+ })());
212
+ signal.throwIfAborted();
213
+ const evaluation = evaluatePiComparison(item.id, output);
214
+ return finish({ status: evaluation.passed ? 'passed' : 'rejected', output, evaluation });
215
+ }
216
+ catch (error) {
217
+ return finish({ status: options.signal?.aborted ? 'cancelled' : 'failed', failure: signal.reason instanceof ComparisonStop ? signal.reason.code : error instanceof PiRunError ? `pi_${error.reason}` : 'execution_failed' });
218
+ }
219
+ finally {
220
+ clearTimeout(timer);
221
+ options.signal?.removeEventListener('abort', externalAbort);
222
+ signal.removeEventListener('abort', onAbort);
223
+ abort('arm_finished');
224
+ }
225
+ function finish(result) {
226
+ return { arm, ...result, elapsedMs: Math.round(performance.now() - started), requests: { ...requests }, toolCalls, submissions, tokens: { ...tokens } };
227
+ }
228
+ }
229
+ }
@@ -111,6 +111,7 @@ function localDiagnostic(error) {
111
111
  empty_selection: 'The selection list is empty and none is not allowed. Handle missing evidence explicitly.',
112
112
  missing_map_result: 'A map body did not produce its resultPath. Select a path in the completed body state; a body with as is collected automatically when resultPath is omitted.',
113
113
  parallel_write_conflict: 'Parallel branches changed the same top-level state key. Give independent branches distinct output keys and combine them after the parallel node.',
114
+ reserved_state_key: 'A node or host hook wrote an engine-owned "$"-prefixed state key such as $host. Choose another state key; host state is written only through decodeSubmission.',
114
115
  };
115
116
  return { code: error.code, stage: error.stage.slice(0, 200),
116
117
  // Parallel patch keys may be computed from source values, unlike authored paths.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parcha/agentrun-pi",
3
- "version": "0.1.0-beta.1",
3
+ "version": "0.1.0-beta.2",
4
4
  "description": "Pi extension for building, running and evaluating AgentRun workflows",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -36,8 +36,8 @@
36
36
  "@earendil-works/pi-ai": "0.87.0"
37
37
  },
38
38
  "dependencies": {
39
- "@parcha/agentrun-dsl": "0.1.0-beta.1",
40
- "@parcha/agentrun-jev": "0.1.0-beta.1",
39
+ "@parcha/agentrun-dsl": "0.1.0-beta.2",
40
+ "@parcha/agentrun-jev": "0.1.0-beta.2",
41
41
  "@earendil-works/pi-agent-core": "0.87.0",
42
42
  "@earendil-works/pi-coding-agent": "0.87.0",
43
43
  "@earendil-works/pi-tui": "0.87.0",