@agentic-kit/pi 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -2
- package/embed/lanes.d.ts +65 -0
- package/embed/lanes.js +53 -0
- package/embed/session.d.ts +58 -0
- package/embed/session.js +85 -0
- package/esm/embed/lanes.d.ts +65 -0
- package/esm/embed/lanes.js +50 -0
- package/esm/embed/session.d.ts +58 -0
- package/esm/embed/session.js +82 -0
- package/esm/extensions/gate.d.ts +21 -0
- package/esm/extensions/gate.js +27 -0
- package/esm/extensions/metered-model.d.ts +32 -0
- package/esm/extensions/metered-model.js +50 -0
- package/esm/extensions/run-log.d.ts +35 -0
- package/esm/extensions/run-log.js +57 -0
- package/esm/extensions/usage-report.d.ts +34 -0
- package/esm/extensions/usage-report.js +53 -0
- package/esm/harness.d.ts +16 -0
- package/esm/harness.js +24 -0
- package/esm/index.d.ts +7 -0
- package/esm/index.js +7 -0
- package/extensions/gate.d.ts +21 -0
- package/extensions/gate.js +30 -0
- package/extensions/metered-model.d.ts +32 -0
- package/extensions/metered-model.js +54 -0
- package/extensions/run-log.d.ts +35 -0
- package/extensions/run-log.js +61 -0
- package/extensions/usage-report.d.ts +34 -0
- package/extensions/usage-report.js +56 -0
- package/harness.d.ts +16 -0
- package/harness.js +27 -0
- package/index.d.ts +7 -0
- package/index.js +18 -1
- package/package.json +9 -7
package/README.md
CHANGED
|
@@ -12,7 +12,19 @@
|
|
|
12
12
|
<a href="https://www.npmjs.com/package/@agentic-kit/pi"><img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/constructive?filename=agentic%2Fpi%2Fpackage.json"/></a>
|
|
13
13
|
</p>
|
|
14
14
|
|
|
15
|
-
The [pi coding agent](https://github.com/badlogic/pi-mono) adapter for **agentic-kit**: the Constructive typed database tools and
|
|
15
|
+
The **one** [pi coding agent](https://github.com/badlogic/pi-mono) adapter for **agentic-kit**: the Constructive typed database tools, and everything that attaches a platform run to a pi session — the run gate, the run log, the metering gateway and usage reporting — for any host to register: Constructive Desktop, the [`agent` CLI](https://www.npmjs.com/package/@agentic-kit/cli), or your own pi-based agent.
|
|
16
|
+
|
|
17
|
+
What a run *does* is harness-neutral and lives elsewhere; only its attachment to pi lives here.
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
neutral contracts this package pi
|
|
21
|
+
──────────────────────────────────── ───────────────────────── ────────────────────
|
|
22
|
+
@agentic-kit/harness (gate, adapter) tool_call ─► RunGate pi.on('tool_call')
|
|
23
|
+
@agentic-kit/metering (gateway, usage) MeteredGateway ─► provider pi.registerProvider
|
|
24
|
+
@agentic-kit/run-log (append-only) SessionMirror pi's session manager
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
So a second, compatible harness is a *sibling adapter* — not a fork of the lanes.
|
|
16
28
|
|
|
17
29
|
```bash
|
|
18
30
|
npm install @agentic-kit/pi
|
|
@@ -22,6 +34,8 @@ npm install @agentic-kit/pi
|
|
|
22
34
|
|
|
23
35
|
- **16 typed db tools** — `provision_database`, `provision_blueprint`, `describe_schema`, `add_relation`, `delete_table`, `create_field` / `update_field` / `delete_field`, `add_policies`, `add_records`, `run_codegen`, and the template suite (`list` / `create` / `apply` / `update` / `delete`). Tool schemas are authored in [zod](https://zod.dev) and emitted as plain JSON Schema at pi's tool boundary.
|
|
24
36
|
- **Confirm gate** — the harness's host-neutral gate wired to pi's `tool_call` events. Hosts with a rich confirm surface (Desktop) expose `confirmTool`/`notifyToolSkipped` on `ctx.ui`; everyone else gets pi's built-in `ui.confirm` dialog.
|
|
37
|
+
- **Run lanes** — `composeRun` / `startRun` build a pi session with the run's lanes attached: `createRunLogExtension` mirrors pi's session entries into the run log under `transcriptFormat: 'pi'`, `createMeteredModelExtension` points pi at the Constructive gateway so usage cannot be under-reported, `createUsageReportExtension` self-reports usage when the host owns the provider keys, and `createGateExtension` suspends a gated tool call until a human answers.
|
|
38
|
+
- **`piHarness`** — the `HarnessAdapter` face of the above: an `id`, the `transcriptFormat` its entries are written under, and `startRun`.
|
|
25
39
|
- **Host injection** — credentials, backend endpoints, and data-plane tokens come from your host, not from the package:
|
|
26
40
|
|
|
27
41
|
```ts
|
|
@@ -88,7 +102,9 @@ For local development, the cold path and the warm pool depend on the backend's j
|
|
|
88
102
|
|
|
89
103
|
## Related
|
|
90
104
|
|
|
91
|
-
- [`@agentic-kit/harness`](https://www.npmjs.com/package/@agentic-kit/harness) — host-neutral skills, gating, and
|
|
105
|
+
- [`@agentic-kit/harness`](https://www.npmjs.com/package/@agentic-kit/harness) — host-neutral skills, gating, blueprint core, and the `HarnessAdapter` contract
|
|
106
|
+
- [`@agentic-kit/metering`](https://www.npmjs.com/package/@agentic-kit/metering) — the metered gateway and usage reporting
|
|
107
|
+
- [`@agentic-kit/run-log`](https://www.npmjs.com/package/@agentic-kit/run-log) — the append-only run log
|
|
92
108
|
- [`@agentic-kit/cli`](https://www.npmjs.com/package/@agentic-kit/cli) — `agent`, a local secure-by-default coding agent
|
|
93
109
|
- [`agentic-kit`](https://www.npmjs.com/package/agentic-kit) — the umbrella package
|
|
94
110
|
|
package/embed/lanes.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compose a run's lanes into the extension list pi loads.
|
|
3
|
+
*
|
|
4
|
+
* A run's placement — a developer's laptop, a long-running cloud job — is *only*
|
|
5
|
+
* a difference in these options: which store the log appends to, whether the
|
|
6
|
+
* model calls leave through the metered gateway or a local key, and where an
|
|
7
|
+
* approval question is asked. The composed session is otherwise identical, which
|
|
8
|
+
* is the whole point of the package.
|
|
9
|
+
*/
|
|
10
|
+
import type { MeteredIdentity } from '@agentic-kit/metering';
|
|
11
|
+
import type { ExtensionFactory } from '@earendil-works/pi-coding-agent';
|
|
12
|
+
import { type GateExtension, type GateExtensionOptions } from '../extensions/gate';
|
|
13
|
+
import { type MeteredModelExtension, type MeteredModelExtensionOptions } from '../extensions/metered-model';
|
|
14
|
+
import { type RunLogExtension, type RunLogExtensionOptions } from '../extensions/run-log';
|
|
15
|
+
import { type UsageReportExtension, type UsageReportExtensionOptions } from '../extensions/usage-report';
|
|
16
|
+
/** Run log lane — `runId` comes from the run, not from here. */
|
|
17
|
+
export type RunLogLane = Omit<RunLogExtensionOptions, 'runId'>;
|
|
18
|
+
/**
|
|
19
|
+
* Metering lane. `gateway` is authoritative (the gateway meters what it proxies);
|
|
20
|
+
* `self-report` is the own-provider-key lane and is only as trustworthy as the
|
|
21
|
+
* agent reporting it. They are mutually exclusive on purpose: routing through the
|
|
22
|
+
* gateway *and* self-reporting would double-count the same tokens.
|
|
23
|
+
*/
|
|
24
|
+
export type MeteringLane = ({
|
|
25
|
+
mode: 'gateway';
|
|
26
|
+
} & MeteredModelExtensionOptions) | ({
|
|
27
|
+
mode: 'self-report';
|
|
28
|
+
} & UsageReportExtensionOptions);
|
|
29
|
+
/** Approval lane — `runId` comes from the run. */
|
|
30
|
+
export type GateLane = Omit<GateExtensionOptions, 'runId'>;
|
|
31
|
+
export interface ComposeRunOptions {
|
|
32
|
+
runId: string;
|
|
33
|
+
log?: RunLogLane;
|
|
34
|
+
metering?: MeteringLane;
|
|
35
|
+
gate?: GateLane;
|
|
36
|
+
/**
|
|
37
|
+
* The host's own extensions — workspace tools, prompts, UI glue. Loaded after
|
|
38
|
+
* the lanes so a host tool call is already gated and already logged.
|
|
39
|
+
*/
|
|
40
|
+
extensions?: readonly ExtensionFactory[];
|
|
41
|
+
}
|
|
42
|
+
export interface ComposedLanes {
|
|
43
|
+
log?: RunLogExtension;
|
|
44
|
+
meteredModel?: MeteredModelExtension;
|
|
45
|
+
usageReport?: UsageReportExtension;
|
|
46
|
+
gate?: GateExtension;
|
|
47
|
+
}
|
|
48
|
+
export interface ComposedRun {
|
|
49
|
+
runId: string;
|
|
50
|
+
/** In load order: log, metering, gate, then the host's own. */
|
|
51
|
+
extensions: ExtensionFactory[];
|
|
52
|
+
lanes: ComposedLanes;
|
|
53
|
+
/**
|
|
54
|
+
* Drain every lane that buffers, and throw the first failure. A host calls
|
|
55
|
+
* this before it exits; the extensions also flush themselves on
|
|
56
|
+
* `session_shutdown`, so this is for hosts that end a run without one.
|
|
57
|
+
*/
|
|
58
|
+
flush(): Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
export declare function composeRun(options: ComposeRunOptions): ComposedRun;
|
|
61
|
+
/**
|
|
62
|
+
* The identity every metered lane needs. Exported so a host can build it once
|
|
63
|
+
* from its resolved project context and hand the same value to both lanes.
|
|
64
|
+
*/
|
|
65
|
+
export type { MeteredIdentity };
|
package/embed/lanes.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Compose a run's lanes into the extension list pi loads.
|
|
4
|
+
*
|
|
5
|
+
* A run's placement — a developer's laptop, a long-running cloud job — is *only*
|
|
6
|
+
* a difference in these options: which store the log appends to, whether the
|
|
7
|
+
* model calls leave through the metered gateway or a local key, and where an
|
|
8
|
+
* approval question is asked. The composed session is otherwise identical, which
|
|
9
|
+
* is the whole point of the package.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.composeRun = composeRun;
|
|
13
|
+
const gate_1 = require("../extensions/gate");
|
|
14
|
+
const metered_model_1 = require("../extensions/metered-model");
|
|
15
|
+
const run_log_1 = require("../extensions/run-log");
|
|
16
|
+
const usage_report_1 = require("../extensions/usage-report");
|
|
17
|
+
function composeRun(options) {
|
|
18
|
+
const lanes = {};
|
|
19
|
+
const extensions = [];
|
|
20
|
+
if (options.log) {
|
|
21
|
+
lanes.log = (0, run_log_1.createRunLogExtension)({ runId: options.runId, ...options.log });
|
|
22
|
+
extensions.push(lanes.log.extension);
|
|
23
|
+
}
|
|
24
|
+
const metering = options.metering;
|
|
25
|
+
if (metering?.mode === 'gateway') {
|
|
26
|
+
const { mode: _mode, ...meteredOptions } = metering;
|
|
27
|
+
lanes.meteredModel = (0, metered_model_1.createMeteredModelExtension)(meteredOptions);
|
|
28
|
+
extensions.push(lanes.meteredModel.extension);
|
|
29
|
+
}
|
|
30
|
+
else if (metering?.mode === 'self-report') {
|
|
31
|
+
const { mode: _mode, ...reportOptions } = metering;
|
|
32
|
+
lanes.usageReport = (0, usage_report_1.createUsageReportExtension)(reportOptions);
|
|
33
|
+
extensions.push(lanes.usageReport.extension);
|
|
34
|
+
}
|
|
35
|
+
if (options.gate) {
|
|
36
|
+
lanes.gate = (0, gate_1.createGateExtension)({ runId: options.runId, ...options.gate });
|
|
37
|
+
extensions.push(lanes.gate.extension);
|
|
38
|
+
}
|
|
39
|
+
if (options.extensions)
|
|
40
|
+
extensions.push(...options.extensions);
|
|
41
|
+
return {
|
|
42
|
+
runId: options.runId,
|
|
43
|
+
extensions,
|
|
44
|
+
lanes,
|
|
45
|
+
flush: async () => {
|
|
46
|
+
// Sequential, log first: a usage failure must not cost us the transcript.
|
|
47
|
+
if (lanes.log)
|
|
48
|
+
await lanes.log.flush();
|
|
49
|
+
if (lanes.usageReport)
|
|
50
|
+
await lanes.usageReport.flush();
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Start a pi session with a run's lanes attached.
|
|
3
|
+
*
|
|
4
|
+
* pi ships as ESM only, and every host already imports it dynamically (the
|
|
5
|
+
* desktop main process does this in `bootstrap`, before pi's storage paths are
|
|
6
|
+
* resolved). So the module is *injected* rather than imported here: this package
|
|
7
|
+
* stays CJS+ESM publishable, the host keeps control of when pi loads, and these
|
|
8
|
+
* paths are testable without a model, a network or a filesystem.
|
|
9
|
+
*
|
|
10
|
+
* pi takes extensions through a `ResourceLoader`, not through
|
|
11
|
+
* `createAgentSession`, so embedding means handing the composed factories to a
|
|
12
|
+
* loader — pi's default one, or the host's own (the desktop harness builds one
|
|
13
|
+
* that layers its skills, prompts and templates).
|
|
14
|
+
*/
|
|
15
|
+
import type { CreateAgentSessionOptions, CreateAgentSessionResult, ExtensionFactory, ResourceLoader } from '@earendil-works/pi-coding-agent';
|
|
16
|
+
import { type ComposedRun, type ComposeRunOptions } from './lanes';
|
|
17
|
+
export interface ResourceLoaderRequest {
|
|
18
|
+
/** The lane extensions, in load order. Give these to the loader. */
|
|
19
|
+
extensionFactories: ExtensionFactory[];
|
|
20
|
+
cwd: string;
|
|
21
|
+
agentDir: string;
|
|
22
|
+
}
|
|
23
|
+
export type CreateResourceLoader = (request: ResourceLoaderRequest) => ResourceLoader | Promise<ResourceLoader>;
|
|
24
|
+
/** The slice of the pi module this package needs, so a host can inject it. */
|
|
25
|
+
export interface PiModule {
|
|
26
|
+
createAgentSession: (options: CreateAgentSessionOptions) => Promise<CreateAgentSessionResult>;
|
|
27
|
+
DefaultResourceLoader: new (options: {
|
|
28
|
+
cwd: string;
|
|
29
|
+
agentDir: string;
|
|
30
|
+
extensionFactories?: ExtensionFactory[];
|
|
31
|
+
}) => ResourceLoader;
|
|
32
|
+
getAgentDir?: () => string;
|
|
33
|
+
}
|
|
34
|
+
export interface StartRunOptions extends ComposeRunOptions {
|
|
35
|
+
/** `await import('@earendil-works/pi-coding-agent')`. */
|
|
36
|
+
pi: PiModule;
|
|
37
|
+
cwd?: string;
|
|
38
|
+
/** pi's config/storage dir. Defaults to `pi.getAgentDir()` when available. */
|
|
39
|
+
agentDir?: string;
|
|
40
|
+
/** Passed through to pi, minus the ones this package owns. */
|
|
41
|
+
session?: Omit<CreateAgentSessionOptions, 'cwd' | 'agentDir' | 'resourceLoader'>;
|
|
42
|
+
/** Layer the lanes onto a host's own loader instead of pi's default. */
|
|
43
|
+
createResourceLoader?: CreateResourceLoader;
|
|
44
|
+
}
|
|
45
|
+
export interface EmbeddedRun {
|
|
46
|
+
run: ComposedRun;
|
|
47
|
+
session: CreateAgentSessionResult['session'];
|
|
48
|
+
extensionsResult: CreateAgentSessionResult['extensionsResult'];
|
|
49
|
+
modelFallbackMessage?: string;
|
|
50
|
+
resourceLoader: ResourceLoader;
|
|
51
|
+
/**
|
|
52
|
+
* Flush the lanes, then dispose the session. Flush first and let a failure
|
|
53
|
+
* propagate *after* disposal, so a delivery error is never traded for a leaked
|
|
54
|
+
* session — and never swallowed into a clean-looking shutdown.
|
|
55
|
+
*/
|
|
56
|
+
close(): Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
export declare function startRun(options: StartRunOptions): Promise<EmbeddedRun>;
|
package/embed/session.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Start a pi session with a run's lanes attached.
|
|
4
|
+
*
|
|
5
|
+
* pi ships as ESM only, and every host already imports it dynamically (the
|
|
6
|
+
* desktop main process does this in `bootstrap`, before pi's storage paths are
|
|
7
|
+
* resolved). So the module is *injected* rather than imported here: this package
|
|
8
|
+
* stays CJS+ESM publishable, the host keeps control of when pi loads, and these
|
|
9
|
+
* paths are testable without a model, a network or a filesystem.
|
|
10
|
+
*
|
|
11
|
+
* pi takes extensions through a `ResourceLoader`, not through
|
|
12
|
+
* `createAgentSession`, so embedding means handing the composed factories to a
|
|
13
|
+
* loader — pi's default one, or the host's own (the desktop harness builds one
|
|
14
|
+
* that layers its skills, prompts and templates).
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.startRun = startRun;
|
|
18
|
+
const lanes_1 = require("./lanes");
|
|
19
|
+
async function startRun(options) {
|
|
20
|
+
const cwd = options.cwd ?? process.cwd();
|
|
21
|
+
const agentDir = options.agentDir ?? options.pi.getAgentDir?.();
|
|
22
|
+
if (agentDir === undefined) {
|
|
23
|
+
throw new Error('pi: agentDir is required when the injected pi module has no getAgentDir()');
|
|
24
|
+
}
|
|
25
|
+
const run = (0, lanes_1.composeRun)(options);
|
|
26
|
+
const resourceLoader = await (options.createResourceLoader ?? defaultResourceLoader(options.pi))({
|
|
27
|
+
extensionFactories: run.extensions,
|
|
28
|
+
cwd,
|
|
29
|
+
agentDir
|
|
30
|
+
});
|
|
31
|
+
const result = await options.pi.createAgentSession({ ...options.session, cwd, agentDir, resourceLoader });
|
|
32
|
+
// pi emits `session_start` from `bindExtensions`, which its own CLI modes call
|
|
33
|
+
// once their UI exists — `createAgentSession` never does. An embedder that
|
|
34
|
+
// skips it loads the extensions but never starts them: the metered lane never
|
|
35
|
+
// selects the gateway model, so a cloud run's calls leave on whatever provider
|
|
36
|
+
// key the process happens to hold, unmetered, and the log lane never binds on
|
|
37
|
+
// resume. So the embedding, not the host, fires it.
|
|
38
|
+
await result.session.bindExtensions({});
|
|
39
|
+
assertMeteredModelSelected(run, result.session);
|
|
40
|
+
return {
|
|
41
|
+
run,
|
|
42
|
+
session: result.session,
|
|
43
|
+
extensionsResult: result.extensionsResult,
|
|
44
|
+
...(result.modelFallbackMessage === undefined ? {} : { modelFallbackMessage: result.modelFallbackMessage }),
|
|
45
|
+
resourceLoader,
|
|
46
|
+
close: async () => {
|
|
47
|
+
let failure;
|
|
48
|
+
try {
|
|
49
|
+
await run.flush();
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
failure = error;
|
|
53
|
+
}
|
|
54
|
+
result.session.dispose();
|
|
55
|
+
if (failure !== undefined)
|
|
56
|
+
throw failure;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The metered lane's whole purpose is that usage cannot be under-reported, so a
|
|
62
|
+
* session that ended up on some other provider must fail the run rather than
|
|
63
|
+
* quietly bill nothing.
|
|
64
|
+
*/
|
|
65
|
+
function assertMeteredModelSelected(run, session) {
|
|
66
|
+
const lane = run.lanes.meteredModel;
|
|
67
|
+
if (!lane?.selectedModel)
|
|
68
|
+
return;
|
|
69
|
+
const model = session.model;
|
|
70
|
+
if (model?.provider === lane.providerName && model.id === lane.selectedModel)
|
|
71
|
+
return;
|
|
72
|
+
throw new Error(`pi: the metered lane selected "${lane.providerName}/${lane.selectedModel}" but the ` +
|
|
73
|
+
`session is on "${model ? `${model.provider}/${model.id}` : 'no model'}" — model calls would ` +
|
|
74
|
+
'leave outside the gateway and go unmetered');
|
|
75
|
+
}
|
|
76
|
+
const defaultResourceLoader = (pi) => async (request) => {
|
|
77
|
+
const loader = new pi.DefaultResourceLoader({
|
|
78
|
+
cwd: request.cwd,
|
|
79
|
+
agentDir: request.agentDir,
|
|
80
|
+
extensionFactories: request.extensionFactories
|
|
81
|
+
});
|
|
82
|
+
// pi's loader discovers nothing until it is reloaded once.
|
|
83
|
+
await loader.reload();
|
|
84
|
+
return loader;
|
|
85
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compose a run's lanes into the extension list pi loads.
|
|
3
|
+
*
|
|
4
|
+
* A run's placement — a developer's laptop, a long-running cloud job — is *only*
|
|
5
|
+
* a difference in these options: which store the log appends to, whether the
|
|
6
|
+
* model calls leave through the metered gateway or a local key, and where an
|
|
7
|
+
* approval question is asked. The composed session is otherwise identical, which
|
|
8
|
+
* is the whole point of the package.
|
|
9
|
+
*/
|
|
10
|
+
import type { MeteredIdentity } from '@agentic-kit/metering';
|
|
11
|
+
import type { ExtensionFactory } from '@earendil-works/pi-coding-agent';
|
|
12
|
+
import { type GateExtension, type GateExtensionOptions } from '../extensions/gate';
|
|
13
|
+
import { type MeteredModelExtension, type MeteredModelExtensionOptions } from '../extensions/metered-model';
|
|
14
|
+
import { type RunLogExtension, type RunLogExtensionOptions } from '../extensions/run-log';
|
|
15
|
+
import { type UsageReportExtension, type UsageReportExtensionOptions } from '../extensions/usage-report';
|
|
16
|
+
/** Run log lane — `runId` comes from the run, not from here. */
|
|
17
|
+
export type RunLogLane = Omit<RunLogExtensionOptions, 'runId'>;
|
|
18
|
+
/**
|
|
19
|
+
* Metering lane. `gateway` is authoritative (the gateway meters what it proxies);
|
|
20
|
+
* `self-report` is the own-provider-key lane and is only as trustworthy as the
|
|
21
|
+
* agent reporting it. They are mutually exclusive on purpose: routing through the
|
|
22
|
+
* gateway *and* self-reporting would double-count the same tokens.
|
|
23
|
+
*/
|
|
24
|
+
export type MeteringLane = ({
|
|
25
|
+
mode: 'gateway';
|
|
26
|
+
} & MeteredModelExtensionOptions) | ({
|
|
27
|
+
mode: 'self-report';
|
|
28
|
+
} & UsageReportExtensionOptions);
|
|
29
|
+
/** Approval lane — `runId` comes from the run. */
|
|
30
|
+
export type GateLane = Omit<GateExtensionOptions, 'runId'>;
|
|
31
|
+
export interface ComposeRunOptions {
|
|
32
|
+
runId: string;
|
|
33
|
+
log?: RunLogLane;
|
|
34
|
+
metering?: MeteringLane;
|
|
35
|
+
gate?: GateLane;
|
|
36
|
+
/**
|
|
37
|
+
* The host's own extensions — workspace tools, prompts, UI glue. Loaded after
|
|
38
|
+
* the lanes so a host tool call is already gated and already logged.
|
|
39
|
+
*/
|
|
40
|
+
extensions?: readonly ExtensionFactory[];
|
|
41
|
+
}
|
|
42
|
+
export interface ComposedLanes {
|
|
43
|
+
log?: RunLogExtension;
|
|
44
|
+
meteredModel?: MeteredModelExtension;
|
|
45
|
+
usageReport?: UsageReportExtension;
|
|
46
|
+
gate?: GateExtension;
|
|
47
|
+
}
|
|
48
|
+
export interface ComposedRun {
|
|
49
|
+
runId: string;
|
|
50
|
+
/** In load order: log, metering, gate, then the host's own. */
|
|
51
|
+
extensions: ExtensionFactory[];
|
|
52
|
+
lanes: ComposedLanes;
|
|
53
|
+
/**
|
|
54
|
+
* Drain every lane that buffers, and throw the first failure. A host calls
|
|
55
|
+
* this before it exits; the extensions also flush themselves on
|
|
56
|
+
* `session_shutdown`, so this is for hosts that end a run without one.
|
|
57
|
+
*/
|
|
58
|
+
flush(): Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
export declare function composeRun(options: ComposeRunOptions): ComposedRun;
|
|
61
|
+
/**
|
|
62
|
+
* The identity every metered lane needs. Exported so a host can build it once
|
|
63
|
+
* from its resolved project context and hand the same value to both lanes.
|
|
64
|
+
*/
|
|
65
|
+
export type { MeteredIdentity };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compose a run's lanes into the extension list pi loads.
|
|
3
|
+
*
|
|
4
|
+
* A run's placement — a developer's laptop, a long-running cloud job — is *only*
|
|
5
|
+
* a difference in these options: which store the log appends to, whether the
|
|
6
|
+
* model calls leave through the metered gateway or a local key, and where an
|
|
7
|
+
* approval question is asked. The composed session is otherwise identical, which
|
|
8
|
+
* is the whole point of the package.
|
|
9
|
+
*/
|
|
10
|
+
import { createGateExtension } from '../extensions/gate';
|
|
11
|
+
import { createMeteredModelExtension } from '../extensions/metered-model';
|
|
12
|
+
import { createRunLogExtension } from '../extensions/run-log';
|
|
13
|
+
import { createUsageReportExtension } from '../extensions/usage-report';
|
|
14
|
+
export function composeRun(options) {
|
|
15
|
+
const lanes = {};
|
|
16
|
+
const extensions = [];
|
|
17
|
+
if (options.log) {
|
|
18
|
+
lanes.log = createRunLogExtension({ runId: options.runId, ...options.log });
|
|
19
|
+
extensions.push(lanes.log.extension);
|
|
20
|
+
}
|
|
21
|
+
const metering = options.metering;
|
|
22
|
+
if (metering?.mode === 'gateway') {
|
|
23
|
+
const { mode: _mode, ...meteredOptions } = metering;
|
|
24
|
+
lanes.meteredModel = createMeteredModelExtension(meteredOptions);
|
|
25
|
+
extensions.push(lanes.meteredModel.extension);
|
|
26
|
+
}
|
|
27
|
+
else if (metering?.mode === 'self-report') {
|
|
28
|
+
const { mode: _mode, ...reportOptions } = metering;
|
|
29
|
+
lanes.usageReport = createUsageReportExtension(reportOptions);
|
|
30
|
+
extensions.push(lanes.usageReport.extension);
|
|
31
|
+
}
|
|
32
|
+
if (options.gate) {
|
|
33
|
+
lanes.gate = createGateExtension({ runId: options.runId, ...options.gate });
|
|
34
|
+
extensions.push(lanes.gate.extension);
|
|
35
|
+
}
|
|
36
|
+
if (options.extensions)
|
|
37
|
+
extensions.push(...options.extensions);
|
|
38
|
+
return {
|
|
39
|
+
runId: options.runId,
|
|
40
|
+
extensions,
|
|
41
|
+
lanes,
|
|
42
|
+
flush: async () => {
|
|
43
|
+
// Sequential, log first: a usage failure must not cost us the transcript.
|
|
44
|
+
if (lanes.log)
|
|
45
|
+
await lanes.log.flush();
|
|
46
|
+
if (lanes.usageReport)
|
|
47
|
+
await lanes.usageReport.flush();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Start a pi session with a run's lanes attached.
|
|
3
|
+
*
|
|
4
|
+
* pi ships as ESM only, and every host already imports it dynamically (the
|
|
5
|
+
* desktop main process does this in `bootstrap`, before pi's storage paths are
|
|
6
|
+
* resolved). So the module is *injected* rather than imported here: this package
|
|
7
|
+
* stays CJS+ESM publishable, the host keeps control of when pi loads, and these
|
|
8
|
+
* paths are testable without a model, a network or a filesystem.
|
|
9
|
+
*
|
|
10
|
+
* pi takes extensions through a `ResourceLoader`, not through
|
|
11
|
+
* `createAgentSession`, so embedding means handing the composed factories to a
|
|
12
|
+
* loader — pi's default one, or the host's own (the desktop harness builds one
|
|
13
|
+
* that layers its skills, prompts and templates).
|
|
14
|
+
*/
|
|
15
|
+
import type { CreateAgentSessionOptions, CreateAgentSessionResult, ExtensionFactory, ResourceLoader } from '@earendil-works/pi-coding-agent';
|
|
16
|
+
import { type ComposedRun, type ComposeRunOptions } from './lanes';
|
|
17
|
+
export interface ResourceLoaderRequest {
|
|
18
|
+
/** The lane extensions, in load order. Give these to the loader. */
|
|
19
|
+
extensionFactories: ExtensionFactory[];
|
|
20
|
+
cwd: string;
|
|
21
|
+
agentDir: string;
|
|
22
|
+
}
|
|
23
|
+
export type CreateResourceLoader = (request: ResourceLoaderRequest) => ResourceLoader | Promise<ResourceLoader>;
|
|
24
|
+
/** The slice of the pi module this package needs, so a host can inject it. */
|
|
25
|
+
export interface PiModule {
|
|
26
|
+
createAgentSession: (options: CreateAgentSessionOptions) => Promise<CreateAgentSessionResult>;
|
|
27
|
+
DefaultResourceLoader: new (options: {
|
|
28
|
+
cwd: string;
|
|
29
|
+
agentDir: string;
|
|
30
|
+
extensionFactories?: ExtensionFactory[];
|
|
31
|
+
}) => ResourceLoader;
|
|
32
|
+
getAgentDir?: () => string;
|
|
33
|
+
}
|
|
34
|
+
export interface StartRunOptions extends ComposeRunOptions {
|
|
35
|
+
/** `await import('@earendil-works/pi-coding-agent')`. */
|
|
36
|
+
pi: PiModule;
|
|
37
|
+
cwd?: string;
|
|
38
|
+
/** pi's config/storage dir. Defaults to `pi.getAgentDir()` when available. */
|
|
39
|
+
agentDir?: string;
|
|
40
|
+
/** Passed through to pi, minus the ones this package owns. */
|
|
41
|
+
session?: Omit<CreateAgentSessionOptions, 'cwd' | 'agentDir' | 'resourceLoader'>;
|
|
42
|
+
/** Layer the lanes onto a host's own loader instead of pi's default. */
|
|
43
|
+
createResourceLoader?: CreateResourceLoader;
|
|
44
|
+
}
|
|
45
|
+
export interface EmbeddedRun {
|
|
46
|
+
run: ComposedRun;
|
|
47
|
+
session: CreateAgentSessionResult['session'];
|
|
48
|
+
extensionsResult: CreateAgentSessionResult['extensionsResult'];
|
|
49
|
+
modelFallbackMessage?: string;
|
|
50
|
+
resourceLoader: ResourceLoader;
|
|
51
|
+
/**
|
|
52
|
+
* Flush the lanes, then dispose the session. Flush first and let a failure
|
|
53
|
+
* propagate *after* disposal, so a delivery error is never traded for a leaked
|
|
54
|
+
* session — and never swallowed into a clean-looking shutdown.
|
|
55
|
+
*/
|
|
56
|
+
close(): Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
export declare function startRun(options: StartRunOptions): Promise<EmbeddedRun>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Start a pi session with a run's lanes attached.
|
|
3
|
+
*
|
|
4
|
+
* pi ships as ESM only, and every host already imports it dynamically (the
|
|
5
|
+
* desktop main process does this in `bootstrap`, before pi's storage paths are
|
|
6
|
+
* resolved). So the module is *injected* rather than imported here: this package
|
|
7
|
+
* stays CJS+ESM publishable, the host keeps control of when pi loads, and these
|
|
8
|
+
* paths are testable without a model, a network or a filesystem.
|
|
9
|
+
*
|
|
10
|
+
* pi takes extensions through a `ResourceLoader`, not through
|
|
11
|
+
* `createAgentSession`, so embedding means handing the composed factories to a
|
|
12
|
+
* loader — pi's default one, or the host's own (the desktop harness builds one
|
|
13
|
+
* that layers its skills, prompts and templates).
|
|
14
|
+
*/
|
|
15
|
+
import { composeRun } from './lanes';
|
|
16
|
+
export async function startRun(options) {
|
|
17
|
+
const cwd = options.cwd ?? process.cwd();
|
|
18
|
+
const agentDir = options.agentDir ?? options.pi.getAgentDir?.();
|
|
19
|
+
if (agentDir === undefined) {
|
|
20
|
+
throw new Error('pi: agentDir is required when the injected pi module has no getAgentDir()');
|
|
21
|
+
}
|
|
22
|
+
const run = composeRun(options);
|
|
23
|
+
const resourceLoader = await (options.createResourceLoader ?? defaultResourceLoader(options.pi))({
|
|
24
|
+
extensionFactories: run.extensions,
|
|
25
|
+
cwd,
|
|
26
|
+
agentDir
|
|
27
|
+
});
|
|
28
|
+
const result = await options.pi.createAgentSession({ ...options.session, cwd, agentDir, resourceLoader });
|
|
29
|
+
// pi emits `session_start` from `bindExtensions`, which its own CLI modes call
|
|
30
|
+
// once their UI exists — `createAgentSession` never does. An embedder that
|
|
31
|
+
// skips it loads the extensions but never starts them: the metered lane never
|
|
32
|
+
// selects the gateway model, so a cloud run's calls leave on whatever provider
|
|
33
|
+
// key the process happens to hold, unmetered, and the log lane never binds on
|
|
34
|
+
// resume. So the embedding, not the host, fires it.
|
|
35
|
+
await result.session.bindExtensions({});
|
|
36
|
+
assertMeteredModelSelected(run, result.session);
|
|
37
|
+
return {
|
|
38
|
+
run,
|
|
39
|
+
session: result.session,
|
|
40
|
+
extensionsResult: result.extensionsResult,
|
|
41
|
+
...(result.modelFallbackMessage === undefined ? {} : { modelFallbackMessage: result.modelFallbackMessage }),
|
|
42
|
+
resourceLoader,
|
|
43
|
+
close: async () => {
|
|
44
|
+
let failure;
|
|
45
|
+
try {
|
|
46
|
+
await run.flush();
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
failure = error;
|
|
50
|
+
}
|
|
51
|
+
result.session.dispose();
|
|
52
|
+
if (failure !== undefined)
|
|
53
|
+
throw failure;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The metered lane's whole purpose is that usage cannot be under-reported, so a
|
|
59
|
+
* session that ended up on some other provider must fail the run rather than
|
|
60
|
+
* quietly bill nothing.
|
|
61
|
+
*/
|
|
62
|
+
function assertMeteredModelSelected(run, session) {
|
|
63
|
+
const lane = run.lanes.meteredModel;
|
|
64
|
+
if (!lane?.selectedModel)
|
|
65
|
+
return;
|
|
66
|
+
const model = session.model;
|
|
67
|
+
if (model?.provider === lane.providerName && model.id === lane.selectedModel)
|
|
68
|
+
return;
|
|
69
|
+
throw new Error(`pi: the metered lane selected "${lane.providerName}/${lane.selectedModel}" but the ` +
|
|
70
|
+
`session is on "${model ? `${model.provider}/${model.id}` : 'no model'}" — model calls would ` +
|
|
71
|
+
'leave outside the gateway and go unmetered');
|
|
72
|
+
}
|
|
73
|
+
const defaultResourceLoader = (pi) => async (request) => {
|
|
74
|
+
const loader = new pi.DefaultResourceLoader({
|
|
75
|
+
cwd: request.cwd,
|
|
76
|
+
agentDir: request.agentDir,
|
|
77
|
+
extensionFactories: request.extensionFactories
|
|
78
|
+
});
|
|
79
|
+
// pi's loader discovers nothing until it is reloaded once.
|
|
80
|
+
await loader.reload();
|
|
81
|
+
return loader;
|
|
82
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pi side of the run gate: map pi's `tool_call` event onto the neutral
|
|
3
|
+
* `RunGate` and turn its outcome into pi's block shape.
|
|
4
|
+
*
|
|
5
|
+
* pi's `tool_call` handler can await, so an `ask` verdict simply suspends the
|
|
6
|
+
* tool until the approval channel resolves. That is the whole mechanism for
|
|
7
|
+
* "approve a cloud agent's `rm -rf` from a browser tab" — and it is all the
|
|
8
|
+
* harness-specific code the gate needs; the policy, the approval rendezvous and
|
|
9
|
+
* the audit record live in `@agentic-kit/harness`.
|
|
10
|
+
*/
|
|
11
|
+
import { type ApprovalRequest, type RunGate, type RunGateOptions, type RunGatePolicy } from '@agentic-kit/harness';
|
|
12
|
+
import type { ExtensionFactory } from '@earendil-works/pi-coding-agent';
|
|
13
|
+
export type GateExtensionOptions = RunGateOptions;
|
|
14
|
+
export interface GateExtension {
|
|
15
|
+
extension: ExtensionFactory;
|
|
16
|
+
policy: RunGatePolicy;
|
|
17
|
+
/** Requests currently waiting on a human. */
|
|
18
|
+
pending: ReadonlyMap<string, ApprovalRequest>;
|
|
19
|
+
gate: RunGate;
|
|
20
|
+
}
|
|
21
|
+
export declare function createGateExtension(options: GateExtensionOptions): GateExtension;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pi side of the run gate: map pi's `tool_call` event onto the neutral
|
|
3
|
+
* `RunGate` and turn its outcome into pi's block shape.
|
|
4
|
+
*
|
|
5
|
+
* pi's `tool_call` handler can await, so an `ask` verdict simply suspends the
|
|
6
|
+
* tool until the approval channel resolves. That is the whole mechanism for
|
|
7
|
+
* "approve a cloud agent's `rm -rf` from a browser tab" — and it is all the
|
|
8
|
+
* harness-specific code the gate needs; the policy, the approval rendezvous and
|
|
9
|
+
* the audit record live in `@agentic-kit/harness`.
|
|
10
|
+
*/
|
|
11
|
+
import { createRunGate } from '@agentic-kit/harness';
|
|
12
|
+
export function createGateExtension(options) {
|
|
13
|
+
const gate = createRunGate(options);
|
|
14
|
+
const extension = (pi) => {
|
|
15
|
+
pi.on('tool_call', async (event) => {
|
|
16
|
+
const outcome = await gate.decide({
|
|
17
|
+
toolCallId: event.toolCallId,
|
|
18
|
+
toolName: event.toolName,
|
|
19
|
+
input: (event.input ?? {})
|
|
20
|
+
});
|
|
21
|
+
if (outcome.allowed)
|
|
22
|
+
return {};
|
|
23
|
+
return { block: true, ...(outcome.reason === undefined ? {} : { reason: outcome.reason }) };
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
return { extension, policy: gate.policy, pending: gate.pending, gate };
|
|
27
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pi side of the metering lane: register the resolved gateway as a pi
|
|
3
|
+
* provider, optionally as the session's model.
|
|
4
|
+
*
|
|
5
|
+
* Every model call then leaves the process through `agentic-server`, which is the
|
|
6
|
+
* billing authority, so usage cannot be under-reported by the agent. The
|
|
7
|
+
* endpoint itself is resolved by `@agentic-kit/metering`; only the registration
|
|
8
|
+
* shape below is pi's. The self-reporting lane (own provider keys) is
|
|
9
|
+
* `./usage-report`.
|
|
10
|
+
*/
|
|
11
|
+
import { type MeteredGateway, type MeteredGatewayOptions } from '@agentic-kit/metering';
|
|
12
|
+
import type { ExtensionFactory, ProviderConfig } from '@earendil-works/pi-coding-agent';
|
|
13
|
+
export interface MeteredModelExtensionOptions extends MeteredGatewayOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Model id to select once registered. Defaults to the first model; pass `false`
|
|
16
|
+
* to leave the host's model choice alone.
|
|
17
|
+
*/
|
|
18
|
+
selectModel?: string | false;
|
|
19
|
+
}
|
|
20
|
+
export interface MeteredModelExtension {
|
|
21
|
+
extension: ExtensionFactory;
|
|
22
|
+
/** The config handed to pi — asserted in tests, useful for host logging. */
|
|
23
|
+
config: ProviderConfig;
|
|
24
|
+
/** The neutral endpoint the config was built from. */
|
|
25
|
+
gateway: MeteredGateway;
|
|
26
|
+
providerName: string;
|
|
27
|
+
/** Model id pi selects on session start, if any. */
|
|
28
|
+
selectedModel: string | undefined;
|
|
29
|
+
}
|
|
30
|
+
/** Shape a resolved gateway as pi's provider registration. */
|
|
31
|
+
export declare function piProviderConfig(gateway: MeteredGateway): ProviderConfig;
|
|
32
|
+
export declare function createMeteredModelExtension(options: MeteredModelExtensionOptions): MeteredModelExtension;
|