Skip to content

Commit f4e3944

Browse files
committed
Factor AgentHarness extension compatibility
1 parent bc046ed commit f4e3944

15 files changed

Lines changed: 910 additions & 800 deletions

packages/cli/src/cli-harness.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ import {
1818
import { parseArgs } from "node:util";
1919
import { stderr, stdout } from "node:process";
2020
import { captureScreenshot, type CuaBrowserHandle } from "./harness-browser";
21-
import { loadHarnessExtensions } from "./extensions/setup";
22-
import type { HarnessExtensionHost } from "./extensions/host";
21+
import {
22+
loadHarnessExtensions,
23+
type HarnessExtensions,
24+
} from "./extensions/setup";
2325
import {
2426
type ActionRequest,
2527
type ModelActionType,
@@ -372,7 +374,7 @@ interface HarnessRuntime {
372374
*/
373375
skipInitialScreenshot: boolean;
374376
/** Loaded pi-extension host. Undefined with --no-extensions or an untrusted project + no global extensions. */
375-
host?: HarnessExtensionHost;
377+
host?: HarnessExtensions;
376378
}
377379

378380
export interface SetupHarnessRuntimeOptions {
@@ -508,7 +510,7 @@ async function finishHarnessRuntime(
508510
// runs once this returns, so close the handle here before rethrowing. The
509511
// first-turn screenshot decision reads the session, so keep it inside the same
510512
// guard.
511-
let host: HarnessExtensionHost | undefined;
513+
let host: HarnessExtensions | undefined;
512514
let skipInitialScreenshot: boolean;
513515
try {
514516
// Decide the first-turn screenshot before extensions load: a resumed session
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
import type { AgentToolResult } from "@onkernel/cua-agent";
2+
import {
3+
createSyntheticSourceInfo,
4+
discoverAndLoadExtensions,
5+
type RegisteredTool,
6+
} from "@earendil-works/pi-coding-agent";
7+
import { link, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
8+
import { join } from "node:path";
9+
10+
export interface AddToolInput {
11+
name: string;
12+
label?: string;
13+
description: string;
14+
parameters: Record<string, unknown>;
15+
execute: string;
16+
}
17+
18+
export interface AddToolDetails {
19+
written: string;
20+
valid: true;
21+
addedToolNames: string[];
22+
}
23+
24+
export interface AddToolRegistrationOptions {
25+
cwd: string;
26+
extensionRoot: string | undefined;
27+
hasToolName(name: string): boolean;
28+
installTool(registration: RegisteredTool): Promise<void>;
29+
}
30+
31+
const ADD_TOOL_NAME = "add_tool";
32+
const TOOL_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
33+
34+
const ADD_TOOL_DESCRIPTION = [
35+
"Add one trusted project-local tool and make it available immediately.",
36+
"The definition is validated, persisted beneath .agents/extensions, and",
37+
"activated before this call returns, so it can be called on the next model turn.",
38+
"The execute field must be one async function expression. This capability is not",
39+
"a sandbox: execute code has the same Node.js access as other local extensions.",
40+
].join("\n");
41+
42+
const ADD_TOOL_PARAMETERS = {
43+
type: "object",
44+
properties: {
45+
name: {
46+
type: "string",
47+
description: "provider-safe tool name (letters, digits, _ and -)",
48+
},
49+
label: { type: "string", description: "display label; defaults to name" },
50+
description: { type: "string", description: "non-empty tool description" },
51+
parameters: {
52+
type: "object",
53+
description: 'JSON Schema with top-level type "object"',
54+
},
55+
execute: {
56+
type: "string",
57+
description:
58+
"one async function expression with signature (toolCallId, params, signal, onUpdate)",
59+
},
60+
},
61+
required: ["name", "description", "parameters", "execute"],
62+
additionalProperties: false,
63+
} as const;
64+
65+
/** Build the normal Pi tool registration used by the compatibility host. */
66+
export function createAddToolRegistration(
67+
options: AddToolRegistrationOptions,
68+
): RegisteredTool {
69+
return {
70+
definition: {
71+
name: ADD_TOOL_NAME,
72+
label: "Add tool",
73+
description: ADD_TOOL_DESCRIPTION,
74+
parameters: ADD_TOOL_PARAMETERS,
75+
executionMode: "sequential",
76+
execute: async (
77+
_toolCallId,
78+
rawInput,
79+
): Promise<AgentToolResult<AddToolDetails>> => addTool(options, rawInput),
80+
},
81+
sourceInfo: createSyntheticSourceInfo(ADD_TOOL_NAME, {
82+
source: "cua --self-extend",
83+
scope: "project",
84+
baseDir: options.cwd,
85+
}),
86+
};
87+
}
88+
89+
async function addTool(
90+
options: AddToolRegistrationOptions,
91+
input: unknown,
92+
): Promise<AgentToolResult<AddToolDetails>> {
93+
const extensionRoot = options.extensionRoot;
94+
if (!extensionRoot)
95+
throw new Error("no project extension directory configured for add_tool");
96+
const normalized = validateAddToolInput(input);
97+
const target = join(extensionRoot, `${normalized.name}.ts`);
98+
if (options.hasToolName(normalized.name)) {
99+
throw new Error(`tool name "${normalized.name}" already exists`);
100+
}
101+
102+
await mkdir(extensionRoot, { recursive: true });
103+
const stagingDir = await mkdtemp(join(extensionRoot, ".add-tool-"));
104+
const stagedFile = join(stagingDir, `${normalized.name}.ts`);
105+
try {
106+
await writeFile(stagedFile, renderToolExtension(normalized), {
107+
encoding: "utf8",
108+
flag: "wx",
109+
});
110+
const registered = await trialLoadTool(
111+
stagedFile,
112+
normalized.name,
113+
stagingDir,
114+
);
115+
try {
116+
await link(stagedFile, target);
117+
} catch (error) {
118+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
119+
throw new Error(`extension already exists at ${target}`);
120+
}
121+
throw error;
122+
}
123+
124+
try {
125+
await options.installTool({
126+
definition: registered.definition,
127+
sourceInfo: createSyntheticSourceInfo(target, {
128+
source: target,
129+
scope: "project",
130+
baseDir: extensionRoot,
131+
}),
132+
});
133+
} catch (error) {
134+
await rm(target, { force: true });
135+
throw error;
136+
}
137+
138+
return {
139+
content: [{ type: "text", text: `added ${normalized.name} at ${target}` }],
140+
details: {
141+
written: target,
142+
valid: true,
143+
addedToolNames: [normalized.name],
144+
},
145+
};
146+
} finally {
147+
await rm(stagingDir, { recursive: true, force: true });
148+
}
149+
}
150+
151+
async function trialLoadTool(
152+
filePath: string,
153+
expectedName: string,
154+
isolatedRoot: string,
155+
): Promise<RegisteredTool> {
156+
const result = await discoverAndLoadExtensions(
157+
[filePath],
158+
isolatedRoot,
159+
isolatedRoot,
160+
);
161+
if (result.errors.length > 0) {
162+
throw new Error(
163+
`tool validation failed: ${result.errors.map((entry) => entry.error).join("; ")}`,
164+
);
165+
}
166+
const registrations = result.extensions.flatMap((extension) => [
167+
...extension.tools.values(),
168+
]);
169+
if (
170+
registrations.length !== 1 ||
171+
registrations[0]?.definition.name !== expectedName
172+
) {
173+
throw new Error(
174+
`generated extension must register exactly one tool named "${expectedName}"`,
175+
);
176+
}
177+
const registration = registrations[0];
178+
if (
179+
typeof registration.definition.execute !== "function" ||
180+
registration.definition.execute.constructor.name !== "AsyncFunction"
181+
) {
182+
throw new Error("execute must be one async function expression");
183+
}
184+
return registration;
185+
}
186+
187+
function validateAddToolInput(input: unknown): Required<AddToolInput> {
188+
if (!input || typeof input !== "object" || Array.isArray(input)) {
189+
throw new Error("tool definition must be an object");
190+
}
191+
const candidate = input as Record<string, unknown>;
192+
if (
193+
typeof candidate.name !== "string" ||
194+
!TOOL_NAME_PATTERN.test(candidate.name)
195+
) {
196+
throw new Error(
197+
"name must start with a letter, contain only letters, digits, _ or -, and be at most 64 characters",
198+
);
199+
}
200+
const label = candidate.label ?? candidate.name;
201+
if (typeof label !== "string" || label.trim().length === 0)
202+
throw new Error("label must be non-empty");
203+
if (
204+
typeof candidate.description !== "string" ||
205+
candidate.description.trim().length === 0
206+
) {
207+
throw new Error("description must be non-empty");
208+
}
209+
if (
210+
!candidate.parameters ||
211+
typeof candidate.parameters !== "object" ||
212+
Array.isArray(candidate.parameters) ||
213+
(candidate.parameters as Record<string, unknown>).type !== "object"
214+
) {
215+
throw new Error(
216+
'parameters must be a JSON-serializable object schema with top-level type "object"',
217+
);
218+
}
219+
try {
220+
JSON.stringify(candidate.parameters);
221+
} catch {
222+
throw new Error("parameters must be JSON-serializable");
223+
}
224+
if (
225+
typeof candidate.execute !== "string" ||
226+
!/^(?:\s*)async\b/.test(candidate.execute) ||
227+
hasTopLevelComma(candidate.execute)
228+
) {
229+
throw new Error("execute must be one async function expression");
230+
}
231+
return {
232+
name: candidate.name,
233+
label,
234+
description: candidate.description,
235+
parameters: candidate.parameters as Record<string, unknown>,
236+
execute: candidate.execute,
237+
};
238+
}
239+
240+
function hasTopLevelComma(source: string): boolean {
241+
let parens = 0;
242+
let braces = 0;
243+
let brackets = 0;
244+
let quote: "'" | '"' | "`" | undefined;
245+
let escaped = false;
246+
for (const character of source) {
247+
if (quote) {
248+
if (escaped) escaped = false;
249+
else if (character === "\\") escaped = true;
250+
else if (character === quote) quote = undefined;
251+
continue;
252+
}
253+
if (character === "'" || character === '"' || character === "`") {
254+
quote = character;
255+
continue;
256+
}
257+
if (character === "(") parens += 1;
258+
else if (character === ")") parens -= 1;
259+
else if (character === "{") braces += 1;
260+
else if (character === "}") braces -= 1;
261+
else if (character === "[") brackets += 1;
262+
else if (character === "]") brackets -= 1;
263+
else if (character === "," && parens === 0 && braces === 0 && brackets === 0)
264+
return true;
265+
}
266+
return false;
267+
}
268+
269+
export function renderToolExtension(input: Required<AddToolInput>): string {
270+
return [
271+
`const name = ${JSON.stringify(input.name)};`,
272+
`const label = ${JSON.stringify(input.label)};`,
273+
`const description = ${JSON.stringify(input.description)};`,
274+
`const parameters = ${JSON.stringify(input.parameters)};`,
275+
"",
276+
"export default function (pi) {",
277+
"\tpi.registerTool({",
278+
"\t\tname,",
279+
"\t\tlabel,",
280+
"\t\tdescription,",
281+
"\t\tparameters,",
282+
`\t\texecute: (${input.execute}),`,
283+
"\t});",
284+
"}",
285+
"",
286+
].join("\n");
287+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# AgentHarness extension compatibility
2+
3+
Pi currently binds extensions to `AgentSession`, not `AgentHarness`. This directory
4+
contains the temporary adapter used by the CUA CLI:
5+
6+
- `context.ts` implements Pi extension context actions over the harness.
7+
- `hooks.ts` forwards harness hooks and lifecycle events to `ExtensionRunner`.
8+
- `tool-registry.ts` reconciles extension tools with CUA tool/model changes.
9+
- `host.ts` owns discovery, lifecycle, and manual reload.
10+
11+
Pi's `packages/agent/docs/hooks.md` designs generic `AgentHarness` hooks and its
12+
`packages/agent/docs/agent-harness.md` plans a later coding-agent migration onto
13+
that hook/session facade. When those APIs are public, replace this directory from
14+
`../setup.ts`; `../add-tool.ts` and its standard Pi extension artifacts should not
15+
need to move with it.

packages/cli/src/extensions/seams.ts renamed to packages/cli/src/extensions/compat/context.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
* runner only needs the call to be enqueued, matching how pi forwards these to
1717
* its own session.
1818
*/
19-
export interface SeamHooks {
19+
export interface HarnessContextBindings {
2020
refreshTools: () => void;
2121
getActiveTools: () => string[];
2222
/** Forward user text through the host's first-turn screenshot prompt path. */
@@ -32,7 +32,7 @@ export interface SeamHooks {
3232
export function makeExtensionActions(
3333
harness: AgentHarness,
3434
session: Session,
35-
hooks: SeamHooks,
35+
bindings: HarnessContextBindings,
3636
): ExtensionActions {
3737
return {
3838
sendMessage(message): void {
@@ -45,22 +45,22 @@ export function makeExtensionActions(
4545
},
4646
sendUserMessage(content): void {
4747
const text = typeof content === "string" ? content : textPartsOf(content);
48-
void hooks.sendUserMessage(text);
48+
void bindings.sendUserMessage(text);
4949
},
5050
appendEntry(customType, data): void {
5151
void session.appendCustomEntry(customType, data);
5252
},
5353
setSessionName(name): void {
54-
hooks.setSessionName(name);
54+
bindings.setSessionName(name);
5555
void session.appendSessionName(name);
5656
},
5757
getSessionName(): string | undefined {
58-
return hooks.getSessionName();
58+
return bindings.getSessionName();
5959
},
6060
// Labels are a TUI-only affordance (entry bookmarking); no headless sink.
6161
setLabel(): void {},
6262
getActiveTools(): string[] {
63-
return hooks.getActiveTools();
63+
return bindings.getActiveTools();
6464
},
6565
getAllTools(): ToolInfo[] {
6666
return harness.getTools().map(
@@ -74,10 +74,10 @@ export function makeExtensionActions(
7474
);
7575
},
7676
setActiveTools(names): void {
77-
void hooks.setActiveTools(names);
77+
void bindings.setActiveTools(names);
7878
},
7979
refreshTools(): void {
80-
hooks.refreshTools();
80+
bindings.refreshTools();
8181
},
8282
// Slash commands are Tier B; none are surfaced from this host.
8383
getCommands(): never[] {

0 commit comments

Comments
 (0)