diff --git a/packages/agent-provider-tangle/package.json b/packages/agent-provider-tangle/package.json index 7069555..4ff8b2d 100644 --- a/packages/agent-provider-tangle/package.json +++ b/packages/agent-provider-tangle/package.json @@ -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", diff --git a/packages/agent-provider-tangle/src/tangle-events.test.ts b/packages/agent-provider-tangle/src/tangle-events.test.ts index 8eb963d..773ff82 100644 --- a/packages/agent-provider-tangle/src/tangle-events.test.ts +++ b/packages/agent-provider-tangle/src/tangle-events.test.ts @@ -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"; @@ -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/); + }); }); diff --git a/packages/agent-provider-tangle/src/tangle-prompt.ts b/packages/agent-provider-tangle/src/tangle-prompt.ts index e2461d5..3519e70 100644 --- a/packages/agent-provider-tangle/src/tangle-prompt.ts +++ b/packages/agent-provider-tangle/src/tangle-prompt.ts @@ -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");