@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.
- package/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +28 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +127 -8
- package/package.json +1 -1
- package/src/harness.ts +107 -8
- package/src/tool-result-guard.ts +133 -0
- package/test/tool-result-guard.test.ts +123 -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.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
|
[34mCLI[39m Target: es2022
|
|
10
10
|
[34mESM[39m Build start
|
|
11
11
|
[32mESM[39m [1mdist/isolate-F2PPSUL6.js [22m[32m53.82 KB[39m
|
|
12
|
-
[32mESM[39m [1mdist/index.js [22m[
|
|
13
|
-
[32mESM[39m ⚡️ Build success in
|
|
12
|
+
[32mESM[39m [1mdist/index.js [22m[32m570.16 KB[39m
|
|
13
|
+
[32mESM[39m ⚡️ Build success in 242ms
|
|
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 6639ms
|
|
16
|
+
[32mDTS[39m [1mdist/index.d.ts [22m[32m103.12 KB[39m
|
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
|
|
11821
|
-
const
|
|
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:
|
|
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:
|
|
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 =
|
|
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:
|
|
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:
|
|
11992
|
+
output: { type: "json", value: effectiveOutput }
|
|
11874
11993
|
});
|
|
11875
11994
|
}
|
|
11876
11995
|
}
|
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";
|
|
@@ -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
|
-
|
|
3696
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
+
});
|