@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.
- package/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +46 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +147 -8
- package/package.json +1 -1
- package/src/harness.ts +131 -8
- package/src/tool-result-guard.ts +150 -0
- package/test/tool-result-guard.test.ts +135 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @poncho-ai/harness@0.59.
|
|
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
|
[34mCLI[39m tsup v8.5.1
|
|
9
9
|
[34mCLI[39m Target: es2022
|
|
10
10
|
[34mESM[39m Build start
|
|
11
|
+
[32mESM[39m [1mdist/index.js [22m[32m571.49 KB[39m
|
|
11
12
|
[32mESM[39m [1mdist/isolate-F2PPSUL6.js [22m[32m53.82 KB[39m
|
|
12
|
-
[32mESM[39m
|
|
13
|
-
[32mESM[39m ⚡️ Build success in 217ms
|
|
13
|
+
[32mESM[39m ⚡️ Build success in 239ms
|
|
14
14
|
[34mDTS[39m Build start
|
|
15
|
-
[32mDTS[39m ⚡️ Build success in
|
|
16
|
-
[32mDTS[39m [1mdist/index.d.ts [22m[
|
|
15
|
+
[32mDTS[39m ⚡️ Build success in 6987ms
|
|
16
|
+
[32mDTS[39m [1mdist/index.d.ts [22m[32m103.12 KB[39m
|
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
|
|
11821
|
-
const
|
|
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:
|
|
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:
|
|
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 =
|
|
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:
|
|
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:
|
|
12012
|
+
output: { type: "json", value: effectiveOutput }
|
|
11874
12013
|
});
|
|
11875
12014
|
}
|
|
11876
12015
|
}
|
package/package.json
CHANGED
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
|
-
|
|
3696
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
+
});
|