Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/agent-provider-tangle/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tangle-network/agent-provider-tangle",
"version": "1.1.8",
"version": "1.1.9",
"description": "AgentEnvironmentProvider adapter for Tangle sandboxes",
"type": "module",
"license": "MIT",
Expand Down
23 changes: 22 additions & 1 deletion packages/agent-provider-tangle/src/tangle-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { SandboxEvent } from "@tangle-network/sandbox";
import { AgentTurnResultSchema, type AgentEnvironmentEvent } from "@tangle-network/agent-interface/environment-provider";
import { describe, expect, it } from "vitest";
import { createTangleProvider } from "./index.js";
import { assertBoundedJson } from "./tangle-contract-safety.js";
import { assertBoundedJson, MAX_STRING_LENGTH as CONTRACT_MAX_STRING_LENGTH } from "./tangle-contract-safety.js";
import { environmentEventFromSandboxEvent } from "./tangle-events.js";
import { controlRefForTurn, retainedDeployment } from "./retained-control-test-helpers.js";
import type { SandboxSessionLike } from "./index.js";
Expand Down Expand Up @@ -131,4 +131,25 @@ describe("Sandbox stream event content", () => {
expect(() => validatedSandboxPromptResult({ ...result, traceId: recordedOutput })).toThrow(/JSON bound/);
expect(() => validatedSandboxPromptResult({ ...result, usage: { inputTokens: 2, outputTokens: -1 } })).toThrow(/output token count/);
});

it("bounds tool output as content, not as metadata", () => {
// A tool result is whatever a tool returned: one webfetch of a paper is routinely tens or
// hundreds of kilobytes. Holding it to CONTRACT_MAX_STRING_LENGTH rejected turns the Sandbox
// SDK had already accepted — it serializes each tool value up to 4 MiB, a 256x mismatch — and
// because this validator runs inside the terminal result read, after the stream drained and the
// usage was credited, the rejection discarded a finished, fully paid turn. Measured 2026-09-11:
// 143 of 199 children across 16 pursuits, every one at iterations 0.
const result = { success: true, status: "success" as const, durationMs: 1, response: "ok" };
const toolResult = (length: number) => ({
...result,
toolInvocations: [{ toolName: "webfetch", args: { url: "https://arxiv.org/abs/2402.02364" }, result: "x".repeat(length) }],
});
// The exact wall the failures piled against: one character over the metadata bound.
expect(() => validatedSandboxPromptResult(toolResult(CONTRACT_MAX_STRING_LENGTH + 1))).not.toThrow();
expect(() => validatedSandboxPromptResult(toolResult(200_000))).not.toThrow();
// The content bound still governs it, so an unbounded tool result is still refused.
expect(() => validatedSandboxPromptResult(toolResult(2 * 1024 * 1024))).toThrow(/JSON bound/);
// Every other field keeps the metadata bound; only what the agent produced is content.
expect(() => validatedSandboxPromptResult({ ...result, traceId: "x".repeat(CONTRACT_MAX_STRING_LENGTH + 1) })).toThrow(/JSON bound/);
});
});
24 changes: 22 additions & 2 deletions packages/agent-provider-tangle/src/tangle-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,10 +582,30 @@ export function validatedSandboxPromptResult(
if (!content.success) {
throw new Error("Tangle prompt result exceeded its JSON bound", { cause: content.error });
}
// Response text is content; all other fields retain their metadata limits.
// What the agent produced is content and is bounded as content by the check above; the fields
// that describe the turn keep their metadata limits.
//
// `toolInvocations` belongs with the response text, not with the metadata. It carries whatever a
// tool returned, and one `webfetch` of a paper or an API page is routinely tens or hundreds of
// kilobytes, while the metadata bound is CONTRACT_MAX_STRING_LENGTH — 16,384 characters per
// string. `isBoundedEventContentJson` was written for exactly this material ("content may contain
// a single large transcript or tool result") and the whole record has already passed it at
// CONTRACT_MAX_JSON_BYTES.
//
// Holding tool output to the metadata bound rejected a turn the Sandbox SDK had already accepted:
// it serializes each tool value up to MAX_SERIALIZED_TOOL_VALUE_BYTES (4 MiB), a 256x mismatch.
// Because this validator runs inside the terminal result read, AFTER the live stream has drained
// and the usage receipt has been credited, the rejection did not fail the tool call — it
// converted a finished, fully paid turn into an unreconcilable retained execution that a
// supervisor reports as a child that did no work at all. Measured 2026-09-11 in one Discovery Lab
// worktree: 143 of 199 children across 16 pursuits, every one at `iterations: 0`, and the
// enumerate and extract stages that fetch papers were the ones that died.
assertBoundedJson(Object.fromEntries(
Object.entries(record).filter(([field]) =>
field !== "response" && field !== "text" && field !== "finalText"),
field !== "response" &&
field !== "text" &&
field !== "finalText" &&
field !== "toolInvocations"),
));
if (typeof record.success !== "boolean") {
throw new Error("Tangle prompt result omitted its success status");
Expand Down