@ai-sdk/harness-pi 1.0.125 → 1.0.128
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/CHANGELOG.md +28 -0
- package/README.md +8 -4
- package/dist/index.d.ts +4 -0
- package/dist/index.js +172 -17
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/pi-harness.ts +4 -1
- package/src/pi-session.ts +84 -19
- package/src/pi-tool-result.ts +147 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-sdk/harness-pi",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.128",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@ai-sdk/harness": "1.0.
|
|
30
|
-
"@ai-sdk/provider-utils": "5.0.
|
|
29
|
+
"@ai-sdk/harness": "1.0.126",
|
|
30
|
+
"@ai-sdk/provider-utils": "5.0.49",
|
|
31
31
|
"@earendil-works/pi-ai": "0.74.2",
|
|
32
32
|
"@earendil-works/pi-coding-agent": "^0.85.1",
|
|
33
33
|
"pi-mcp-adapter": "2.12.1",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"zod": "^3.25.76 || ^4.1.8"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@ai-sdk/sandbox-just-bash": "1.0.
|
|
40
|
+
"@ai-sdk/sandbox-just-bash": "1.0.126",
|
|
41
41
|
"@types/node": "22.19.19",
|
|
42
42
|
"@vercel/ai-tsconfig": "0.0.0",
|
|
43
43
|
"tsup": "^8.5.1",
|
package/src/pi-harness.ts
CHANGED
|
@@ -74,9 +74,12 @@ const PI_BUILTIN_TOOLS = {
|
|
|
74
74
|
read: commonTool('read', {
|
|
75
75
|
nativeName: 'read',
|
|
76
76
|
toolUseKind: 'readonly',
|
|
77
|
-
description:
|
|
77
|
+
description:
|
|
78
|
+
'Read file contents. Output is limited to 2,000 lines or 50KB. Use offset and limit to read large files in pages.',
|
|
78
79
|
inputSchema: z.object({
|
|
79
80
|
file_path: z.string(),
|
|
81
|
+
offset: z.number().int().positive().optional(),
|
|
82
|
+
limit: z.number().int().positive().optional(),
|
|
80
83
|
}),
|
|
81
84
|
}),
|
|
82
85
|
write: commonTool('write', {
|
package/src/pi-session.ts
CHANGED
|
@@ -50,6 +50,11 @@ import { getPiTerminalError, parseNativeEvent } from './pi-events';
|
|
|
50
50
|
import { createPiModelResolver } from './pi-model-resolver';
|
|
51
51
|
import { createPiPathMapper } from './pi-paths';
|
|
52
52
|
import { createPiRemoteOps, type PiRemoteOps } from './pi-remote-ops';
|
|
53
|
+
import {
|
|
54
|
+
formatPiReadToolOutput,
|
|
55
|
+
truncatePiToolOutputHead,
|
|
56
|
+
truncatePiToolOutputTail,
|
|
57
|
+
} from './pi-tool-result';
|
|
53
58
|
|
|
54
59
|
import {
|
|
55
60
|
persistSessionFileToSandbox,
|
|
@@ -97,9 +102,10 @@ type PiMcpAdapterModule = {
|
|
|
97
102
|
/*
|
|
98
103
|
* Pi runs in this Node process, not behind an attachable in-sandbox bridge.
|
|
99
104
|
* During a tool approval pause the Pi turn is still alive and blocked on the
|
|
100
|
-
* custom tool promise, so
|
|
101
|
-
* same-process resume instead of stopping it and resolving the
|
|
102
|
-
* error. Cross-process resume still falls back to the persisted
|
|
105
|
+
* custom tool promise, so in-process reattachment parks that live session for
|
|
106
|
+
* the next same-process resume instead of stopping it and resolving the
|
|
107
|
+
* promise as an error. Cross-process resume still falls back to the persisted
|
|
108
|
+
* session file.
|
|
103
109
|
*/
|
|
104
110
|
const parkedPiSessions = new Map<
|
|
105
111
|
string,
|
|
@@ -810,9 +816,8 @@ export async function createPiSession(
|
|
|
810
816
|
* rerun's context carries the real outputs — without this, Pi's message
|
|
811
817
|
* transform synthesizes an error result ("No result provided") for each
|
|
812
818
|
* dangling call and the model continues as if the tool never answered. The
|
|
813
|
-
* serialized text matches what a live turn would have
|
|
814
|
-
*
|
|
815
|
-
* bytes either way.
|
|
819
|
+
* serialized and truncated text matches what a live turn would have
|
|
820
|
+
* produced, so the model sees the same result either way.
|
|
816
821
|
*/
|
|
817
822
|
function appendDeliveredHostToolResults(): boolean {
|
|
818
823
|
if (deliveredDanglingResults.size === 0 || resumeSessionFilePath == null) {
|
|
@@ -826,7 +831,13 @@ export async function createPiSession(
|
|
|
826
831
|
toolCallId,
|
|
827
832
|
toolName: delivered.toolName,
|
|
828
833
|
content: [
|
|
829
|
-
{
|
|
834
|
+
{
|
|
835
|
+
type: 'text',
|
|
836
|
+
text: truncatePiToolOutputHead(
|
|
837
|
+
serializeToolOutput(delivered.output),
|
|
838
|
+
'Call the tool again with narrower parameters to inspect the omitted output.',
|
|
839
|
+
),
|
|
840
|
+
},
|
|
830
841
|
],
|
|
831
842
|
isError: delivered.isError,
|
|
832
843
|
timestamp: Date.now(),
|
|
@@ -1570,7 +1581,10 @@ export async function createPiSession(
|
|
|
1570
1581
|
doStop,
|
|
1571
1582
|
|
|
1572
1583
|
doDetach: async (): Promise<HarnessV1ResumeSessionState> => {
|
|
1573
|
-
if (
|
|
1584
|
+
if (
|
|
1585
|
+
input.settings.reattachInProcess !== false &&
|
|
1586
|
+
(activeTurn != null || pendingToolResults.size > 0)
|
|
1587
|
+
) {
|
|
1574
1588
|
parkedPiSessions.set(input.sessionId, {
|
|
1575
1589
|
session: sessionImpl,
|
|
1576
1590
|
input,
|
|
@@ -1602,6 +1616,7 @@ export async function createPiSession(
|
|
|
1602
1616
|
throw new Error('Pi session has been stopped.');
|
|
1603
1617
|
}
|
|
1604
1618
|
if (
|
|
1619
|
+
input.settings.reattachInProcess !== false &&
|
|
1605
1620
|
activeTurn != null &&
|
|
1606
1621
|
(pendingToolResults.size > 0 || pendingToolApprovals.size > 0)
|
|
1607
1622
|
) {
|
|
@@ -1640,8 +1655,11 @@ export async function createPiSession(
|
|
|
1640
1655
|
*/
|
|
1641
1656
|
suspending = true;
|
|
1642
1657
|
const turnToSuspend = activeTurn;
|
|
1643
|
-
|
|
1658
|
+
const abortingTurn = turnToSuspend?.abort();
|
|
1644
1659
|
deferredRerun?.cancel();
|
|
1660
|
+
settlePendingToolResults('Pi session suspended');
|
|
1661
|
+
settlePendingToolApprovals('Pi session suspended');
|
|
1662
|
+
await abortingTurn;
|
|
1645
1663
|
await turnToSuspend?.done.catch(() => {});
|
|
1646
1664
|
|
|
1647
1665
|
/*
|
|
@@ -1668,8 +1686,6 @@ export async function createPiSession(
|
|
|
1668
1686
|
|
|
1669
1687
|
stopped = true;
|
|
1670
1688
|
parkedPiSessions.delete(input.sessionId);
|
|
1671
|
-
settlePendingToolResults('Pi session suspended');
|
|
1672
|
-
settlePendingToolApprovals('Pi session suspended');
|
|
1673
1689
|
await disposePiSession();
|
|
1674
1690
|
workspaceVfs.unmount();
|
|
1675
1691
|
await rm(hostRoot, { recursive: true, force: true });
|
|
@@ -1775,8 +1791,23 @@ function buildBuiltinToolDefinition(input: {
|
|
|
1775
1791
|
return defineTool({
|
|
1776
1792
|
name: 'read',
|
|
1777
1793
|
label: 'read',
|
|
1778
|
-
description:
|
|
1779
|
-
|
|
1794
|
+
description:
|
|
1795
|
+
'Read file contents. Output is limited to 2,000 lines or 50KB. Use offset and limit to read large files in pages.',
|
|
1796
|
+
parameters: Type.Object({
|
|
1797
|
+
file_path: Type.String(),
|
|
1798
|
+
offset: Type.Optional(
|
|
1799
|
+
Type.Integer({
|
|
1800
|
+
minimum: 1,
|
|
1801
|
+
description: 'Line number to start reading from (1-indexed).',
|
|
1802
|
+
}),
|
|
1803
|
+
),
|
|
1804
|
+
limit: Type.Optional(
|
|
1805
|
+
Type.Integer({
|
|
1806
|
+
minimum: 1,
|
|
1807
|
+
description: 'Maximum number of lines to read.',
|
|
1808
|
+
}),
|
|
1809
|
+
),
|
|
1810
|
+
}),
|
|
1780
1811
|
async execute(toolCallId, params) {
|
|
1781
1812
|
const denied = await maybeDenyPiBuiltinTool({
|
|
1782
1813
|
toolCallId,
|
|
@@ -1785,7 +1816,14 @@ function buildBuiltinToolDefinition(input: {
|
|
|
1785
1816
|
});
|
|
1786
1817
|
if (denied) return denied;
|
|
1787
1818
|
const buf = await input.remoteOps.readBuffer(params.file_path);
|
|
1788
|
-
return asPiToolResult(
|
|
1819
|
+
return asPiToolResult(
|
|
1820
|
+
formatPiReadToolOutput({
|
|
1821
|
+
text: buf.toString('utf8'),
|
|
1822
|
+
filePath: params.file_path,
|
|
1823
|
+
offset: params.offset,
|
|
1824
|
+
limit: params.limit,
|
|
1825
|
+
}),
|
|
1826
|
+
);
|
|
1789
1827
|
},
|
|
1790
1828
|
});
|
|
1791
1829
|
case 'write':
|
|
@@ -1865,7 +1903,12 @@ function buildBuiltinToolDefinition(input: {
|
|
|
1865
1903
|
const text = `${out}${
|
|
1866
1904
|
result.exitCode != null ? `\n\n(exit ${result.exitCode})` : ''
|
|
1867
1905
|
}`.trim();
|
|
1868
|
-
return asPiToolResult(
|
|
1906
|
+
return asPiToolResult(
|
|
1907
|
+
truncatePiToolOutputTail(
|
|
1908
|
+
text,
|
|
1909
|
+
'Re-run the command with narrower output, or redirect it to a file and use read with offset and limit.',
|
|
1910
|
+
),
|
|
1911
|
+
);
|
|
1869
1912
|
},
|
|
1870
1913
|
});
|
|
1871
1914
|
case 'grep':
|
|
@@ -1890,7 +1933,12 @@ function buildBuiltinToolDefinition(input: {
|
|
|
1890
1933
|
});
|
|
1891
1934
|
if (denied) return denied;
|
|
1892
1935
|
const out = await input.remoteOps.grepFiles(params.pattern, params);
|
|
1893
|
-
return asPiToolResult(
|
|
1936
|
+
return asPiToolResult(
|
|
1937
|
+
truncatePiToolOutputHead(
|
|
1938
|
+
out,
|
|
1939
|
+
'Narrow the pattern or path, or read the matching file with offset and limit.',
|
|
1940
|
+
),
|
|
1941
|
+
);
|
|
1894
1942
|
},
|
|
1895
1943
|
});
|
|
1896
1944
|
case 'find':
|
|
@@ -1915,7 +1963,12 @@ function buildBuiltinToolDefinition(input: {
|
|
|
1915
1963
|
params.path ?? '.',
|
|
1916
1964
|
params.limit ?? 1_000,
|
|
1917
1965
|
);
|
|
1918
|
-
return asPiToolResult(
|
|
1966
|
+
return asPiToolResult(
|
|
1967
|
+
truncatePiToolOutputHead(
|
|
1968
|
+
matches.join('\n'),
|
|
1969
|
+
'Narrow the pattern or search path to inspect the omitted matches.',
|
|
1970
|
+
),
|
|
1971
|
+
);
|
|
1919
1972
|
},
|
|
1920
1973
|
});
|
|
1921
1974
|
case 'ls':
|
|
@@ -1938,7 +1991,12 @@ function buildBuiltinToolDefinition(input: {
|
|
|
1938
1991
|
params.path ?? '.',
|
|
1939
1992
|
params.limit ?? 500,
|
|
1940
1993
|
);
|
|
1941
|
-
return asPiToolResult(
|
|
1994
|
+
return asPiToolResult(
|
|
1995
|
+
truncatePiToolOutputHead(
|
|
1996
|
+
entries.join('\n'),
|
|
1997
|
+
'List a narrower directory path to inspect the omitted entries.',
|
|
1998
|
+
),
|
|
1999
|
+
);
|
|
1942
2000
|
},
|
|
1943
2001
|
});
|
|
1944
2002
|
}
|
|
@@ -1961,7 +2019,14 @@ function buildUserToolDefinition(
|
|
|
1961
2019
|
async execute(toolCallId) {
|
|
1962
2020
|
return new Promise<unknown>(resolve => {
|
|
1963
2021
|
pending.set(toolCallId, { resolve });
|
|
1964
|
-
}).then(output =>
|
|
2022
|
+
}).then(output =>
|
|
2023
|
+
asPiToolResult(
|
|
2024
|
+
truncatePiToolOutputHead(
|
|
2025
|
+
serializeToolOutput(output),
|
|
2026
|
+
'Call the tool again with narrower parameters to inspect the omitted output.',
|
|
2027
|
+
),
|
|
2028
|
+
),
|
|
2029
|
+
);
|
|
1965
2030
|
},
|
|
1966
2031
|
});
|
|
1967
2032
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_MAX_BYTES,
|
|
3
|
+
formatSize,
|
|
4
|
+
truncateHead,
|
|
5
|
+
truncateTail,
|
|
6
|
+
type TruncationResult,
|
|
7
|
+
} from '@earendil-works/pi-coding-agent';
|
|
8
|
+
|
|
9
|
+
type ReadToolOutputOptions = {
|
|
10
|
+
text: string;
|
|
11
|
+
filePath: string;
|
|
12
|
+
offset?: number;
|
|
13
|
+
limit?: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function joinContentAndNotice(content: string, notice: string): string {
|
|
17
|
+
return content.length > 0 ? `${content}\n\n[${notice}]` : `[${notice}]`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function truncateUtf8Start(text: string, maxBytes: number): string {
|
|
21
|
+
const buffer = Buffer.from(text, 'utf8');
|
|
22
|
+
if (buffer.length <= maxBytes) return text;
|
|
23
|
+
|
|
24
|
+
let end = maxBytes;
|
|
25
|
+
while (end > 0 && (buffer[end] & 0xc0) === 0x80) {
|
|
26
|
+
end--;
|
|
27
|
+
}
|
|
28
|
+
return buffer.subarray(0, end).toString('utf8');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function formatHeadNotice(
|
|
32
|
+
truncation: TruncationResult,
|
|
33
|
+
continuation: string,
|
|
34
|
+
): string {
|
|
35
|
+
const limit =
|
|
36
|
+
truncation.truncatedBy === 'bytes'
|
|
37
|
+
? ` (${formatSize(truncation.maxBytes)} limit)`
|
|
38
|
+
: '';
|
|
39
|
+
return `Output truncated: showing the first ${truncation.outputLines} of ${truncation.totalLines} lines${limit}. ${continuation}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function formatTailNotice(
|
|
43
|
+
truncation: TruncationResult,
|
|
44
|
+
continuation: string,
|
|
45
|
+
): string {
|
|
46
|
+
const limit =
|
|
47
|
+
truncation.truncatedBy === 'bytes'
|
|
48
|
+
? ` (${formatSize(truncation.maxBytes)} limit)`
|
|
49
|
+
: '';
|
|
50
|
+
return `Output truncated: showing the last ${truncation.outputLines} of ${truncation.totalLines} lines${limit}. ${continuation}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function truncatePiToolOutputHead(
|
|
54
|
+
text: string,
|
|
55
|
+
continuation: string,
|
|
56
|
+
): string {
|
|
57
|
+
const truncation = truncateHead(text);
|
|
58
|
+
if (!truncation.truncated) return text;
|
|
59
|
+
|
|
60
|
+
if (truncation.firstLineExceedsLimit) {
|
|
61
|
+
const content = truncateUtf8Start(text, DEFAULT_MAX_BYTES);
|
|
62
|
+
return joinContentAndNotice(
|
|
63
|
+
content,
|
|
64
|
+
`Output truncated: showing the first ${formatSize(
|
|
65
|
+
Buffer.byteLength(content, 'utf8'),
|
|
66
|
+
)} of a ${formatSize(truncation.totalBytes)} line. ${continuation}`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return joinContentAndNotice(
|
|
71
|
+
truncation.content,
|
|
72
|
+
formatHeadNotice(truncation, continuation),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function truncatePiToolOutputTail(
|
|
77
|
+
text: string,
|
|
78
|
+
continuation: string,
|
|
79
|
+
): string {
|
|
80
|
+
const truncation = truncateTail(text);
|
|
81
|
+
if (!truncation.truncated) return text;
|
|
82
|
+
|
|
83
|
+
return joinContentAndNotice(
|
|
84
|
+
truncation.content,
|
|
85
|
+
formatTailNotice(truncation, continuation),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function formatPiReadToolOutput({
|
|
90
|
+
text,
|
|
91
|
+
filePath,
|
|
92
|
+
offset,
|
|
93
|
+
limit,
|
|
94
|
+
}: ReadToolOutputOptions): string {
|
|
95
|
+
const allLines = text.split('\n');
|
|
96
|
+
const startLine = offset == null ? 0 : Math.max(0, Math.floor(offset) - 1);
|
|
97
|
+
const startLineDisplay = startLine + 1;
|
|
98
|
+
|
|
99
|
+
if (startLine >= allLines.length) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`Offset ${offset} is beyond end of file (${allLines.length} lines total)`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const normalizedLimit =
|
|
106
|
+
limit == null ? undefined : Math.max(1, Math.floor(limit));
|
|
107
|
+
const endLine =
|
|
108
|
+
normalizedLimit == null
|
|
109
|
+
? allLines.length
|
|
110
|
+
: Math.min(startLine + normalizedLimit, allLines.length);
|
|
111
|
+
const selectedContent = allLines.slice(startLine, endLine).join('\n');
|
|
112
|
+
const truncation = truncateHead(selectedContent);
|
|
113
|
+
|
|
114
|
+
if (truncation.firstLineExceedsLimit) {
|
|
115
|
+
const lineSize = formatSize(
|
|
116
|
+
Buffer.byteLength(allLines[startLine] ?? '', 'utf8'),
|
|
117
|
+
);
|
|
118
|
+
return `[Line ${startLineDisplay} is ${lineSize}, exceeds ${formatSize(
|
|
119
|
+
DEFAULT_MAX_BYTES,
|
|
120
|
+
)} limit. Use bash: sed -n '${startLineDisplay}p' ${filePath} | head -c ${DEFAULT_MAX_BYTES}]`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (truncation.truncated) {
|
|
124
|
+
const endLineDisplay =
|
|
125
|
+
startLineDisplay + Math.max(0, truncation.outputLines - 1);
|
|
126
|
+
const nextOffset = endLineDisplay + 1;
|
|
127
|
+
const limitNotice =
|
|
128
|
+
truncation.truncatedBy === 'bytes'
|
|
129
|
+
? ` (${formatSize(DEFAULT_MAX_BYTES)} limit)`
|
|
130
|
+
: '';
|
|
131
|
+
return joinContentAndNotice(
|
|
132
|
+
truncation.content,
|
|
133
|
+
`Showing lines ${startLineDisplay}-${endLineDisplay} of ${allLines.length}${limitNotice}. Use offset=${nextOffset} to continue.`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (endLine < allLines.length) {
|
|
138
|
+
return joinContentAndNotice(
|
|
139
|
+
truncation.content,
|
|
140
|
+
`${allLines.length - endLine} more lines in file. Use offset=${
|
|
141
|
+
endLine + 1
|
|
142
|
+
} to continue.`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return truncation.content;
|
|
147
|
+
}
|