@poncho-ai/harness 0.59.14 → 0.59.15

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.15 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
@@ -9,8 +9,8 @@
9
9
  CLI Target: es2022
10
10
  ESM Build start
11
11
  ESM dist/isolate-F2PPSUL6.js 53.82 KB
12
- ESM dist/index.js 564.58 KB
13
- ESM ⚡️ Build success in 217ms
12
+ ESM dist/index.js 570.16 KB
13
+ ESM ⚡️ Build success in 242ms
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 6639ms
16
+ DTS dist/index.d.ts 103.12 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # @poncho-ai/harness
2
2
 
3
+ ## 0.59.15
4
+
5
+ ### Patch Changes
6
+
7
+ - [`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
8
+ window. `truncateHistoricalToolResults` only shrinks results from prior runs
9
+ (it deliberately preserves the latest run's results so the model can read what
10
+ it just fetched), so a tool that returns a payload larger than the window in
11
+ one shot — e.g. an MCP email-fetch returning 1.6M–3.3M tokens of full bodies —
12
+ was never truncated and failed the very next step with "prompt is too long".
13
+
14
+ The result is now guarded at the moment it's produced, before the
15
+ `tool:completed` event is emitted (so the WS stream never carries the megabytes
16
+ either) and before it reaches the model:
17
+ - **Spill mode** (opt-in via the `__toolResultSpill` run parameter
18
+ `{ enabled, dir?, thresholdChars?, keepLast? }`): the full payload is written
19
+ to a VFS file (JSONL for arrays, pretty JSON otherwise) and the model gets a
20
+ small handle + preview pointing at it, to read back in bounded chunks with
21
+ bash/jq. Durable and lossless; the spill dir is pruned to `keepLast` files.
22
+ - **Inline-truncate mode** (default when spill isn't enabled, and the fallback
23
+ if a spill write fails): the payload is replaced with a preview + "re-run
24
+ narrower" notice.
25
+
26
+ Both modes apply to every tool uniformly (MCP, bash, run_code, subagent
27
+ results) and only trigger above `thresholdChars` (default 500k ≈ 125k tokens),
28
+ so normal-sized results are untouched. Inline media (images/PDFs) is separated
29
+ out first and unaffected.
30
+
3
31
  ## 0.59.14
4
32
 
5
33
  ### 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,61 @@ 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, path, format, serialized, records } = opts;
5680
+ const approxTokens = Math.ceil(serialized.length / 4);
5681
+ const isArray = format === "jsonl";
5682
+ return {
5683
+ __toolResultSpilled: true,
5684
+ tool: toolName,
5685
+ path,
5686
+ format,
5687
+ ...records !== void 0 ? { records } : {},
5688
+ totalChars: serialized.length,
5689
+ approxTokens,
5690
+ preview: serialized.slice(0, SPILLED_PREVIEW_CHARS),
5691
+ note: `This result was too large to return inline (~${approxTokens.toLocaleString()} tokens) and was saved to ${path}. Inspect it with bash \u2014 e.g. \`wc -l ${path}\`, \`head -c 4000 ${path}\`, ` + (isArray ? `\`sed -n '1,5p' ${path}\`, \`grep -i <term> ${path}\`, or \`jq\` per line` : `\`grep -i <term> ${path}\``) + `. Do NOT cat or read_file the whole file (it re-overflows). Or re-run the tool with a narrower request (fewer items / a filter / metadata only).`
5692
+ };
5693
+ };
5694
+ var buildInlineTruncation = (toolName, serialized) => {
5695
+ const approxTokens = Math.ceil(serialized.length / 4);
5696
+ const omittedChars = Math.max(0, serialized.length - INLINE_TRUNCATE_PREVIEW_CHARS);
5697
+ return {
5698
+ __toolResultTruncated: true,
5699
+ tool: toolName,
5700
+ approxTokens,
5701
+ omittedChars,
5702
+ preview: serialized.slice(0, INLINE_TRUNCATE_PREVIEW_CHARS),
5703
+ 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.`
5704
+ };
5705
+ };
5706
+
5652
5707
  // src/vfs/bash-tool.ts
5653
5708
  import { defineTool as defineTool2 } from "@poncho-ai/sdk";
5654
5709
  var createBashTool = (bashManager) => defineTool2({
@@ -9825,6 +9880,59 @@ var AgentHarness = class _AgentHarness {
9825
9880
  this.archivedToolResultsByConversation.set(conversationId, merged);
9826
9881
  return merged;
9827
9882
  }
9883
+ /**
9884
+ * Guard a single FRESH tool result against overflowing the context window.
9885
+ * `serialized` is the JSON-serialized (media-stripped) output. Returns a
9886
+ * replacement value to put in front of the model (a handle or a truncated
9887
+ * preview) when the result is oversized, or `null` to leave it untouched.
9888
+ * Spilling writes the full payload to a VFS file; on any write failure it
9889
+ * falls back to inline truncation, so this never throws into the run loop.
9890
+ */
9891
+ async guardOversizedToolResult(opts) {
9892
+ const { tenantId, toolName, toolCallId, output, serialized, policy } = opts;
9893
+ if (!isOversizedToolResult(serialized, policy)) return null;
9894
+ if (policy.enabled && this.bashManager) {
9895
+ try {
9896
+ const vfs = this.createVfsAccess(tenantId);
9897
+ const { content, format, records } = formatSpillPayload(output);
9898
+ const safeTool = toolName.replace(/[^A-Za-z0-9_-]/g, "_");
9899
+ const path = `${policy.dir}/${safeTool}_${toolCallId}.${format}`;
9900
+ await vfs.mkdir(policy.dir, { recursive: true });
9901
+ await vfs.writeText(path, content);
9902
+ await this.pruneSpillDir(vfs, policy.dir, policy.keepLast);
9903
+ return buildSpillHandle({ toolName, path, format, serialized, records });
9904
+ } catch {
9905
+ }
9906
+ }
9907
+ return buildInlineTruncation(toolName, serialized);
9908
+ }
9909
+ /** Best-effort: keep only the newest `keepLast` files in the spill dir. */
9910
+ async pruneSpillDir(vfs, dir, keepLast) {
9911
+ try {
9912
+ const entries = await vfs.readdir(dir);
9913
+ const names = (Array.isArray(entries) ? entries : []).map((e) => typeof e === "string" ? e : e.name).filter((n) => typeof n === "string");
9914
+ if (names.length <= keepLast) return;
9915
+ const stated = await Promise.all(
9916
+ names.map(async (name) => {
9917
+ try {
9918
+ const s = await vfs.stat(`${dir}/${name}`);
9919
+ return { name, mtime: s.updatedAt ?? 0 };
9920
+ } catch {
9921
+ return { name, mtime: 0 };
9922
+ }
9923
+ })
9924
+ );
9925
+ stated.sort((a, b) => a.mtime - b.mtime);
9926
+ const doomed = stated.slice(0, stated.length - keepLast);
9927
+ for (const d of doomed) {
9928
+ try {
9929
+ await vfs.rm(`${dir}/${d.name}`);
9930
+ } catch {
9931
+ }
9932
+ }
9933
+ } catch {
9934
+ }
9935
+ }
9828
9936
  truncateHistoricalToolResults(messages, conversationId) {
9829
9937
  let latestRunId;
9830
9938
  let latestToolMessageIndex = -1;
@@ -10676,6 +10784,7 @@ var AgentHarness = class _AgentHarness {
10676
10784
  const messages = [...input.messages ?? []];
10677
10785
  const conversationId = input.conversationId ?? "__default__";
10678
10786
  this.seedToolResultArchive(conversationId, input.parameters);
10787
+ const spillPolicy = readSpillPolicy(input.parameters);
10679
10788
  const truncationSummary = this.truncateHistoricalToolResults(messages, conversationId);
10680
10789
  if (truncationSummary.changed) {
10681
10790
  costLog.debug(
@@ -11817,29 +11926,39 @@ ${textContent}` };
11817
11926
  span.setStatus({ code: SpanStatusCode.OK });
11818
11927
  span.end();
11819
11928
  }
