Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ jobs:
- batch: graphile-unit
packages: 'graphile/graphile-plugin-utils graphile/graphile-realtime-subscriptions graphile/graphile-sql-expression-validator graphile/graphile-upload-plugin'
- batch: agentic
packages: 'agentic/protocol agentic/agentic-kit agentic/agent agentic/harness agentic/chat agentic/cli agentic/pi agentic/react agentic/agentic-server agentic/anthropic agentic/openai agentic/ollama'
packages: 'agentic/protocol agentic/agentic-kit agentic/agent agentic/harness agentic/chat agentic/cli agentic/pi agentic/react agentic/agentic-server agentic/anthropic agentic/openai agentic/ollama agentic/run-log agentic/pi-ext-run-log agentic/pi-ext-metered-model agentic/pi-ext-usage-report agentic/pi-ext-gate agentic/pi-embed'
- batch: pgpm-unit
packages: 'pgpm/types pgpm/naming-spec pgpm/diff pgpm/import pgpm/slice pgpm/transform'
- batch: pglite
Expand Down
54 changes: 54 additions & 0 deletions agentic/pi-embed/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<p align="center" width="100%">
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
</p>

# @agentic-kit/pi-embed

One embedding of the pi coding agent for **both placements**. A run's lanes — append-only run log, metering, tool approvals — are composed from config, so "local" and "cloud" are *values*, not code paths, and there is exactly one agent loop to reason about.

```ts
import { startRun } from '@agentic-kit/pi-embed';

const embedded = await startRun({
runId,
cwd: workspace,
log: { store }, // @agentic-kit/pi-ext-run-log
metering: { mode: 'gateway', gatewayUrl, identity, models: [{ id: 'gpt-5' }] },
gate: { policy, approvals }, // @agentic-kit/pi-ext-gate
extensions: [createDbTools(host)] // the host's own tools
});

await embedded.session.prompt('add a users table');
await embedded.close();
```

The same call runs on a laptop by changing only the values:

```ts
log: { store: fileRunLogStore(dir) },
metering: { mode: 'self-report', gatewayUrl, identity }, // own provider key
gate: { policy: gatePolicy({ defaultDecision: 'allow' }) } // a human is watching
```

## What it decides for you

- **Load order is lanes first, host extensions second**, so by the time a host tool call happens it is already gated and already logged.
- **`runId` is threaded into every lane** that records against a run — one id ties the transcript, the usage rows and the approval requests together.
- **The two metering lanes are mutually exclusive.** `gateway` is authoritative (the gateway meters what it proxies); `self-report` is the own-key lane and only as trustworthy as the agent reporting it. Enabling both would double-count the same tokens, so the config makes it impossible.
- **Extensions reach pi through a `ResourceLoader`,** not `createAgentSession` — that is pi's design, so `startRun` builds one (`DefaultResourceLoader` by default) and lets a host supply `createResourceLoader` to layer the lanes onto its own resources (skills, prompts, templates — the desktop harness already builds such a loader).
- **`close()` flushes the lanes, then disposes the session, then rethrows.** A delivery failure is never traded for a leaked session, and it is never swallowed into a clean-looking shutdown.
- **Lane misconfiguration fails at `composeRun`** — a gateway URL that already ends in `/v1`, a policy that can `ask` with nowhere to ask — rather than at the first model call or the first sensitive tool.

`composeRun` is available on its own for a host that already owns session creation and just wants the extension list plus a `flush()`.

## Testing

`pnpm test` — both the session factory and the loader factory are injectable, so every path here is covered without a model, a network or a filesystem.

## Related

- `@agentic-kit/run-log` — the record types, projectors and store contracts
- `@agentic-kit/pi-ext-run-log` — mirrors pi session entries into the log
- `@agentic-kit/pi-ext-metered-model` — the authoritative (gateway) metering lane
- `@agentic-kit/pi-ext-usage-report` — the self-reported (own key) metering lane
- `@agentic-kit/pi-ext-gate` — policy + remote approvals for tool calls
132 changes: 132 additions & 0 deletions agentic/pi-embed/__tests__/lanes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { MemoryRunLogStore } from '@agentic-kit/run-log';
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';

import { composeRun } from '../src';

const identity = { databaseId: 'db-1', entityId: 'ent-1', actorId: 'actor-1' };
const gatewayUrl = 'https://gateway.constructive.io';
const models = [{ id: 'gpt-5', contextWindow: 200_000, maxTokens: 32_000 }];

