@ai-sdk/harness-pi 0.0.0 → 1.0.0-beta.10

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.
@@ -0,0 +1,69 @@
1
+ import path from 'node:path';
2
+ import type { HarnessV1Skill } from '@ai-sdk/harness';
3
+ import type { Experimental_SandboxSession } from '@ai-sdk/provider-utils';
4
+ import { renderPiSkillFile, safePiMetadataSegment } from './pi-utils';
5
+
6
+ /**
7
+ * Materialize Pi skills as files under
8
+ * `$HOME/.agents/skills/<name>/SKILL.md` inside the sandbox.
9
+ */
10
+ export async function writePiSkills(args: {
11
+ readonly sandbox: Experimental_SandboxSession;
12
+ readonly sandboxHomeDir: string;
13
+ readonly skills: ReadonlyArray<HarnessV1Skill>;
14
+ readonly abortSignal?: AbortSignal;
15
+ }): Promise<void> {
16
+ for (const skill of args.skills) {
17
+ safePiMetadataSegment(skill.name, 'skill');
18
+ for (const file of skill.files ?? []) {
19
+ safePiSkillFilePath({ skillName: skill.name, filePath: file.path });
20
+ }
21
+ }
22
+
23
+ for (const skill of args.skills) {
24
+ const name = safePiMetadataSegment(skill.name, 'skill');
25
+ const sandboxSkillDir = path.posix.join(
26
+ args.sandboxHomeDir,
27
+ '.agents',
28
+ 'skills',
29
+ name,
30
+ );
31
+ const content = renderPiSkillFile(skill);
32
+
33
+ await args.sandbox.writeTextFile({
34
+ path: path.posix.join(sandboxSkillDir, 'SKILL.md'),
35
+ content,
36
+ ...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
37
+ });
38
+
39
+ for (const file of skill.files ?? []) {
40
+ const filePath = safePiSkillFilePath({
41
+ skillName: skill.name,
42
+ filePath: file.path,
43
+ });
44
+ await args.sandbox.writeTextFile({
45
+ path: path.posix.join(sandboxSkillDir, filePath),
46
+ content: file.content,
47
+ ...(args.abortSignal ? { abortSignal: args.abortSignal } : {}),
48
+ });
49
+ }
50
+ }
51
+ }
52
+
53
+ function safePiSkillFilePath({
54
+ skillName,
55
+ filePath,
56
+ }: {
57
+ skillName: string;
58
+ filePath: string;
59
+ }): string {
60
+ const normalized = path.posix.normalize(filePath);
61
+ if (
62
+ normalized === '.' ||
63
+ normalized.startsWith('../') ||
64
+ path.posix.isAbsolute(normalized)
65
+ ) {
66
+ throw new Error(`Invalid Pi skill file path for ${skillName}: ${filePath}`);
67
+ }
68
+ return normalized;
69
+ }
@@ -0,0 +1,331 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import type { HarnessV1StreamPart } from '@ai-sdk/harness';
3
+ import { extractAssistantText, type PiSessionEvent } from './pi-events';
4
+ import { serializeToolOutput } from './pi-utils';
5
+
6
+ /**
7
+ * Translator state shared across all events of a single turn. Reset at the
8
+ * start of every `doPromptTurn`. Callers update the same instance and read it to
9
+ * decide when a turn has settled into a steady state (e.g. for gap-filling).
10
+ */
11
+ export interface PiTranslatorState {
12
+ /**
13
+ * True once a `turn_start` or assistant `message_start` event has been
14
+ * observed. Suppresses spurious deltas that arrive before the turn opens.
15
+ */
16
+ promptStarted: boolean;
17
+ /** Accumulated assistant text from `text_delta` events. */
18
+ streamedAssistantText: string;
19
+ /** Stream-part id for the active text block; synthesized on first delta. */
20
+ currentTextId: string | undefined;
21
+ /**
22
+ * Stream-part id for the active reasoning block; synthesized lazily on
23
+ * first `thinking_delta`.
24
+ */
25
+ currentReasoningId: string | undefined;
26
+ /** Whether a `reasoning-start` event has already been emitted. */
27
+ reasoningStarted: boolean;
28
+ /** Tool-call id → tool name (used to fill in `toolName` on results). */
29
+ observedToolNames: Map<string, string>;
30
+ /**
31
+ * Tool-call id → the exact output value the host submitted for a
32
+ * user-registered (host-executed) tool. Pi only echoes the tool result back
33
+ * as serialized text (the tool handler stringifies the output before handing
34
+ * it to the runtime so the model can read it), which would otherwise reach
35
+ * consumers as a string and lose the original object structure. Keeping the
36
+ * submitted value here lets the result projection surface the original object
37
+ * — matching the other adapters — while the model still receives the text.
38
+ * Populated by the session's `submitToolResult`; consumed (and cleared) when
39
+ * the matching `tool_result`/`tool_execution_end` event is translated.
40
+ */
41
+ hostToolResults: Map<string, unknown>;
42
+ /**
43
+ * Names of tools that Pi executes natively (read/write/edit/bash/grep/
44
+ * find/ls). `tool-call` events for these get `providerExecuted: true`
45
+ * so the harness host doesn't try to dispatch them. User-registered
46
+ * tools are not in this set.
47
+ */
48
+ readonly builtinToolNames: ReadonlySet<string>;
49
+ /**
50
+ * Map of native tool name → common name. `find` → `glob`, etc. Pi emits
51
+ * native names on its events; the wire `toolName` is the common name when
52
+ * one exists.
53
+ */
54
+ readonly nativeToCommonNameMap: ReadonlyMap<string, string>;
55
+ }
56
+
57
+ export interface PiTranslatorStateOptions {
58
+ readonly builtinToolNames?: ReadonlyArray<string>;
59
+ readonly nativeToCommon?:
60
+ | ReadonlyMap<string, string>
61
+ | Record<string, string>;
62
+ }
63
+
64
+ export function createPiTranslatorState(
65
+ options: PiTranslatorStateOptions = {},
66
+ ): PiTranslatorState {
67
+ const map =
68
+ options.nativeToCommon instanceof Map
69
+ ? options.nativeToCommon
70
+ : new Map(Object.entries(options.nativeToCommon ?? {}));
71
+ return {
72
+ promptStarted: false,
73
+ streamedAssistantText: '',
74
+ currentTextId: undefined,
75
+ currentReasoningId: undefined,
76
+ reasoningStarted: false,
77
+ observedToolNames: new Map(),
78
+ hostToolResults: new Map(),
79
+ builtinToolNames: new Set(options.builtinToolNames ?? []),
80
+ nativeToCommonNameMap: map,
81
+ };
82
+ }
83
+
84
+ function newId(): string {
85
+ return randomBytes(8).toString('hex');
86
+ }
87
+
88
+ /**
89
+ * Pi's `tool_execution_end` event payload (`result`) is a Pi `AgentToolResult`
90
+ * envelope `{ content: (TextContent | ImageContent)[], details, terminate? }`.
91
+ * The `tool_result` event uses a flat shape with `content` and `details` at
92
+ * the top level. In both cases we extract just the text payload (joined when
93
+ * multiple text parts are present) so the AI SDK consumer sees the raw
94
+ * string the tool produced.
95
+ */
96
+ function unwrapPiToolResult(event: PiSessionEvent): never {
97
+ const candidates: unknown[] = [];
98
+ const result = event.result as unknown;
99
+ if (result && typeof result === 'object') {
100
+ const inner = (result as { content?: unknown }).content;
101
+ if (Array.isArray(inner)) candidates.push(inner);
102
+ }
103
+ if (Array.isArray(event.content)) candidates.push(event.content);
104
+
105
+ for (const content of candidates) {
106
+ if (!Array.isArray(content)) continue;
107
+ const text = content
108
+ .filter(
109
+ (p): p is { type: 'text'; text: string } =>
110
+ !!p &&
111
+ typeof p === 'object' &&
112
+ (p as { type?: unknown }).type === 'text' &&
113
+ typeof (p as { text?: unknown }).text === 'string',
114
+ )
115
+ .map(p => p.text)
116
+ .join('');
117
+ if (text) return text as never;
118
+ }
119
+
120
+ if (typeof event.result === 'string') return event.result as never;
121
+ if (typeof event.content === 'string') return event.content as never;
122
+ return (event.result ?? event.content ?? null) as never;
123
+ }
124
+
125
+ function resolveToolName(
126
+ state: PiTranslatorState,
127
+ nativeName: string,
128
+ ): { wire: string; native: string } {
129
+ const common = state.nativeToCommonNameMap.get(nativeName);
130
+ return { wire: common ?? nativeName, native: nativeName };
131
+ }
132
+
133
+ /**
134
+ * Translate a single Pi `session.subscribe` event into zero or more
135
+ * `HarnessV1StreamPart`s, updating the translator state in place. Returns
136
+ * an empty array for events that produce no output (e.g. events emitted
137
+ * before `turn_start`).
138
+ *
139
+ * The translator does NOT emit `stream-start`/`finish` — those are
140
+ * lifecycle signals owned by the session layer.
141
+ */
142
+ export function translatePiEvent(
143
+ event: PiSessionEvent,
144
+ state: PiTranslatorState,
145
+ ): HarnessV1StreamPart[] {
146
+ switch (event.type) {
147
+ case 'turn_start':
148
+ case 'message_start': {
149
+ if (
150
+ event.type === 'message_start' &&
151
+ event.message?.role !== 'assistant'
152
+ ) {
153
+ return [];
154
+ }
155
+ state.promptStarted = true;
156
+ state.streamedAssistantText = '';
157
+ state.currentTextId = undefined;
158
+ state.currentReasoningId = undefined;
159
+ state.reasoningStarted = false;
160
+ return [];
161
+ }
162
+
163
+ case 'message_update': {
164
+ if (!state.promptStarted) return [];
165
+ const update = event.assistantMessageEvent;
166
+ if (!update) return [];
167
+ if (update.type === 'text_delta' && typeof update.delta === 'string') {
168
+ const parts: HarnessV1StreamPart[] = [];
169
+ // If reasoning was active, close it before opening the text block so
170
+ // consumers can reset block-scoped formatting (ANSI colors, etc.)
171
+ // between sections.
172
+ if (state.reasoningStarted && state.currentReasoningId) {
173
+ parts.push({
174
+ type: 'reasoning-end',
175
+ id: state.currentReasoningId,
176
+ });
177
+ state.reasoningStarted = false;
178
+ state.currentReasoningId = undefined;
179
+ }
180
+ if (!state.currentTextId) {
181
+ state.currentTextId = newId();
182
+ parts.push({ type: 'text-start', id: state.currentTextId });
183
+ }
184
+ state.streamedAssistantText += update.delta;
185
+ parts.push({
186
+ type: 'text-delta',
187
+ id: state.currentTextId,
188
+ delta: update.delta,
189
+ });
190
+ return parts;
191
+ }
192
+ if (
193
+ update.type === 'thinking_delta' &&
194
+ typeof update.delta === 'string'
195
+ ) {
196
+ const parts: HarnessV1StreamPart[] = [];
197
+ // Symmetric to the text branch: close any open text block before
198
+ // starting a fresh reasoning block.
199
+ if (state.currentTextId) {
200
+ parts.push({ type: 'text-end', id: state.currentTextId });
201
+ state.currentTextId = undefined;
202
+ }
203
+ if (!state.currentReasoningId) {
204
+ state.currentReasoningId = newId();
205
+ }
206
+ if (!state.reasoningStarted) {
207
+ state.reasoningStarted = true;
208
+ parts.push({ type: 'reasoning-start', id: state.currentReasoningId });
209
+ }
210
+ parts.push({
211
+ type: 'reasoning-delta',
212
+ id: state.currentReasoningId,
213
+ delta: update.delta,
214
+ });
215
+ return parts;
216
+ }
217
+ return [];
218
+ }
219
+
220
+ case 'message_end':
221
+ case 'turn_end': {
222
+ if (!state.promptStarted) return [];
223
+ const parts: HarnessV1StreamPart[] = [];
224
+ const fullText = extractAssistantText(event.message);
225
+ if (
226
+ state.currentTextId &&
227
+ fullText.startsWith(state.streamedAssistantText) &&
228
+ fullText.length > state.streamedAssistantText.length
229
+ ) {
230
+ const missing = fullText.slice(state.streamedAssistantText.length);
231
+ state.streamedAssistantText = fullText;
232
+ parts.push({
233
+ type: 'text-delta',
234
+ id: state.currentTextId,
235
+ delta: missing,
236
+ });
237
+ }
238
+ if (state.currentTextId) {
239
+ parts.push({ type: 'text-end', id: state.currentTextId });
240
+ state.currentTextId = undefined;
241
+ }
242
+ if (state.reasoningStarted && state.currentReasoningId) {
243
+ parts.push({ type: 'reasoning-end', id: state.currentReasoningId });
244
+ state.reasoningStarted = false;
245
+ state.currentReasoningId = undefined;
246
+ }
247
+ return parts;
248
+ }
249
+
250
+ case 'tool_execution_start': {
251
+ if (!event.toolCallId || !event.toolName) return [];
252
+ const { wire, native } = resolveToolName(state, event.toolName);
253
+ state.observedToolNames.set(event.toolCallId, wire);
254
+ const providerExecuted = state.builtinToolNames.has(native);
255
+ const input = serializeToolOutput(event.args ?? event.input ?? {});
256
+ return [
257
+ {
258
+ type: 'tool-call',
259
+ toolCallId: event.toolCallId,
260
+ toolName: wire,
261
+ input,
262
+ ...(wire !== native ? { nativeName: native } : {}),
263
+ ...(providerExecuted ? { providerExecuted: true } : {}),
264
+ } as HarnessV1StreamPart,
265
+ ];
266
+ }
267
+
268
+ case 'tool_execution_end':
269
+ case 'tool_result': {
270
+ if (!event.toolCallId) return [];
271
+ const recordedName = state.observedToolNames.get(event.toolCallId);
272
+ const nativeName = event.toolName;
273
+ const wire =
274
+ recordedName ??
275
+ (nativeName ? resolveToolName(state, nativeName).wire : undefined);
276
+ if (!wire) return [];
277
+ /*
278
+ * Prefer the exact value the host submitted for user-registered tools
279
+ * (see `hostToolResults`). Built-in tools, whose results Pi produces and
280
+ * reports as text, are not in the map and fall back to unwrapping the
281
+ * event's text payload.
282
+ */
283
+ const result = state.hostToolResults.has(event.toolCallId)
284
+ ? ((state.hostToolResults.get(event.toolCallId) ?? null) as Extract<
285
+ HarnessV1StreamPart,
286
+ { type: 'tool-result' }
287
+ >['result'])
288
+ : unwrapPiToolResult(event);
289
+ state.hostToolResults.delete(event.toolCallId);
290
+ return [
291
+ {
292
+ type: 'tool-result',
293
+ toolCallId: event.toolCallId,
294
+ toolName: wire,
295
+ result,
296
+ ...(event.isError ? { isError: true } : {}),
297
+ } as HarnessV1StreamPart,
298
+ ];
299
+ }
300
+
301
+ case 'compaction_end': {
302
+ /*
303
+ * Pi performs the compaction itself; we observe its result. Skip aborted
304
+ * or result-less compactions (nothing happened). A result with no summary
305
+ * still represents a real compaction, so emit it with a placeholder
306
+ * rather than dropping the event. `reason` is `'manual'` for an explicit
307
+ * `session.compact()` call, `'threshold'`/`'overflow'` for Pi's automatic
308
+ * compaction — both map to `'auto'` on the wire. Pi reports `tokensBefore`
309
+ * but not `tokensAfter`.
310
+ */
311
+ if (event.aborted) return [];
312
+ const result = event.result;
313
+ if (!result || typeof result !== 'object') return [];
314
+ const rawSummary = (result as { summary?: unknown }).summary;
315
+ const summary =
316
+ typeof rawSummary === 'string' ? rawSummary : '(no summary provided)';
317
+ const tokensBefore = (result as { tokensBefore?: unknown }).tokensBefore;
318
+ return [
319
+ {
320
+ type: 'compaction',
321
+ trigger: event.reason === 'manual' ? 'manual' : 'auto',
322
+ summary,
323
+ ...(typeof tokensBefore === 'number' ? { tokensBefore } : {}),
324
+ },
325
+ ];
326
+ }
327
+
328
+ default:
329
+ return [];
330
+ }
331
+ }
@@ -0,0 +1,11 @@
1
+ import { Type, type TSchema } from 'typebox';
2
+
3
+ /**
4
+ * Wrap a JSON Schema 7 fragment so Pi's `defineTool({ parameters })` accepts
5
+ * it. Pi v0.77 uses TypeBox at the type level but treats `parameters` as
6
+ * opaque schema metadata at runtime, so `Type.Unsafe(jsonSchema)` is
7
+ * sufficient — no per-property structural conversion is needed.
8
+ */
9
+ export function toolSpecToTypeBoxParameters(jsonSchema: unknown): TSchema {
10
+ return Type.Unsafe(jsonSchema as object);
11
+ }
@@ -0,0 +1,91 @@
1
+ import {
2
+ HarnessCapabilityUnsupportedError,
3
+ type HarnessV1Prompt,
4
+ type HarnessV1Skill,
5
+ } from '@ai-sdk/harness';
6
+
7
+ const HARNESS_ID = 'pi';
8
+
9
+ /**
10
+ * Extract a single user text string from a `HarnessV1Prompt`. Pi's
11
+ * `session.prompt(text)` accepts a string; multimodal user content
12
+ * is not supported in this foundational version.
13
+ */
14
+ export function extractUserText(prompt: HarnessV1Prompt): string {
15
+ if (typeof prompt === 'string') {
16
+ return prompt;
17
+ }
18
+
19
+ const { content } = prompt;
20
+ if (typeof content === 'string') {
21
+ return content;
22
+ }
23
+
24
+ const parts: string[] = [];
25
+ for (const part of content) {
26
+ if (part.type !== 'text') {
27
+ throw new HarnessCapabilityUnsupportedError({
28
+ message: `pi: only text user-message parts are supported; got '${part.type}'.`,
29
+ harnessId: HARNESS_ID,
30
+ });
31
+ }
32
+ parts.push(part.text);
33
+ }
34
+ return parts.join('\n\n');
35
+ }
36
+
37
+ /*
38
+ * Frame session instructions and the user's text so the runtime treats the
39
+ * instructions as system-provided operating guidance, not something the user
40
+ * wrote. Without the wrapper the agent can echo the prepended text back as if
41
+ * the user had asked for it, which is confusing since the user never typed it.
42
+ * Applied only to the first user message of a fresh session.
43
+ */
44
+ export function frameInstructions(
45
+ instructions: string,
46
+ userText: string,
47
+ ): string {
48
+ return (
49
+ '<session-instructions>\n' +
50
+ 'The block below is operating guidance from the system, not a message from the user — follow it, but do not mention it or attribute it to the user.\n\n' +
51
+ `${instructions}\n` +
52
+ '</session-instructions>\n\n' +
53
+ `<user-message>\n${userText}\n</user-message>`
54
+ );
55
+ }
56
+
57
+ /** POSIX shell single-quote escape. */
58
+ export function shellQuote(value: string): string {
59
+ return `'${value.replace(/'/g, `'\\''`)}'`;
60
+ }
61
+
62
+ /** Serialize a tool output to the string Pi feeds back to the model. */
63
+ export function serializeToolOutput(output: unknown): string {
64
+ if (typeof output === 'string') {
65
+ return output;
66
+ }
67
+ const serialized = JSON.stringify(output);
68
+ return serialized ?? 'null';
69
+ }
70
+
71
+ export function getErrorText(error: unknown): string {
72
+ return error instanceof Error ? error.message : String(error);
73
+ }
74
+
75
+ /**
76
+ * Validate that a name is safe to use as a filesystem path segment under
77
+ * `.pi/skills/<name>/` or `.pi/agents/<name>.md`. Refuses anything that
78
+ * could be interpreted as a path traversal or contains shell-sensitive
79
+ * characters.
80
+ */
81
+ export function safePiMetadataSegment(name: string, label: string): string {
82
+ if (!/^[A-Za-z0-9._-]+$/.test(name) || name === '.' || name === '..') {
83
+ throw new Error(`Invalid Pi ${label} name: ${name}`);
84
+ }
85
+ return name;
86
+ }
87
+
88
+ /** Frontmatter renderer for `.pi/skills/<name>/SKILL.md`. */
89
+ export function renderPiSkillFile(skill: HarnessV1Skill): string {
90
+ return `---\nname: ${skill.name}\ndescription: ${skill.description}\n---\n\n${skill.content}`;
91
+ }