@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/harness-pi",
3
- "version": "1.0.125",
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.123",
30
- "@ai-sdk/provider-utils": "5.0.47",
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.123",
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: 'Read file contents.',
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 detach must park that live session for the next
101
- * same-process resume instead of stopping it and resolving the promise as an
102
- * error. Cross-process resume still falls back to the persisted session file.
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 produced
814
- * (`asPiToolResult(serializeToolOutput(...))`), so the model sees the same
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
- { type: 'text', text: serializeToolOutput(delivered.output) },
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 (activeTurn != null || pendingToolResults.size > 0) {
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
- await turnToSuspend?.abort();
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: 'Read file contents.',
1779
- parameters: Type.Object({ file_path: Type.String() }),
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(buf.toString('utf8'));
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(text);
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(out);
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(matches.join('\n'));
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(entries.join('\n'));
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 => asPiToolResult(serializeToolOutput(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
+ }