describe('composeRun', () => {
it('composes nothing but the host extensions when no lane is configured', () => {
const hostExtension = jest.fn();
const run = composeRun({ runId: 'run-1', extensions: [hostExtension] });

expect(run.extensions).toEqual([hostExtension]);
expect(run.lanes).toEqual({});
});

it('loads the lanes before the host extensions, so host tools are gated and logged', () => {
const hostExtension = jest.fn();
const run = composeRun({
runId: 'run-1',
log: { store: new MemoryRunLogStore() },
metering: { mode: 'gateway', gatewayUrl, identity, models },
gate: { policy: { defaultDecision: 'allow' } },
extensions: [hostExtension]
});

expect(run.extensions).toHaveLength(4);
expect(run.extensions[0]).toBe(run.lanes.log!.extension);
expect(run.extensions[1]).toBe(run.lanes.meteredModel!.extension);
expect(run.extensions[2]).toBe(run.lanes.gate!.extension);
expect(run.extensions[3]).toBe(hostExtension);
});

it('threads the run id into every lane that records against a run', async () => {
const store = new MemoryRunLogStore();
const decisions: string[] = [];
const run = composeRun({
runId: 'run-42',
log: { store },
gate: { policy: { defaultDecision: 'allow' }, onDecision: (record) => decisions.push(record.runId) }
});

const handlers = new Map<string, (event: any, ctx: any) => unknown>();
run.lanes.gate!.extension({ on: (event: string, handler: any) => handlers.set(event, handler) } as unknown as ExtensionAPI);
await handlers.get('tool_call')!({ type: 'tool_call', toolCallId: 'c1', toolName: 'read', input: {} }, {});

expect(decisions).toEqual(['run-42']);
expect(run.runId).toBe('run-42');
});

it('picks the gateway lane as the metered one, with no self-report double count', () => {
const run = composeRun({
runId: 'run-1',
metering: { mode: 'gateway', gatewayUrl, identity, models }
});

expect(run.lanes.meteredModel).toBeDefined();
expect(run.lanes.usageReport).toBeUndefined();
expect(run.lanes.meteredModel!.selectedModel).toBe('gpt-5');
});

it('picks the self-report lane for a run on the host’s own provider key', () => {
const run = composeRun({
runId: 'run-1',
metering: { mode: 'self-report', identity, sink: () => Promise.resolve() }
});

expect(run.lanes.usageReport).toBeDefined();
expect(run.lanes.meteredModel).toBeUndefined();
});

it('surfaces a lane’s own configuration error at compose time', () => {
expect(() =>
composeRun({
runId: 'run-1',
metering: { mode: 'gateway', gatewayUrl: `${gatewayUrl}/v1`, identity, models }
})
).toThrow();

// A policy that can ask needs somewhere to ask.
expect(() => composeRun({ runId: 'run-1', gate: { policy: {} } })).toThrow(/approvals channel is required/);
});

it('flushes the log before usage, so a usage failure cannot cost the transcript', async () => {
const order: string[] = [];
const run = composeRun({
runId: 'run-1',
log: { store: new MemoryRunLogStore() },
metering: {
mode: 'self-report',
identity,
sink: () => {
order.push('usage');
return Promise.resolve();
}
}
});

const logFlush = jest.spyOn(run.lanes.log!, 'flush').mockImplementation(async () => {
order.push('log');
return [];
});
run.lanes.usageReport!.reporter.enqueue({
model: 'gpt-5',
provider: 'constructive',
service: 'chat',
operation: 'pi/chat',
input_tokens: 1,
output_tokens: 1,
total_tokens: 2,
latency_ms: 0,
status: 'ok'
});

await run.flush();

expect(logFlush).toHaveBeenCalled();
expect(order).toEqual(['log', 'usage']);
});

it('propagates a flush failure instead of reporting a clean shutdown', async () => {
const run = composeRun({
runId: 'run-1',
log: { store: new MemoryRunLogStore() }
});
jest.spyOn(run.lanes.log!, 'flush').mockRejectedValue(new Error('store unreachable'));

await expect(run.flush()).rejects.toThrow('store unreachable');
});
});
144 changes: 144 additions & 0 deletions agentic/pi-embed/__tests__/session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { MemoryRunLogStore } from '@agentic-kit/run-log';
import type {
CreateAgentSessionOptions,
CreateAgentSessionResult,
ResourceLoader
} from '@earendil-works/pi-coding-agent';

import { type PiModule, startRun } from '../src';

const fakeLoader = {} as ResourceLoader;