11820
- const serialized = JSON.stringify(result2.output ?? null);
11821
- const outputTokenEstimate = Math.ceil(serialized.length / 4);
11929
+ const { mediaItems, strippedOutput } = extractMediaFromToolOutput(result2.output);
11930
+ const strippedSerialized = JSON.stringify(strippedOutput ?? null);
11931
+ const guarded = await this.guardOversizedToolResult({
11932
+ tenantId: input.tenantId ?? "__default__",
11933
+ toolName: result2.tool,
11934
+ toolCallId: result2.callId,
11935
+ output: strippedOutput,
11936
+ serialized: strippedSerialized,
11937
+ policy: spillPolicy
11938
+ });
11939
+ const effectiveOutput = guarded ?? strippedOutput ?? null;
11940
+ const effectiveSerialized = guarded ? JSON.stringify(guarded) : strippedSerialized;
11941
+ const outputTokenEstimate = Math.ceil(effectiveSerialized.length / 4);
11822
11942
  toolOutputEstimateSinceModel += outputTokenEstimate;
11823
11943
  yield pushEvent({
11824
11944
  type: "tool:completed",
11825
11945
  tool: result2.tool,
11826
11946
  toolCallId: result2.callId,
11827
11947
  input: callInputMap.get(result2.callId),
11828
- output: result2.output,
11948
+ output: effectiveOutput,
11829
11949
  duration: now() - batchStart,
11830
11950
  outputTokenEstimate
11831
11951
  });
