@poncho-ai/harness 0.59.14 → 0.59.16

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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @poncho-ai/harness@0.59.14 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
2
+ > @poncho-ai/harness@0.59.16 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
3
3
  > node scripts/embed-docs.js && tsup src/index.ts --format esm --dts
4
4
 
5
5
  [embed-docs] Generated poncho-docs.ts with 4 topics
@@ -8,9 +8,9 @@
8
8
  CLI tsup v8.5.1
9
9
  CLI Target: es2022
10
10
  ESM Build start
11
+ ESM dist/index.js 571.49 KB
11
12
  ESM dist/isolate-F2PPSUL6.js 53.82 KB
12
- ESM dist/index.js 564.58 KB
13
- ESM ⚡️ Build success in 217ms
13
+ ESM ⚡️ Build success in 239ms
14
14
  DTS Build start
15
- DTS ⚡️ Build success in 8055ms
16
- DTS dist/index.d.ts 102.50 KB
15
+ DTS ⚡️ Build success in 6987ms
16
+ DTS dist/index.d.ts 103.12 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # @poncho-ai/harness
2
2
 
3
+ ## 0.59.16
4
+
5
+ ### Patch Changes
6
+
7
+ - [`96bbf55`](https://github.com/cesr/poncho-ai/commit/96bbf5532d85de1ae0957f770758bf26b9c1e50b) Thanks [@cesr](https://github.com/cesr)! - Tighten the oversized-tool-result spill read-back path (follow-up to the
8
+ spill guard):
9
+ - The spill handle now exposes `toolResultId` / `toolCallId` explicitly, so
10
+ the model passes the right id to `get_tool_result_by_id` instead of guessing
11
+ from the path stem.
12
+ - `get_tool_result_by_id` now redirects when it hits a spill envelope: it
13
+ returns `{ spilled: true, path, totalChars }` pointing at the VFS file rather
14
+ than silently paging the ~6k-char envelope (which read as "I've got
15
+ everything"). The file is the source of truth; read it with bash.
16
+ - The handle's `note` is now format-aware: JSONL spills keep the line tools
17
+ (`sed`/`grep`/`jq` per line), but pretty-JSON spills steer to byte-offset
18
+ reads and `jq -r` to unescape — `wc -l`/`grep` mislead on JSON whose string
19
+ fields carry escaped newlines on one line.
20
+
21
+ ## 0.59.15
22
+
23
+ ### Patch Changes
24
+
25
+ - [`c5f6d7f`](https://github.com/cesr/poncho-ai/commit/c5f6d7f999bc3d4699295c376b23fc3894302e3a) Thanks [@cesr](https://github.com/cesr)! - Guard a single oversized FRESH tool result from overflowing the context
26
+ window. `truncateHistoricalToolResults` only shrinks results from prior runs
27
+ (it deliberately preserves the latest run's results so the model can read what
28
+ it just fetched), so a tool that returns a payload larger than the window in
29
+ one shot — e.g. an MCP email-fetch returning 1.6M–3.3M tokens of full bodies —
30
+ was never truncated and failed the very next step with "prompt is too long".
31
+
32
+ The result is now guarded at the moment it's produced, before the
33
+ `tool:completed` event is emitted (so the WS stream never carries the megabytes
34
+ either) and before it reaches the model:
35
+ - **Spill mode** (opt-in via the `__toolResultSpill` run parameter
36
+ `{ enabled, dir?, thresholdChars?, keepLast? }`): the full payload is written
37
+ to a VFS file (JSONL for arrays, pretty JSON otherwise) and the model gets a
38
+ small handle + preview pointing at it, to read back in bounded chunks with
39
+ bash/jq. Durable and lossless; the spill dir is pruned to `keepLast` files.
40
+ - **Inline-truncate mode** (default when spill isn't enabled, and the fallback
41
+ if a spill write fails): the payload is replaced with a preview + "re-run
42
+ narrower" notice.
43
+
44
+ Both modes apply to every tool uniformly (MCP, bash, run_code, subagent
45
+ results) and only trigger above `thresholdChars` (default 500k ≈ 125k tokens),
46
+ so normal-sized results are untouched. Inline media (images/PDFs) is separated
47
+ out first and unaffected.
48
+
3
49
  ## 0.59.14
4
50
 
5
51
  ### Patch Changes
package/dist/index.d.ts CHANGED
@@ -1494,6 +1494,17 @@ declare class AgentHarness {
1494
1494
  get frontmatter(): AgentFrontmatter | undefined;
1495
1495
  getToolResultArchive(conversationId: string): Record<string, ArchivedToolResult>;
1496
1496
  private seedToolResultArchive;
1497
+ /**
1498
+ * Guard a single FRESH tool result against overflowing the context window.
1499
+ * `serialized` is the JSON-serialized (media-stripped) output. Returns a
1500
+ * replacement value to put in front of the model (a handle or a truncated
1501
+ * preview) when the result is oversized, or `null` to leave it untouched.
1502
+ * Spilling writes the full payload to a VFS file; on any write failure it
1503
+ * falls back to inline truncation, so this never throws into the run loop.
1504
+ */
1505
+ private guardOversizedToolResult;
1506
+ /** Best-effort: keep only the newest `keepLast` files in the spill dir. */
1507
+ private pruneSpillDir;
1497
1508
  private truncateHistoricalToolResults;
1498
1509
  private shouldPreserveSkillToolResult;
1499
1510
  getTodos(conversationId: string): Promise<TodoItem[]>;
package/dist/index.js CHANGED
@@ -5649,6 +5649,66 @@ var BashEnvironmentManager = class {
5649
5649
  }
5650
5650
  };
5651
5651
 
5652
+ // src/tool-result-guard.ts
5653
+ var SPILL_POLICY_PARAM = "__toolResultSpill";
5654
+ var DEFAULT_OVERSIZED_TOOL_RESULT_CHARS = 5e5;
5655
+ var SPILLED_PREVIEW_CHARS = 6e3;
5656
+ var INLINE_TRUNCATE_PREVIEW_CHARS = 6e4;
5657
+ var DEFAULT_SPILL_DIR = "/tmp/tool-results";
5658
+ var DEFAULT_SPILL_KEEP_LAST = 20;
5659
+ var readSpillPolicy = (parameters) => {
5660
+ const raw = parameters?.[SPILL_POLICY_PARAM];
5661
+ const obj = typeof raw === "object" && raw !== null ? raw : {};
5662
+ const thresholdChars = typeof obj.thresholdChars === "number" && obj.thresholdChars > 0 ? obj.thresholdChars : DEFAULT_OVERSIZED_TOOL_RESULT_CHARS;
5663
+ const dir = typeof obj.dir === "string" && obj.dir.startsWith("/") ? obj.dir : DEFAULT_SPILL_DIR;
5664
+ const keepLast = typeof obj.keepLast === "number" && obj.keepLast > 0 ? Math.floor(obj.keepLast) : DEFAULT_SPILL_KEEP_LAST;
5665
+ return { enabled: obj.enabled === true, dir, thresholdChars, keepLast };
5666
+ };
5667
+ var isOversizedToolResult = (serialized, policy) => serialized.length > policy.thresholdChars;
5668
+ var formatSpillPayload = (output) => {
5669
+ if (Array.isArray(output)) {
5670
+ return {
5671
+ content: output.map((row) => JSON.stringify(row)).join("\n"),
5672
+ format: "jsonl",
5673
+ records: output.length
5674
+ };
5675
+ }
5676
+ return { content: JSON.stringify(output ?? null, null, 2), format: "json" };
5677
+ };
5678
+ var buildSpillHandle = (opts) => {
5679
+ const { toolName, toolCallId, path, format, serialized, records } = opts;
5680
+ const approxTokens = Math.ceil(serialized.length / 4);
5681
+ const bytesHint = `\`wc -c ${path}\` (size), \`head -c 4000 ${path}\`, \`tail -c +<N> ${path} | head -c 4000\` (read from byte N)`;
5682
+ const note = format === "jsonl" ? `Result too large to return inline (~${approxTokens.toLocaleString()} tokens, ${records} records, one JSON object per line). Read it with bash: ${bytesHint}, \`sed -n '1,5p' ${path}\`, \`grep -i <term> ${path}\`, or \`jq -c 'select(...)' ${path}\` per line. Do NOT cat/read_file the whole file (it re-overflows). Or re-run the tool with a narrower request.` : `Result too large to return inline (~${approxTokens.toLocaleString()} tokens, JSON). Its string fields are escaped onto single lines, so \`wc -l\`/\`grep\` MISLEAD \u2014 read by byte offset (${bytesHint}) or unescape first with \`jq -r '.stdout // .' ${path}\` (then pipe to wc -l / grep). Do NOT cat/read_file the whole file (it re-overflows). Or re-run the tool with a narrower request.`;
5683
+ return {
5684
+ __toolResultSpilled: true,
5685
+ tool: toolName,
5686
+ // Expose the id explicitly so the model passes the right value if it
5687
+ // reaches for get_tool_result_by_id (the path stem is NOT the id).
5688
+ toolResultId: toolCallId,
5689
+ toolCallId,
5690
+ path,
5691
+ format,
5692
+ ...records !== void 0 ? { records } : {},
5693
+ totalChars: serialized.length,
5694
+ approxTokens,
5695
+ preview: serialized.slice(0, SPILLED_PREVIEW_CHARS),
5696
+ note
5697
+ };
5698
+ };
5699
+ var buildInlineTruncation = (toolName, serialized) => {
5700
+ const approxTokens = Math.ceil(serialized.length / 4);
5701
+ const omittedChars = Math.max(0, serialized.length - INLINE_TRUNCATE_PREVIEW_CHARS);
5702
+ return {
5703
+ __toolResultTruncated: true,
5704
+ tool: toolName,
5705
+ approxTokens,
5706
+ omittedChars,
5707
+ preview: serialized.slice(0, INLINE_TRUNCATE_PREVIEW_CHARS),
5708
+ note: `This result was too large (~${approxTokens.toLocaleString()} tokens) and was truncated to fit the context window \u2014 only the first ${INLINE_TRUNCATE_PREVIEW_CHARS.toLocaleString()} characters are shown. Re-run with a narrower request (fewer items / a filter / metadata only). Do NOT retry the same call unchanged \u2014 it will overflow again.`
5709
+ };
5710
+ };
5711
+
5652
5712
  // src/vfs/bash-tool.ts
5653
5713
  import { defineTool as defineTool2 } from "@poncho-ai/sdk";
5654
5714
  var createBashTool = (bashManager) => defineTool2({
@@ -9718,6 +9778,21 @@ var AgentHarness = class _AgentHarness {
9718
9778
  error: `No archived tool result found for id "${toolResultId}" in this conversation.`
9719
9779
  };
9720
9780
  }
9781
+ try {
9782
+ const env = JSON.parse(record.payload);
9783
+ if (env && env.__toolResultSpilled === true && typeof env.path === "string") {
9784
+ return {
9785
+ toolResultId: record.toolResultId,
9786
+ toolName: record.toolName,
9787
+ toolCallId: record.toolCallId,
9788
+ spilled: true,
9789
+ path: env.path,
9790
+ totalChars: typeof env.totalChars === "number" ? env.totalChars : void 0,
9791
+ note: `This result was spilled to ${env.path} \u2014 the archive only holds a preview. Read the full payload there with bash (head -c / tail -c +<N> | head -c, or jq), NOT via get_tool_result_by_id.`
9792
+ };
9793
+ }
9794
+ } catch {
9795
+ }
9721
9796
  const offset = Math.max(0, Number(input.offset) || 0);
9722
9797
  const limit = Math.min(Math.max(Number(input.limit) || 6e3, 1), 2e4);
9723
9798
  const end = Math.min(record.payload.length, offset + limit);
@@ -9825,6 +9900,59 @@ var AgentHarness = class _AgentHarness {
9825
9900
  this.archivedToolResultsByConversation.set(conversationId, merged);
9826
9901
  return merged;
9827
9902
  }
9903
+ /**
9904
+ * Guard a single FRESH tool result against overflowing the context window.
9905
+ * `serialized` is the JSON-serialized (media-stripped) output. Returns a
9906
+ * replacement value to put in front of the model (a handle or a truncated
9907
+ * preview) when the result is oversized, or `null` to leave it untouched.
9908
+ * Spilling writes the full payload to a VFS file; on any write failure it
9909
+ * falls back to inline truncation, so this never throws into the run loop.
9910
+ */
9911
+ async guardOversizedToolResult(opts) {
9912
+ const { tenantId, toolName, toolCallId, output, serialized, policy } = opts;
9913
+ if (!isOversizedToolResult(serialized, policy)) return null;
9914
+ if (policy.enabled && this.bashManager) {
9915
+ try {
9916
+ const vfs = this.createVfsAccess(tenantId);
9917
+ const { content, format, records } = formatSpillPayload(output);
9918
+ const safeTool = toolName.replace(/[^A-Za-z0-9_-]/g, "_");
9919
+ const path = `${policy.dir}/${safeTool}_${toolCallId}.${format}`;
9920
+ await vfs.mkdir(policy.dir, { recursive: true });
9921
+ await vfs.writeText(path, content);
9922
+ await this.pruneSpillDir(vfs, policy.dir, policy.keepLast);
9923
+ return buildSpillHandle({ toolName, toolCallId, path, format, serialized, records });
9924
+ } catch {
9925
+ }
9926
+ }
9927
+ return buildInlineTruncation(toolName, serialized);
9928
+ }
9929
+ /** Best-effort: keep only the newest `keepLast` files in the spill dir. */
9930
+ async pruneSpillDir(vfs, dir, keepLast) {
9931
+ try {
9932
+ const entries = await vfs.readdir(dir);
9933
+ const names = (Array.isArray(entries) ? entries : []).map((e) => typeof e === "string" ? e : e.name).filter((n) => typeof n === "string");
9934
+ if (names.length <= keepLast) return;
9935
+ const stated = await Promise.all(
9936
+ names.map(async (name) => {
9937
+ try {
9938
+ const s = await vfs.stat(`${dir}/${name}`);
9939
+ return { name, mtime: s.updatedAt ?? 0 };
9940
+ } catch {
9941
+ return { name, mtime: 0 };
9942
+ }
9943
+ })
9944
+ );
9945
+ stated.sort((a, b) => a.mtime - b.mtime);
9946
+ const doomed = stated.slice(0, stated.length - keepLast);
9947
+ for (const d of doomed) {
9948
+ try {
9949
+ await vfs.rm(`${dir}/${d.name}`);
9950
+ } catch {
9951
+ }
9952
+ }
9953
+ } catch {
9954
+ }
9955
+ }
9828
9956
  truncateHistoricalToolResults(messages, conversationId) {
9829
9957
  let latestRunId;
9830
9958
  let latestToolMessageIndex = -1;
@@ -10676,6 +10804,7 @@ var AgentHarness = class _AgentHarness {
10676
10804
  const messages = [...input.messages ?? []];
10677
10805
  const conversationId = input.conversationId ?? "__default__";
10678
10806
  this.seedToolResultArchive(conversationId, input.parameters);
10807
+ const spillPolicy = readSpillPolicy(input.parameters);
10679
10808
  const truncationSummary = this.truncateHistoricalToolResults(messages, conversationId);
10680
10809
  if (truncationSummary.changed) {
10681
10810
  costLog.debug(
@@ -11817,29 +11946,39 @@ ${textContent}` };
11817
11946
  span.setStatus({ code: SpanStatusCode.OK });
11818
11947
  span.end();
11819
11948
  }
11820
- const serialized = JSON.stringify(result2.output ?? null);
11821
- const outputTokenEstimate = Math.ceil(serialized.length / 4);
11949
+ const { mediaItems, strippedOutput } = extractMediaFromToolOutput(result2.output);
11950
+ const strippedSerialized = JSON.stringify(strippedOutput ?? null);
11951
+ const guarded = await this.guardOversizedToolResult({
11952
+ tenantId: input.tenantId ?? "__default__",
11953
+ toolName: result2.tool,
11954
+ toolCallId: result2.callId,
11955
+ output: strippedOutput,
11956
+ serialized: strippedSerialized,
11957
+ policy: spillPolicy
11958
+ });
11959
+ const effectiveOutput = guarded ?? strippedOutput ?? null;
11960
+ const effectiveSerialized = guarded ? JSON.stringify(guarded) : strippedSerialized;
11961
+ const outputTokenEstimate = Math.ceil(effectiveSerialized.length / 4);
11822
11962
  toolOutputEstimateSinceModel += outputTokenEstimate;
11823
11963
  yield pushEvent({
11824
11964
  type: "tool:completed",
11825
11965
  tool: result2.tool,
11826
11966
  toolCallId: result2.callId,
11827
11967
  input: callInputMap.get(result2.callId),
11828
- output: result2.output,
11968
+ output: effectiveOutput,
11829
11969
  duration: now() - batchStart,
11830
11970
  outputTokenEstimate
11831
11971
  });
11832
- const { mediaItems, strippedOutput } = extractMediaFromToolOutput(result2.output);
11833
11972
  toolResultsForModel.push({
11834
11973
  type: "tool_result",
11835
11974
  tool_use_id: result2.callId,
11836
11975
  tool_name: result2.tool,
11837
- content: JSON.stringify(strippedOutput ?? null)
11976
+ content: effectiveSerialized
11838
11977
  });
11839
11978
  {
11840
11979
  const archive = this.archivedToolResultsByConversation.get(conversationId);
11841
11980
  if (archive && !NON_ARCHIVABLE_TOOL_NAMES.has(result2.tool)) {
11842
- const payload = JSON.stringify(result2.output ?? null);
11981
+ const payload = effectiveSerialized;
11843
11982
  archive[result2.callId] = {
11844
11983
  toolResultId: result2.callId,
11845
11984
  conversationId,
@@ -11860,7 +11999,7 @@ ${textContent}` };
11860
11999
  output: {
11861
12000
  type: "content",
11862
12001
  value: [
11863
- { type: "text", text: JSON.stringify(strippedOutput ?? null) },
12002
+ { type: "text", text: effectiveSerialized },
11864
12003
  ...mediaItems
11865
12004
  ]
11866
12005
  }
@@ -11870,7 +12009,7 @@ ${textContent}` };
11870
12009
  type: "tool-result",
11871
12010
  toolCallId: result2.callId,
11872
12011
  toolName: result2.tool,
11873
- output: { type: "json", value: result2.output ?? null }
12012
+ output: { type: "json", value: effectiveOutput }
11874
12013
  });
11875
12014
  }
11876
12015
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/harness",
3
- "version": "0.59.14",
3
+ "version": "0.59.16",
4
4
  "description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
5
5
  "repository": {
6
6
  "type": "git",
package/src/harness.ts CHANGED
@@ -30,6 +30,14 @@ import {
30
30
  createReminderStoreFromEngine,
31
31
  } from "./storage/store-adapters.js";
32
32
  import { BashEnvironmentManager } from "./vfs/bash-manager.js";
33
+ import {
34
+ type ToolResultSpillPolicy,
35
+ readSpillPolicy,
36
+ isOversizedToolResult,
37
+ formatSpillPayload,
38
+ buildSpillHandle,
39
+ buildInlineTruncation,
40
+ } from "./tool-result-guard.js";
33
41
  import type { VirtualMount } from "./vfs/poncho-fs-adapter.js";
34
42
  export type { VirtualMount } from "./vfs/poncho-fs-adapter.js";
35
43
  import { createBashTool } from "./vfs/bash-tool.js";
@@ -1060,6 +1068,30 @@ export class AgentHarness {
1060
1068
  error: `No archived tool result found for id "${toolResultId}" in this conversation.`,
1061
1069
  };
1062
1070
  }
1071
+ // Spill envelopes: the archived payload is the small handle, not the
1072
+ // data — the real payload lives in a VFS file. Paging the envelope here
1073
+ // would silently return ~6k chars and `hasMore:false`, reading as "I've
1074
+ // got everything". Redirect to the file (read it with bash) instead of
1075
+ // building a second read path the agent already has via bash/jq.
1076
+ try {
1077
+ const env = JSON.parse(record.payload) as Record<string, unknown>;
1078
+ if (env && env.__toolResultSpilled === true && typeof env.path === "string") {
1079
+ return {
1080
+ toolResultId: record.toolResultId,
1081
+ toolName: record.toolName,
1082
+ toolCallId: record.toolCallId,
1083
+ spilled: true,
1084
+ path: env.path,
1085
+ totalChars: typeof env.totalChars === "number" ? env.totalChars : undefined,
1086
+ note:
1087
+ `This result was spilled to ${env.path} — the archive only holds a preview. ` +
1088
+ `Read the full payload there with bash (head -c / tail -c +<N> | head -c, or ` +
1089
+ `jq), NOT via get_tool_result_by_id.`,
1090
+ };
1091
+ }
1092
+ } catch {
1093
+ // Not JSON / not an envelope — fall through to normal paging.
1094
+ }
1063
1095
  const offset = Math.max(0, Number(input.offset) || 0);
1064
1096
  const limit = Math.min(Math.max(Number(input.limit) || 6000, 1), 20_000);
1065
1097
  const end = Math.min(record.payload.length, offset + limit);
@@ -1181,6 +1213,77 @@ export class AgentHarness {
1181
1213
  return merged;
1182
1214
  }
1183
1215
 
1216
+ /**
1217
+ * Guard a single FRESH tool result against overflowing the context window.
1218
+ * `serialized` is the JSON-serialized (media-stripped) output. Returns a
1219
+ * replacement value to put in front of the model (a handle or a truncated
1220
+ * preview) when the result is oversized, or `null` to leave it untouched.
1221
+ * Spilling writes the full payload to a VFS file; on any write failure it
1222
+ * falls back to inline truncation, so this never throws into the run loop.
1223
+ */
1224
+ private async guardOversizedToolResult(opts: {
1225
+ tenantId: string;
1226
+ toolName: string;
1227
+ toolCallId: string;
1228
+ output: unknown;
1229
+ serialized: string;
1230
+ policy: ToolResultSpillPolicy;
1231
+ }): Promise<Record<string, unknown> | null> {
1232
+ const { tenantId, toolName, toolCallId, output, serialized, policy } = opts;
1233
+ if (!isOversizedToolResult(serialized, policy)) return null;
1234
+
1235
+ if (policy.enabled && this.bashManager) {
1236
+ try {
1237
+ const vfs = this.createVfsAccess(tenantId);
1238
+ const { content, format, records } = formatSpillPayload(output);
1239
+ // toolCallId is unique per call and stable across a checkpoint→resume,
1240
+ // so the path is idempotent (a resumed run rewrites the same file).
1241
+ const safeTool = toolName.replace(/[^A-Za-z0-9_-]/g, "_");
1242
+ const path = `${policy.dir}/${safeTool}_${toolCallId}.${format}`;
1243
+ await vfs.mkdir(policy.dir, { recursive: true });
1244
+ await vfs.writeText(path, content);
1245
+ await this.pruneSpillDir(vfs, policy.dir, policy.keepLast);
1246
+ return buildSpillHandle({ toolName, toolCallId, path, format, serialized, records });
1247
+ } catch {
1248
+ // Fall through to inline truncation below.
1249
+ }
1250
+ }
1251
+
1252
+ return buildInlineTruncation(toolName, serialized);
1253
+ }
1254
+
1255
+ /** Best-effort: keep only the newest `keepLast` files in the spill dir. */
1256
+ private async pruneSpillDir(
1257
+ vfs: NonNullable<ToolContext["vfs"]>,
1258
+ dir: string,
1259
+ keepLast: number,
1260
+ ): Promise<void> {
1261
+ try {
1262
+ const entries = await vfs.readdir(dir);
1263
+ const names = (Array.isArray(entries) ? entries : [])
1264
+ .map((e) => (typeof e === "string" ? e : (e as { name?: string }).name))
1265
+ .filter((n): n is string => typeof n === "string");
1266
+ if (names.length <= keepLast) return;
1267
+ const stated = await Promise.all(
1268
+ names.map(async (name) => {
1269
+ try {
1270
+ const s = await vfs.stat(`${dir}/${name}`);
1271
+ return { name, mtime: s.updatedAt ?? 0 };
1272
+ } catch {
1273
+ return { name, mtime: 0 };
1274
+ }
1275
+ }),
1276
+ );
1277
+ stated.sort((a, b) => a.mtime - b.mtime); // oldest first
1278
+ const doomed = stated.slice(0, stated.length - keepLast);
1279
+ for (const d of doomed) {
1280
+ try { await vfs.rm(`${dir}/${d.name}`); } catch { /* best-effort */ }
1281
+ }
1282
+ } catch {
1283
+ // Dir unreadable — nothing to prune.
1284
+ }
1285
+ }
1286
+
1184
1287
  private truncateHistoricalToolResults(
1185
1288
  messages: Message[],
1186
1289
  conversationId: string,
@@ -2212,6 +2315,7 @@ export class AgentHarness {
2212
2315
  const messages: Message[] = [...(input.messages ?? [])];
2213
2316
  const conversationId = input.conversationId ?? "__default__";
2214
2317
  this.seedToolResultArchive(conversationId, input.parameters);
2318
+ const spillPolicy = readSpillPolicy(input.parameters);
2215
2319
  const truncationSummary = this.truncateHistoricalToolResults(messages, conversationId);
2216
2320
  if (truncationSummary.changed) {
2217
2321
  costLog.debug(
@@ -3692,30 +3796,49 @@ Code is wrapped in an async IIFE — use \`return\` to return a value to the too
3692
3796
  span.setStatus({ code: SpanStatusCode.OK });
3693
3797
  span.end();
3694
3798
  }
3695
- const serialized = JSON.stringify(result.output ?? null);
3696
- const outputTokenEstimate = Math.ceil(serialized.length / 4);
3799
+ // Separate inline media (images/PDFs) from the textual output, then
3800
+ // guard the textual part against overflowing the window. A single
3801
+ // oversized fresh result is replaced with a handle (spill) or a
3802
+ // preview (inline) BEFORE we emit the event or feed the model — so
3803
+ // neither the WS stream nor the next step ever carries the full
3804
+ // multi-MB payload.
3805
+ const { mediaItems, strippedOutput } = extractMediaFromToolOutput(result.output);
3806
+ const strippedSerialized = JSON.stringify(strippedOutput ?? null);
3807
+ const guarded = await this.guardOversizedToolResult({
3808
+ tenantId: input.tenantId ?? "__default__",
3809
+ toolName: result.tool,
3810
+ toolCallId: result.callId,
3811
+ output: strippedOutput,
3812
+ serialized: strippedSerialized,
3813
+ policy: spillPolicy,
3814
+ });
3815
+ const effectiveOutput: unknown = guarded ?? strippedOutput ?? null;
3816
+ const effectiveSerialized = guarded ? JSON.stringify(guarded) : strippedSerialized;
3817
+ const outputTokenEstimate = Math.ceil(effectiveSerialized.length / 4);
3697
3818
  toolOutputEstimateSinceModel += outputTokenEstimate;
3698
3819
  yield pushEvent({
3699
3820
  type: "tool:completed",
3700
3821
  tool: result.tool,
3701
3822
  toolCallId: result.callId,
3702
3823
  input: callInputMap.get(result.callId),
3703
- output: result.output,
3824
+ output: effectiveOutput,
3704
3825
  duration: now() - batchStart,
3705
3826
  outputTokenEstimate,
3706
3827
  });
3707
3828
 
3708
- const { mediaItems, strippedOutput } = extractMediaFromToolOutput(result.output);
3709
3829
  toolResultsForModel.push({
3710
3830
  type: "tool_result",
3711
3831
  tool_use_id: result.callId,
3712
3832
  tool_name: result.tool,
3713
- content: JSON.stringify(strippedOutput ?? null),
3833
+ content: effectiveSerialized,
3714
3834
  });
3715
3835
  {
3716
3836
  const archive = this.archivedToolResultsByConversation.get(conversationId);
3717
3837
  if (archive && !NON_ARCHIVABLE_TOOL_NAMES.has(result.tool)) {
3718
- const payload = JSON.stringify(result.output ?? null);
3838
+ // Archive the model-visible payload (handle/preview when guarded).
3839
+ // When spilled, the VFS file is the durable full copy; keeping the
3840
+ // in-memory archive small avoids re-introducing the same bloat.
3841
+ const payload = effectiveSerialized;
3719
3842
  archive[result.callId] = {
3720
3843
  toolResultId: result.callId,
3721
3844
  conversationId,
@@ -3737,7 +3860,7 @@ Code is wrapped in an async IIFE — use \`return\` to return a value to the too
3737
3860
  output: {
3738
3861
  type: "content",
3739
3862
  value: [
3740
- { type: "text", text: JSON.stringify(strippedOutput ?? null) },
3863
+ { type: "text", text: effectiveSerialized },
3741
3864
  ...mediaItems,
3742
3865
  ],
3743
3866
  },
@@ -3747,7 +3870,7 @@ Code is wrapped in an async IIFE — use \`return\` to return a value to the too
3747
3870
  type: "tool-result",
3748
3871
  toolCallId: result.callId,
3749
3872
  toolName: result.tool,
3750
- output: { type: "json", value: result.output ?? null },
3873
+ output: { type: "json", value: effectiveOutput },
3751
3874
  });
3752
3875
  }
3753
3876
  }
@@ -0,0 +1,150 @@
1
+ // Oversized FRESH tool-result guard — pure logic.
2
+ //
3
+ // `truncateHistoricalToolResults` (in harness.ts) only shrinks results from
4
+ // PRIOR runs — it deliberately preserves the latest run's results so the model
5
+ // can read what it just fetched. So a single fresh result that is itself larger
6
+ // than the context window is never truncated, and the next step fails with
7
+ // "prompt is too long" (seen with MCP email-fetch tools returning 1.6M–3.3M
8
+ // token payloads). This module decides what to put in front of the model when
9
+ // that happens; the I/O (the VFS write + prune) lives on the Harness class,
10
+ // which has the per-tenant filesystem. Splitting the pure decision out keeps it
11
+ // unit-testable without a Harness instance.
12
+
13
+ /** Run parameter key carrying the spill policy (a freeform-params bag entry,
14
+ * same mechanism as `__toolResultArchive`). */
15
+ export const SPILL_POLICY_PARAM = "__toolResultSpill";
16
+
17
+ /** Results whose serialized form exceeds this many chars are guarded. ~125k
18
+ * tokens — well under any current window, well above any normal result, so
19
+ * normal-sized results are never touched. */
20
+ export const DEFAULT_OVERSIZED_TOOL_RESULT_CHARS = 500_000;
21
+ /** Preview kept inline when SPILLING — small, because the full payload is in
22
+ * the file. */
23
+ export const SPILLED_PREVIEW_CHARS = 6_000;
24
+ /** Preview kept inline when TRUNCATING — larger, because there's no file to
25
+ * fall back to. */
26
+ export const INLINE_TRUNCATE_PREVIEW_CHARS = 60_000;
27
+ export const DEFAULT_SPILL_DIR = "/tmp/tool-results";
28
+ export const DEFAULT_SPILL_KEEP_LAST = 20;
29
+
30
+ export interface ToolResultSpillPolicy {
31
+ enabled: boolean;
32
+ dir: string;
33
+ thresholdChars: number;
34
+ keepLast: number;
35
+ }
36
+
37
+ /** Parse the spill policy out of the run parameters, applying defaults. A
38
+ * missing/malformed bag yields a disabled policy with safe defaults. */
39
+ export const readSpillPolicy = (
40
+ parameters: Record<string, unknown> | undefined,
41
+ ): ToolResultSpillPolicy => {
42
+ const raw = parameters?.[SPILL_POLICY_PARAM];
43
+ const obj = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
44
+ const thresholdChars =
45
+ typeof obj.thresholdChars === "number" && obj.thresholdChars > 0
46
+ ? obj.thresholdChars
47
+ : DEFAULT_OVERSIZED_TOOL_RESULT_CHARS;
48
+ const dir =
49
+ typeof obj.dir === "string" && obj.dir.startsWith("/") ? obj.dir : DEFAULT_SPILL_DIR;
50
+ const keepLast =
51
+ typeof obj.keepLast === "number" && obj.keepLast > 0
52
+ ? Math.floor(obj.keepLast)
53
+ : DEFAULT_SPILL_KEEP_LAST;
54
+ return { enabled: obj.enabled === true, dir, thresholdChars, keepLast };
55
+ };
56
+
57
+ /** True when this serialized result is large enough to need guarding. */
58
+ export const isOversizedToolResult = (
59
+ serialized: string,
60
+ policy: ToolResultSpillPolicy,
61
+ ): boolean => serialized.length > policy.thresholdChars;
62
+
63
+ /** Serialize an oversized payload for the spill file. Arrays become JSONL (one
64
+ * record per line — bash/jq/sed friendly, line ranges map to records); other
65
+ * shapes become pretty JSON. */
66
+ export const formatSpillPayload = (
67
+ output: unknown,
68
+ ): { content: string; format: "jsonl" | "json"; records?: number } => {
69
+ if (Array.isArray(output)) {
70
+ return {
71
+ content: output.map((row) => JSON.stringify(row)).join("\n"),
72
+ format: "jsonl",
73
+ records: output.length,
74
+ };
75
+ }
76
+ return { content: JSON.stringify(output ?? null, null, 2), format: "json" };
77
+ };
78
+
79
+ /** The handle object placed in front of the model when a result is spilled to
80
+ * a file: a small preview plus the path and bash-readback instructions. */
81
+ export const buildSpillHandle = (opts: {
82
+ toolName: string;
83
+ toolCallId: string;
84
+ path: string;
85
+ format: "jsonl" | "json";
86
+ serialized: string;
87
+ records?: number;
88
+ }): Record<string, unknown> => {
89
+ const { toolName, toolCallId, path, format, serialized, records } = opts;
90
+ const approxTokens = Math.ceil(serialized.length / 4);
91
+ // Byte-offset reads are correct for BOTH formats; line tools (`wc -l`,
92
+ // `grep`, `sed -n`) only behave for JSONL (one record per line). For pretty
93
+ // JSON the payload is a JSON document whose string fields (e.g. a command's
94
+ // stdout) carry escaped `\n` on a single line, so `wc -l`/`grep` mislead —
95
+ // unescape with `jq -r` first. Tailor the hint to the format so the model
96
+ // doesn't act on wrong line/match counts.
97
+ const bytesHint =
98
+ `\`wc -c ${path}\` (size), \`head -c 4000 ${path}\`, ` +
99
+ `\`tail -c +<N> ${path} | head -c 4000\` (read from byte N)`;
100
+ const note =
101
+ format === "jsonl"
102
+ ? `Result too large to return inline (~${approxTokens.toLocaleString()} tokens, ` +
103
+ `${records} records, one JSON object per line). Read it with bash: ${bytesHint}, ` +
104
+ `\`sed -n '1,5p' ${path}\`, \`grep -i <term> ${path}\`, or \`jq -c 'select(...)' ${path}\` ` +
105
+ `per line. Do NOT cat/read_file the whole file (it re-overflows). Or re-run the tool ` +
106
+ `with a narrower request.`
107
+ : `Result too large to return inline (~${approxTokens.toLocaleString()} tokens, JSON). ` +
108
+ `Its string fields are escaped onto single lines, so \`wc -l\`/\`grep\` MISLEAD — ` +
109
+ `read by byte offset (${bytesHint}) or unescape first with ` +
110
+ `\`jq -r '.stdout // .' ${path}\` (then pipe to wc -l / grep). Do NOT cat/read_file the ` +
111
+ `whole file (it re-overflows). Or re-run the tool with a narrower request.`;
112
+ return {
113
+ __toolResultSpilled: true,
114
+ tool: toolName,
115
+ // Expose the id explicitly so the model passes the right value if it
116
+ // reaches for get_tool_result_by_id (the path stem is NOT the id).
117
+ toolResultId: toolCallId,
118
+ toolCallId,
119
+ path,
120
+ format,
121
+ ...(records !== undefined ? { records } : {}),
122
+ totalChars: serialized.length,
123
+ approxTokens,
124
+ preview: serialized.slice(0, SPILLED_PREVIEW_CHARS),
125
+ note,
126
+ };
127
+ };
128
+
129
+ /** The replacement placed in front of the model when a result is too large and
130
+ * spill is unavailable: a preview plus a "re-run narrower" instruction. */
131
+ export const buildInlineTruncation = (
132
+ toolName: string,
133
+ serialized: string,
134
+ ): Record<string, unknown> => {
135
+ const approxTokens = Math.ceil(serialized.length / 4);
136
+ const omittedChars = Math.max(0, serialized.length - INLINE_TRUNCATE_PREVIEW_CHARS);
137
+ return {
138
+ __toolResultTruncated: true,
139
+ tool: toolName,
140
+ approxTokens,
141
+ omittedChars,
142
+ preview: serialized.slice(0, INLINE_TRUNCATE_PREVIEW_CHARS),
143
+ note:
144
+ `This result was too large (~${approxTokens.toLocaleString()} tokens) and was ` +
145
+ `truncated to fit the context window — only the first ` +
146
+ `${INLINE_TRUNCATE_PREVIEW_CHARS.toLocaleString()} characters are shown. Re-run ` +
147
+ `with a narrower request (fewer items / a filter / metadata only). Do NOT retry ` +
148
+ `the same call unchanged — it will overflow again.`,
149
+ };
150
+ };
@@ -0,0 +1,135 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ DEFAULT_OVERSIZED_TOOL_RESULT_CHARS,
4
+ DEFAULT_SPILL_DIR,
5
+ DEFAULT_SPILL_KEEP_LAST,
6
+ INLINE_TRUNCATE_PREVIEW_CHARS,
7
+ SPILLED_PREVIEW_CHARS,
8
+ buildInlineTruncation,
9
+ buildSpillHandle,
10
+ formatSpillPayload,
11
+ isOversizedToolResult,
12
+ readSpillPolicy,
13
+ } from "../src/tool-result-guard.js";
14
+
15
+ describe("readSpillPolicy", () => {
16
+ it("defaults to disabled with safe defaults when the param is absent", () => {
17
+ expect(readSpillPolicy(undefined)).toEqual({
18
+ enabled: false,
19
+ dir: DEFAULT_SPILL_DIR,
20
+ thresholdChars: DEFAULT_OVERSIZED_TOOL_RESULT_CHARS,
21
+ keepLast: DEFAULT_SPILL_KEEP_LAST,
22
+ });
23
+ });
24
+
25
+ it("defaults when the param is malformed", () => {
26
+ expect(readSpillPolicy({ __toolResultSpill: "nope" }).enabled).toBe(false);
27
+ expect(readSpillPolicy({ __toolResultSpill: 42 }).dir).toBe(DEFAULT_SPILL_DIR);
28
+ });
29
+
30
+ it("reads enabled + dir + threshold + keepLast", () => {
31
+ const p = readSpillPolicy({
32
+ __toolResultSpill: { enabled: true, dir: "/tmp/x", thresholdChars: 1000, keepLast: 5 },
33
+ });
34
+ expect(p).toEqual({ enabled: true, dir: "/tmp/x", thresholdChars: 1000, keepLast: 5 });
35
+ });
36
+
37
+ it("rejects a non-absolute dir, a non-positive threshold, and a bad keepLast", () => {
38
+ const p = readSpillPolicy({
39
+ __toolResultSpill: { enabled: true, dir: "relative", thresholdChars: 0, keepLast: -3 },
40
+ });
41
+ expect(p.dir).toBe(DEFAULT_SPILL_DIR);
42
+ expect(p.thresholdChars).toBe(DEFAULT_OVERSIZED_TOOL_RESULT_CHARS);
43
+ expect(p.keepLast).toBe(DEFAULT_SPILL_KEEP_LAST);
44
+ });
45
+
46
+ it("only treats enabled === true as enabled (not truthy strings)", () => {
47
+ expect(readSpillPolicy({ __toolResultSpill: { enabled: "yes" } }).enabled).toBe(false);
48
+ expect(readSpillPolicy({ __toolResultSpill: { enabled: 1 } }).enabled).toBe(false);
49
+ });
50
+ });
51
+
52
+ describe("isOversizedToolResult", () => {
53
+ const policy = readSpillPolicy({ __toolResultSpill: { enabled: true, thresholdChars: 100 } });
54
+ it("is false at or below the threshold (boundary)", () => {
55
+ expect(isOversizedToolResult("a".repeat(100), policy)).toBe(false);
56
+ expect(isOversizedToolResult("a".repeat(99), policy)).toBe(false);
57
+ });
58
+ it("is true above the threshold", () => {
59
+ expect(isOversizedToolResult("a".repeat(101), policy)).toBe(true);
60
+ });
61
+ });
62
+
63
+ describe("formatSpillPayload", () => {
64
+ it("formats an array as JSONL with a record count", () => {
65
+ const r = formatSpillPayload([{ a: 1 }, { b: 2 }, { c: 3 }]);
66
+ expect(r.format).toBe("jsonl");
67
+ expect(r.records).toBe(3);
68
+ expect(r.content.split("\n")).toEqual(['{"a":1}', '{"b":2}', '{"c":3}']);
69
+ });
70
+
71
+ it("formats a non-array as pretty JSON with no record count", () => {
72
+ const r = formatSpillPayload({ hello: "world" });
73
+ expect(r.format).toBe("json");
74
+ expect(r.records).toBeUndefined();
75
+ expect(r.content).toBe('{\n "hello": "world"\n}');
76
+ });
77
+
78
+ it("handles null", () => {
79
+ expect(formatSpillPayload(null)).toEqual({ content: "null", format: "json" });
80
+ });
81
+ });
82
+
83
+ describe("buildSpillHandle", () => {
84
+ it("returns a handle with path, format, records, preview, the call id, and bash instructions", () => {
85
+ const serialized = "x".repeat(SPILLED_PREVIEW_CHARS + 50_000);
86
+ const h = buildSpillHandle({
87
+ toolName: "mcp_gmail_GMAIL_FETCH_EMAILS",
88
+ toolCallId: "toolu_01Ctij",
89
+ path: "/tmp/tool-results/mcp_gmail_GMAIL_FETCH_EMAILS_toolu_01Ctij.jsonl",
90
+ format: "jsonl",
91
+ serialized,
92
+ records: 312,
93
+ });
94
+ expect(h.__toolResultSpilled).toBe(true);
95
+ expect(h.records).toBe(312);
96
+ expect(h.totalChars).toBe(serialized.length);
97
+ expect((h.preview as string).length).toBe(SPILLED_PREVIEW_CHARS);
98
+ // Issue 1: the call id is exposed explicitly (not just embedded in the path).
99
+ expect(h.toolCallId).toBe("toolu_01Ctij");
100
+ expect(h.toolResultId).toBe("toolu_01Ctij");
101
+ expect(String(h.note)).toContain("/tmp/tool-results/");
102
+ expect(String(h.note)).toContain("Do NOT cat");
103
+ // JSONL: line tools are valid (one record per line).
104
+ expect(String(h.note)).toContain("sed -n");
105
+ });
106
+
107
+ it("json format steers AWAY from line tools and toward byte-offset / jq (Issue 3)", () => {
108
+ const h = buildSpillHandle({
109
+ toolName: "run_code",
110
+ toolCallId: "toolu_2",
111
+ path: "/tmp/tool-results/run_code_toolu_2.json",
112
+ format: "json",
113
+ serialized: "y".repeat(10),
114
+ });
115
+ expect(h.records).toBeUndefined();
116
+ const note = String(h.note);
117
+ // It must warn that wc -l / grep mislead, and point at byte-offset + jq -r.
118
+ expect(note).toContain("MISLEAD");
119
+ expect(note).toContain("tail -c +");
120
+ expect(note).toContain("jq -r");
121
+ // It must NOT suggest sed-by-line for escaped-JSON.
122
+ expect(note).not.toContain("sed -n");
123
+ });
124
+ });
125
+
126
+ describe("buildInlineTruncation", () => {
127
+ it("returns a truncation marker with a capped preview and omitted-char count", () => {
128
+ const serialized = "z".repeat(INLINE_TRUNCATE_PREVIEW_CHARS + 1234);
129
+ const t = buildInlineTruncation("some_tool", serialized);
130
+ expect(t.__toolResultTruncated).toBe(true);
131
+ expect((t.preview as string).length).toBe(INLINE_TRUNCATE_PREVIEW_CHARS);
132
+ expect(t.omittedChars).toBe(1234);
133
+ expect(String(t.note)).toContain("Do NOT retry");
134
+ });
135
+ });