const fakePi = () => {
const session = { dispose: jest.fn() };
const createAgentSession = jest.fn(
(_options: CreateAgentSessionOptions): Promise<CreateAgentSessionResult> =>
Promise.resolve({
session: session as unknown as CreateAgentSessionResult['session'],
extensionsResult: { extensions: [], errors: [], runtime: {} as never } as never
})
);
const loaderOptions: unknown[] = [];
const reload = jest.fn(() => Promise.resolve());
class DefaultResourceLoader {
constructor(options: unknown) {
loaderOptions.push(options);
}
reload = reload;
}

const pi = {
createAgentSession,
DefaultResourceLoader: DefaultResourceLoader as unknown as PiModule['DefaultResourceLoader'],
getAgentDir: () => '/default-agent-dir'
} satisfies PiModule;

return { pi, session, createAgentSession, loaderOptions, reload };
};

describe('startRun', () => {
it('hands the composed lanes to the resource loader, since that is how pi takes extensions', async () => {
const { pi, createAgentSession } = fakePi();
const createResourceLoader = jest.fn().mockReturnValue(fakeLoader);

const embedded = await startRun({
runId: 'run-1',
pi,
cwd: '/workspace',
agentDir: '/agent',
log: { store: new MemoryRunLogStore() },
createResourceLoader
});

expect(createResourceLoader).toHaveBeenCalledWith({
extensionFactories: embedded.run.extensions,
cwd: '/workspace',
agentDir: '/agent'
});
expect(createAgentSession).toHaveBeenCalledWith(
expect.objectContaining({ cwd: '/workspace', agentDir: '/agent', resourceLoader: fakeLoader })
);
expect(embedded.resourceLoader).toBe(fakeLoader);
});

it('builds pi’s default loader with the lanes and reloads it, since it discovers nothing until then', async () => {
const { pi, loaderOptions, reload } = fakePi();

const embedded = await startRun({ runId: 'run-1', pi, cwd: '/workspace', log: { store: new MemoryRunLogStore() } });

expect(loaderOptions).toEqual([
{ cwd: '/workspace', agentDir: '/default-agent-dir', extensionFactories: embedded.run.extensions }
]);
expect(reload).toHaveBeenCalled();
});

it('demands an agentDir when the injected pi module cannot supply one', async () => {
const { pi } = fakePi();
const withoutAgentDir: PiModule = {
createAgentSession: pi.createAgentSession,
DefaultResourceLoader: pi.DefaultResourceLoader
};

await expect(startRun({ runId: 'run-1', pi: withoutAgentDir })).rejects.toThrow(/agentDir is required/);
});

it('awaits an async loader builder, which a host layering its own resources needs', async () => {
const { pi } = fakePi();
const embedded = await startRun({
runId: 'run-1',
pi,
createResourceLoader: () => Promise.resolve(fakeLoader)
});

expect(embedded.resourceLoader).toBe(fakeLoader);
});

it('passes the host’s session options through without letting them override the embedding', async () => {
const { pi, createAgentSession } = fakePi();
await startRun({
runId: 'run-1',
pi,
cwd: '/workspace',
session: { tools: ['read'], noTools: 'builtin' },
createResourceLoader: () => fakeLoader
});

const options = createAgentSession.mock.calls[0][0];
expect(options.tools).toEqual(['read']);
expect(options.noTools).toBe('builtin');
expect(options.cwd).toBe('/workspace');
});

it('flushes the lanes before disposing the session', async () => {
const { pi, session } = fakePi();
const embedded = await startRun({
runId: 'run-1',
pi,
log: { store: new MemoryRunLogStore() },
createResourceLoader: () => fakeLoader
});

const order: string[] = [];
jest.spyOn(embedded.run, 'flush').mockImplementation(async () => {
order.push('flush');
});
session.dispose.mockImplementation(() => order.push('dispose'));

await embedded.close();
expect(order).toEqual(['flush', 'dispose']);
});

it('still disposes the session when the flush fails, then reports the failure', async () => {
const { pi, session } = fakePi();
const embedded = await startRun({
runId: 'run-1',
pi,
log: { store: new MemoryRunLogStore() },
createResourceLoader: () => fakeLoader
});
jest.spyOn(embedded.run, 'flush').mockRejectedValue(new Error('store unreachable'));

await expect(embedded.close()).rejects.toThrow('store unreachable');
expect(session.dispose).toHaveBeenCalled();
});
});
21 changes: 21 additions & 0 deletions agentic/pi-embed/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
babelConfig: false,
tsconfig: 'tsconfig.json',
},
],
},
transformIgnorePatterns: [`/node_modules/*`],
testRegex: '(/__tests__/.*\\.(test|spec))\\.(jsx?|tsx?)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
modulePathIgnorePatterns: ['dist/*'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
};
Loading
Loading