11832
- const { mediaItems, strippedOutput } = extractMediaFromToolOutput(result2.output);
11833
11952
  toolResultsForModel.push({
11834
11953
  type: "tool_result",
11835
11954
  tool_use_id: result2.callId,
11836
11955
  tool_name: result2.tool,
11837
- content: JSON.stringify(strippedOutput ?? null)
11956
+ content: effectiveSerialized
11838
11957
  });
11839
11958
  {
11840
11959
  const archive = this.archivedToolResultsByConversation.get(conversationId);
11841
11960
  if (archive && !NON_ARCHIVABLE_TOOL_NAMES.has(result2.tool)) {
11842
- const payload = JSON.stringify(result2.output ?? null);
11961
+ const payload = effectiveSerialized;
11843
11962
  archive[result2.callId] = {
11844
11963
  toolResultId: result2.callId,
11845
11964
  conversationId,
@@ -11860,7 +11979,7 @@ ${textContent}` };
11860
11979
  output: {
11861
11980
  type: "content",
11862
11981
  value: [
11863
- { type: "text", text: JSON.stringify(strippedOutput ?? null) },
11982
+ { type: "text", text: effectiveSerialized },
11864
11983
  ...mediaItems
11865
11984
  ]
11866
11985
  }
@@ -11870,7 +11989,7 @@ ${textContent}` };
11870
11989
  type: "tool-result",
11871
11990
  toolCallId: result2.callId,
11872
11991
  toolName: result2.tool,
11873
- output: { type: "json", value: result2.output ?? null }
11992
+ output: { type: "json", value: effectiveOutput }
11874
11993
  });
11875
11994
  }
11876
11995
  }
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.15",
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";
@@ -1181,6 +1189,77 @@ export class AgentHarness {
1181
1189
  return merged;
1182
1190
  }
1183
1191
 
1192
+ /**
1193
+ * Guard a single FRESH tool result against overflowing the context window.
1194
+ * `serialized` is the JSON-serialized (media-stripped) output. Returns a
1195
+ * replacement value to put in front of the model (a handle or a truncated
1196
+ * preview) when the result is oversized, or `null` to leave it untouched.
1197
+ * Spilling writes the full payload to a VFS file; on any write failure it
1198
+ * falls back to inline truncation, so this never throws into the run loop.
1199
+ */
1200
+ private async guardOversizedToolResult(opts: {
1201
+ tenantId: string;
1202
+ toolName: string;
1203
+ toolCallId: string;
1204
+ output: unknown;
1205
+ serialized: string;
1206
+ policy: ToolResultSpillPolicy;
1207
+ }): Promise<Record<string, unknown> | null> {
1208
+ const { tenantId, toolName, toolCallId, output, serialized, policy } = opts;
1209
+ if (!isOversizedToolResult(serialized, policy)) return null;
1210
+
1211
+ if (policy.enabled && this.bashManager) {
1212
+ try {
1213
+ const vfs = this.createVfsAccess(tenantId);
1214
+ const { content, format, records } = formatSpillPayload(output);
1215
+ // toolCallId is unique per call and stable across a checkpoint→resume,
1216
+ // so the path is idempotent (a resumed run rewrites the same file).
1217
+ const safeTool = toolName.replace(/[^A-Za-z0-9_-]/g, "_");
1218
+ const path = `${policy.dir}/${safeTool}_${toolCallId}.${format}`;
1219
+ await vfs.mkdir(policy.dir, { recursive: true });
1220
+ await vfs.writeText(path, content);
1221
+ await this.pruneSpillDir(vfs, policy.dir, policy.keepLast);
1222
+ return buildSpillHandle({ toolName, path, format, serialized, records });
1223
+ } catch {
1224
+ // Fall through to inline truncation below.
1225
+ }
1226
+ }
1227
+
1228
+ return buildInlineTruncation(toolName, serialized);
1229
+ }
1230
+
1231
+ /** Best-effort: keep only the newest `keepLast` files in the spill dir. */
1232
+ private async pruneSpillDir(
1233
+ vfs: NonNullable<ToolContext["vfs"]>,
1234
+ dir: string,
1235
+ keepLast: number,
1236
+ ): Promise<void> {
1237
+ try {
1238
+ const entries = await vfs.readdir(dir);
1239
+ const names = (Array.isArray(entries) ? entries : [])
1240
+ .map((e) => (typeof e === "string" ? e : (e as { name?: string }).name))
1241
+ .filter((n): n is string => typeof n === "string");
1242
+ if (names.length <= keepLast) return;
1243
+ const stated = await Promise.all(
1244
+ names.map(async (name) => {
1245
+ try {
1246
+ const s = await vfs.stat(`${dir}/${name}`);
1247
+ return { name, mtime: s.updatedAt ?? 0 };
1248
+ } catch {
1249
+ return { name, mtime: 0 };
1250
+ }
1251
+ }),
1252
+ );
1253
+ stated.sort((a, b) => a.mtime - b.mtime); // oldest first
1254
+ const doomed = stated.slice(0, stated.length - keepLast);
1255
+ for (const d of doomed) {
1256
+ try { await vfs.rm(`${dir}/${d.name}`); } catch { /* best-effort */ }
1257
+ }
1258
+ } catch {
1259
+ // Dir unreadable — nothing to prune.
1260
+ }
1261
+ }
1262
+
1184
1263
  private truncateHistoricalToolResults(
1185
1264
  messages: Message[],
1186
1265
  conversationId: string,
@@ -2212,6 +2291,7 @@ export class AgentHarness {
2212
2291
  const messages: Message[] = [...(input.messages ?? [])];
2213
2292
  const conversationId = input.conversationId ?? "__default__";
2214
2293
  this.seedToolResultArchive(conversationId, input.parameters);
2294
+ const spillPolicy = readSpillPolicy(input.parameters);
2215
2295
  const truncationSummary = this.truncateHistoricalToolResults(messages, conversationId);
2216
2296
  if (truncationSummary.changed) {
2217
2297
  costLog.debug(
@@ -3692,30 +3772,49 @@ Code is wrapped in an async IIFE — use \`return\` to return a value to the too
3692
3772
  span.setStatus({ code: SpanStatusCode.OK });
3693
3773
  span.end();
3694
3774
  }
3695
- const serialized = JSON.stringify(result.output ?? null);
3696
- const outputTokenEstimate = Math.ceil(serialized.length / 4);
3775
+ // Separate inline media (images/PDFs) from the textual output, then
3776
+ // guard the textual part against overflowing the window. A single
3777
+ // oversized fresh result is replaced with a handle (spill) or a
3778
+ // preview (inline) BEFORE we emit the event or feed the model — so
3779
+ // neither the WS stream nor the next step ever carries the full
3780
+ // multi-MB payload.
3781
+ const { mediaItems, strippedOutput } = extractMediaFromToolOutput(result.output);
3782
+ const strippedSerialized = JSON.stringify(strippedOutput ?? null);
3783
+ const guarded = await this.guardOversizedToolResult({
3784
+ tenantId: input.tenantId ?? "__default__",
3785
+ toolName: result.tool,
3786
+ toolCallId: result.callId,
3787
+ output: strippedOutput,
3788
+ serialized: strippedSerialized,
3789
+ policy: spillPolicy,
3790
+ });
3791
+ const effectiveOutput: unknown = guarded ?? strippedOutput ?? null;
3792
+ const effectiveSerialized = guarded ? JSON.stringify(guarded) : strippedSerialized;
3793
+ const outputTokenEstimate = Math.ceil(effectiveSerialized.length / 4);
3697
3794
  toolOutputEstimateSinceModel += outputTokenEstimate;
3698
3795
  yield pushEvent({
3699
3796
  type: "tool:completed",
3700
3797
  tool: result.tool,
3701
3798
  toolCallId: result.callId,
3702
3799
  input: callInputMap.get(result.callId),
3703
- output: result.output,
3800
+ output: effectiveOutput,
3704
3801
  duration: now() - batchStart,
3705
3802
  outputTokenEstimate,
3706
3803
  });
3707
3804
 
3708
- const { mediaItems, strippedOutput } = extractMediaFromToolOutput(result.output);
3709
3805
  toolResultsForModel.push({
3710
3806
  type: "tool_result",
3711
3807
  tool_use_id: result.callId,
3712
3808
  tool_name: result.tool,
3713
- content: JSON.stringify(strippedOutput ?? null),
3809
+ content: effectiveSerialized,
3714
3810
  });
3715
3811
  {
3716
3812
  const archive = this.archivedToolResultsByConversation.get(conversationId);
3717
3813
  if (archive && !NON_ARCHIVABLE_TOOL_NAMES.has(result.tool)) {
3718
- const payload = JSON.stringify(result.output ?? null);
3814
+ // Archive the model-visible payload (handle/preview when guarded).
3815
+ // When spilled, the VFS file is the durable full copy; keeping the
3816
+ // in-memory archive small avoids re-introducing the same bloat.
3817
+ const payload = effectiveSerialized;
3719
3818
  archive[result.callId] = {
3720
3819
  toolResultId: result.callId,
3721
3820
  conversationId,
@@ -3737,7 +3836,7 @@ Code is wrapped in an async IIFE — use \`return\` to return a value to the too
3737
3836
  output: {
3738
3837
  type: "content",
3739
3838
  value: [
3740
- { type: "text", text: JSON.stringify(strippedOutput ?? null) },
3839
+ { type: "text", text: effectiveSerialized },
3741
3840
  ...mediaItems,
3742
3841
  ],
3743
3842
  },
@@ -3747,7 +3846,7 @@ Code is wrapped in an async IIFE — use \`return\` to return a value to the too
3747
3846
  type: "tool-result",
3748
3847
  toolCallId: result.callId,
3749
3848
  toolName: result.tool,
3750
- output: { type: "json", value: result.output ?? null },
3849
+ output: { type: "json", value: effectiveOutput },
3751
3850
  });
3752
3851
  }
3753
3852
  }
@@ -0,0 +1,133 @@
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
+ path: string;
84
+ format: "jsonl" | "json";
85
+ serialized: string;
86
+ records?: number;
87
+ }): Record<string, unknown> => {
88
+ const { toolName, path, format, serialized, records } = opts;
89
+ const approxTokens = Math.ceil(serialized.length / 4);
90
+ const isArray = format === "jsonl";
91
+ return {
92
+ __toolResultSpilled: true,
93
+ tool: toolName,
94
+ path,
95
+ format,
96
+ ...(records !== undefined ? { records } : {}),
97
+ totalChars: serialized.length,
98
+ approxTokens,
99
+ preview: serialized.slice(0, SPILLED_PREVIEW_CHARS),
100
+ note:
101
+ `This result was too large to return inline (~${approxTokens.toLocaleString()} ` +
102
+ `tokens) and was saved to ${path}. Inspect it with bash — e.g. ` +
103
+ `\`wc -l ${path}\`, \`head -c 4000 ${path}\`, ` +
104
+ (isArray
105
+ ? `\`sed -n '1,5p' ${path}\`, \`grep -i <term> ${path}\`, or \`jq\` per line`
106
+ : `\`grep -i <term> ${path}\``) +
107
+ `. Do NOT cat or read_file the whole file (it re-overflows). Or re-run the ` +
108
+ `tool with a narrower request (fewer items / a filter / metadata only).`,
109
+ };
110
+ };
111
+
112
+ /** The replacement placed in front of the model when a result is too large and
113
+ * spill is unavailable: a preview plus a "re-run narrower" instruction. */
114
+ export const buildInlineTruncation = (
115
+ toolName: string,
116
+ serialized: string,
117
+ ): Record<string, unknown> => {
118
+ const approxTokens = Math.ceil(serialized.length / 4);
119
+ const omittedChars = Math.max(0, serialized.length - INLINE_TRUNCATE_PREVIEW_CHARS);
120
+ return {
121
+ __toolResultTruncated: true,
122
+ tool: toolName,
123
+ approxTokens,
124
+ omittedChars,
125
+ preview: serialized.slice(0, INLINE_TRUNCATE_PREVIEW_CHARS),
126
+ note:
127
+ `This result was too large (~${approxTokens.toLocaleString()} tokens) and was ` +
128
+ `truncated to fit the context window — only the first ` +
129
+ `${INLINE_TRUNCATE_PREVIEW_CHARS.toLocaleString()} characters are shown. Re-run ` +
130
+ `with a narrower request (fewer items / a filter / metadata only). Do NOT retry ` +
131
+ `the same call unchanged — it will overflow again.`,
132
+ };
133
+ };
@@ -0,0 +1,123 @@
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, 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
+ path: "/tmp/tool-results/mcp_gmail_GMAIL_FETCH_EMAILS_tool_1.jsonl",
89
+ format: "jsonl",
90
+ serialized,
91
+ records: 312,
92
+ });
93
+ expect(h.__toolResultSpilled).toBe(true);
94
+ expect(h.records).toBe(312);
95
+ expect(h.totalChars).toBe(serialized.length);
96
+ expect((h.preview as string).length).toBe(SPILLED_PREVIEW_CHARS);
97
+ expect(String(h.note)).toContain("/tmp/tool-results/");
98
+ expect(String(h.note)).toContain("jq"); // array → jsonl hint
99
+ expect(String(h.note)).toContain("Do NOT cat");
100
+ });
101
+
102
+ it("omits the array-only jq hint and records for json format", () => {
103
+ const h = buildSpillHandle({
104
+ toolName: "t",
105
+ path: "/tmp/tool-results/t_x.json",
106
+ format: "json",
107
+ serialized: "y".repeat(10),
108
+ });
109
+ expect(h.records).toBeUndefined();
110
+ expect(String(h.note)).not.toContain("jq");
111
+ });
112
+ });
113
+
114
+ describe("buildInlineTruncation", () => {
115
+ it("returns a truncation marker with a capped preview and omitted-char count", () => {
116
+ const serialized = "z".repeat(INLINE_TRUNCATE_PREVIEW_CHARS + 1234);
117
+ const t = buildInlineTruncation("some_tool", serialized);
118
+ expect(t.__toolResultTruncated).toBe(true);
119
+ expect((t.preview as string).length).toBe(INLINE_TRUNCATE_PREVIEW_CHARS);
120
+ expect(t.omittedChars).toBe(1234);
121
+ expect(String(t.note)).toContain("Do NOT retry");
122
+ });
123
+ });