From 8f519befc78bddbae4a50b3cbd60a5a68a4e1b44 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 14:20:42 +0000 Subject: [PATCH 01/40] Collapse PTC to exclusive-only: single PTC experiment + RLM sub-experiment Past evals showed supplement-mode PTC (code_execution alongside normal tools) measured ~2x tokens/cost vs both PTC-off and exclusive. Remove supplement mode entirely: - The programmatic-tool-calling experiment now always activates the exclusive posture (bridgeable tools hidden; code_execution + non-bridgeable tools + mcp_prompt_get model-visible). - Delete the programmatic-tool-calling-exclusive experiment ID and the programmaticToolCallingExclusive flag everywhere (schemas, IPC types, frontend subscriptions, CLI experiment builders, eval scenarios). - RLM gating simplifies to rlm && ptc via the central predicates (isRlmModeEnabled, resolveSlashCommandExperimentValue); RLM alone stays fully inert. - Delete rebuildCodeExecutionAfterAssembleHook and the retarget/ reconcile helper chain: they existed solely for supplement mode where hook-visible tools and the bridge coexisted. Exclusive mode keeps bridgeable tools out of the hook-visible record by design. - Stale persisted payloads (feature_flags.json overrides, sendOptions/ taskExperiments) are ignored, never rejected; covered by new stale-payload tests. --- scripts/rlm-eval/scenarios.ts | 7 +- .../CommandPalette/CommandPalette.tsx | 5 - src/browser/features/ChatInput/index.tsx | 6 - .../Settings/Sections/ExperimentsSection.tsx | 13 -- src/browser/hooks/useSendMessageOptions.ts | 4 - .../utils/messages/buildSendMessageOptions.ts | 1 - src/browser/utils/messages/sendOptions.ts | 3 - .../slashCommands/experimentVisibility.ts | 7 +- .../utils/slashCommands/suggestions.test.ts | 12 +- src/cli/run.ts | 1 - src/cli/workflow.ts | 3 - src/common/constants/experiments.ts | 13 +- src/common/orpc/schemas/stream.test.ts | 15 ++ src/common/orpc/schemas/stream.ts | 1 - .../schemas/project.taskExperiments.test.ts | 23 +++ src/common/schemas/project.ts | 1 - src/common/utils/messages/extractReadFiles.ts | 2 +- src/common/utils/tools/tools.ts | 1 - src/node/services/aiService.ts | 135 +----------------- src/node/services/branchSummary.test.ts | 21 +-- src/node/services/branchSummary.ts | 6 +- src/node/services/experimentsService.test.ts | 27 ++++ src/node/services/ptc/runtime.ts | 2 +- src/node/services/ptc/toolBridge.ts | 2 +- src/node/services/ptc/types.ts | 2 +- src/node/services/taskService.ts | 1 - src/node/services/toolAssembly.test.ts | 110 +++----------- src/node/services/toolAssembly.ts | 114 ++++----------- .../services/tools/code_execution.test.ts | 66 +-------- src/node/services/tools/code_execution.ts | 41 +----- .../workflows/WorkflowTaskServiceAdapter.ts | 1 - 31 files changed, 144 insertions(+), 502 deletions(-) create mode 100644 src/common/schemas/project.taskExperiments.test.ts diff --git a/scripts/rlm-eval/scenarios.ts b/scripts/rlm-eval/scenarios.ts index 47a1fd0bc8..2bf3fdd43c 100644 --- a/scripts/rlm-eval/scenarios.ts +++ b/scripts/rlm-eval/scenarios.ts @@ -29,7 +29,6 @@ export interface EvalConfig { id: string; experiments: { programmaticToolCalling: boolean; - programmaticToolCallingExclusive?: boolean; rlm: boolean; }; /** Optional prompting lever, sent as additionalSystemInstructions. */ @@ -236,13 +235,13 @@ export const CONFIGS: EvalConfig[] = [ id: "flat-bash", experiments: { programmaticToolCalling: false, rlm: false }, }, + // PTC is exclusive-only (supplement mode measured ~2x flat tokens/cost and + // was removed): this measures the exclusive code_execution toolset without + // the persistent kernel. { id: "ptc-only", experiments: { programmaticToolCalling: true, rlm: false }, }, - // RLM is exclusive-only (supplement-mode RLM measured ~2x flat tokens/cost - // and was removed): the rlm flag alone yields the kernel-first exclusive - // toolset. The explicit exclusive flag is redundant but harmless. { id: "rlm-excl", experiments: { programmaticToolCalling: true, rlm: true }, diff --git a/src/browser/components/CommandPalette/CommandPalette.tsx b/src/browser/components/CommandPalette/CommandPalette.tsx index 166f7b0d34..7cfd22d150 100644 --- a/src/browser/components/CommandPalette/CommandPalette.tsx +++ b/src/browser/components/CommandPalette/CommandPalette.tsx @@ -69,9 +69,6 @@ export const CommandPalette: React.FC = ({ getSlashContext ); const rlmExperimentEnabled = useExperimentValue(EXPERIMENT_IDS.RLM); const ptcExperimentEnabled = useExperimentValue(EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING); - const ptcExclusiveExperimentEnabled = useExperimentValue( - EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING_EXCLUSIVE - ); const slashContext = getSlashContext?.(); const slashWorkspaceId = slashContext?.workspaceId; @@ -306,7 +303,6 @@ export const CommandPalette: React.FC = ({ getSlashContext memoryConsolidation: memoryConsolidationExperimentEnabled, rlm: rlmExperimentEnabled, programmaticToolCalling: ptcExperimentEnabled, - programmaticToolCallingExclusive: ptcExclusiveExperimentEnabled, }), }); const section = "Slash Commands"; @@ -385,7 +381,6 @@ export const CommandPalette: React.FC = ({ getSlashContext memoryConsolidationExperimentEnabled, rlmExperimentEnabled, ptcExperimentEnabled, - ptcExclusiveExperimentEnabled, ]); useEffect(() => { diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index ce73cf4c36..310a318d69 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -326,9 +326,6 @@ const ChatInputInner: React.FC = (props) => { ); const rlmExperimentEnabled = useExperimentValue(EXPERIMENT_IDS.RLM); const ptcExperimentEnabled = useExperimentValue(EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING); - const ptcExclusiveExperimentEnabled = useExperimentValue( - EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING_EXCLUSIVE - ); const atMentionProjectPath = variant === "creation" && props.kind !== "scratch" ? props.projectPath : null; const asyncCommandScopeRef = useRef<{ variant: typeof variant; workspaceId: string | null }>({ @@ -1752,7 +1749,6 @@ const ChatInputInner: React.FC = (props) => { memoryConsolidation: memoryConsolidationExperimentEnabled, rlm: rlmExperimentEnabled, programmaticToolCalling: ptcExperimentEnabled, - programmaticToolCallingExclusive: ptcExclusiveExperimentEnabled, }), }); setCommandSuggestions((prev) => replaceSuggestions(prev, suggestions)); @@ -1769,7 +1765,6 @@ const ChatInputInner: React.FC = (props) => { memoryConsolidationExperimentEnabled, rlmExperimentEnabled, ptcExperimentEnabled, - ptcExclusiveExperimentEnabled, ]); // Watch input/cursor for `\symbol` backslash commands and surface the menu. @@ -1807,7 +1802,6 @@ const ChatInputInner: React.FC = (props) => { memoryConsolidation: memoryConsolidationExperimentEnabled, rlm: rlmExperimentEnabled, programmaticToolCalling: ptcExperimentEnabled, - programmaticToolCallingExclusive: ptcExclusiveExperimentEnabled, }), }); diff --git a/src/browser/features/Settings/Sections/ExperimentsSection.tsx b/src/browser/features/Settings/Sections/ExperimentsSection.tsx index 55e88678bb..2f2b5aec65 100644 --- a/src/browser/features/Settings/Sections/ExperimentsSection.tsx +++ b/src/browser/features/Settings/Sections/ExperimentsSection.tsx @@ -695,9 +695,6 @@ export function ExperimentsSection() { const workspaceHeartbeatsEnabled = useExperimentValue(EXPERIMENT_IDS.WORKSPACE_HEARTBEATS); const memoryEnabled = useExperimentValue(EXPERIMENT_IDS.MEMORY); const ptcEnabled = useExperimentValue(EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING); - const ptcExclusiveEnabled = useExperimentValue( - EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING_EXCLUSIVE - ); const settingsConfigRequestRef = useRef<{ api: APIClient; request: Promise; @@ -808,16 +805,6 @@ export function ExperimentsSection() { )} - {/* RLM rides EITHER accepted PTC parent (toolAssembly accepts - exclusive + rlm too); render under Exclusive only when plain - PTC is off so the row never appears twice. */} - {exp.id === EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING_EXCLUSIVE && - ptcExclusiveEnabled && - !ptcEnabled && ( - - - - )} {exp.id === EXPERIMENT_IDS.PORTABLE_DESKTOP && } {exp.id === EXPERIMENT_IDS.CONFIGURABLE_BIND_URL && } diff --git a/src/browser/hooks/useSendMessageOptions.ts b/src/browser/hooks/useSendMessageOptions.ts index 09d454c89a..87eb65e33a 100644 --- a/src/browser/hooks/useSendMessageOptions.ts +++ b/src/browser/hooks/useSendMessageOptions.ts @@ -55,9 +55,6 @@ export function useSendMessageOptions(workspaceId: string): SendMessageOptionsWi const programmaticToolCalling = useExperimentOverrideValue( EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING ); - const programmaticToolCallingExclusive = useExperimentOverrideValue( - EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING_EXCLUSIVE - ); const rlm = useExperimentOverrideValue(EXPERIMENT_IDS.RLM); const advisorTool = useExperimentOverrideValue(EXPERIMENT_IDS.ADVISOR_TOOL); const dynamicWorkflows = useExperimentOverrideValue(EXPERIMENT_IDS.DYNAMIC_WORKFLOWS); @@ -80,7 +77,6 @@ export function useSendMessageOptions(workspaceId: string): SendMessageOptionsWi providerOptions, experiments: { programmaticToolCalling, - programmaticToolCallingExclusive, rlm, advisorTool, dynamicWorkflows, diff --git a/src/browser/utils/messages/buildSendMessageOptions.ts b/src/browser/utils/messages/buildSendMessageOptions.ts index 30ded2fc7b..b3d45804c4 100644 --- a/src/browser/utils/messages/buildSendMessageOptions.ts +++ b/src/browser/utils/messages/buildSendMessageOptions.ts @@ -5,7 +5,6 @@ import { normalizeSelectedModel } from "@/common/utils/ai/models"; export interface ExperimentValues { programmaticToolCalling: boolean | undefined; - programmaticToolCallingExclusive: boolean | undefined; /** RLM mode (sub-experiment of PTC): backend ignores it unless PTC is on. */ rlm: boolean | undefined; advisorTool: boolean | undefined; diff --git a/src/browser/utils/messages/sendOptions.ts b/src/browser/utils/messages/sendOptions.ts index 30b566d698..b9f1561825 100644 --- a/src/browser/utils/messages/sendOptions.ts +++ b/src/browser/utils/messages/sendOptions.ts @@ -93,9 +93,6 @@ export function getSendOptionsFromStorage(workspaceId: string): SendMessageOptio disableWorkspaceAgents, experiments: { programmaticToolCalling: isExperimentEnabled(EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING), - programmaticToolCallingExclusive: isExperimentEnabled( - EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING_EXCLUSIVE - ), rlm: isExperimentEnabled(EXPERIMENT_IDS.RLM), advisorTool: isExperimentEnabled(EXPERIMENT_IDS.ADVISOR_TOOL), dynamicWorkflows: isExperimentEnabled(EXPERIMENT_IDS.DYNAMIC_WORKFLOWS), diff --git a/src/browser/utils/slashCommands/experimentVisibility.ts b/src/browser/utils/slashCommands/experimentVisibility.ts index 36601b539d..60b3c03a58 100644 --- a/src/browser/utils/slashCommands/experimentVisibility.ts +++ b/src/browser/utils/slashCommands/experimentVisibility.ts @@ -7,7 +7,6 @@ export interface SlashCommandExperimentSnapshot { memoryConsolidation?: boolean; rlm?: boolean; programmaticToolCalling?: boolean; - programmaticToolCallingExclusive?: boolean; } export function resolveSlashCommandExperimentValue( @@ -27,11 +26,7 @@ export function resolveSlashCommandExperimentValue( // Sub-experiment of Programmatic Tool Calling: the backend refuses // /refine unless RLM AND a PTC parent flag are on, so the sub-flag // alone must not surface the command. - return ( - snapshot.rlm === true && - (snapshot.programmaticToolCalling === true || - snapshot.programmaticToolCallingExclusive === true) - ); + return snapshot.rlm === true && snapshot.programmaticToolCalling === true; default: return undefined; } diff --git a/src/browser/utils/slashCommands/suggestions.test.ts b/src/browser/utils/slashCommands/suggestions.test.ts index bfb67bbccf..07e3bea8b6 100644 --- a/src/browser/utils/slashCommands/suggestions.test.ts +++ b/src/browser/utils/slashCommands/suggestions.test.ts @@ -22,8 +22,8 @@ describe("resolveSlashCommandExperimentValue", () => { ).toBe(true); }); - it("requires a PTC parent flag for rlm-mode", () => { - // The backend refuses /refine unless RLM AND a PTC flag are on, so the + it("requires the PTC parent flag for rlm-mode", () => { + // The backend refuses /refine unless RLM AND PTC are on, so the // sub-flag alone must not surface the command. expect( resolveSlashCommandExperimentValue(EXPERIMENT_IDS.RLM, { @@ -38,14 +38,6 @@ describe("resolveSlashCommandExperimentValue", () => { programmaticToolCalling: true, }) ).toBe(true); - // Exclusive mode alone is a valid PTC parent too. - expect( - resolveSlashCommandExperimentValue(EXPERIMENT_IDS.RLM, { - workspaceHeartbeats: false, - rlm: true, - programmaticToolCallingExclusive: true, - }) - ).toBe(true); }); }); diff --git a/src/cli/run.ts b/src/cli/run.ts index 6e88549ea8..d20fe397bc 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -287,7 +287,6 @@ function buildExperimentsObject(experimentIds: string[]): SendMessageOptions["ex return { programmaticToolCalling: experimentIds.includes("programmatic-tool-calling"), - programmaticToolCallingExclusive: experimentIds.includes("programmatic-tool-calling-exclusive"), dynamicWorkflows: experimentIds.includes("dynamic-workflows"), workspaceHeartbeats: experimentIds.includes(EXPERIMENT_IDS.WORKSPACE_HEARTBEATS), }; diff --git a/src/cli/workflow.ts b/src/cli/workflow.ts index ef32495c5d..d57970b129 100644 --- a/src/cli/workflow.ts +++ b/src/cli/workflow.ts @@ -227,9 +227,6 @@ async function copyPersistentConfig(realConfig: Config, config: Config): Promise function buildExperimentsObject(experimentIds: readonly string[]) { return { programmaticToolCalling: experimentIds.includes(EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING), - programmaticToolCallingExclusive: experimentIds.includes( - EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING_EXCLUSIVE - ), // Invoking `xum workflow` is an explicit opt-in, so the dynamic-workflows // experiment is enabled implicitly for this invocation (never persisted). dynamicWorkflows: true, diff --git a/src/common/constants/experiments.ts b/src/common/constants/experiments.ts index 09fb2be127..6774b1b571 100644 --- a/src/common/constants/experiments.ts +++ b/src/common/constants/experiments.ts @@ -7,7 +7,6 @@ export const EXPERIMENT_IDS = { PROGRAMMATIC_TOOL_CALLING: "programmatic-tool-calling", - PROGRAMMATIC_TOOL_CALLING_EXCLUSIVE: "programmatic-tool-calling-exclusive", RLM: "rlm-mode", CONFIGURABLE_BIND_URL: "configurable-bind-url", MUX_GOVERNOR: "mux-governor", @@ -55,14 +54,8 @@ export const EXPERIMENTS: Record = { [EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING]: { id: EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING, name: "Programmatic Tool Calling", - description: "Enable code_execution tool for multi-tool workflows in a sandboxed JS runtime", - enabledByDefault: false, - showInSettings: true, - }, - [EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING_EXCLUSIVE]: { - id: EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING_EXCLUSIVE, - name: "PTC Exclusive Mode", - description: "Replace all tools with code_execution (forces PTC usage)", + description: + "Replace the standard toolset with a sandboxed code_execution tool; bridged tools are called as xum.(...) from JS", enabledByDefault: false, showInSettings: true, }, @@ -73,7 +66,7 @@ export const EXPERIMENTS: Record = { id: EXPERIMENT_IDS.RLM, name: "RLM Mode", description: - "Kernel-first exclusive toolset: code_execution becomes the primary tool, backed by a persistent sandbox kernel (vars survive across calls/turns, bulk file loads, result handles, fire-and-forget sub-agents). Implies PTC Exclusive posture; supplement mode is not supported.", + "Kernel-first exclusive toolset: code_execution becomes the primary tool, backed by a persistent sandbox kernel (vars survive across calls/turns, bulk file loads, result handles, fire-and-forget sub-agents). Requires Programmatic Tool Calling.", enabledByDefault: false, showInSettings: true, }, diff --git a/src/common/orpc/schemas/stream.test.ts b/src/common/orpc/schemas/stream.test.ts index 7b184b9e62..7ac911fb60 100644 --- a/src/common/orpc/schemas/stream.test.ts +++ b/src/common/orpc/schemas/stream.test.ts @@ -14,4 +14,19 @@ describe("SendMessageOptions experiments", () => { expect(parsed.experiments?.programmaticToolCalling).toBe(true); expect(parsed.experiments && "bogus" in parsed.experiments).toBe(false); }); + + test("stale programmaticToolCallingExclusive payloads parse cleanly and drop the key", () => { + // The exclusive experiment was removed (PTC is exclusive-only now). Older + // clients/persisted payloads may still send the flag; it must be ignored, + // never rejected. + const parsed = SendMessageOptionsSchema.parse({ + model: "anthropic:claude-sonnet-4-5", + agentId: "exec", + experiments: { programmaticToolCalling: true, programmaticToolCallingExclusive: true }, + }); + expect(parsed.experiments?.programmaticToolCalling).toBe(true); + expect(parsed.experiments && "programmaticToolCallingExclusive" in parsed.experiments).toBe( + false + ); + }); }); diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts index cee51a5812..28d1b34952 100644 --- a/src/common/orpc/schemas/stream.ts +++ b/src/common/orpc/schemas/stream.ts @@ -740,7 +740,6 @@ export const ToolPolicySchema = z.array(ToolPolicyFilterSchema).meta({ // default behavior, so we do not need to retain a deprecated field. export const ExperimentsSchema = z.object({ programmaticToolCalling: z.boolean().optional(), - programmaticToolCallingExclusive: z.boolean().optional(), /** * RLM mode (sub-experiment of Programmatic Tool Calling): persistent * sandbox kernel for code_execution. Inert unless a PTC flag is also on. diff --git a/src/common/schemas/project.taskExperiments.test.ts b/src/common/schemas/project.taskExperiments.test.ts new file mode 100644 index 0000000000..8ca4435acc --- /dev/null +++ b/src/common/schemas/project.taskExperiments.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import { WorkspaceConfigSchema } from "./project"; + +describe("WorkspaceConfig taskExperiments", () => { + test("stale programmaticToolCallingExclusive entries parse cleanly and drop the key", () => { + // The exclusive experiment was removed (PTC is exclusive-only now). + // Workspaces stamped by older builds may still carry the flag on disk; + // it must be ignored, never rejected. + const parsed = WorkspaceConfigSchema.parse({ + path: "/tmp/ws", + taskExperiments: { + programmaticToolCalling: true, + rlm: true, + programmaticToolCallingExclusive: true, + }, + }); + expect(parsed.taskExperiments?.programmaticToolCalling).toBe(true); + expect(parsed.taskExperiments?.rlm).toBe(true); + expect( + parsed.taskExperiments && "programmaticToolCallingExclusive" in parsed.taskExperiments + ).toBe(false); + }); +}); diff --git a/src/common/schemas/project.ts b/src/common/schemas/project.ts index 20f5d15413..40d4ef4511 100644 --- a/src/common/schemas/project.ts +++ b/src/common/schemas/project.ts @@ -180,7 +180,6 @@ export const WorkspaceConfigSchema = z.object({ taskExperiments: z .object({ programmaticToolCalling: z.boolean().optional(), - programmaticToolCallingExclusive: z.boolean().optional(), // RLM mode is stamped at spawn so child sessions keep RLM-gated features // (persistent sandbox kernel, family messaging tools) across app restarts // without depending on live frontend experiment state. diff --git a/src/common/utils/messages/extractReadFiles.ts b/src/common/utils/messages/extractReadFiles.ts index 8959b4ea8f..a51f5bb4f6 100644 --- a/src/common/utils/messages/extractReadFiles.ts +++ b/src/common/utils/messages/extractReadFiles.ts @@ -20,7 +20,7 @@ interface NestedToolCallRecord { * exclusive posture file access happens as nested xum.file_read / xum.load * calls, so the outer part is named "code_execution" and the reads live in * its output's toolCalls records. Success = no error, and for kernel compact - * records ok !== false (supplement-mode records carry no ok field). + * records ok !== false (non-RLM inline-results records carry no ok field). */ function collectNestedReadPaths(output: unknown): string[] { if (typeof output !== "object" || output === null) return []; diff --git a/src/common/utils/tools/tools.ts b/src/common/utils/tools/tools.ts index 4032c269cf..fc286a097c 100644 --- a/src/common/utils/tools/tools.ts +++ b/src/common/utils/tools/tools.ts @@ -280,7 +280,6 @@ export interface ToolConfiguration { /** Experiments inherited from parent (for subagent spawning) */ experiments?: { programmaticToolCalling?: boolean; - programmaticToolCallingExclusive?: boolean; /** RLM mode: inherited to subagent spawns so children are stamped at spawn time. */ rlm?: boolean; advisorTool?: boolean; diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 444212a103..365d266f4c 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -190,14 +190,9 @@ import { import { applyToolPolicyAndExperiments, captureMcpToolTelemetry, - reconcileHookReplacedCodeExecution, resolveBackendGatedPtcExperiments, - retargetCodeExecution, } from "./toolAssembly"; -import { - createKernelFileLoader, - type KernelFileLoader, -} from "@/node/services/tools/kernelFileLoad"; +import { createKernelFileLoader } from "@/node/services/tools/kernelFileLoad"; import { eventSpine, type RequestAssembleContext } from "@/node/services/events/eventSpine"; import { getErrorMessage } from "@/common/utils/errors"; import { validateJsonSchemaSubsetSchema } from "@/common/utils/jsonSchemaSubset"; @@ -1077,81 +1072,6 @@ export class AIService extends EventEmitter { }); } - /** - * Supplement-mode PTC reconcile after the request.assemble waterfall: when - * middleware changed any tool the bridge exposes (added/removed names OR a - * same-name replacement such as an audit wrapper), the pre-hook - * code_execution instance closes over a stale ToolBridge — rebuild it from - * the post-hook record. When the hook replaced code_execution ITSELF, its - * replacement wins (never silently drop a middleware wrapper) — but such a - * wrapper typically delegates to the PRE-hook instance, so that instance is - * retargeted in place onto the rebuilt bridge/mount. Delegation through the - * wrapper then reaches the post-hook toolset even when the wrapper captured - * the original execute function directly. - */ - private async rebuildCodeExecutionAfterAssembleHook(opts: { - preHookTools: Record; - postHookTools: Record; - effectiveToolPolicy: ToolPolicy | undefined; - experiments: SendMessageOptions["experiments"]; - emitNestedToolEvent: (event: PTCEventWithParent) => void; - workspaceId: string; - kernelFileLoader: KernelFileLoader; - }): Promise> { - const { preHookTools, postHookTools, workspaceId } = opts; - const hookReplacedCodeExecution = - postHookTools.code_execution !== undefined && - preHookTools.code_execution !== undefined && - postHookTools.code_execution !== preHookTools.code_execution; - // Identity-aware change detection over everything EXCEPT code_execution - // (the instance this rebuild replaces): names and same-name replacements. - const bridgeRelevantChanged = - Object.keys(postHookTools).filter((n) => n !== "code_execution").length !== - Object.keys(preHookTools).filter((n) => n !== "code_execution").length || - Object.entries(postHookTools).some( - ([name, t]) => name !== "code_execution" && preHookTools[name] !== t - ); - if (!bridgeRelevantChanged) { - return postHookTools; - } - const { code_execution: hookCodeExecution, ...bridgeInputTools } = postHookTools; - const rebuilt = await applyToolPolicyAndExperiments({ - allTools: bridgeInputTools, - effectiveToolPolicy: opts.effectiveToolPolicy, - experiments: opts.experiments, - emitNestedToolEvent: opts.emitNestedToolEvent, - sandbox: { - workspaceId, - sessionDir: this.config.getSessionDir(workspaceId), - kernelFileLoader: opts.kernelFileLoader, - }, - }); - // Reinstate a middleware-provided code_execution replacement over the - // freshly built instance — but first graft the rebuilt bridge/mount onto - // the PRE-hook instance the wrapper delegates to. code_execution reads its - // bridge late-bound at call time, so this retargets the wrapper's - // delegation path (even a captured execute reference) to the post-hook - // toolset instead of leaving it closed over the stale bridge. Then - // reconcile model-facing metadata a spread-style wrapper inherited from - // the pre-hook instance (description advertising removed tools). - if (hookReplacedCodeExecution && hookCodeExecution !== undefined) { - const rebuiltCodeExecution = rebuilt.code_execution; - if (rebuiltCodeExecution !== undefined && preHookTools.code_execution !== undefined) { - await retargetCodeExecution(preHookTools.code_execution, rebuiltCodeExecution); - return { - ...rebuilt, - code_execution: reconcileHookReplacedCodeExecution( - preHookTools.code_execution, - hookCodeExecution, - rebuiltCodeExecution - ), - }; - } - return { ...rebuilt, code_execution: hookCodeExecution }; - } - return rebuilt; - } - private wrapToolsForDelegation( workspaceId: string, tools: Record, @@ -2924,9 +2844,7 @@ export class AIService extends EventEmitter { // PTC gate uses the same condition toolAssembly uses to add code_execution: // presence-sniffing the record would misfire on an MCP tool named // code_execution (see prepareToolSearch). - const ptcEnabled = - experiments?.programmaticToolCalling === true || - experiments?.programmaticToolCallingExclusive === true; + const ptcEnabled = experiments?.programmaticToolCalling === true; if (toolSearchRuntime) { const toolSearchPrep = prepareToolSearch({ tools, @@ -2997,10 +2915,6 @@ export class AIService extends EventEmitter { // (append-time materialization) — see eventSpine module docs. Gated on // hasMiddleware so the empty-pipeline hot path skips ctx construction. if (eventSpine.hasMiddleware("request.assemble")) { - // Shallow copy: detects added/removed names AND same-name - // replacements (e.g. middleware wrapping a tool with an audit check), - // which a key-only comparison would miss. - const preHookTools = { ...tools }; const assembleCtx: RequestAssembleContext = { workspaceId, modelString, @@ -3009,27 +2923,9 @@ export class AIService extends EventEmitter { }; await eventSpine.run("request.assemble", assembleCtx); tools = assembleCtx.tools; - // Supplement-mode PTC: the code_execution instance created during - // assembly closes over a ToolBridge built from the PRE-hook toolset - // (and its description advertises those tools), so a hook-removed or - // hook-replaced bridgeable tool would remain reachable via mux.*. - // Rebuild code_execution from the post-hook record. Exclusive mode is - // unaffected: bridgeable tools are not in the hook-visible record. - if ( - experiments?.programmaticToolCalling === true && - experiments?.programmaticToolCallingExclusive !== true && - tools.code_execution !== undefined - ) { - tools = await this.rebuildCodeExecutionAfterAssembleHook({ - preHookTools, - postHookTools: tools, - effectiveToolPolicy, - experiments, - emitNestedToolEvent: emitNestedPtcToolEvent, - workspaceId, - kernelFileLoader, - }); - } + // PTC needs no post-hook bridge reconcile: bridgeable tools are not + // in the hook-visible record, so middleware cannot invalidate the + // ToolBridge code_execution closes over. // Tool-search state was classified from the pre-hook record; a hook // that added/removed tools would leave allToolNames/deferred/active // sets stale (prepareStep scoping + sentinel names both read them). @@ -3722,9 +3618,6 @@ export class AIService extends EventEmitter { // request.assemble over the rebuilt request too (see the // primary-path run above). if (eventSpine.hasMiddleware("request.assemble")) { - // Shallow copy: same identity-aware change detection as - // the primary path (names AND same-name replacements). - const preHookNextTools = { ...nextTools }; const nextAssembleCtx: RequestAssembleContext = { workspaceId, modelString: nextModelString, @@ -3733,24 +3626,6 @@ export class AIService extends EventEmitter { }; await eventSpine.run("request.assemble", nextAssembleCtx); nextTools = nextAssembleCtx.tools; - // Same rebuild as the primary path: hook-removed or - // hook-replaced tools must not stay reachable via a stale - // code_execution bridge (supplement mode only). - if ( - experiments?.programmaticToolCalling === true && - experiments?.programmaticToolCallingExclusive !== true && - nextTools.code_execution !== undefined - ) { - nextTools = await this.rebuildCodeExecutionAfterAssembleHook({ - preHookTools: preHookNextTools, - postHookTools: nextTools, - effectiveToolPolicy, - experiments, - emitNestedToolEvent: emitNestedPtcToolEvent, - workspaceId, - kernelFileLoader, - }); - } // Same reconcile as the primary path: tool-search state // must describe the post-hook toolset. if (toolSearchRuntime?.state) { diff --git a/src/node/services/branchSummary.test.ts b/src/node/services/branchSummary.test.ts index 3ec9e6a113..5a53b0d4f6 100644 --- a/src/node/services/branchSummary.test.ts +++ b/src/node/services/branchSummary.test.ts @@ -136,12 +136,9 @@ function meatyExchange(idPrefix: string): MuxMessage[] { } describe("isRlmModeEnabled", () => { - test("send-option experiments gate on RLM plus a PTC parent flag", () => { + test("send-option experiments gate on RLM plus the PTC parent flag", () => { expect(isRlmModeEnabled({ rlm: true, programmaticToolCalling: true }, undefined)).toBe(true); - expect(isRlmModeEnabled({ rlm: true, programmaticToolCallingExclusive: true }, undefined)).toBe( - true - ); - // RLM without a PTC parent stays inert; PTC without RLM stays off. + // RLM without the PTC parent stays inert; PTC without RLM stays off. expect(isRlmModeEnabled({ rlm: true }, undefined)).toBe(false); expect(isRlmModeEnabled({ programmaticToolCalling: true }, undefined)).toBe(false); }); @@ -162,17 +159,9 @@ describe("isRlmModeEnabled", () => { const allOn = () => true; expect(isRlmModeEnabled({ rlm: false, programmaticToolCalling: true }, allOn)).toBe(false); expect(isRlmModeEnabled({ rlm: true, programmaticToolCalling: true }, () => false)).toBe(true); - // Per-field fallback (matching resolveBackendGatedPtcExperiments): an - // explicit ptc: false does not silence a backend-enabled ptcExclusive — - // tool assembly would build the exclusive kernel in this scenario, and - // this predicate must agree with it. - expect(isRlmModeEnabled({ rlm: true, programmaticToolCalling: false }, allOn)).toBe(true); - expect( - isRlmModeEnabled( - { rlm: true, programmaticToolCalling: false, programmaticToolCallingExclusive: false }, - allOn - ) - ).toBe(false); + // Explicit ptc: false is authoritative and must not fall through to + // machine overrides that have PTC enabled. + expect(isRlmModeEnabled({ rlm: true, programmaticToolCalling: false }, allOn)).toBe(false); }); test("missing flags on a defined experiments object fall back to backend overrides", () => { diff --git a/src/node/services/branchSummary.ts b/src/node/services/branchSummary.ts index 27599cf2a0..13c8236439 100644 --- a/src/node/services/branchSummary.ts +++ b/src/node/services/branchSummary.ts @@ -68,7 +68,6 @@ export type BranchSummaryAiService = Pick< export interface RlmExperimentFlags { rlm?: boolean; programmaticToolCalling?: boolean; - programmaticToolCallingExclusive?: boolean; } /** @@ -95,10 +94,7 @@ export function isRlmModeEnabled( const rlm = experiments?.rlm ?? backend(EXPERIMENT_IDS.RLM); const ptc = experiments?.programmaticToolCalling ?? backend(EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING); - const ptcExclusive = - experiments?.programmaticToolCallingExclusive ?? - backend(EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING_EXCLUSIVE); - return rlm && (ptc || ptcExclusive); + return rlm && ptc; } function extractTextForTranscript(message: MuxMessage): string { diff --git a/src/node/services/experimentsService.test.ts b/src/node/services/experimentsService.test.ts index bb3df339ce..e6ba6543ed 100644 --- a/src/node/services/experimentsService.test.ts +++ b/src/node/services/experimentsService.test.ts @@ -148,6 +148,33 @@ describe("ExperimentsService", () => { expect(service.isExperimentEnabled(EXPERIMENT_IDS.TOOL_SEARCH)).toBe(false); }); + test("stale overrides for removed experiments are ignored, never rejected", async () => { + // "programmatic-tool-calling-exclusive" was a real experiment ID before + // PTC became exclusive-only. Users upgrading with the old key persisted + // must load cleanly with the stale entry filtered out. + await fs.writeFile( + path.join(tempDir, OVERRIDES_FILE), + JSON.stringify({ + version: 1, + experiments: {}, + overrides: { + "programmatic-tool-calling-exclusive": true, + [EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING]: true, + }, + }), + "utf-8" + ); + + const { telemetryService } = createTelemetryService(); + const service = new ExperimentsService({ telemetryService, xumHome: tempDir }); + await service.initialize(); + + expect(service.isExperimentEnabled(EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING)).toBe(true); + expect(await service.getOverrides()).toEqual({ + [EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING]: true, + }); + }); + test("a client with empty local state does not clear overrides it never knew about", async () => { await fs.writeFile( path.join(tempDir, OVERRIDES_FILE), diff --git a/src/node/services/ptc/runtime.ts b/src/node/services/ptc/runtime.ts index 4f8d5bd5bb..a4412d12e2 100644 --- a/src/node/services/ptc/runtime.ts +++ b/src/node/services/ptc/runtime.ts @@ -88,7 +88,7 @@ export interface IJSRuntime extends Disposable { * cannot protect host memory or the session history that streamed events * land in: a guest looping `xum.tool({big: vars.large})` would otherwise * retain and emit every full payload. Pass undefined to disable (ephemeral - * mode keeps full records — the byte-identical supplement contract). + * mode keeps full records — the non-RLM inline-results contract). */ setKernelRecordBounds(bounds: KernelRecordBounds | undefined): void; diff --git a/src/node/services/ptc/toolBridge.ts b/src/node/services/ptc/toolBridge.ts index c07021484e..7baa9a447b 100644 --- a/src/node/services/ptc/toolBridge.ts +++ b/src/node/services/ptc/toolBridge.ts @@ -220,7 +220,7 @@ export class ToolBridge { register(runtime: IJSRuntime, kernel?: KernelBridgeOptions): void { // Kernel mode bounds record/event capture at creation (host memory and // streamed-to-history events); ephemeral registrations keep full records - // (the byte-identical supplement contract). Post-eval compaction still + // (the non-RLM inline-results contract). Post-eval compaction still // bounds the model-visible set. runtime.setKernelRecordBounds( kernel !== undefined diff --git a/src/node/services/ptc/types.ts b/src/node/services/ptc/types.ts index 9f0c598893..924172e72a 100644 --- a/src/node/services/ptc/types.ts +++ b/src/node/services/ptc/types.ts @@ -58,7 +58,7 @@ export interface PTCToolCallRecord { * suppressed result). The guest already received the full value during * execution; its channels for surfacing data are the return value, console * output, and `vars`. Absent in ephemeral/RLM-off records, which keep full - * inline results (byte-identical supplement-mode contract). + * inline results (the non-RLM inline-results contract). */ ok?: boolean; bytes?: number; diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 4a62f33058..18d217b440 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -329,7 +329,6 @@ export interface TaskCreateArgs { /** Experiments to inherit to subagent */ experiments?: { programmaticToolCalling?: boolean; - programmaticToolCallingExclusive?: boolean; /** RLM mode: persisted on the task record so RLM-gated child features survive restarts. */ rlm?: boolean; advisorTool?: boolean; diff --git a/src/node/services/toolAssembly.test.ts b/src/node/services/toolAssembly.test.ts index f7c040ce00..97c2e2bf95 100644 --- a/src/node/services/toolAssembly.test.ts +++ b/src/node/services/toolAssembly.test.ts @@ -4,11 +4,7 @@ import * as path from "node:path"; import { z } from "zod"; import type { Tool } from "ai"; -import { - applyToolPolicyAndExperiments, - reconcileHookReplacedCodeExecution, - resolveBackendGatedPtcExperiments, -} from "./toolAssembly"; +import { applyToolPolicyAndExperiments, resolveBackendGatedPtcExperiments } from "./toolAssembly"; import { buildToolsetManifest } from "./turnEnvelope"; import { sandboxHostService } from "@/node/services/sandbox/sandboxHostService"; import { DisposableTempDir } from "@/node/services/tempDir"; @@ -24,14 +20,14 @@ function executableTool(description: string): Tool { } describe("applyToolPolicyAndExperiments", () => { - test("exclusive PTC mode keeps mcp_prompt_get directly visible", async () => { + test("PTC keeps mcp_prompt_get directly visible", async () => { const result = await applyToolPolicyAndExperiments({ allTools: { bash: executableTool("Run a command"), mcp_prompt_get: executableTool("Fetch a prompt\n\nAvailable MCP prompts:\n- mcp__s__p"), }, effectiveToolPolicy: undefined, - experiments: { programmaticToolCallingExclusive: true }, + experiments: { programmaticToolCalling: true }, emitNestedToolEvent: () => undefined, }); @@ -245,7 +241,7 @@ describe("persistent kernel graduation (RLM mode)", () => { }); }); -describe("toolset composition (PTC × RLM × exclusive)", () => { +describe("toolset composition (PTC × RLM)", () => { const originalEnv = process.env.MUX_SANDBOX_PERSISTENT_MOUNTS; beforeEach(() => { @@ -277,7 +273,6 @@ describe("toolset composition (PTC × RLM × exclusive)", () => { sessionDir: string, experiments: { programmaticToolCalling?: boolean; - programmaticToolCallingExclusive?: boolean; rlm?: boolean; }, capabilityGrants?: Parameters[0]["capabilityGrants"] @@ -291,15 +286,6 @@ describe("toolset composition (PTC × RLM × exclusive)", () => { capabilityGrants, }); - const SUPPLEMENT_NAMES = [ - "agent_report", - "ask_user_question", - "bash", - "code_execution", - "file_read", - "mcp_prompt_get", - "todo_write", - ]; // Exclusive: bridgeable tools reachable only via code_execution; the // interaction tools and mcp_prompt_get stay model-visible. const EXCLUSIVE_NAMES = [ @@ -310,19 +296,15 @@ describe("toolset composition (PTC × RLM × exclusive)", () => { "todo_write", ]; - test("PTC only: supplement set, no kernel surfaces", async () => { + test("PTC only: exclusive narrowed set, no kernel surfaces", async () => { using tmp = new DisposableTempDir("compose-ptc"); const tools = await assemble("ws-compose-ptc", tmp.path, { programmaticToolCalling: true }); - expect(Object.keys(tools).sort()).toEqual(SUPPLEMENT_NAMES); + expect(Object.keys(tools).sort()).toEqual(EXCLUSIVE_NAMES); expect(tools.code_execution.description).not.toContain("Persistent kernel"); expect(tools.code_execution.description).not.toContain("Kernel-first"); }); - test("PTC + RLM: exclusive-only — RLM forces the kernel-first narrowed set", async () => { - // RLM is exclusive-only: supplement-mode RLM measured ~2x flat tokens/cost - // (flat schemas + kernel defs shipped while models took the flat path), so - // the rlm flag implies the exclusive posture even without the exclusive - // experiment. This pins the removal of supplement-mode RLM. + test("PTC + RLM: kernel-first narrowed set + rollback + kernel-first preamble", async () => { using tmp = new DisposableTempDir("compose-ptc-rlm"); try { const tools = await assemble("ws-compose-ptc-rlm", tmp.path, { @@ -330,48 +312,23 @@ describe("toolset composition (PTC × RLM × exclusive)", () => { rlm: true, }); expect(Object.keys(tools).sort()).toEqual([...EXCLUSIVE_NAMES, "refinement_rollback"].sort()); - expect(tools.code_execution.description).toContain("Persistent kernel"); - expect(tools.code_execution.description).toContain("Kernel-first"); - } finally { - await sandboxHostService.disposeScope("ws-compose-ptc-rlm"); - } - }); - - test("exclusive only: narrowed set, descriptions unchanged (no kernel surfaces)", async () => { - using tmp = new DisposableTempDir("compose-excl"); - const tools = await assemble("ws-compose-excl", tmp.path, { - programmaticToolCallingExclusive: true, - }); - expect(Object.keys(tools).sort()).toEqual(EXCLUSIVE_NAMES); - expect(tools.code_execution.description).not.toContain("Persistent kernel"); - expect(tools.code_execution.description).not.toContain("Kernel-first"); - }); - - test("exclusive + RLM: single-kernel posture — narrowed set + rollback + kernel-first preamble", async () => { - using tmp = new DisposableTempDir("compose-excl-rlm"); - try { - const tools = await assemble("ws-compose-excl-rlm", tmp.path, { - programmaticToolCallingExclusive: true, - rlm: true, - }); - expect(Object.keys(tools).sort()).toEqual([...EXCLUSIVE_NAMES, "refinement_rollback"].sort()); // agent_report must stay top-level: taskService reads its args from history. expect(tools.agent_report).toBeDefined(); const desc = (tools.code_execution as { description?: string }).description ?? ""; expect(desc.startsWith("**Kernel-first workflow:**")).toBe(true); expect(desc).toContain("Persistent kernel"); } finally { - await sandboxHostService.disposeScope("ws-compose-excl-rlm"); + await sandboxHostService.disposeScope("ws-compose-ptc-rlm"); } }); - test("exclusive + RLM re-applies the grants ceiling to non-bridgeable tools and refinement_rollback", async () => { + test("PTC + RLM re-applies the grants ceiling to non-bridgeable tools and refinement_rollback", async () => { using tmp = new DisposableTempDir("compose-excl-rlm-grants"); try { const tools = await assemble( "ws-compose-excl-rlm-grants", tmp.path, - { programmaticToolCallingExclusive: true, rlm: true }, + { programmaticToolCalling: true, rlm: true }, { version: 1, bridgeTools: { allow: ["file_read"] }, @@ -430,11 +387,11 @@ describe("toolset composition (PTC × RLM × exclusive)", () => { } }); - test("turn-envelope manifest fingerprints the narrowed exclusive + RLM toolset", async () => { + test("turn-envelope manifest fingerprints the narrowed PTC + RLM toolset", async () => { using tmp = new DisposableTempDir("compose-envelope"); try { const tools = await assemble("ws-compose-envelope", tmp.path, { - programmaticToolCallingExclusive: true, + programmaticToolCalling: true, rlm: true, }); const manifest = buildToolsetManifest(tools); @@ -457,35 +414,6 @@ describe("toolset composition (PTC × RLM × exclusive)", () => { }); }); -describe("reconcileHookReplacedCodeExecution", () => { - test("spread-style wrapper gets the rebuilt description but keeps its execute", () => { - const preHook = executableTool("defs: function bash; function file_read"); - // Middleware wrapped by spreading the pre-hook tool: same description, - // new execute. - const wrappedExecute = () => Promise.resolve({ success: true, audited: true }); - const hookReplacement: Tool = { ...preHook, execute: wrappedExecute }; - const rebuilt = executableTool("defs: function file_read"); - - const result = reconcileHookReplacedCodeExecution(preHook, hookReplacement, rebuilt); - - // Model-facing metadata follows the rebuilt toolset (bash removed)... - expect(result.description).toBe("defs: function file_read"); - // ...while the middleware's execution wrapper is preserved. - expect(result.execute).toBe(wrappedExecute); - }); - - test("middleware-authored description is preserved", () => { - const preHook = executableTool("defs: function bash; function file_read"); - const hookReplacement = executableTool("audited code execution"); - const rebuilt = executableTool("defs: function file_read"); - - const result = reconcileHookReplacedCodeExecution(preHook, hookReplacement, rebuilt); - - // Middleware took ownership of the model-facing contract; return it as-is. - expect(result).toBe(hookReplacement); - }); -}); - describe("resolveBackendGatedPtcExperiments", () => { const backendEnabled = new Set(["rlm-mode", "programmatic-tool-calling"]); const isEnabled = (id: string) => backendEnabled.has(id); @@ -497,19 +425,21 @@ describe("resolveBackendGatedPtcExperiments", () => { const resolved = resolveBackendGatedPtcExperiments(undefined, isEnabled); expect(resolved.rlm).toBe(true); expect(resolved.programmaticToolCalling).toBe(true); - expect(resolved.programmaticToolCallingExclusive).toBe(false); }); test("explicit renderer values (true or false) win over the backend", () => { - const resolved = resolveBackendGatedPtcExperiments( - { rlm: false, programmaticToolCallingExclusive: true }, - isEnabled - ); + const resolved = resolveBackendGatedPtcExperiments({ rlm: false }, isEnabled); // Explicit false is NOT backfilled to the backend's true. expect(resolved.rlm).toBe(false); - expect(resolved.programmaticToolCallingExclusive).toBe(true); // Undefined still backfills. expect(resolved.programmaticToolCalling).toBe(true); + + // Explicit true wins over a backend-disabled flag. + const explicitTrue = resolveBackendGatedPtcExperiments( + { programmaticToolCalling: true }, + () => false + ); + expect(explicitTrue.programmaticToolCalling).toBe(true); }); test("preserves unrelated experiment flags untouched", () => { diff --git a/src/node/services/toolAssembly.ts b/src/node/services/toolAssembly.ts index 9394b0c018..3febe79168 100644 --- a/src/node/services/toolAssembly.ts +++ b/src/node/services/toolAssembly.ts @@ -25,7 +25,6 @@ import type { CapabilityGrants } from "@/common/types/capabilityGrants"; import type { PTCEventWithParent, createCodeExecutionTool as CreateCodeExecutionToolFn, - retargetCodeExecutionTool as RetargetCodeExecutionToolFn, } from "@/node/services/tools/code_execution"; import type { QuickJSRuntimeFactory } from "@/node/services/ptc/quickjsRuntime"; import type { ToolBridge } from "@/node/services/ptc/toolBridge"; @@ -50,7 +49,6 @@ import { getRuntimeTypeForTelemetry, roundToBase2 } from "@/common/telemetry/uti // Dynamic imports are justified: PTC pulls in ~10MB of dependencies that would slow startup. interface PTCModules { createCodeExecutionTool: typeof CreateCodeExecutionToolFn; - retargetCodeExecutionTool: typeof RetargetCodeExecutionToolFn; QuickJSRuntimeFactory: typeof QuickJSRuntimeFactory; ToolBridge: typeof ToolBridge; runtimeFactory: QuickJSRuntimeFactory | null; @@ -71,7 +69,6 @@ async function getPTCModules(): Promise { ptcModules = { createCodeExecutionTool: codeExecution.createCodeExecutionTool, - retargetCodeExecutionTool: codeExecution.retargetCodeExecutionTool, QuickJSRuntimeFactory: quickjs.QuickJSRuntimeFactory, ToolBridge: toolBridge.ToolBridge, runtimeFactory: null, @@ -79,43 +76,6 @@ async function getPTCModules(): Promise { return ptcModules; } -/** - * Lazy-loading wrapper around code_execution's retargetCodeExecutionTool for - * callers (aiService) that must not statically import the PTC modules. - * Returns false when either tool was not created by createCodeExecutionTool. - */ -export async function retargetCodeExecution(target: Tool, donor: Tool): Promise { - const ptc = await getPTCModules(); - return ptc.retargetCodeExecutionTool(target, donor); -} - -/** - * Reinstate a request.assemble middleware's code_execution replacement over a - * rebuilt instance while reconciling stale model-facing metadata. A wrapper - * built by spreading the pre-hook tool (`{ ...tool, execute: wrapped }`) - * inherits its description, whose embedded TypeScript definitions still - * advertise tools the rebuild removed/replaced — execution fails closed, but - * the model keeps being instructed those tools exist. When the wrapper - * inherited the pre-hook description verbatim, swap in the rebuilt - * description; middleware that authored its own description keeps it (it took - * ownership of the model-facing contract). - */ -export function reconcileHookReplacedCodeExecution( - preHook: Tool, - hookReplacement: Tool, - rebuilt: Tool -): Tool { - if ( - hookReplacement.description !== undefined && - hookReplacement.description === preHook.description && - rebuilt.description !== undefined && - rebuilt.description !== hookReplacement.description - ) { - return { ...hookReplacement, description: rebuilt.description }; - } - return hookReplacement; -} - // --------------------------------------------------------------------------- // Tool Policy + PTC Application // --------------------------------------------------------------------------- @@ -131,7 +91,6 @@ export interface ApplyToolPolicyAndExperimentsOptions { /** PTC experiment flags. */ experiments?: { programmaticToolCalling?: boolean; - programmaticToolCallingExclusive?: boolean; /** * RLM mode: graduate code_execution onto the persistent per-workspace * kernel mount (shared `vars`, snapshot/restore). Gated on the PTC parent @@ -166,7 +125,7 @@ export function persistentSandboxMountsEnabled(): boolean { } /** - * Backfill the PTC/RLM experiment trio from the backend's persisted overrides + * Backfill the PTC/RLM experiment pair from the backend's persisted overrides * (same `?? isExperimentEnabled` pattern as other backend-gated experiments in * streamMessage). A renderer with no origin-local override sends `undefined` * for these flags while the effective UI and /refine gate resolve against the @@ -183,9 +142,6 @@ export function resolveBackendGatedPtcExperiments( programmaticToolCalling: experiments?.programmaticToolCalling ?? isExperimentEnabled(EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING), - programmaticToolCallingExclusive: - experiments?.programmaticToolCallingExclusive ?? - isExperimentEnabled(EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING_EXCLUSIVE), rlm: experiments?.rlm ?? isExperimentEnabled(EXPERIMENT_IDS.RLM), }; } @@ -196,9 +152,11 @@ export function resolveBackendGatedPtcExperiments( * Steps: * 1. Merge extra tools (CLI tools bypass policy — injected by runtime, not user) * 2. Apply tool policy (agent → caller → system workspace deny/enable rules) - * 3. If PTC experiment is enabled, lazy-load PTC and create code_execution tool: - * - Supplement mode: adds code_execution alongside existing tools - * - Exclusive mode: replaces bridgeable tools with code_execution only + * 3. If PTC experiment is enabled, lazy-load PTC and create code_execution tool, + * replacing bridgeable tools with code_execution only (exclusive posture). + * A supplement mode (code_execution alongside the flat tools) used to exist + * but measured ~2x tokens/cost vs both PTC-off and exclusive, so it was + * removed. * * @returns The final tool set ready for the AI model. */ @@ -231,15 +189,12 @@ export async function applyToolPolicyAndExperiments( ? applyToolPolicy(allToolsWithExtra, effectiveToolPolicy) : policyFilteredTools; - // Handle PTC experiments — add or replace tools with code_execution + // Handle PTC experiment — replace bridgeable tools with code_execution. let toolsForModel = policyFilteredTools; - // RLM is exclusive-only: supplement-mode RLM measured ~2x flat tokens/cost - // (flat schemas + kernel type defs shipped while models still take the flat - // path), so enabling RLM forces the kernel-first exclusive toolset. The - // standalone exclusive experiment stays usable without RLM (no kernel). + // RLM rides the PTC parent flag: this flag is only read inside the PTC + // branch below, so RLM alone (PTC off) is inert by construction. const rlmActive = experiments?.rlm === true; - const exclusiveActive = experiments?.programmaticToolCallingExclusive === true || rlmActive; - if (experiments?.programmaticToolCalling || experiments?.programmaticToolCallingExclusive) { + if (experiments?.programmaticToolCalling) { try { // Lazy-load PTC modules only when experiments are enabled const ptc = await getPTCModules(); @@ -287,41 +242,32 @@ export async function applyToolPolicyAndExperiments( toolBridge, emitNestedToolEvent, withMount, - // Kernel-first description preamble rides RLM (which is exclusive-only - // now); exclusive alone (or the env-var mount override) keeps today's - // exclusive descriptions byte-identical. createCodeExecutionTool - // additionally requires a live persistent mount before honoring it. + // Kernel-first description preamble rides RLM; PTC alone (or the + // env-var mount override) keeps the non-kernel exclusive descriptions. + // createCodeExecutionTool additionally requires a live persistent + // mount before honoring it. { kernelFirst: rlmActive, loadFile: sandbox?.kernelFileLoader, } ); - if (exclusiveActive) { - // Exclusive mode: code_execution is mandatory — it's the only way to use bridged - // tools. The experiment flag is the opt-in; policy cannot disable it here since - // that would leave no way to access tools. nonBridgeable is policy-filtered but - // comes from the PRE-grant bridge input, so re-apply the grants ceiling here to - // keep grant-denied non-bridgeable tools out of the model-visible set. - const nonBridgeable = opts.capabilityGrants - ? applyCapabilityGrants(toolBridge.getNonBridgeableTools(), opts.capabilityGrants) - : toolBridge.getNonBridgeableTools(); - // Keep mcp_prompt_get direct because sandbox declarations omit its - // multiline prompt catalog. - const promptGet = policyFilteredTools.mcp_prompt_get; - toolsForModel = { - ...nonBridgeable, - ...(promptGet !== undefined ? { mcp_prompt_get: promptGet } : {}), - code_execution: codeExecutionTool, - }; - } else { - // Supplement mode: add code_execution, then apply policy to determine final set. - // This correctly handles all policy combinations (require, enable, disable). - toolsForModel = applyToolPolicy( - { ...policyFilteredTools, code_execution: codeExecutionTool }, - effectiveToolPolicy - ); - } + // code_execution is mandatory — it's the only way to use bridged + // tools. The experiment flag is the opt-in; policy cannot disable it here since + // that would leave no way to access tools. nonBridgeable is policy-filtered but + // comes from the PRE-grant bridge input, so re-apply the grants ceiling here to + // keep grant-denied non-bridgeable tools out of the model-visible set. + const nonBridgeable = opts.capabilityGrants + ? applyCapabilityGrants(toolBridge.getNonBridgeableTools(), opts.capabilityGrants) + : toolBridge.getNonBridgeableTools(); + // Keep mcp_prompt_get direct because sandbox declarations omit its + // multiline prompt catalog. + const promptGet = policyFilteredTools.mcp_prompt_get; + toolsForModel = { + ...nonBridgeable, + ...(promptGet !== undefined ? { mcp_prompt_get: promptGet } : {}), + code_execution: codeExecutionTool, + }; // RLM-only model surface: ID-addressed rollback of journaled harness // self-modifications (refinement rows). Read inside the PTC branch by diff --git a/src/node/services/tools/code_execution.test.ts b/src/node/services/tools/code_execution.test.ts index e10a950a02..80aaf52651 100644 --- a/src/node/services/tools/code_execution.test.ts +++ b/src/node/services/tools/code_execution.test.ts @@ -3,12 +3,7 @@ */ import { describe, it, expect, mock } from "bun:test"; -import { - createCodeExecutionTool, - retargetCodeExecutionTool, - clearTypeCaches, - type MountRunner, -} from "./code_execution"; +import { createCodeExecutionTool, clearTypeCaches, type MountRunner } from "./code_execution"; import { QuickJSRuntimeFactory } from "@/node/services/ptc/quickjsRuntime"; import { ToolBridge } from "@/node/services/ptc/toolBridge"; import type { Tool, ToolExecutionOptions } from "ai"; @@ -509,65 +504,6 @@ describe("createCodeExecutionTool", () => { }); }); - describe("retargetCodeExecutionTool", () => { - it("retargets a captured execute reference onto the donor's bridge", async () => { - // The request.assemble wrapper scenario: middleware captured the - // pre-hook instance's execute, while a later rebuild removed `bash` - // from the bridgeable set. After retargeting, the captured reference - // must dispatch through the donor's bridge — bash is gone, file_read - // still works. - const bashExecute = mock(() => mockResults.bash); - const preHookTool = await createCodeExecutionTool( - runtimeFactory, - new ToolBridge({ - bash: createMockTool("bash", z.object({ script: z.string() }), bashExecute), - file_read: createMockTool("file_read", z.object({ filePath: z.string() })), - }) - ); - const donorTool = await createCodeExecutionTool( - runtimeFactory, - new ToolBridge({ - file_read: createMockTool("file_read", z.object({ filePath: z.string() })), - }) - ); - - // Wrapper-style capture BEFORE the retarget. - const capturedExecute = preHookTool.execute!.bind(preHookTool); - - expect(retargetCodeExecutionTool(preHookTool, donorTool)).toBe(true); - - const bashResult = (await capturedExecute( - { code: 'return mux.bash({ script: "echo hi" })' }, - mockToolCallOptions - )) as PTCExecutionResult; - expect(bashResult.success).toBe(false); - expect(bashExecute).not.toHaveBeenCalled(); - - const readResult = (await capturedExecute( - { code: 'return mux.file_read({ filePath: "a.txt" })' }, - mockToolCallOptions - )) as PTCExecutionResult; - expect(readResult.success).toBe(true); - expect(readResult.result).toMatchObject({ content: "mock file content" }); - }); - - it("returns false when either tool was not created by the factory", async () => { - const realTool = await createCodeExecutionTool(runtimeFactory, new ToolBridge({})); - const foreignTool = createMockTool("bash", z.object({ script: z.string() })); - - expect(retargetCodeExecutionTool(foreignTool, realTool)).toBe(false); - expect(retargetCodeExecutionTool(realTool, foreignTool)).toBe(false); - - // The real tool stays functional after rejected retargets. - const result = (await realTool.execute!( - { code: "return 7" }, - mockToolCallOptions - )) as PTCExecutionResult; - expect(result.success).toBe(true); - expect(result.result).toBe(7); - }); - }); - describe("event streaming", () => { it("emits events for tool calls", async () => { const events: PTCEvent[] = []; diff --git a/src/node/services/tools/code_execution.ts b/src/node/services/tools/code_execution.ts index 40ca74f208..df84c20185 100644 --- a/src/node/services/tools/code_execution.ts +++ b/src/node/services/tools/code_execution.ts @@ -69,42 +69,14 @@ export type MountRunner = ( fn: (mount: SandboxMount) => Promise ) => Promise; -/** - * Late-bound dispatch state for a created code_execution instance. execute() - * reads bridge + mount runner from here at CALL time (not closure-capture - * time) so retargetCodeExecutionTool can swing an already-created instance — - * and any middleware wrapper delegating to it, even through a captured - * `execute` function reference — onto a fresh bridge/mount. - */ -interface RetargetableState { +/** Dispatch state (bridge + mount runner + file loader) for a created code_execution instance. */ +interface DispatchState { toolBridge: ToolBridge; withMount: MountRunner | undefined; /** Host file loader backing mux.load (kernel mode only); see KernelBridgeOptions. */ loadFile: KernelFileLoader | undefined; } -const retargetableStates = new WeakMap(); - -/** - * Point `target` (an instance returned by createCodeExecutionTool) at the - * bridge + mount runner of `donor` (another such instance). Used when a - * request.assemble hook wrapped/replaced code_execution while also editing - * other bridgeable tools: the wrapper delegates to the PRE-hook instance, - * which must dispatch through the rebuilt post-hook bridge instead of the - * stale one. Returns false when either tool was not created by this factory. - */ -export function retargetCodeExecutionTool(target: Tool, donor: Tool): boolean { - const targetState = retargetableStates.get(target); - const donorState = retargetableStates.get(donor); - if (targetState === undefined || donorState === undefined) { - return false; - } - targetState.toolBridge = donorState.toolBridge; - targetState.withMount = donorState.withMount; - targetState.loadFile = donorState.loadFile; - return true; -} - /** Model-visible replacement for an offloaded oversized value. */ export interface OffloadedValueRecord { /** Guest expression holding the full value, e.g. "vars.__h3". */ @@ -486,7 +458,7 @@ export async function createCodeExecutionTool( options?: CodeExecutionToolOptions ): Promise { const bridgeableTools = toolBridge.getBridgeableTools(); - const state: RetargetableState = { toolBridge, withMount, loadFile: options?.loadFile }; + const state: DispatchState = { toolBridge, withMount, loadFile: options?.loadFile }; // Kernel mode = persistent mount available (RLM experiment, or the // XUM_SANDBOX_PERSISTENT_MOUNTS dev override that rides the same path). @@ -580,13 +552,9 @@ ${xumTypes} ): Promise => { const execStartTime = Date.now(); - // Late-bound dispatch: snapshot the CURRENT bridge + mount runner as a - // pair so a retarget (see retargetCodeExecutionTool) lands atomically — - // the whole call uses either the old pair or the new pair, never a mix. const { toolBridge: activeBridge, withMount: activeMount, loadFile: activeLoadFile } = state; - // Mirrors the creation-time loadEnabled gate against the ACTIVE bridge - // (a retarget may have narrowed file_read away). + // Mirrors the creation-time loadEnabled gate. const loadActive = activeLoadFile !== undefined && activeBridge.getBridgeableToolNames().includes("file_read"); @@ -873,6 +841,5 @@ ${xumTypes} } }, }); - retargetableStates.set(codeExecutionTool, state); return codeExecutionTool; } diff --git a/src/node/services/workflows/WorkflowTaskServiceAdapter.ts b/src/node/services/workflows/WorkflowTaskServiceAdapter.ts index 389f57807d..81a13a5dbf 100644 --- a/src/node/services/workflows/WorkflowTaskServiceAdapter.ts +++ b/src/node/services/workflows/WorkflowTaskServiceAdapter.ts @@ -30,7 +30,6 @@ export const DEFAULT_WORKFLOW_AGENT_ID = "exec"; interface WorkflowTaskExperiments { programmaticToolCalling?: boolean; - programmaticToolCallingExclusive?: boolean; advisorTool?: boolean; workspaceHeartbeats?: boolean; subagentFileReports?: boolean; From 6bada51ffdfd5ca3be00a064591b743b9f880672 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 16:36:46 +0000 Subject: [PATCH 02/40] Address Codex review round 1 - Fail closed when PTC exclusive assembly fails (no silent flat fallback) - Honor disable-all tool policies: skip code_execution synthesis when the policy leaves no tools (auto-compaction contract) - Keep policy-required tools model-visible so stop-when conditions can observe their top-level toolResults - Make memory/advisor (context-coupled) and attach_file/desktop_screenshot (media-producing) non-bridgeable so system-prompt context and media extraction keep working under the exclusive posture - Elide base64 media payloads from bridged (MCP) content-container results - Extract nested file_edit_* diffs/paths and agent_skill_read snapshots from code_execution records for compaction persistence - Alias the legacy programmatic-tool-calling-exclusive override onto PTC on read and mirror it back on write (upgrade keeps the posture; downgrade runs exclusive instead of 2x supplement) --- .../utils/messages/extractEditedFiles.test.ts | 66 ++++++++++++ .../utils/messages/extractEditedFiles.ts | 101 +++++++++++++++--- .../agentSkills/loadedSkillSnapshots.test.ts | 44 ++++++++ .../agentSkills/loadedSkillSnapshots.ts | 42 +++++++- src/node/services/experimentsService.test.ts | 50 +++++++-- src/node/services/experimentsService.ts | 26 +++++ src/node/services/ptc/toolBridge.test.ts | 32 ++++++ src/node/services/ptc/toolBridge.ts | 42 +++++++- src/node/services/toolAssembly.test.ts | 55 ++++++++++ src/node/services/toolAssembly.ts | 52 ++++++--- 10 files changed, 466 insertions(+), 44 deletions(-) diff --git a/src/common/utils/messages/extractEditedFiles.test.ts b/src/common/utils/messages/extractEditedFiles.test.ts index 8cecb66f59..3d8a9bd7ee 100644 --- a/src/common/utils/messages/extractEditedFiles.test.ts +++ b/src/common/utils/messages/extractEditedFiles.test.ts @@ -50,6 +50,72 @@ function makeDiff(filePath: string, oldContent: string, newContent: string): str return createPatch(filePath, oldContent, newContent, "", "", { context: 3 }); } +/** + * Helper to create an assistant message with one code_execution part whose + * output carries nested PTC tool-call records (exclusive posture). + */ +function createCodeExecutionMessage(toolCalls: unknown[]): MuxMessage { + return { + id: `msg-${Math.random().toString(36).slice(2)}`, + role: "assistant", + parts: [ + { + type: "dynamic-tool" as const, + toolCallId: `tc-${Math.random().toString(36).slice(2)}`, + toolName: "code_execution", + state: "output-available" as const, + input: { code: "..." }, + output: { success: true, toolCalls }, + }, + ], + }; +} + +describe("nested PTC edit records (exclusive posture)", () => { + it("extracts paths and diffs from successful nested file_edit_* records", () => { + const nestedDiff = makeDiff("/nested.ts", "old", "new"); + const messages: MuxMessage[] = [ + createCodeExecutionMessage([ + { + toolName: "file_edit_replace_string", + args: { path: "/nested.ts" }, + result: { success: true, diff: nestedDiff }, + }, + // Failed nested edits (bridge error, resolved failure, kernel ok bit) + // are all skipped. + { toolName: "file_edit_insert", args: { path: "/errored.ts" }, error: "denied" }, + { + toolName: "file_edit_replace_string", + args: { path: "/resolved-failed.ts" }, + result: { success: false }, + }, + { toolName: "file_edit_insert", args: { path: "/kernel-failed.ts" }, ok: false }, + // Non-edit nested calls are ignored. + { toolName: "bash", args: { script: "true" }, result: { success: true } }, + ]), + ]; + + expect(extractEditedFilePaths(messages)).toEqual(["/nested.ts"]); + const diffs = extractEditedFileDiffs(messages); + expect(diffs).toHaveLength(1); + expect(diffs[0].path).toBe("/nested.ts"); + expect(diffs[0].diff).toBe(nestedDiff); + }); + + it("kernel-compacted records surface the path but no diff", () => { + // Kernel record compaction drops result contents (ok bit only): the edit + // is still tracked by path, but no diff content survives to preserve. + const messages: MuxMessage[] = [ + createCodeExecutionMessage([ + { toolName: "file_edit_replace_string", args: { path: "/kernel.ts" }, ok: true, bytes: 9 }, + ]), + ]; + + expect(extractEditedFilePaths(messages)).toEqual(["/kernel.ts"]); + expect(extractEditedFileDiffs(messages)).toEqual([]); + }); +}); + describe("extractEditedFilePaths", () => { it("should extract file paths from successful edits", () => { const messages: MuxMessage[] = [ diff --git a/src/common/utils/messages/extractEditedFiles.ts b/src/common/utils/messages/extractEditedFiles.ts index b33f0b2d8c..8a1bc89ebe 100644 --- a/src/common/utils/messages/extractEditedFiles.ts +++ b/src/common/utils/messages/extractEditedFiles.ts @@ -14,6 +14,53 @@ interface FileEditToolOutput { diff?: string; } +/** + * One successful nested edit found inside a code_execution output. `diff` is + * present only for classic (non-kernel) PTC records, which retain the full + * tool result; kernel-compacted records keep args (so the path survives) but + * drop result contents, leaving nothing to rebuild a diff from. + */ +interface NestedEditRecord { + filePath: string; + diff?: string; +} + +/** + * Nested edit calls inside a code_execution part (exclusive PTC): file edits + * happen as nested xum.file_edit_* calls, so the outer part is named + * "code_execution" and the edits live in its output's toolCalls records. + * Mirrors collectNestedReadPaths in extractReadFiles.ts. Records are returned + * in chronological (execution) order. + */ +function collectNestedEditRecords(output: unknown): NestedEditRecord[] { + if (typeof output !== "object" || output === null) return []; + const toolCalls = (output as { toolCalls?: unknown }).toolCalls; + if (!Array.isArray(toolCalls)) return []; + + const records: NestedEditRecord[] = []; + for (const record of toolCalls as Array>) { + if (typeof record !== "object" || record === null) continue; + if (!FILE_EDIT_TOOL_NAMES.includes(record.toolName as (typeof FILE_EDIT_TOOL_NAMES)[number])) { + continue; + } + // Success = no error, and for kernel-compacted records ok !== false. + if (record.error !== undefined || record.ok === false) continue; + const result = record.result as FileEditToolOutput | undefined; + // Classic records retain the full result: edits resolve with + // {success: false} instead of throwing, so require an explicit success. + // Kernel-compacted records carry no result; their ok bit above decides. + if (result !== undefined && result.success !== true) continue; + const filePath = extractToolFilePath(record.args); + if (!filePath) continue; + const diff = + result !== undefined + ? (getToolOutputUiOnly(result)?.file_edit?.diff ?? result.diff) + : undefined; + records.push({ filePath, ...(diff !== undefined ? { diff } : {}) }); + } + return records; +} + /** * Represents a file and its combined diff from all edits. */ @@ -42,11 +89,21 @@ export function extractEditedFilePaths(messages: MuxMessage[]): string[] { for (const part of message.parts) { if (part.type !== "dynamic-tool") continue; - if (!FILE_EDIT_TOOL_NAMES.includes(part.toolName as (typeof FILE_EDIT_TOOL_NAMES)[number])) + if (part.state !== "output-available") continue; + + if (part.toolName === "code_execution") { + // Nested edits that completed before a later failure still landed. + for (const record of collectNestedEditRecords(part.output)) { + if (!seen.has(record.filePath)) { + seen.add(record.filePath); + editedFiles.push(record.filePath); + } + } continue; + } - // Only count successful edits (output-available with success) - if (part.state !== "output-available") continue; + if (!FILE_EDIT_TOOL_NAMES.includes(part.toolName as (typeof FILE_EDIT_TOOL_NAMES)[number])) + continue; // Check if the tool result indicates success const output = part.output as { success?: boolean } | undefined; @@ -181,14 +238,39 @@ export function extractEditedFileDiffs(messages: MuxMessage[]): FileEditDiff[] { const diffsByPath = new Map(); const editOrder: string[] = []; // Track order of last edit per file + const addDiff = (filePath: string, diff: string): void => { + if (!diffsByPath.has(filePath)) { + diffsByPath.set(filePath, []); + } + diffsByPath.get(filePath)!.push(diff); + + // Update edit order (move to end if already exists) + const idx = editOrder.indexOf(filePath); + if (idx !== -1) editOrder.splice(idx, 1); + editOrder.push(filePath); + }; + for (const message of messages) { if (message.role !== "assistant") continue; for (const part of message.parts) { if (part.type !== "dynamic-tool") continue; + if (part.state !== "output-available") continue; + + if (part.toolName === "code_execution") { + // Classic PTC records retain the full nested result (including the + // diff); kernel-compacted records surface path-only edits and are + // skipped here (no diff contents survive compaction of the record). + for (const record of collectNestedEditRecords(part.output)) { + if (record.diff !== undefined && record.diff.length > 0) { + addDiff(record.filePath, record.diff); + } + } + continue; + } + if (!FILE_EDIT_TOOL_NAMES.includes(part.toolName as (typeof FILE_EDIT_TOOL_NAMES)[number])) continue; - if (part.state !== "output-available") continue; const output = part.output as FileEditToolOutput | undefined; if (!output?.success) continue; @@ -200,16 +282,7 @@ export function extractEditedFileDiffs(messages: MuxMessage[]): FileEditDiff[] { const filePath = extractToolFilePath(part.input); if (!filePath) continue; - // Add diff to this file's list - if (!diffsByPath.has(filePath)) { - diffsByPath.set(filePath, []); - } - diffsByPath.get(filePath)!.push(diff); - - // Update edit order (move to end if already exists) - const idx = editOrder.indexOf(filePath); - if (idx !== -1) editOrder.splice(idx, 1); - editOrder.push(filePath); + addDiff(filePath, diff); } } diff --git a/src/node/services/agentSkills/loadedSkillSnapshots.test.ts b/src/node/services/agentSkills/loadedSkillSnapshots.test.ts index 6b125d3b1e..0e178d7ad4 100644 --- a/src/node/services/agentSkills/loadedSkillSnapshots.test.ts +++ b/src/node/services/agentSkills/loadedSkillSnapshots.test.ts @@ -70,6 +70,50 @@ function createSyntheticSkillSnapshotMessage(args: { } describe("extractLoadedSkillSnapshotsFromMessages", () => { + it("extracts snapshots from nested agent_skill_read records inside code_execution", () => { + // Exclusive PTC: skill reads happen as nested xum.agent_skill_read calls, + // so the snapshot must be recovered from the code_execution record. + const nestedMessage: MuxMessage = { + id: "nested", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: "tool-nested", + toolName: "code_execution", + state: "output-available", + input: { code: "..." }, + output: { + success: true, + toolCalls: [ + { + toolName: "agent_skill_read", + args: { name: "nested-skill" }, + result: { + success: true, + skill: { + scope: "project", + directoryName: "nested-skill", + frontmatter: { name: "nested-skill", description: "nested description" }, + body: "Nested body", + }, + }, + }, + // Failed and kernel-compacted records (no full result) yield nothing. + { toolName: "agent_skill_read", args: { name: "failed-skill" }, error: "denied" }, + { toolName: "agent_skill_read", args: { name: "kernel-skill" }, ok: true, bytes: 9 }, + { toolName: "bash", args: { script: "true" }, result: { success: true } }, + ], + }, + }, + ], + }; + + const snapshots = extractLoadedSkillSnapshotsFromMessages([nestedMessage]); + expect(snapshots.map((snapshot) => snapshot.name)).toEqual(["nested-skill"]); + expect(snapshots[0].body).toContain("Nested body"); + }); + it("dedupes by scope/name and keeps the latest read order", () => { const snapshots = extractLoadedSkillSnapshotsFromMessages([ createAgentSkillReadToolMessage({ diff --git a/src/node/services/agentSkills/loadedSkillSnapshots.ts b/src/node/services/agentSkills/loadedSkillSnapshots.ts index e92f337db5..4f566b07ff 100644 --- a/src/node/services/agentSkills/loadedSkillSnapshots.ts +++ b/src/node/services/agentSkills/loadedSkillSnapshots.ts @@ -167,15 +167,47 @@ function extractLoadedSkillSnapshotFromSyntheticMessage( }); } +/** + * Nested agent_skill_read calls inside a code_execution part (exclusive PTC): + * skill reads happen as nested xum.agent_skill_read calls, so the outer part + * is named "code_execution" and the results live in its output's toolCalls + * records. Classic PTC records retain the full result (snapshot recoverable); + * kernel-compacted records drop result contents, so nothing survives there — + * extractLoadedSkillSnapshotFromToolOutput rejects those records naturally. + */ +function extractLoadedSkillSnapshotsFromCodeExecutionOutput( + output: unknown +): LoadedSkillSnapshot[] { + if (typeof output !== "object" || output === null) return []; + const toolCalls = (output as { toolCalls?: unknown }).toolCalls; + if (!Array.isArray(toolCalls)) return []; + + const snapshots: LoadedSkillSnapshot[] = []; + for (const record of toolCalls as Array>) { + if (typeof record !== "object" || record === null) continue; + if (record.toolName !== "agent_skill_read" || record.error !== undefined) continue; + const snapshot = extractLoadedSkillSnapshotFromToolOutput(record.result); + if (snapshot) { + snapshots.push(snapshot); + } + } + return snapshots; +} + function extractLoadedSkillSnapshotsFromMessage(message: MuxMessage): LoadedSkillSnapshot[] { const snapshots: LoadedSkillSnapshot[] = []; for (const part of message.parts) { - if ( - part.type !== "dynamic-tool" || - part.toolName !== "agent_skill_read" || - part.state !== "output-available" - ) { + if (part.type !== "dynamic-tool" || part.state !== "output-available") { + continue; + } + + if (part.toolName === "code_execution") { + snapshots.push(...extractLoadedSkillSnapshotsFromCodeExecutionOutput(part.output)); + continue; + } + + if (part.toolName !== "agent_skill_read") { continue; } diff --git a/src/node/services/experimentsService.test.ts b/src/node/services/experimentsService.test.ts index e6ba6543ed..a400a4d9b5 100644 --- a/src/node/services/experimentsService.test.ts +++ b/src/node/services/experimentsService.test.ts @@ -148,19 +148,17 @@ describe("ExperimentsService", () => { expect(service.isExperimentEnabled(EXPERIMENT_IDS.TOOL_SEARCH)).toBe(false); }); - test("stale overrides for removed experiments are ignored, never rejected", async () => { - // "programmatic-tool-calling-exclusive" was a real experiment ID before - // PTC became exclusive-only. Users upgrading with the old key persisted - // must load cleanly with the stale entry filtered out. + test("legacy exclusive-only override keeps PTC enabled after upgrade", async () => { + // "programmatic-tool-calling-exclusive" was a separate experiment before + // PTC became exclusive-only. A user who had ONLY that toggle enabled opted + // into exactly the posture the merged PTC experiment activates, so the + // alias must keep PTC on instead of silently disabling it. await fs.writeFile( path.join(tempDir, OVERRIDES_FILE), JSON.stringify({ version: 1, experiments: {}, - overrides: { - "programmatic-tool-calling-exclusive": true, - [EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING]: true, - }, + overrides: { "programmatic-tool-calling-exclusive": true }, }), "utf-8" ); @@ -175,6 +173,42 @@ describe("ExperimentsService", () => { }); }); + test("a disabled legacy exclusive override stays ignored", async () => { + await fs.writeFile( + path.join(tempDir, OVERRIDES_FILE), + JSON.stringify({ + version: 1, + experiments: {}, + overrides: { "programmatic-tool-calling-exclusive": false }, + }), + "utf-8" + ); + + const { telemetryService } = createTelemetryService(); + const service = new ExperimentsService({ telemetryService, xumHome: tempDir }); + await service.initialize(); + + expect(service.isExperimentEnabled(EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING)).toBe(false); + expect(await service.getOverrides()).toEqual({}); + }); + + test("enabled PTC writes the legacy exclusive key for downgrade compatibility", async () => { + // A downgraded build reads a bare ptc:true as the removed (~2x cost) + // supplement mode; mirroring the legacy exclusive key preserves the + // exclusive posture across downgrade. Disabling PTC drops both keys. + const { telemetryService } = createTelemetryService(); + const service = new ExperimentsService({ telemetryService, xumHome: tempDir }); + await service.setOverride(EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING, true); + + expect((await readOverridesFile()).overrides).toEqual({ + [EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING]: true, + "programmatic-tool-calling-exclusive": true, + }); + + await service.setOverride(EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING, null); + expect((await readOverridesFile()).overrides).toEqual({}); + }); + test("a client with empty local state does not clear overrides it never knew about", async () => { await fs.writeFile( path.join(tempDir, OVERRIDES_FILE), diff --git a/src/node/services/experimentsService.ts b/src/node/services/experimentsService.ts index ecc0290253..78030df41b 100644 --- a/src/node/services/experimentsService.ts +++ b/src/node/services/experimentsService.ts @@ -1,5 +1,6 @@ import assert from "@/common/utils/assert"; import { + EXPERIMENT_IDS, EXPERIMENTS, isExperimentSupportedOnPlatform, type ExperimentId, @@ -25,6 +26,16 @@ interface ExperimentsFile { const OVERRIDES_FILE_NAME = "feature_flags.json"; const OVERRIDES_FILE_VERSION = 1; +/** + * Pre-merge experiment ID: "PTC Exclusive Mode" was a separate experiment + * before Programmatic Tool Calling became exclusive-only. Reads alias a + * persisted `true` onto the merged PTC key (a user who opted into exclusive + * opted into exactly the posture PTC now activates), and writes mirror an + * enabled PTC back onto this key so a downgraded build runs its exclusive + * posture instead of the removed (~2x cost) supplement mode. + */ +const LEGACY_PTC_EXCLUSIVE_ID = "programmatic-tool-calling-exclusive"; + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -52,6 +63,15 @@ async function readOverridesFile(filePath: string): Promise { + describe("media elision", () => { + it("replaces media items in content-container results with text placeholders", async () => { + // Bridged MCP tools may return { type: "content", value: [...media] }. + // Nested records bypass extractToolMediaAsUserMessages, so raw base64 + // must never enter the sandbox result / model-visible record. + const bridge = new ToolBridge({ + mcp__shots__take: createMockTool("mcp__shots__take", z.object({}), () => ({ + type: "content", + value: [ + { type: "text", text: "took a screenshot" }, + { type: "media", mediaType: "image/png", data: "aGVsbG8=" }, + ], + })), + }); + let registeredMux: Record Promise> = {}; + const mockRegisterObject = mock( + (name: string, obj: Record Promise>) => { + if (name === "mux") registeredMux = obj; + } + ); + bridge.register(createMockRuntime({ registerObject: mockRegisterObject })); + + const result = (await registeredMux.mcp__shots__take({})) as { + value: Array<{ type: string; text?: string; data?: string }>; + }; + expect(result.value[0]).toEqual({ type: "text", text: "took a screenshot" }); + expect(result.value[1].type).toBe("text"); + expect(result.value[1].text).toContain("media elided: image/png"); + expect(JSON.stringify(result)).not.toContain("aGVsbG8="); + }); + }); + describe("constructor", () => { it("filters out excluded tools", () => { const tools: Record = { diff --git a/src/node/services/ptc/toolBridge.ts b/src/node/services/ptc/toolBridge.ts index 7baa9a447b..b1cffdb01c 100644 --- a/src/node/services/ptc/toolBridge.ts +++ b/src/node/services/ptc/toolBridge.ts @@ -134,6 +134,18 @@ const EXCLUDED_TOOLS = new Set([ "todo_read", // UI-specific "status_set", // UI-specific "agent_report", // Must be top-level for taskService to read args from history + // Context-coupled tools: AIService keys system-prompt context off their + // top-level presence (memory index / hot-set block for `memory`, proactive + // guidance for `advisor`). Bridging them would silently drop that context + // in the exclusive posture. + "memory", + "advisor", + // Media-producing tools: their content-container outputs are converted into + // model-visible multimodal parts by extractToolMediaAsUserMessages, which + // only sees TOP-LEVEL tool outputs — nested inside a code_execution record + // the attachment would stay as base64 JSON text (context blowup, no image). + "attach_file", + "desktop_screenshot", ]); /** @@ -481,9 +493,37 @@ export class ToolBridge { private serializeResult(result: unknown): unknown { try { // Round-trip through JSON to ensure QuickJS can handle the value - return JSON.parse(JSON.stringify(result)); + return JSON.parse(JSON.stringify(elideContentMediaPayloads(result))); } catch { return { error: "Result not JSON-serializable" }; } } } + +/** + * Replace base64 media items inside MCP-style content-container results + * ({ type: "content", value: [...] }) with small text placeholders before the + * result enters the sandbox and its model-visible record. Known + * media-producing built-ins are non-bridgeable (EXCLUDED_TOOLS), but any + * bridged MCP tool may return media: nested records bypass + * extractToolMediaAsUserMessages, so the raw payload would otherwise ride as + * JSON text into guest vars and provider context. + */ +function elideContentMediaPayloads(result: unknown): unknown { + if (typeof result !== "object" || result === null) return result; + const container = result as { type?: unknown; value?: unknown }; + if (container.type !== "content" || !Array.isArray(container.value)) return result; + + let changed = false; + const value = container.value.map((item: unknown) => { + if (typeof item !== "object" || item === null) return item; + const media = item as { type?: unknown; data?: unknown; mediaType?: unknown }; + if (media.type !== "media" || typeof media.data !== "string") return item; + changed = true; + return { + type: "text", + text: `[media elided: ${typeof media.mediaType === "string" ? media.mediaType : "unknown"}, ${media.data.length} base64 chars — media returned by bridged tools is not model-visible inside code_execution; call a top-level tool to attach it]`, + }; + }); + return changed ? { ...result, value } : result; +} diff --git a/src/node/services/toolAssembly.test.ts b/src/node/services/toolAssembly.test.ts index 97c2e2bf95..f894b7b37e 100644 --- a/src/node/services/toolAssembly.test.ts +++ b/src/node/services/toolAssembly.test.ts @@ -40,6 +40,61 @@ describe("applyToolPolicyAndExperiments", () => { expect(result.mcp_prompt_get.description).toContain("mcp__s__p"); }); + test("context-coupled and media tools stay model-visible under PTC", async () => { + // memory/advisor: AIService keys system-prompt context (memory index / + // hot set, advisor guidance) off their top-level presence. attach_file / + // desktop_screenshot: extractToolMediaAsUserMessages only converts + // TOP-LEVEL media outputs into model-visible multimodal parts. + const result = await applyToolPolicyAndExperiments({ + allTools: { + bash: executableTool("Run a command"), + memory: executableTool("Memory"), + advisor: executableTool("Advisor"), + attach_file: executableTool("Attach"), + desktop_screenshot: executableTool("Screenshot"), + }, + effectiveToolPolicy: undefined, + experiments: { programmaticToolCalling: true }, + emitNestedToolEvent: () => undefined, + }); + expect(Object.keys(result).sort()).toEqual([ + "advisor", + "attach_file", + "code_execution", + "desktop_screenshot", + "memory", + ]); + }); + + test("a disable-all policy yields no tools at all (no code_execution)", async () => { + // Auto-compaction inherits the original send's experiment flags and sets a + // `.*` disable policy: that no-tools contract must win over the exclusive + // posture's otherwise-mandatory code_execution. + const result = await applyToolPolicyAndExperiments({ + allTools: { bash: executableTool("Run a command"), todo_write: executableTool("Todos") }, + effectiveToolPolicy: [{ regex_match: ".*", action: "disable" }], + experiments: { programmaticToolCalling: true }, + emitNestedToolEvent: () => undefined, + }); + expect(Object.keys(result)).toEqual([]); + }); + + test("policy-required bridgeable tools stay model-visible in the exclusive set", async () => { + // "require" gates run completion on a TOP-LEVEL toolResult for that name + // (StreamManager.createStopWhenCondition); a nested xum.* call never + // satisfies it, so the required tool must not be bridged away. + const result = await applyToolPolicyAndExperiments({ + allTools: { + bash: executableTool("Run a command"), + file_read: executableTool("Read a file"), + }, + effectiveToolPolicy: [{ regex_match: "bash", action: "require" }], + experiments: { programmaticToolCalling: true }, + emitNestedToolEvent: () => undefined, + }); + expect(Object.keys(result).sort()).toEqual(["bash", "code_execution"]); + }); + test("grant-denied tools are hidden from the model but stubbed in the sandbox", async () => { const result = await applyToolPolicyAndExperiments({ allTools: { diff --git a/src/node/services/toolAssembly.ts b/src/node/services/toolAssembly.ts index 3febe79168..1c67c264d6 100644 --- a/src/node/services/toolAssembly.ts +++ b/src/node/services/toolAssembly.ts @@ -18,7 +18,11 @@ import type { SendMessageOptions } from "@/common/orpc/types"; /** Renderer-sent experiment flags (SendMessageOptions.experiments). */ type SendMessageExperiments = SendMessageOptions["experiments"]; -import { applyToolPolicy, type ToolPolicy } from "@/common/utils/tools/toolPolicy"; +import { + applyToolPolicy, + buildRequiredToolPatterns, + type ToolPolicy, +} from "@/common/utils/tools/toolPolicy"; import { applyCapabilityGrants } from "@/common/utils/tools/capabilityGrants"; import type { CapabilityGrants } from "@/common/types/capabilityGrants"; // PTC types only — modules lazy-loaded to avoid loading typescript/prettier at startup @@ -194,7 +198,14 @@ export async function applyToolPolicyAndExperiments( // RLM rides the PTC parent flag: this flag is only read inside the PTC // branch below, so RLM alone (PTC off) is inert by construction. const rlmActive = experiments?.rlm === true; - if (experiments?.programmaticToolCalling) { + // A policy that disables EVERY tool (e.g. auto-compaction's `.*` disable + // rule, inherited alongside the original send's experiment flags) is a + // no-tools contract: synthesizing code_execution anyway would hand that + // flow a tool it explicitly forbade. Checked on the PRE-grant policy + // result so a least-privilege grants ceiling (which stubs, not disables) + // keeps code_execution as the mandatory bridge entry point. + const policyLeavesNoTools = Object.keys(policyFilteredPreGrant).length === 0; + if (experiments?.programmaticToolCalling && !policyLeavesNoTools) { try { // Lazy-load PTC modules only when experiments are enabled const ptc = await getPTCModules(); @@ -263,8 +274,21 @@ export async function applyToolPolicyAndExperiments( // Keep mcp_prompt_get direct because sandbox declarations omit its // multiline prompt catalog. const promptGet = policyFilteredTools.mcp_prompt_get; + // Policy-REQUIRED tools stay model-visible: "require" gates run + // completion on a top-level toolResult for that name + // (StreamManager.createStopWhenCondition), which a nested xum.* call + // inside a code_execution record never satisfies. Sourced from the + // grant-and-policy-filtered record so both ceilings still apply; the + // tool also stays bridged, which is harmless duplication. + const requiredPatterns = buildRequiredToolPatterns(effectiveToolPolicy); + const requiredTools = Object.fromEntries( + Object.entries(policyFilteredTools).filter(([name]) => + requiredPatterns.some((pattern) => pattern.test(name)) + ) + ); toolsForModel = { ...nonBridgeable, + ...requiredTools, ...(promptGet !== undefined ? { mcp_prompt_get: promptGet } : {}), code_execution: codeExecutionTool, }; @@ -292,20 +316,16 @@ export async function applyToolPolicyAndExperiments( toolsForModel = { ...toolsForModel, ...rollback }; } } catch (error) { - // RLM fails CLOSED (r49): silently degrading to the complete flat - // toolset would drop the exclusive persistent kernel and its - // nested-result context isolation while the run is still recorded as - // RLM — bulk tool results would leak into model context and corrupt - // RLM evaluations. Surfacing the failure lets the send fail visibly - // and the user retry once the cause (e.g. QuickJS WASM load) clears. - if (rlmActive) { - throw new Error( - `RLM kernel assembly failed and RLM must not silently fall back to flat tools: ${getErrorMessage(error)}` - ); - } - // Non-RLM PTC keeps the legacy behavior: fall back to policy-filtered - // tools if code_execution creation fails. - log.error("Failed to create code_execution tool, falling back to base tools", { error }); + // PTC fails CLOSED (r49): the experiment is exclusive-only, so silently + // degrading to the complete flat toolset would change user-visible + // semantics while the run is still recorded as PTC (corrupting + // experiment results) — and for RLM would additionally drop the + // persistent kernel's nested-result context isolation. Surfacing the + // failure lets the send fail visibly and the user retry once the cause + // (e.g. QuickJS WASM load) clears. + throw new Error( + `PTC exclusive assembly failed and must not silently fall back to flat tools: ${getErrorMessage(error)}` + ); } } From 55bb8fdd81e79435ad6496d04ef16917f35eecd3 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 17:23:54 +0000 Subject: [PATCH 03/40] Address Codex review round 2: legacy taskExperiments/localStorage aliases, nested media extraction, newest-first nested edit paths --- .../contexts/ExperimentsContext.test.tsx | 57 +++++++++++++- src/browser/contexts/ExperimentsContext.tsx | 34 +++++++++ src/browser/hooks/useExperiments.test.ts | 31 +++++++- src/browser/hooks/useExperiments.ts | 11 +++ src/common/constants/experiments.ts | 20 +++++ .../schemas/project.taskExperiments.test.ts | 25 ++++++ src/common/schemas/project.ts | 33 +++++--- .../utils/messages/extractEditedFiles.test.ts | 15 ++++ .../utils/messages/extractEditedFiles.ts | 4 +- src/node/services/experimentsService.ts | 19 ++--- src/node/services/ptc/toolBridge.test.ts | 32 -------- src/node/services/ptc/toolBridge.ts | 45 +++-------- .../extractToolMediaAsUserMessages.test.ts | 76 +++++++++++++++++++ .../utils/messages/toolResultAttachments.ts | 44 ++++++++++- 14 files changed, 353 insertions(+), 93 deletions(-) diff --git a/src/browser/contexts/ExperimentsContext.test.tsx b/src/browser/contexts/ExperimentsContext.test.tsx index 3b3ccddc04..d95018234f 100644 --- a/src/browser/contexts/ExperimentsContext.test.tsx +++ b/src/browser/contexts/ExperimentsContext.test.tsx @@ -4,7 +4,11 @@ import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promi import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { GlobalWindow } from "happy-dom"; -import { EXPERIMENT_IDS, getExperimentKey } from "@/common/constants/experiments"; +import { + EXPERIMENT_IDS, + getExperimentKey, + getLegacyPtcExclusiveExperimentKey, +} from "@/common/constants/experiments"; import { requireTestModule, type RecursivePartial } from "@/browser/testUtils"; import type * as APIModule from "./API"; import type { APIClient } from "./API"; @@ -248,4 +252,55 @@ describe("ExperimentsProvider", () => { expect(getByTestId("toggle").textContent).toBe("true"); }); }); + + test("stale legacy exclusive true reads as PTC on, and toggling PTC rewrites the legacy key", async () => { + currentClientMock = { + experiments: { + setOverride: mock(() => Promise.resolve()), + getOverrides: mock(() => Promise.resolve({})), + }, + }; + + // Pre-merge state: "PTC Exclusive Mode" enabled — exactly the posture + // merged PTC activates, so the upgrade must keep PTC on. + globalThis.window.localStorage.setItem( + getLegacyPtcExclusiveExperimentKey(), + JSON.stringify(true) + ); + + function Toggle() { + const [enabled, setEnabled] = useExperiment(EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING); + return ( + + ); + } + + const { getByTestId } = render( + + + + + + ); + + expect(getByTestId("toggle").textContent).toBe("true"); + + // Toggling PTC off must rewrite the legacy key too: a downgraded renderer + // treats it as an explicit override that wins over the mirrored backend + // value, so a stale entry would resurrect the pre-merge posture. + fireEvent.click(getByTestId("toggle")); + await waitFor(() => { + expect(getByTestId("toggle").textContent).toBe("false"); + }); + expect(globalThis.window.localStorage.getItem(getLegacyPtcExclusiveExperimentKey())).toBe( + "false" + ); + expect( + globalThis.window.localStorage.getItem( + getExperimentKey(EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING) + ) + ).toBe("false"); + }); }); diff --git a/src/browser/contexts/ExperimentsContext.tsx b/src/browser/contexts/ExperimentsContext.tsx index 8676fa52d1..74e64ea096 100644 --- a/src/browser/contexts/ExperimentsContext.tsx +++ b/src/browser/contexts/ExperimentsContext.tsx @@ -8,8 +8,10 @@ import React, { } from "react"; import { type ExperimentId, + EXPERIMENT_IDS, EXPERIMENTS, getExperimentKey, + getLegacyPtcExclusiveExperimentKey, isExperimentSupportedOnPlatform, } from "@/common/constants/experiments"; import { getStorageChangeEvent } from "@/common/constants/events"; @@ -43,11 +45,34 @@ function isExperimentSupported(experimentId: ExperimentId): boolean { return isExperimentSupportedOnPlatform(experimentId, getCurrentDesktopPlatform()); } +/** + * Upgrade alias (see LEGACY_PTC_EXCLUSIVE_EXPERIMENT_ID): a stored legacy + * exclusive `true` opted into exactly the posture merged PTC activates, so PTC + * reads as enabled — winning even over an explicit supplement-off value, + * matching the backend read alias. setExperimentState rewrites the legacy key + * on every PTC toggle, so the alias never overrides a choice made in this + * build. + */ +export function hasLegacyPtcExclusiveOverride(): boolean { + try { + return window.localStorage.getItem(getLegacyPtcExclusiveExperimentKey()) === "true"; + } catch { + return false; + } +} + /** * Get explicit localStorage override for an experiment. * Returns undefined if no value is set or parsing fails. */ function getExperimentOverrideSnapshot(experimentId: ExperimentId): boolean | undefined { + if ( + experimentId === EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING && + hasLegacyPtcExclusiveOverride() + ) { + return true; + } + const key = getExperimentKey(experimentId); try { @@ -97,6 +122,15 @@ function setExperimentState(experimentId: ExperimentId, enabled: boolean): void try { window.localStorage.setItem(key, JSON.stringify(enabled)); + // Downgrade sync (see LEGACY_PTC_EXCLUSIVE_EXPERIMENT_ID): a downgraded + // renderer reads the pre-merge exclusive key as an explicit override that + // wins over the mirrored backend value in its send options, so a stale + // entry would resurrect supplement mode (stale false) or re-enable PTC + // after the user turned it off (stale true). Keep it equal to PTC. + if (experimentId === EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING) { + window.localStorage.setItem(getLegacyPtcExclusiveExperimentKey(), JSON.stringify(enabled)); + } + // Dispatch custom event for same-tab synchronization const customEvent = new CustomEvent(getStorageChangeEvent(key), { detail: { key, newValue: enabled }, diff --git a/src/browser/hooks/useExperiments.test.ts b/src/browser/hooks/useExperiments.test.ts index ab9999f81f..04a0a3786a 100644 --- a/src/browser/hooks/useExperiments.test.ts +++ b/src/browser/hooks/useExperiments.test.ts @@ -8,7 +8,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { GlobalWindow } from "happy-dom"; -import { EXPERIMENT_IDS, getExperimentKey } from "@/common/constants/experiments"; +import { + EXPERIMENT_IDS, + getExperimentKey, + getLegacyPtcExclusiveExperimentKey, +} from "@/common/constants/experiments"; import { isExperimentEnabled } from "./useExperiments"; describe("isExperimentEnabled", () => { @@ -60,4 +64,29 @@ describe("isExperimentEnabled", () => { globalThis.window.localStorage.setItem(key, JSON.stringify("test")); expect(isExperimentEnabled(EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING)).toBeUndefined(); }); + + test("legacy exclusive true reads as PTC enabled, winning over an explicit PTC false", () => { + // Pre-merge builds stored "PTC Exclusive Mode" under its own key; that + // posture is exactly what merged PTC activates, so it must keep PTC on + // even when the old supplement flag was explicitly off. + globalThis.window.localStorage.setItem( + getLegacyPtcExclusiveExperimentKey(), + JSON.stringify(true) + ); + expect(isExperimentEnabled(EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING)).toBe(true); + + globalThis.window.localStorage.setItem( + getExperimentKey(EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING), + JSON.stringify(false) + ); + expect(isExperimentEnabled(EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING)).toBe(true); + }); + + test("legacy exclusive false does not alias onto PTC", () => { + globalThis.window.localStorage.setItem( + getLegacyPtcExclusiveExperimentKey(), + JSON.stringify(false) + ); + expect(isExperimentEnabled(EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING)).toBeUndefined(); + }); }); diff --git a/src/browser/hooks/useExperiments.ts b/src/browser/hooks/useExperiments.ts index ad0f8100d9..89854062d5 100644 --- a/src/browser/hooks/useExperiments.ts +++ b/src/browser/hooks/useExperiments.ts @@ -1,9 +1,11 @@ import { readPersistedState } from "./usePersistedState"; import { + EXPERIMENT_IDS, type ExperimentId, getExperimentKey, isExperimentSupportedOnPlatform, } from "@/common/constants/experiments"; +import { hasLegacyPtcExclusiveOverride } from "@/browser/contexts/ExperimentsContext"; // Re-export reactive hooks from context for convenience export { @@ -29,6 +31,15 @@ export function isExperimentEnabled(experimentId: ExperimentId): boolean | undef return false; } + // Upgrade alias — mirrors getExperimentOverrideSnapshot so one-time reads + // (send options) agree with the reactive hooks. + if ( + experimentId === EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING && + hasLegacyPtcExclusiveOverride() + ) { + return true; + } + const stored = readPersistedState(getExperimentKey(experimentId), undefined); return typeof stored === "boolean" ? stored : undefined; } diff --git a/src/common/constants/experiments.ts b/src/common/constants/experiments.ts index 6774b1b571..1f92f76984 100644 --- a/src/common/constants/experiments.ts +++ b/src/common/constants/experiments.ts @@ -28,6 +28,17 @@ export const EXPERIMENT_IDS = { export type ExperimentId = (typeof EXPERIMENT_IDS)[keyof typeof EXPERIMENT_IDS]; +/** + * Pre-merge experiment ID: "PTC Exclusive Mode" was a separate experiment + * before Programmatic Tool Calling became exclusive-only. Persistence layers + * (backend feature_flags.json, renderer localStorage) alias a stored `true` + * onto the merged PTC key on read and mirror the merged PTC value back onto + * this key on write, so upgrades keep the user's exclusive posture and a + * downgraded build runs exclusive mode instead of the removed (~2x cost) + * supplement mode. + */ +export const LEGACY_PTC_EXCLUSIVE_EXPERIMENT_ID = "programmatic-tool-calling-exclusive"; + export interface ExperimentDefinition { id: ExperimentId; name: string; @@ -258,6 +269,15 @@ export function getExperimentKey(experimentId: ExperimentId): string { return `experiment:${experimentId}`; } +/** + * localStorage key of the removed exclusive experiment (see + * LEGACY_PTC_EXCLUSIVE_EXPERIMENT_ID). Kept out of getExperimentKey's + * signature so ordinary call sites can't target a removed experiment. + */ +export function getLegacyPtcExclusiveExperimentKey(): string { + return `experiment:${LEGACY_PTC_EXCLUSIVE_EXPERIMENT_ID}`; +} + /** * Get all experiment definitions as an array for iteration. */ diff --git a/src/common/schemas/project.taskExperiments.test.ts b/src/common/schemas/project.taskExperiments.test.ts index 8ca4435acc..9261c94f61 100644 --- a/src/common/schemas/project.taskExperiments.test.ts +++ b/src/common/schemas/project.taskExperiments.test.ts @@ -20,4 +20,29 @@ describe("WorkspaceConfig taskExperiments", () => { parsed.taskExperiments && "programmaticToolCallingExclusive" in parsed.taskExperiments ).toBe(false); }); + + test("exclusive-only legacy tasks keep PTC (and therefore RLM) on resumption", () => { + // A task stamped by an older build with ONLY the exclusive flag opted into + // exactly the posture merged PTC activates; stripping the key would drop + // PTC and make the stamped rlm flag inert on restart-safe resumption. + const parsed = WorkspaceConfigSchema.parse({ + path: "/tmp/ws", + taskExperiments: { + rlm: true, + programmaticToolCallingExclusive: true, + }, + }); + expect(parsed.taskExperiments?.programmaticToolCalling).toBe(true); + expect(parsed.taskExperiments?.rlm).toBe(true); + }); + + test("a legacy exclusive false is dropped without aliasing", () => { + const parsed = WorkspaceConfigSchema.parse({ + path: "/tmp/ws", + taskExperiments: { + programmaticToolCallingExclusive: false, + }, + }); + expect(parsed.taskExperiments?.programmaticToolCalling).toBeUndefined(); + }); }); diff --git a/src/common/schemas/project.ts b/src/common/schemas/project.ts index 40d4ef4511..23752222a7 100644 --- a/src/common/schemas/project.ts +++ b/src/common/schemas/project.ts @@ -178,15 +178,30 @@ export const WorkspaceConfigSchema = z.object({ "Initial prompt for a queued agent task (persisted only until the task actually starts).", }), taskExperiments: z - .object({ - programmaticToolCalling: z.boolean().optional(), - // RLM mode is stamped at spawn so child sessions keep RLM-gated features - // (persistent sandbox kernel, family messaging tools) across app restarts - // without depending on live frontend experiment state. - rlm: z.boolean().optional(), - advisorTool: z.boolean().optional(), - dynamicWorkflows: z.boolean().optional(), - }) + .preprocess( + // Legacy alias: tasks stamped by builds where "PTC Exclusive Mode" was a + // separate experiment may carry only programmaticToolCallingExclusive. + // The merged PTC experiment activates exactly that posture, so the flag + // must map onto programmaticToolCalling on resumption instead of being + // stripped (which would silently drop PTC and make rlm inert). `true` + // wins over an explicit programmaticToolCalling: false because the old + // exclusive flag activated the posture regardless of the supplement flag. + (value) => + typeof value === "object" && + value !== null && + (value as Record).programmaticToolCallingExclusive === true + ? { ...value, programmaticToolCalling: true } + : value, + z.object({ + programmaticToolCalling: z.boolean().optional(), + // RLM mode is stamped at spawn so child sessions keep RLM-gated features + // (persistent sandbox kernel, family messaging tools) across app restarts + // without depending on live frontend experiment state. + rlm: z.boolean().optional(), + advisorTool: z.boolean().optional(), + dynamicWorkflows: z.boolean().optional(), + }) + ) .optional() .meta({ description: "Experiments inherited from parent for restart-safe resumptions.", diff --git a/src/common/utils/messages/extractEditedFiles.test.ts b/src/common/utils/messages/extractEditedFiles.test.ts index 3d8a9bd7ee..736035fc1e 100644 --- a/src/common/utils/messages/extractEditedFiles.test.ts +++ b/src/common/utils/messages/extractEditedFiles.test.ts @@ -102,6 +102,21 @@ describe("nested PTC edit records (exclusive posture)", () => { expect(diffs[0].diff).toBe(nestedDiff); }); + it("returns nested batch paths newest-first", () => { + // The surrounding scan walks history backward to keep the LATEST edits + // under MAX_EDITED_FILES; nested records are chronological, so a batch + // must be traversed in reverse. + const messages: MuxMessage[] = [ + createCodeExecutionMessage([ + { toolName: "file_edit_insert", args: { path: "/first.ts" }, ok: true }, + { toolName: "file_edit_insert", args: { path: "/second.ts" }, ok: true }, + { toolName: "file_edit_insert", args: { path: "/third.ts" }, ok: true }, + ]), + ]; + + expect(extractEditedFilePaths(messages)).toEqual(["/third.ts", "/second.ts", "/first.ts"]); + }); + it("kernel-compacted records surface the path but no diff", () => { // Kernel record compaction drops result contents (ok bit only): the edit // is still tracked by path, but no diff content survives to preserve. diff --git a/src/common/utils/messages/extractEditedFiles.ts b/src/common/utils/messages/extractEditedFiles.ts index 8a1bc89ebe..4bb1c327ee 100644 --- a/src/common/utils/messages/extractEditedFiles.ts +++ b/src/common/utils/messages/extractEditedFiles.ts @@ -93,7 +93,9 @@ export function extractEditedFilePaths(messages: MuxMessage[]): string[] { if (part.toolName === "code_execution") { // Nested edits that completed before a later failure still landed. - for (const record of collectNestedEditRecords(part.output)) { + // Records are chronological; reverse them so this newest-first scan + // keeps the LATEST edits of a large batch under MAX_EDITED_FILES. + for (const record of collectNestedEditRecords(part.output).reverse()) { if (!seen.has(record.filePath)) { seen.add(record.filePath); editedFiles.push(record.filePath); diff --git a/src/node/services/experimentsService.ts b/src/node/services/experimentsService.ts index 78030df41b..a8dd7de195 100644 --- a/src/node/services/experimentsService.ts +++ b/src/node/services/experimentsService.ts @@ -3,6 +3,7 @@ import { EXPERIMENT_IDS, EXPERIMENTS, isExperimentSupportedOnPlatform, + LEGACY_PTC_EXCLUSIVE_EXPERIMENT_ID, type ExperimentId, } from "@/common/constants/experiments"; import { getXumHome } from "@/common/constants/paths"; @@ -26,16 +27,6 @@ interface ExperimentsFile { const OVERRIDES_FILE_NAME = "feature_flags.json"; const OVERRIDES_FILE_VERSION = 1; -/** - * Pre-merge experiment ID: "PTC Exclusive Mode" was a separate experiment - * before Programmatic Tool Calling became exclusive-only. Reads alias a - * persisted `true` onto the merged PTC key (a user who opted into exclusive - * opted into exactly the posture PTC now activates), and writes mirror an - * enabled PTC back onto this key so a downgraded build runs its exclusive - * posture instead of the removed (~2x cost) supplement mode. - */ -const LEGACY_PTC_EXCLUSIVE_ID = "programmatic-tool-calling-exclusive"; - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -64,12 +55,12 @@ async function readOverridesFile(filePath: string): Promise { - describe("media elision", () => { - it("replaces media items in content-container results with text placeholders", async () => { - // Bridged MCP tools may return { type: "content", value: [...media] }. - // Nested records bypass extractToolMediaAsUserMessages, so raw base64 - // must never enter the sandbox result / model-visible record. - const bridge = new ToolBridge({ - mcp__shots__take: createMockTool("mcp__shots__take", z.object({}), () => ({ - type: "content", - value: [ - { type: "text", text: "took a screenshot" }, - { type: "media", mediaType: "image/png", data: "aGVsbG8=" }, - ], - })), - }); - let registeredMux: Record Promise> = {}; - const mockRegisterObject = mock( - (name: string, obj: Record Promise>) => { - if (name === "mux") registeredMux = obj; - } - ); - bridge.register(createMockRuntime({ registerObject: mockRegisterObject })); - - const result = (await registeredMux.mcp__shots__take({})) as { - value: Array<{ type: string; text?: string; data?: string }>; - }; - expect(result.value[0]).toEqual({ type: "text", text: "took a screenshot" }); - expect(result.value[1].type).toBe("text"); - expect(result.value[1].text).toContain("media elided: image/png"); - expect(JSON.stringify(result)).not.toContain("aGVsbG8="); - }); - }); - describe("constructor", () => { it("filters out excluded tools", () => { const tools: Record = { diff --git a/src/node/services/ptc/toolBridge.ts b/src/node/services/ptc/toolBridge.ts index b1cffdb01c..406b52f85b 100644 --- a/src/node/services/ptc/toolBridge.ts +++ b/src/node/services/ptc/toolBridge.ts @@ -140,10 +140,11 @@ const EXCLUDED_TOOLS = new Set([ // in the exclusive posture. "memory", "advisor", - // Media-producing tools: their content-container outputs are converted into - // model-visible multimodal parts by extractToolMediaAsUserMessages, which - // only sees TOP-LEVEL tool outputs — nested inside a code_execution record - // the attachment would stay as base64 JSON text (context blowup, no image). + // Media-producing tools: these exist solely to put media in front of the + // model. Nested media is recovered at request time only from classic + // (non-RLM) records — kernel-compacted records drop result contents — so + // keep them top-level, where extractToolMediaAsUserMessages guarantees the + // attachment becomes model-visible in both modes. "attach_file", "desktop_screenshot", ]); @@ -492,38 +493,14 @@ export class ToolBridge { private serializeResult(result: unknown): unknown { try { - // Round-trip through JSON to ensure QuickJS can handle the value - return JSON.parse(JSON.stringify(elideContentMediaPayloads(result))); + // Round-trip through JSON to ensure QuickJS can handle the value. + // Media returned by bridged MCP tools passes through intact: the guest + // may legitimately process the bytes, and the classic (non-RLM) record + // keeps the full result so extractAttachmentsFromToolOutput can lift + // nested media into model-visible multimodal parts at request time. + return JSON.parse(JSON.stringify(result)); } catch { return { error: "Result not JSON-serializable" }; } } } - -/** - * Replace base64 media items inside MCP-style content-container results - * ({ type: "content", value: [...] }) with small text placeholders before the - * result enters the sandbox and its model-visible record. Known - * media-producing built-ins are non-bridgeable (EXCLUDED_TOOLS), but any - * bridged MCP tool may return media: nested records bypass - * extractToolMediaAsUserMessages, so the raw payload would otherwise ride as - * JSON text into guest vars and provider context. - */ -function elideContentMediaPayloads(result: unknown): unknown { - if (typeof result !== "object" || result === null) return result; - const container = result as { type?: unknown; value?: unknown }; - if (container.type !== "content" || !Array.isArray(container.value)) return result; - - let changed = false; - const value = container.value.map((item: unknown) => { - if (typeof item !== "object" || item === null) return item; - const media = item as { type?: unknown; data?: unknown; mediaType?: unknown }; - if (media.type !== "media" || typeof media.data !== "string") return item; - changed = true; - return { - type: "text", - text: `[media elided: ${typeof media.mediaType === "string" ? media.mediaType : "unknown"}, ${media.data.length} base64 chars — media returned by bridged tools is not model-visible inside code_execution; call a top-level tool to attach it]`, - }; - }); - return changed ? { ...result, value } : result; -} diff --git a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts index 37568c9c2d..c6e1255b85 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts @@ -81,6 +81,82 @@ describe("extractToolMediaAsUserMessages", () => { } }); + it("extracts media from nested bridged tool records inside code_execution output", async () => { + // Exclusive PTC: bridged MCP tools run nested inside code_execution and + // their full results land in the classic record's toolCalls. Media there + // must become a model-visible attachment (with a placeholder in the + // record) instead of riding as raw base64 JSON text. + const base64 = ( + await sharp({ + create: { + width: 10, + height: 10, + channels: 3, + background: { r: 0, g: 0, b: 255 }, + }, + }) + .png() + .toBuffer() + ).toString("base64"); + + const input: MuxMessage[] = [ + { + id: "ce1", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: "call1", + toolName: "code_execution", + input: { code: "return xum.mcp__shots__take({});" }, + state: "output-available", + output: { + success: true, + toolCalls: [ + { + toolName: "mcp__shots__take", + args: {}, + result: { + type: "content", + value: [ + { type: "text", text: "took a screenshot" }, + { type: "media", mediaType: "image/png", data: base64 }, + ], + }, + }, + // Kernel-compacted / error records carry no extractable result. + { toolName: "bash", args: { script: "true" }, ok: true, bytes: 4 }, + ], + }, + }, + ], + metadata: { timestamp: 1 }, + }, + ]; + + const rewritten = await extractToolMediaAsUserMessages(input); + expect(rewritten).toHaveLength(2); + + const toolPart = rewritten[0].parts[0]; + if (toolPart.type !== "dynamic-tool" || toolPart.state !== "output-available") { + throw new Error("Expected an output-available dynamic-tool part"); + } + const outputText = JSON.stringify(toolPart.output); + expect(outputText).toContain("[Attachment attached:"); + expect(outputText).not.toContain(base64); + // Untouched sibling records survive the rewrite. + expect(outputText).toContain('"bytes":4'); + + const syntheticUser = rewritten[1]; + expect(syntheticUser.role).toBe("user"); + const filePart = syntheticUser.parts.find((part) => part.type === "file"); + if (filePart?.type !== "file") { + throw new Error("Expected a synthetic file part for nested tool media"); + } + expect(filePart.mediaType).toBe("image/png"); + expect(filePart.url).toContain(base64.slice(0, 100)); + }); + it("self-heals oversized raster tool attachments by downscaling them for provider requests", async () => { const oversizedPng = await sharp({ create: { diff --git a/src/node/utils/messages/toolResultAttachments.ts b/src/node/utils/messages/toolResultAttachments.ts index d3b65924ce..b89b8d723d 100644 --- a/src/node/utils/messages/toolResultAttachments.ts +++ b/src/node/utils/messages/toolResultAttachments.ts @@ -122,7 +122,7 @@ export function extractAttachmentsFromToolOutput( } if (!isContentContainer(output)) { - return null; + return extractAttachmentsFromNestedToolCalls(output); } const attachments: ExtractedToolAttachment[] = []; @@ -162,6 +162,48 @@ export function extractAttachmentsFromToolOutput( }; } +/** + * code_execution outputs carry nested tool-call records ({ toolName, args, + * result }). Classic (non-RLM) records retain the full bridged result, so a + * bridged MCP tool's media lands nested instead of top-level; extract it the + * same way top-level media is extracted so the model sees the attachment + * instead of raw base64 riding as JSON text. Kernel-compacted records drop + * result contents, so there is nothing to extract (the guest still received + * the full data and can offload it via vars/return handles). + */ +function extractAttachmentsFromNestedToolCalls( + output: unknown +): { newOutput: unknown; attachments: ExtractedToolAttachment[] } | null { + if (typeof output !== "object" || output === null) { + return null; + } + const toolCalls = (output as { toolCalls?: unknown }).toolCalls; + if (!Array.isArray(toolCalls)) { + return null; + } + + const attachments: ExtractedToolAttachment[] = []; + let didChange = false; + const newToolCalls = toolCalls.map((record: unknown) => { + if (typeof record !== "object" || record === null) { + return record; + } + const extracted = extractAttachmentsFromToolOutput((record as { result?: unknown }).result); + if (extracted == null) { + return record; + } + didChange = true; + attachments.push(...extracted.attachments); + return { ...record, result: extracted.newOutput }; + }); + + if (!didChange) { + return null; + } + + return { newOutput: { ...output, toolCalls: newToolCalls }, attachments }; +} + type ProviderReadyToolAttachment = | { type: "attachment"; attachment: ExtractedToolAttachment } | { type: "text"; text: string }; From 050a990161c5332170e59c66ee816c823dfa47f2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 18:04:37 +0000 Subject: [PATCH 04/40] Address Codex review round 3: runtime config alias, persisted-state mirror, kernel persistence records, outer-result media, bridge dedup --- src/browser/contexts/ExperimentsContext.tsx | 12 ++-- .../utils/messages/extractEditedFiles.test.ts | 6 +- src/node/config.test.ts | 42 +++++++++++++ src/node/config.ts | 26 +++++++- src/node/services/aiService.ts | 5 +- src/node/services/toolAssembly.test.ts | 24 ++++++- src/node/services/toolAssembly.ts | 46 +++++++++----- .../services/tools/code_execution.test.ts | 46 ++++++++++++++ src/node/services/tools/code_execution.ts | 24 +++++++ .../extractToolMediaAsUserMessages.test.ts | 62 +++++++++++++++++++ .../utils/messages/toolResultAttachments.ts | 35 ++++++++++- 11 files changed, 298 insertions(+), 30 deletions(-) diff --git a/src/browser/contexts/ExperimentsContext.tsx b/src/browser/contexts/ExperimentsContext.tsx index 74e64ea096..ffb89e6df3 100644 --- a/src/browser/contexts/ExperimentsContext.tsx +++ b/src/browser/contexts/ExperimentsContext.tsx @@ -15,6 +15,7 @@ import { isExperimentSupportedOnPlatform, } from "@/common/constants/experiments"; import { getStorageChangeEvent } from "@/common/constants/events"; +import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { useAPI } from "@/browser/contexts/API"; /** @@ -54,11 +55,7 @@ function isExperimentSupported(experimentId: ExperimentId): boolean { * build. */ export function hasLegacyPtcExclusiveOverride(): boolean { - try { - return window.localStorage.getItem(getLegacyPtcExclusiveExperimentKey()) === "true"; - } catch { - return false; - } + return readPersistedState(getLegacyPtcExclusiveExperimentKey(), undefined) === true; } /** @@ -127,8 +124,11 @@ function setExperimentState(experimentId: ExperimentId, enabled: boolean): void // wins over the mirrored backend value in its send options, so a stale // entry would resurrect supplement mode (stale false) or re-enable PTC // after the user turned it off (stale true). Keep it equal to PTC. + // Routed through updatePersistedState so the mirror participates in the + // shared write-listener/subscriber notification path like other + // persisted preferences. if (experimentId === EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING) { - window.localStorage.setItem(getLegacyPtcExclusiveExperimentKey(), JSON.stringify(enabled)); + updatePersistedState(getLegacyPtcExclusiveExperimentKey(), enabled); } // Dispatch custom event for same-tab synchronization diff --git a/src/common/utils/messages/extractEditedFiles.test.ts b/src/common/utils/messages/extractEditedFiles.test.ts index 736035fc1e..63b607b8a5 100644 --- a/src/common/utils/messages/extractEditedFiles.test.ts +++ b/src/common/utils/messages/extractEditedFiles.test.ts @@ -118,8 +118,10 @@ describe("nested PTC edit records (exclusive posture)", () => { }); it("kernel-compacted records surface the path but no diff", () => { - // Kernel record compaction drops result contents (ok bit only): the edit - // is still tracked by path, but no diff content survives to preserve. + // Current kernel compaction exempts file_edit_* records (results kept for + // exactly this extractor), but result-less compact records still exist in + // history persisted by earlier builds and must degrade to path-only + // tracking instead of being dropped. const messages: MuxMessage[] = [ createCodeExecutionMessage([ { toolName: "file_edit_replace_string", args: { path: "/kernel.ts" }, ok: true, bytes: 9 }, diff --git a/src/node/config.test.ts b/src/node/config.test.ts index d624917816..484f9fb306 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -802,6 +802,48 @@ describe("Config", () => { }); }); + describe("legacy PTC exclusive taskExperiments alias", () => { + it("aliases programmaticToolCallingExclusive onto programmaticToolCalling at load time", () => { + // Tasks stamped by pre-merge builds may carry only the exclusive flag; + // loadConfigOrDefault does not parse workspaces through + // WorkspaceConfigSchema, so the runtime loader must apply the alias + // itself or resumed tasks silently lose PTC (and rlm becomes inert). + const configFile = path.join(tempDir, "config.json"); + fs.writeFileSync( + configFile, + JSON.stringify({ + projects: [ + [ + "/repo", + { + workspaces: [ + { + path: "/repo/task-ws", + id: "task-ws-1", + name: "task-ws", + taskExperiments: { rlm: true, programmaticToolCallingExclusive: true }, + }, + ], + }, + ], + ], + }) + ); + + const loaded = config.loadConfigOrDefault(); + const workspaces = (loaded.projects.get("/repo") as Record | undefined) + ?.workspaces; + const workspace = Array.isArray(workspaces) + ? (workspaces[0] as { taskExperiments?: Record } | undefined) + : undefined; + + expect(workspace?.taskExperiments?.programmaticToolCalling).toBe(true); + expect(workspace?.taskExperiments?.rlm).toBe(true); + // The legacy key is retained for downgrade compatibility. + expect(workspace?.taskExperiments?.programmaticToolCallingExclusive).toBe(true); + }); + }); + describe("editConfig", () => { it("serializes concurrent edits so no update is lost", async () => { // Regression: editConfig used to be a non-serialized read-modify-write diff --git a/src/node/config.ts b/src/node/config.ts index ab3f6882f8..596c1388a3 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -649,13 +649,37 @@ function normalizePersistedWorkspace( }; const hasLegacyWorkflowSchedule = Object.hasOwn(persisted, "workflowSchedule"); const hasBestOf = Object.hasOwn(persisted, "bestOf"); - if (!hasLegacyWorkflowSchedule && !hasBestOf) { + // Legacy alias: tasks stamped by builds where "PTC Exclusive Mode" was a + // separate experiment may carry only programmaticToolCallingExclusive. The + // merged PTC experiment activates exactly that posture, so resumed tasks + // must read it as programmaticToolCalling (`true` wins over an explicit + // false, matching the schema preprocess and backend feature-flag alias). + // This is the runtime path: loadConfigOrDefault does not parse workspaces + // through WorkspaceConfigSchema, so the schema-level alias alone never runs + // here. The legacy key is retained for downgrade compatibility. + const taskExperiments = + typeof persisted.taskExperiments === "object" && persisted.taskExperiments !== null + ? (persisted.taskExperiments as Record) + : undefined; + const hasLegacyPtcExclusive = + taskExperiments?.programmaticToolCallingExclusive === true && + taskExperiments.programmaticToolCalling !== true; + if (!hasLegacyWorkflowSchedule && !hasBestOf && !hasLegacyPtcExclusive) { return workspace; } const nextWorkspace = { ...persisted }; delete nextWorkspace.workflowSchedule; + if (hasLegacyPtcExclusive) { + // Spreading the typed field copies ALL persisted keys at runtime — + // including the legacy one, which stays for downgrade compatibility. + nextWorkspace.taskExperiments = { + ...persisted.taskExperiments, + programmaticToolCalling: true, + }; + } + if (hasBestOf) { const bestOf = normalizeWorkspaceBestOf(persisted.bestOf); if (bestOf) { diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 365d266f4c..76486dddbe 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -2925,7 +2925,10 @@ export class AIService extends EventEmitter { tools = assembleCtx.tools; // PTC needs no post-hook bridge reconcile: bridgeable tools are not // in the hook-visible record, so middleware cannot invalidate the - // ToolBridge code_execution closes over. + // ToolBridge code_execution closes over. Tools promoted to the + // model-visible set (policy-required tools, mcp_prompt_get) are + // excluded from the bridge at assembly time (see toolAssembly), so + // a hook that filters or wraps them affects the only dispatch path. // Tool-search state was classified from the pre-hook record; a hook // that added/removed tools would leave allToolNames/deferred/active // sets stale (prepareStep scoping + sentinel names both read them). diff --git a/src/node/services/toolAssembly.test.ts b/src/node/services/toolAssembly.test.ts index f894b7b37e..ed080e2387 100644 --- a/src/node/services/toolAssembly.test.ts +++ b/src/node/services/toolAssembly.test.ts @@ -38,13 +38,24 @@ describe("applyToolPolicyAndExperiments", () => { // hide the prompt catalog. expect(names).toContain("mcp_prompt_get"); expect(result.mcp_prompt_get.description).toContain("mcp__s__p"); + + // Promoted tools must not ALSO stay bridged: request.assemble hooks see + // only top-level tools, and a bridged duplicate would keep dispatching + // the pre-hook implementation behind a hook's filter or wrapper. + const evalResult = (await result.code_execution.execute!( + { code: "return typeof mux.mcp_prompt_get;" }, + { toolCallId: "test-call-id", messages: [], context: undefined } + )) as { success: boolean; result?: unknown }; + expect(evalResult.success).toBe(true); + expect(evalResult.result).toBe("undefined"); }); test("context-coupled and media tools stay model-visible under PTC", async () => { // memory/advisor: AIService keys system-prompt context (memory index / // hot set, advisor guidance) off their top-level presence. attach_file / - // desktop_screenshot: extractToolMediaAsUserMessages only converts - // TOP-LEVEL media outputs into model-visible multimodal parts. + // desktop_screenshot: top-level outputs guarantee model-visible media in + // both classic and kernel modes (kernel-compacted nested records drop + // result contents, so nested media would be lost to the model there). const result = await applyToolPolicyAndExperiments({ allTools: { bash: executableTool("Run a command"), @@ -93,6 +104,15 @@ describe("applyToolPolicyAndExperiments", () => { emitNestedToolEvent: () => undefined, }); expect(Object.keys(result).sort()).toEqual(["bash", "code_execution"]); + + // The promoted tool leaves the bridge entirely (no duplicated dispatch + // path that assemble hooks cannot see); other bridgeable tools remain. + const evalResult = (await result.code_execution.execute!( + { code: "return [typeof mux.bash, typeof mux.file_read];" }, + { toolCallId: "test-call-id", messages: [], context: undefined } + )) as { success: boolean; result?: unknown }; + expect(evalResult.success).toBe(true); + expect(evalResult.result).toEqual(["undefined", "function"]); }); test("grant-denied tools are hidden from the model but stubbed in the sandbox", async () => { diff --git a/src/node/services/toolAssembly.ts b/src/node/services/toolAssembly.ts index 1c67c264d6..efa2d9c38d 100644 --- a/src/node/services/toolAssembly.ts +++ b/src/node/services/toolAssembly.ts @@ -210,9 +210,38 @@ export async function applyToolPolicyAndExperiments( // Lazy-load PTC modules only when experiments are enabled const ptc = await getPTCModules(); + // Keep mcp_prompt_get direct because sandbox declarations omit its + // multiline prompt catalog. + const promptGet = policyFilteredTools.mcp_prompt_get; + // Policy-REQUIRED tools stay model-visible: "require" gates run + // completion on a top-level toolResult for that name + // (StreamManager.createStopWhenCondition), which a nested xum.* call + // inside a code_execution record never satisfies. Sourced from the + // grant-and-policy-filtered record so both ceilings still apply. + const requiredPatterns = buildRequiredToolPatterns(effectiveToolPolicy); + const requiredTools = Object.fromEntries( + Object.entries(policyFilteredTools).filter(([name]) => + requiredPatterns.some((pattern) => pattern.test(name)) + ) + ); + + // Tools promoted to the model-visible set must NOT also stay bridged: + // the request.assemble hook contract lets middleware filter or wrap + // top-level tools, and a bridged duplicate would keep dispatching the + // pre-hook implementation behind the hook's back (the assemble-hook + // rebuild machinery was removed on the premise that bridged tools are + // never hook-visible — promotion must preserve that premise). + const promotedToolNames = new Set(Object.keys(requiredTools)); + if (promptGet !== undefined) { + promotedToolNames.add("mcp_prompt_get"); + } + // ToolBridge uses the pre-grant policy-filtered tools — the bridge // enforces grants itself (denied tools become explicit error stubs). - const toolBridge = new ptc.ToolBridge(policyFilteredPreGrant, opts.capabilityGrants); + const bridgeInput = Object.fromEntries( + Object.entries(policyFilteredPreGrant).filter(([name]) => !promotedToolNames.has(name)) + ); + const toolBridge = new ptc.ToolBridge(bridgeInput, opts.capabilityGrants); // Singleton runtime factory (WASM module is expensive to load) ptc.runtimeFactory ??= new ptc.QuickJSRuntimeFactory(); @@ -271,21 +300,6 @@ export async function applyToolPolicyAndExperiments( const nonBridgeable = opts.capabilityGrants ? applyCapabilityGrants(toolBridge.getNonBridgeableTools(), opts.capabilityGrants) : toolBridge.getNonBridgeableTools(); - // Keep mcp_prompt_get direct because sandbox declarations omit its - // multiline prompt catalog. - const promptGet = policyFilteredTools.mcp_prompt_get; - // Policy-REQUIRED tools stay model-visible: "require" gates run - // completion on a top-level toolResult for that name - // (StreamManager.createStopWhenCondition), which a nested xum.* call - // inside a code_execution record never satisfies. Sourced from the - // grant-and-policy-filtered record so both ceilings still apply; the - // tool also stays bridged, which is harmless duplication. - const requiredPatterns = buildRequiredToolPatterns(effectiveToolPolicy); - const requiredTools = Object.fromEntries( - Object.entries(policyFilteredTools).filter(([name]) => - requiredPatterns.some((pattern) => pattern.test(name)) - ) - ); toolsForModel = { ...nonBridgeable, ...requiredTools, diff --git a/src/node/services/tools/code_execution.test.ts b/src/node/services/tools/code_execution.test.ts index 80aaf52651..5aa6dfaf12 100644 --- a/src/node/services/tools/code_execution.test.ts +++ b/src/node/services/tools/code_execution.test.ts @@ -869,6 +869,52 @@ describe("createCodeExecutionTool", () => { await host.disposeScope("ws-offload"); }); + it("keeps results on persistence-critical records (file_edit_*/agent_skill_read) in kernel mode", async () => { + // Post-compaction persistence extractors mine nested file_edit_* diffs + // (extractEditedFileDiffs) and agent_skill_read snapshots + // (loadedSkillSnapshots) from history; suppressing these like ordinary + // kernel records would silently lose that context after compaction. + using tmp = new DisposableTempDir("code-exec-persist-records"); + const host = new SandboxHostService(); + const tools: Record = { + file_edit_insert: createMockTool( + "file_edit_insert", + z.object({ path: z.string() }), + () => ({ + success: true, + diff: "@@ -0,0 +1 @@\n+hello", + }) + ), + agent_skill_read: createMockTool( + "agent_skill_read", + z.object({ name: z.string() }), + () => ({ + success: true, + content: "---\nname: demo\n---\nBody", + }) + ), + }; + const tool = await createCodeExecutionTool( + runtimeFactory, + new ToolBridge(tools), + undefined, + persistentRunner(host, "ws-persist-records", tmp.path) + ); + + const result = (await tool.execute!( + { + code: 'mux.file_edit_insert({path: "/a.ts"}); mux.agent_skill_read({name: "demo"}); return true;', + }, + mockToolCallOptions + )) as PTCExecutionResult; + expect(result.success).toBe(true); + const editRecord = result.toolCalls.find((r) => r.toolName === "file_edit_insert"); + expect((editRecord?.result as { diff?: string })?.diff).toContain("+hello"); + const skillRecord = result.toolCalls.find((r) => r.toolName === "agent_skill_read"); + expect((skillRecord?.result as { content?: string })?.content).toContain("name: demo"); + await host.disposeScope("ws-persist-records"); + }); + it("marks compact records not-ok when the tool resolved with success:false", async () => { // file_read-style tools resolve normally with {success:false} for // missing/oversized/directory paths — no thrown error. The compact diff --git a/src/node/services/tools/code_execution.ts b/src/node/services/tools/code_execution.ts index df84c20185..0c8178881a 100644 --- a/src/node/services/tools/code_execution.ts +++ b/src/node/services/tools/code_execution.ts @@ -32,6 +32,7 @@ import { } from "@/constants/resultHandles"; import { KERNEL_COMPACT_ARGS_CAP_BYTES, KERNEL_CONSOLE_CAP_BYTES } from "@/constants/kernelOutput"; import { sliceUtf8Bytes } from "@/common/utils/sliceUtf8Bytes"; +import { FILE_EDIT_TOOL_NAMES } from "@/common/types/tools"; // Default limits const DEFAULT_MEMORY_BYTES = 64 * 1024 * 1024; // 64MB @@ -279,6 +280,10 @@ async function offloadOversizedReturnValue( * touches the record), and the model needs the key/shape it just created. * When the kernel load is inactive, a bridged tool that happens to be named * "load" gets no exception (its records are ordinary and must not leak). + * + * Exception: agent_skill_read and file_edit_* records keep their result — + * post-compaction persistence extractors depend on it (see the inline + * comment below). */ function compactKernelToolCallRecords(result: PTCExecutionResult, loadActive: boolean): void { result.toolCalls = result.toolCalls.map((record) => { @@ -297,6 +302,25 @@ function compactKernelToolCallRecords(result: PTCExecutionResult, loadActive: bo ...(record.error !== undefined ? { error: boundCompactRecordError(record.error) } : {}), }; } + // Persistence-critical records also keep their result: compaction mines + // successful nested file_edit_* diffs (extractEditedFileDiffs / + // extractEditedFilePaths) and agent_skill_read snapshots + // (loadedSkillSnapshots) out of history, so suppressing these like + // ordinary kernel records would silently lose edited-file diffs and + // loaded-skill gating context after compaction in RLM mode. Their results + // are repo-controlled (unified diffs / SKILL.md snapshots) — the same + // trust and size class classic-mode records already expose — and + // creation-time kernel record bounds still stub oversized values. + if ( + record.toolName === "agent_skill_read" || + FILE_EDIT_TOOL_NAMES.includes(record.toolName as (typeof FILE_EDIT_TOOL_NAMES)[number]) + ) { + return { + ...record, + args: boundCompactRecordArgs(record.args), + ...(record.error !== undefined ? { error: boundCompactRecordError(record.error) } : {}), + }; + } let bytes = 0; if (record.result !== undefined) { // Creation-time bounding (kernel mode) may have replaced the result diff --git a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts index c6e1255b85..febc0c479a 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts @@ -157,6 +157,68 @@ describe("extractToolMediaAsUserMessages", () => { expect(filePart.url).toContain(base64.slice(0, 100)); }); + it("dedupes media returned from code_execution (record + outer result carry the same payload)", async () => { + // `return xum.(...)` duplicates the media container in the + // nested record AND the outer result; both copies must be replaced, and + // the model should receive a single attachment. + const base64 = ( + await sharp({ + create: { + width: 10, + height: 10, + channels: 3, + background: { r: 0, g: 255, b: 0 }, + }, + }) + .png() + .toBuffer() + ).toString("base64"); + + const mediaContainer = { + type: "content", + value: [{ type: "media", mediaType: "image/png", data: base64 }], + }; + const input: MuxMessage[] = [ + { + id: "ce2", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: "call1", + toolName: "code_execution", + input: { code: "return xum.mcp__shots__take({});" }, + state: "output-available", + output: { + success: true, + result: mediaContainer, + toolCalls: [{ toolName: "mcp__shots__take", args: {}, result: mediaContainer }], + }, + }, + ], + metadata: { timestamp: 1 }, + }, + ]; + + const rewritten = await extractToolMediaAsUserMessages(input); + expect(rewritten).toHaveLength(2); + + const toolPart = rewritten[0].parts[0]; + if (toolPart.type !== "dynamic-tool" || toolPart.state !== "output-available") { + throw new Error("Expected an output-available dynamic-tool part"); + } + // Both copies replaced — no base64 rides as JSON text anywhere. + expect(JSON.stringify(toolPart.output)).not.toContain(base64); + + const syntheticUser = rewritten[1]; + const fileParts = syntheticUser.parts.filter((part) => part.type === "file"); + expect(fileParts).toHaveLength(1); + expect(syntheticUser.parts[0]).toEqual({ + type: "text", + text: "[Attached 1 attachment(s) from tool output]", + }); + }); + it("self-heals oversized raster tool attachments by downscaling them for provider requests", async () => { const oversizedPng = await sharp({ create: { diff --git a/src/node/utils/messages/toolResultAttachments.ts b/src/node/utils/messages/toolResultAttachments.ts index b89b8d723d..115dbe6de4 100644 --- a/src/node/utils/messages/toolResultAttachments.ts +++ b/src/node/utils/messages/toolResultAttachments.ts @@ -170,6 +170,12 @@ export function extractAttachmentsFromToolOutput( * instead of raw base64 riding as JSON text. Kernel-compacted records drop * result contents, so there is nothing to extract (the guest still received * the full data and can offload it via vars/return handles). + * + * The outer `result` (the guest's return value) is processed too: sandbox + * code that does `return xum.(...)` duplicates the media container + * in both the record and the return value, and rewriting only the record + * would still ship the oversized JSON. Identical media appearing in both + * places is deduplicated into a single attachment. */ function extractAttachmentsFromNestedToolCalls( output: unknown @@ -183,6 +189,17 @@ function extractAttachmentsFromNestedToolCalls( } const attachments: ExtractedToolAttachment[] = []; + const seen = new Set(); + const pushUnique = (items: ExtractedToolAttachment[]) => { + for (const item of items) { + const key = `${item.mediaType}:${item.filename ?? ""}:${item.data}`; + if (!seen.has(key)) { + seen.add(key); + attachments.push(item); + } + } + }; + let didChange = false; const newToolCalls = toolCalls.map((record: unknown) => { if (typeof record !== "object" || record === null) { @@ -193,15 +210,29 @@ function extractAttachmentsFromNestedToolCalls( return record; } didChange = true; - attachments.push(...extracted.attachments); + pushUnique(extracted.attachments); return { ...record, result: extracted.newOutput }; }); + const outerResult = (output as { result?: unknown }).result; + const extractedOuter = extractAttachmentsFromToolOutput(outerResult); + if (extractedOuter != null) { + didChange = true; + pushUnique(extractedOuter.attachments); + } + if (!didChange) { return null; } - return { newOutput: { ...output, toolCalls: newToolCalls }, attachments }; + return { + newOutput: { + ...output, + toolCalls: newToolCalls, + ...(extractedOuter != null ? { result: extractedOuter.newOutput } : {}), + }, + attachments, + }; } type ProviderReadyToolAttachment = From b1f8f35c9a8b19633c77603e31c4e9a7dcaed9b7 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 18:29:10 +0000 Subject: [PATCH 05/40] Address Codex review round 4: exempt persistence/media results from kernel capture bounding, newest-first part traversal --- .../utils/messages/extractEditedFiles.test.ts | 15 ++++++ .../utils/messages/extractEditedFiles.ts | 7 ++- src/node/services/ptc/quickjsRuntime.ts | 17 ++++--- src/node/services/ptc/runtime.ts | 8 ++++ src/node/services/ptc/toolBridge.ts | 18 ++++--- src/node/services/ptc/types.ts | 46 ++++++++++++++++++ .../services/tools/code_execution.test.ts | 48 +++++++++++++++++++ src/node/services/tools/code_execution.ts | 29 +++++------ 8 files changed, 157 insertions(+), 31 deletions(-) diff --git a/src/common/utils/messages/extractEditedFiles.test.ts b/src/common/utils/messages/extractEditedFiles.test.ts index 63b607b8a5..1a050639ce 100644 --- a/src/common/utils/messages/extractEditedFiles.test.ts +++ b/src/common/utils/messages/extractEditedFiles.test.ts @@ -102,6 +102,21 @@ describe("nested PTC edit records (exclusive posture)", () => { expect(diffs[0].diff).toBe(nestedDiff); }); + it("returns edits from the newest code_execution part first when one message has several", () => { + // Successive SDK steps append separate code_execution parts to one + // assistant message; the later execution's edits must fill the + // MAX_EDITED_FILES cap first. + const older = createCodeExecutionMessage([ + { toolName: "file_edit_insert", args: { path: "/older.ts" }, ok: true }, + ]); + const newer = createCodeExecutionMessage([ + { toolName: "file_edit_insert", args: { path: "/newer.ts" }, ok: true }, + ]); + const combined: MuxMessage = { ...older, parts: [...older.parts, ...newer.parts] }; + + expect(extractEditedFilePaths([combined])).toEqual(["/newer.ts", "/older.ts"]); + }); + it("returns nested batch paths newest-first", () => { // The surrounding scan walks history backward to keep the LATEST edits // under MAX_EDITED_FILES; nested records are chronological, so a batch diff --git a/src/common/utils/messages/extractEditedFiles.ts b/src/common/utils/messages/extractEditedFiles.ts index 4bb1c327ee..722ffec7c5 100644 --- a/src/common/utils/messages/extractEditedFiles.ts +++ b/src/common/utils/messages/extractEditedFiles.ts @@ -87,7 +87,12 @@ export function extractEditedFilePaths(messages: MuxMessage[]): string[] { const message = messages[i]; if (message.role !== "assistant") continue; - for (const part of message.parts) { + // Parts are chronological too (successive SDK steps can each add a + // code_execution batch): walk them backward so a later execution's edits + // fill the MAX_EDITED_FILES cap before an earlier one's (mirrors + // extractReadFilePaths). + for (let partIndex = message.parts.length - 1; partIndex >= 0; partIndex--) { + const part = message.parts[partIndex]; if (part.type !== "dynamic-tool") continue; if (part.state !== "output-available") continue; diff --git a/src/node/services/ptc/quickjsRuntime.ts b/src/node/services/ptc/quickjsRuntime.ts index beeaec4fd5..675aca77e6 100644 --- a/src/node/services/ptc/quickjsRuntime.ts +++ b/src/node/services/ptc/quickjsRuntime.ts @@ -312,7 +312,7 @@ export class QuickJSRuntime implements IJSRuntime { const result = await fn(...args); const endTime = Date.now(); const duration_ms = endTime - startTime; - const recordResult = this.boundCaptureResult(result); + const recordResult = this.boundCaptureResult(result, name); // Record tool call this.toolCalls.push({ @@ -478,7 +478,7 @@ export class QuickJSRuntime implements IJSRuntime { const endTime = Date.now(); // Same creation-time bounding as synchronous bridges (kernel mode). const recordArgs = this.boundCaptureArgs(args[0]); - const recordResult = this.boundCaptureResult(result); + const recordResult = this.boundCaptureResult(result, name); toolCalls.push({ toolName: name, args: recordArgs, @@ -617,10 +617,13 @@ export class QuickJSRuntime implements IJSRuntime { return `${sliceUtf8Bytes(errorStr, capBytes)}…[${bytes} bytes total; truncated]`; } - private boundCaptureResult(value: unknown): unknown { - return this.kernelRecordBounds === undefined - ? value - : this.boundCapture(value, this.kernelRecordBounds.resultCapBytes); + private boundCaptureResult(value: unknown, toolName: string): unknown { + if (this.kernelRecordBounds === undefined) return value; + // Exempt records (persistence-critical tools, media containers) keep the + // full result: compaction and request-time extractors reconstruct + // context from them, so a bounded preview would silently lose it. + if (this.kernelRecordBounds.resultExempt?.(toolName, value) === true) return value; + return this.boundCapture(value, this.kernelRecordBounds.resultCapBytes); } setPendingJobGate(gate: (run: () => void) => void): void { @@ -800,7 +803,7 @@ export class QuickJSRuntime implements IJSRuntime { const result = await fn(...args); const endTime = Date.now(); const duration_ms = endTime - startTime; - const recordResult = this.boundCaptureResult(result); + const recordResult = this.boundCaptureResult(result, methodName); // Record tool call this.toolCalls.push({ diff --git a/src/node/services/ptc/runtime.ts b/src/node/services/ptc/runtime.ts index a4412d12e2..993a418ff3 100644 --- a/src/node/services/ptc/runtime.ts +++ b/src/node/services/ptc/runtime.ts @@ -139,6 +139,14 @@ export interface KernelRecordBounds { argsCapBytes: number; /** Max serialized bytes of `result` kept in a record/event. */ resultCapBytes: number; + /** + * Skip RESULT bounding for records that must retain their full payload + * through capture (persistence-critical tools, media containers — see + * isKernelRecordResultExempt): post-eval compaction and request-time + * extractors need the original value. Args and errors stay bounded + * regardless. + */ + resultExempt?: (toolName: string, result: unknown) => boolean; } /** diff --git a/src/node/services/ptc/toolBridge.ts b/src/node/services/ptc/toolBridge.ts index 406b52f85b..a0041a2b2f 100644 --- a/src/node/services/ptc/toolBridge.ts +++ b/src/node/services/ptc/toolBridge.ts @@ -20,6 +20,7 @@ import { isBridgeToolGranted, type CapabilityGrants, } from "@/common/types/capabilityGrants"; +import { isKernelRecordResultExempt } from "./types"; /** * Result shape of an AI SDK Schema's optional custom validator @@ -140,11 +141,13 @@ const EXCLUDED_TOOLS = new Set([ // in the exclusive posture. "memory", "advisor", - // Media-producing tools: these exist solely to put media in front of the - // model. Nested media is recovered at request time only from classic - // (non-RLM) records — kernel-compacted records drop result contents — so - // keep them top-level, where extractToolMediaAsUserMessages guarantees the - // attachment becomes model-visible in both modes. + // Media-producing built-ins: these exist solely to put media in front of + // the model, so keep them top-level where their content-container output + // feeds extractToolMediaAsUserMessages directly. Bridged MCP tools that + // return media are still covered without this static list: kernel capture + // bounding and compaction exempt media containers (see + // isKernelRecordResultExempt), so nested media survives to request-time + // extraction in classic AND kernel modes. "attach_file", "desktop_screenshot", ]); @@ -234,12 +237,15 @@ export class ToolBridge { // Kernel mode bounds record/event capture at creation (host memory and // streamed-to-history events); ephemeral registrations keep full records // (the non-RLM inline-results contract). Post-eval compaction still - // bounds the model-visible set. + // bounds the model-visible set. Exempt records (persistence-critical + // tools, media containers) keep full results through BOTH stages — see + // isKernelRecordResultExempt. runtime.setKernelRecordBounds( kernel !== undefined ? { argsCapBytes: KERNEL_COMPACT_ARGS_CAP_BYTES, resultCapBytes: RESULT_HANDLE_OFFLOAD_THRESHOLD_BYTES, + resultExempt: isKernelRecordResultExempt, } : undefined ); diff --git a/src/node/services/ptc/types.ts b/src/node/services/ptc/types.ts index 924172e72a..93d459757a 100644 --- a/src/node/services/ptc/types.ts +++ b/src/node/services/ptc/types.ts @@ -5,6 +5,8 @@ * multi-tool workflows via code execution. */ +import { FILE_EDIT_TOOL_NAMES } from "@/common/types/tools"; + /** * Event emitted when a tool call starts within the sandbox. */ @@ -89,3 +91,47 @@ export interface PTCExecutionResult { /** Total execution time in milliseconds */ duration_ms: number; } + +/** + * Nested records whose FULL result must survive kernel-mode capture bounding + * and post-eval compaction (both consult this predicate): + * + * - Persistence-critical tools: post-compaction extractors mine successful + * nested file_edit_* diffs (extractEditedFileDiffs) and agent_skill_read + * snapshots (loadedSkillSnapshots) out of history. Their results are + * repo-controlled and tool-side bounded (~50k-char diff/snapshot caps), so + * retaining them matches what classic-mode records already expose. + * - Media-bearing results: any bridged MCP tool may return a content + * container carrying base64 media. Request-time extraction + * (extractToolMediaAsUserMessages) turns nested media into model-visible + * multimodal attachments — impossible if capture bounding or compaction + * already dropped the payload, which would leave RLM users unable to see + * bridged screenshots/images at all. Media size is host-tool-produced (the + * same trust class as classic-mode records) and rasters are resized at + * request time. + */ +export function isKernelRecordResultExempt(toolName: string, result: unknown): boolean { + return isPersistenceCriticalRecordToolName(toolName) || containsMediaContentPayload(result); +} + +/** See isKernelRecordResultExempt (persistence-critical branch). */ +export function isPersistenceCriticalRecordToolName(toolName: string): boolean { + return ( + toolName === "agent_skill_read" || + FILE_EDIT_TOOL_NAMES.includes(toolName as (typeof FILE_EDIT_TOOL_NAMES)[number]) + ); +} + +/** MCP-style content container ({ type: "content", value: [...] }) holding at least one media part. */ +export function containsMediaContentPayload(result: unknown): boolean { + if (typeof result !== "object" || result === null) return false; + const container = result as { type?: unknown; value?: unknown }; + if (container.type !== "content" || !Array.isArray(container.value)) return false; + return container.value.some( + (item: unknown) => + typeof item === "object" && + item !== null && + (item as { type?: unknown }).type === "media" && + typeof (item as { data?: unknown }).data === "string" + ); +} diff --git a/src/node/services/tools/code_execution.test.ts b/src/node/services/tools/code_execution.test.ts index 5aa6dfaf12..6017efe611 100644 --- a/src/node/services/tools/code_execution.test.ts +++ b/src/node/services/tools/code_execution.test.ts @@ -915,6 +915,54 @@ describe("createCodeExecutionTool", () => { await host.disposeScope("ws-persist-records"); }); + it("keeps oversized persistence results and media containers through capture bounding", async () => { + // Creation-time bounding replaces results over the 16KB threshold with + // a __kernelBounded marker BEFORE compaction runs, so the compaction + // exemption alone cannot save an oversized diff (repo caps allow up to + // ~50k chars) — and media containers must survive both stages or RLM + // users never see bridged MCP screenshots as attachments. + using tmp = new DisposableTempDir("code-exec-exempt-bounds"); + const host = new SandboxHostService(); + const bigDiff = `@@ -0,0 +1 @@\n+${"x".repeat(20_000)}`; + const mediaData = "aGVsbG8="; + const tools: Record = { + file_edit_replace_string: createMockTool( + "file_edit_replace_string", + z.object({ path: z.string() }), + () => ({ success: true, diff: bigDiff }) + ), + mcp__shots__take: createMockTool("mcp__shots__take", z.object({}), () => ({ + type: "content", + value: [ + { type: "text", text: "took a screenshot" }, + { type: "media", mediaType: "image/png", data: mediaData }, + ], + })), + }; + const tool = await createCodeExecutionTool( + runtimeFactory, + new ToolBridge(tools), + undefined, + persistentRunner(host, "ws-exempt-bounds", tmp.path) + ); + + const result = (await tool.execute!( + { + code: 'mux.file_edit_replace_string({path: "/big.ts"}); mux.mcp__shots__take({}); return true;', + }, + mockToolCallOptions + )) as PTCExecutionResult; + expect(result.success).toBe(true); + + const editRecord = result.toolCalls.find((r) => r.toolName === "file_edit_replace_string"); + expect((editRecord?.result as { diff?: string })?.diff).toBe(bigDiff); + + const shotRecord = result.toolCalls.find((r) => r.toolName === "mcp__shots__take"); + const shotValue = (shotRecord?.result as { value?: Array<{ data?: string }> })?.value; + expect(shotValue?.[1]?.data).toBe(mediaData); + await host.disposeScope("ws-exempt-bounds"); + }); + it("marks compact records not-ok when the tool resolved with success:false", async () => { // file_read-style tools resolve normally with {success:false} for // missing/oversized/directory paths — no thrown error. The compact diff --git a/src/node/services/tools/code_execution.ts b/src/node/services/tools/code_execution.ts index 0c8178881a..c1ab4d81ff 100644 --- a/src/node/services/tools/code_execution.ts +++ b/src/node/services/tools/code_execution.ts @@ -32,7 +32,7 @@ import { } from "@/constants/resultHandles"; import { KERNEL_COMPACT_ARGS_CAP_BYTES, KERNEL_CONSOLE_CAP_BYTES } from "@/constants/kernelOutput"; import { sliceUtf8Bytes } from "@/common/utils/sliceUtf8Bytes"; -import { FILE_EDIT_TOOL_NAMES } from "@/common/types/tools"; +import { isKernelRecordResultExempt } from "@/node/services/ptc/types"; // Default limits const DEFAULT_MEMORY_BYTES = 64 * 1024 * 1024; // 64MB @@ -281,9 +281,9 @@ async function offloadOversizedReturnValue( * When the kernel load is inactive, a bridged tool that happens to be named * "load" gets no exception (its records are ordinary and must not leak). * - * Exception: agent_skill_read and file_edit_* records keep their result — - * post-compaction persistence extractors depend on it (see the inline - * comment below). + * Exception: exempt records (agent_skill_read, file_edit_*, media-bearing + * results — see isKernelRecordResultExempt) keep their result for + * post-compaction persistence extractors and request-time media extraction. */ function compactKernelToolCallRecords(result: PTCExecutionResult, loadActive: boolean): void { result.toolCalls = result.toolCalls.map((record) => { @@ -302,19 +302,14 @@ function compactKernelToolCallRecords(result: PTCExecutionResult, loadActive: bo ...(record.error !== undefined ? { error: boundCompactRecordError(record.error) } : {}), }; } - // Persistence-critical records also keep their result: compaction mines - // successful nested file_edit_* diffs (extractEditedFileDiffs / - // extractEditedFilePaths) and agent_skill_read snapshots - // (loadedSkillSnapshots) out of history, so suppressing these like - // ordinary kernel records would silently lose edited-file diffs and - // loaded-skill gating context after compaction in RLM mode. Their results - // are repo-controlled (unified diffs / SKILL.md snapshots) — the same - // trust and size class classic-mode records already expose — and - // creation-time kernel record bounds still stub oversized values. - if ( - record.toolName === "agent_skill_read" || - FILE_EDIT_TOOL_NAMES.includes(record.toolName as (typeof FILE_EDIT_TOOL_NAMES)[number]) - ) { + // Exempt records also keep their result (see isKernelRecordResultExempt; + // creation-time capture bounding applies the same predicate, so the full + // payload actually reaches this point): persistence extractors mine + // nested file_edit_* diffs and agent_skill_read snapshots out of history + // after compaction, and media containers from bridged MCP tools must + // reach request-time attachment extraction or RLM users could never see + // bridged screenshots/images. + if (isKernelRecordResultExempt(record.toolName, record.result)) { return { ...record, args: boundCompactRecordArgs(record.args), From b6308a3ab570a46a7e49a40ca39a2a37430d1255 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 18:56:02 +0000 Subject: [PATCH 06/40] Address Codex review round 5: taskExperiments downgrade mirror, null-safe nested edit records, extractable-only media exemption --- .../schemas/project.taskExperiments.test.ts | 32 +++++++++--- src/common/schemas/project.ts | 21 ++++++++ .../utils/messages/extractEditedFiles.test.ts | 20 +++++++ .../utils/messages/extractEditedFiles.ts | 10 ++++ src/node/services/ptc/types.ts | 15 +++++- src/node/services/taskService.ts | 7 +-- .../services/tools/code_execution.test.ts | 30 +++++++++++ .../extractToolMediaAsUserMessages.test.ts | 52 +++++++++++++++++++ .../utils/messages/toolResultAttachments.ts | 37 +++++++++---- 9 files changed, 202 insertions(+), 22 deletions(-) diff --git a/src/common/schemas/project.taskExperiments.test.ts b/src/common/schemas/project.taskExperiments.test.ts index 9261c94f61..b80c7d8076 100644 --- a/src/common/schemas/project.taskExperiments.test.ts +++ b/src/common/schemas/project.taskExperiments.test.ts @@ -1,11 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { WorkspaceConfigSchema } from "./project"; +import { toPersistedTaskExperiments, WorkspaceConfigSchema } from "./project"; describe("WorkspaceConfig taskExperiments", () => { - test("stale programmaticToolCallingExclusive entries parse cleanly and drop the key", () => { - // The exclusive experiment was removed (PTC is exclusive-only now). + test("legacy programmaticToolCallingExclusive entries parse cleanly and are retained", () => { + // The exclusive experiment was merged into PTC (exclusive-only now). // Workspaces stamped by older builds may still carry the flag on disk; - // it must be ignored, never rejected. + // it must parse cleanly and survive round-trips so a downgrade still + // sees it (downgrade-compat mirror). const parsed = WorkspaceConfigSchema.parse({ path: "/tmp/ws", taskExperiments: { @@ -16,9 +17,7 @@ describe("WorkspaceConfig taskExperiments", () => { }); expect(parsed.taskExperiments?.programmaticToolCalling).toBe(true); expect(parsed.taskExperiments?.rlm).toBe(true); - expect( - parsed.taskExperiments && "programmaticToolCallingExclusive" in parsed.taskExperiments - ).toBe(false); + expect(parsed.taskExperiments?.programmaticToolCallingExclusive).toBe(true); }); test("exclusive-only legacy tasks keep PTC (and therefore RLM) on resumption", () => { @@ -36,7 +35,7 @@ describe("WorkspaceConfig taskExperiments", () => { expect(parsed.taskExperiments?.rlm).toBe(true); }); - test("a legacy exclusive false is dropped without aliasing", () => { + test("a legacy exclusive false is not aliased onto PTC", () => { const parsed = WorkspaceConfigSchema.parse({ path: "/tmp/ws", taskExperiments: { @@ -46,3 +45,20 @@ describe("WorkspaceConfig taskExperiments", () => { expect(parsed.taskExperiments?.programmaticToolCalling).toBeUndefined(); }); }); + +describe("toPersistedTaskExperiments", () => { + test("mirrors an enabled PTC onto the legacy exclusive key for downgrades", () => { + expect(toPersistedTaskExperiments({ programmaticToolCalling: true, rlm: true })).toEqual({ + programmaticToolCalling: true, + rlm: true, + programmaticToolCallingExclusive: true, + }); + }); + + test("leaves PTC-off and undefined snapshots untouched", () => { + expect(toPersistedTaskExperiments({ programmaticToolCalling: false })).toEqual({ + programmaticToolCalling: false, + }); + expect(toPersistedTaskExperiments(undefined)).toBeUndefined(); + }); +}); diff --git a/src/common/schemas/project.ts b/src/common/schemas/project.ts index 23752222a7..5de052264a 100644 --- a/src/common/schemas/project.ts +++ b/src/common/schemas/project.ts @@ -194,6 +194,11 @@ export const WorkspaceConfigSchema = z.object({ : value, z.object({ programmaticToolCalling: z.boolean().optional(), + // Downgrade-compat mirror (see toPersistedTaskExperiments): retained + // through parsing and stamped alongside programmaticToolCalling so a + // downgraded build resumes the task in its exclusive posture instead + // of reading bare PTC as the removed (~2x cost) supplement mode. + programmaticToolCallingExclusive: z.boolean().optional(), // RLM mode is stamped at spawn so child sessions keep RLM-gated features // (persistent sandbox kernel, family messaging tools) across app restarts // without depending on live frontend experiment state. @@ -326,3 +331,19 @@ export const ProjectConfigSchema = z.object({ export type WorktreeArchiveSnapshotProject = z.infer; export type WorktreeArchiveSnapshot = z.infer; + +/** + * Project runtime experiment flags onto the persisted taskExperiments + * snapshot. A downgraded build interprets a bare `programmaticToolCalling: + * true` as the removed supplement mode (~2x token cost), so an enabled PTC + * also stamps the legacy exclusive flag — the same new-to-legacy mirror the + * backend applies to feature_flags.json and the renderer applies to + * localStorage. Read-side aliases (schema preprocess above + the runtime + * config loader) handle the opposite direction. + */ +export function toPersistedTaskExperiments( + experiments: T | undefined +): (T & { programmaticToolCallingExclusive?: boolean }) | undefined { + if (experiments?.programmaticToolCalling !== true) return experiments; + return { ...experiments, programmaticToolCallingExclusive: true }; +} diff --git a/src/common/utils/messages/extractEditedFiles.test.ts b/src/common/utils/messages/extractEditedFiles.test.ts index 1a050639ce..09edb5d017 100644 --- a/src/common/utils/messages/extractEditedFiles.test.ts +++ b/src/common/utils/messages/extractEditedFiles.test.ts @@ -132,6 +132,26 @@ describe("nested PTC edit records (exclusive posture)", () => { expect(extractEditedFilePaths(messages)).toEqual(["/third.ts", "/second.ts", "/first.ts"]); }); + it("skips malformed records (null or primitive result) instead of throwing", () => { + // History rows are untrusted persisted JSON; compaction preparation and + // post-compaction attachment tracking both run this extractor, so one + // corrupt nested result must not repeatedly fail those flows. + const messages: MuxMessage[] = [ + createCodeExecutionMessage([ + { toolName: "file_edit_insert", args: { path: "/null-result.ts" }, result: null }, + { toolName: "file_edit_insert", args: { path: "/string-result.ts" }, result: "corrupt" }, + { + toolName: "file_edit_insert", + args: { path: "/good.ts" }, + result: { success: true, diff: makeDiff("/good.ts", "old", "new") }, + }, + ]), + ]; + + expect(extractEditedFilePaths(messages)).toEqual(["/good.ts"]); + expect(extractEditedFileDiffs(messages)).toHaveLength(1); + }); + it("kernel-compacted records surface the path but no diff", () => { // Current kernel compaction exempts file_edit_* records (results kept for // exactly this extractor), but result-less compact records still exist in diff --git a/src/common/utils/messages/extractEditedFiles.ts b/src/common/utils/messages/extractEditedFiles.ts index 722ffec7c5..8f2e953acf 100644 --- a/src/common/utils/messages/extractEditedFiles.ts +++ b/src/common/utils/messages/extractEditedFiles.ts @@ -45,6 +45,16 @@ function collectNestedEditRecords(output: unknown): NestedEditRecord[] { } // Success = no error, and for kernel-compacted records ok !== false. if (record.error !== undefined || record.ok === false) continue; + // History rows are untrusted persisted JSON: a malformed record can carry + // null (or a primitive) here, and compaction preparation plus + // post-compaction attachment tracking both run this extractor — one + // corrupt row must degrade to a skip, never a repeated throw. + if ( + record.result !== undefined && + (record.result === null || typeof record.result !== "object") + ) { + continue; + } const result = record.result as FileEditToolOutput | undefined; // Classic records retain the full result: edits resolve with // {success: false} instead of throwing, so require an explicit success. diff --git a/src/node/services/ptc/types.ts b/src/node/services/ptc/types.ts index 93d459757a..adabb0aafb 100644 --- a/src/node/services/ptc/types.ts +++ b/src/node/services/ptc/types.ts @@ -6,6 +6,7 @@ */ import { FILE_EDIT_TOOL_NAMES } from "@/common/types/tools"; +import { isSupportedAttachmentMediaType } from "@/common/utils/attachments/supportedAttachmentMediaTypes"; /** * Event emitted when a tool call starts within the sandbox. @@ -122,7 +123,15 @@ export function isPersistenceCriticalRecordToolName(toolName: string): boolean { ); } -/** MCP-style content container ({ type: "content", value: [...] }) holding at least one media part. */ +/** + * MCP-style content container ({ type: "content", value: [...] }) holding at + * least one media part that request-time extraction will actually consume + * (supported attachment types: images/PDF/SVG). Unsupported media (audio, + * blobs — up to MiBs of base64 the model can never see as an attachment) does + * not justify exempting the record from kernel bounding; extraction replaces + * any unsupported parts that ride along in an exempted container with bounded + * placeholders at request time. + */ export function containsMediaContentPayload(result: unknown): boolean { if (typeof result !== "object" || result === null) return false; const container = result as { type?: unknown; value?: unknown }; @@ -132,6 +141,8 @@ export function containsMediaContentPayload(result: unknown): boolean { typeof item === "object" && item !== null && (item as { type?: unknown }).type === "media" && - typeof (item as { data?: unknown }).data === "string" + typeof (item as { data?: unknown }).data === "string" && + typeof (item as { mediaType?: unknown }).mediaType === "string" && + isSupportedAttachmentMediaType((item as { mediaType: string }).mediaType) ); } diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 18d217b440..319593218e 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -9,6 +9,7 @@ import { TASK_TERMINATION_STOP_STREAM_TIMEOUT_MS, TASK_TERMINATION_WORKSPACE_REMOVE_TIMEOUT_MS, } from "@/constants/terminationTimeouts"; +import { toPersistedTaskExperiments } from "@/common/schemas/project"; import { raceWithAbortAndTimeout } from "@/node/utils/concurrency/withTimeout"; import { MutexMap } from "@/node/utils/concurrency/mutexMap"; import { AsyncMutex } from "@/node/utils/concurrency/asyncMutex"; @@ -3444,7 +3445,7 @@ export class TaskService { taskModelString: plan.taskModelString, taskThinkingLevel: plan.effectiveThinkingLevel, taskOnRefusal: plan.onRefusal, - taskExperiments: plan.experiments, + taskExperiments: toPersistedTaskExperiments(plan.experiments), taskIsolation: plan.sharedWorkspacePath != null ? "none" : undefined, taskAttentionPolicy: plan.attentionPolicy, projects: plan.parentMeta.projects, @@ -5217,7 +5218,7 @@ export class TaskService { taskModelString, taskThinkingLevel: effectiveThinkingLevel, taskOnRefusal: args.onRefusal, - taskExperiments: args.experiments, + taskExperiments: toPersistedTaskExperiments(args.experiments), taskIsolation: useSharedWorkspace ? "none" : undefined, taskAttentionPolicy: args.attentionPolicy, projects: parentMeta.projects, @@ -5387,7 +5388,7 @@ export class TaskService { taskModelString, taskThinkingLevel: effectiveThinkingLevel, taskOnRefusal: args.onRefusal, - taskExperiments: args.experiments, + taskExperiments: toPersistedTaskExperiments(args.experiments), taskIsolation: useSharedWorkspace ? "none" : undefined, taskAttentionPolicy: args.attentionPolicy, projects: inheritedProjects, diff --git a/src/node/services/tools/code_execution.test.ts b/src/node/services/tools/code_execution.test.ts index 6017efe611..09041cf1dd 100644 --- a/src/node/services/tools/code_execution.test.ts +++ b/src/node/services/tools/code_execution.test.ts @@ -963,6 +963,36 @@ describe("createCodeExecutionTool", () => { await host.disposeScope("ws-exempt-bounds"); }); + it("does not exempt unsupported media (audio) from kernel record suppression", async () => { + // Request-time extraction only consumes supported attachment types + // (images/PDF); exempting audio/blob media would leave raw base64 in + // persisted records and provider requests with no attachment payoff. + using tmp = new DisposableTempDir("code-exec-audio-bounds"); + const host = new SandboxHostService(); + const tools: Record = { + mcp__rec__capture: createMockTool("mcp__rec__capture", z.object({}), () => ({ + type: "content", + value: [{ type: "media", mediaType: "audio/wav", data: "d2F2" }], + })), + }; + const tool = await createCodeExecutionTool( + runtimeFactory, + new ToolBridge(tools), + undefined, + persistentRunner(host, "ws-audio-bounds", tmp.path) + ); + + const result = (await tool.execute!( + { code: "mux.mcp__rec__capture({}); return true;" }, + mockToolCallOptions + )) as PTCExecutionResult; + expect(result.success).toBe(true); + const record = result.toolCalls.find((r) => r.toolName === "mcp__rec__capture"); + expect(record?.result).toBeUndefined(); + expect(record?.ok).toBe(true); + await host.disposeScope("ws-audio-bounds"); + }); + it("marks compact records not-ok when the tool resolved with success:false", async () => { // file_read-style tools resolve normally with {success:false} for // missing/oversized/directory paths — no thrown error. The compact diff --git a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts index febc0c479a..c75f85fb8b 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts @@ -219,6 +219,58 @@ describe("extractToolMediaAsUserMessages", () => { }); }); + it("replaces unsupported media (audio/blobs) with bounded placeholders", async () => { + // Unsupported media can be MiBs of base64 the model can never consume as + // an attachment; it must never ride into the provider request as JSON + // text (top-level here; nested records share the same helper). + const imageBase64 = ( + await sharp({ + create: { width: 10, height: 10, channels: 3, background: { r: 9, g: 9, b: 9 } }, + }) + .png() + .toBuffer() + ).toString("base64"); + const audioBase64 = Buffer.from("wav bytes".repeat(100)).toString("base64"); + + const input: MuxMessage[] = [ + { + id: "a-audio", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: "call1", + toolName: "mcp__rec__capture", + input: {}, + state: "output-available", + output: { + type: "content", + value: [ + { type: "media", mediaType: "image/png", data: imageBase64 }, + { type: "media", mediaType: "audio/wav", data: audioBase64 }, + ], + }, + }, + ], + metadata: { timestamp: 1 }, + }, + ]; + + const rewritten = await extractToolMediaAsUserMessages(input); + const toolPart = rewritten[0].parts[0]; + if (toolPart.type !== "dynamic-tool" || toolPart.state !== "output-available") { + throw new Error("Expected an output-available dynamic-tool part"); + } + const outputText = JSON.stringify(toolPart.output); + expect(outputText).not.toContain(audioBase64); + expect(outputText).toContain("[Media omitted from provider request: audio/wav"); + + // The supported image still becomes the single synthetic attachment. + const syntheticUser = rewritten[1]; + const fileParts = syntheticUser.parts.filter((part) => part.type === "file"); + expect(fileParts).toHaveLength(1); + }); + it("self-heals oversized raster tool attachments by downscaling them for provider requests", async () => { const oversizedPng = await sharp({ create: { diff --git a/src/node/utils/messages/toolResultAttachments.ts b/src/node/utils/messages/toolResultAttachments.ts index 115dbe6de4..a45b27aa81 100644 --- a/src/node/utils/messages/toolResultAttachments.ts +++ b/src/node/utils/messages/toolResultAttachments.ts @@ -94,6 +94,16 @@ function buildAttachmentPlaceholder(item: AISDKMediaPart): AISDKTextPart { }; } +function buildUnsupportedMediaPlaceholder(item: AISDKMediaPart): AISDKTextPart { + const normalizedMediaType = normalizeAttachmentMediaType(item.mediaType); + const filename = normalizeOptionalFilename(item.filename); + const label = filename != null ? `${filename} (${normalizedMediaType})` : normalizedMediaType; + return { + type: "text", + text: `[Media omitted from provider request: ${label} is not supported as a model attachment (base64 len=${item.data.length})]`, + }; +} + function buildDisplayOnlyFilePlaceholder(item: DisplayOnlyFilePart): AISDKTextPart { const normalizedMediaType = normalizeAttachmentMediaType(item.mediaType); const filename = normalizeOptionalFilename(item.filename); @@ -130,16 +140,25 @@ export function extractAttachmentsFromToolOutput( let didChange = false; for (const item of output.value) { - if (isMediaPart(item) && isSupportedAttachmentMediaType(item.mediaType)) { + if (isMediaPart(item)) { + if (isSupportedAttachmentMediaType(item.mediaType)) { + didChange = true; + attachments.push({ + data: item.data, + mediaType: normalizeAttachmentMediaType(item.mediaType), + ...(normalizeOptionalFilename(item.filename) + ? { filename: normalizeOptionalFilename(item.filename) } + : {}), + }); + newValue.push(buildAttachmentPlaceholder(item)); + continue; + } + + // Unsupported media (audio, blobs) can be MiBs of base64 the model can + // never consume as an attachment; sending it as tool-result JSON text + // would blow up the request, so replace it with a bounded placeholder. didChange = true; - attachments.push({ - data: item.data, - mediaType: normalizeAttachmentMediaType(item.mediaType), - ...(normalizeOptionalFilename(item.filename) - ? { filename: normalizeOptionalFilename(item.filename) } - : {}), - }); - newValue.push(buildAttachmentPlaceholder(item)); + newValue.push(buildUnsupportedMediaPlaceholder(item)); continue; } From 9d02950351d3db466020207a64c63f76cc3d7f0f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 19:17:28 +0000 Subject: [PATCH 07/40] Address Codex review round 6: capture-time mixed-media sanitizer, code_execution allowlist probe, CLI experiment alias, console media redaction --- src/cli/run.ts | 13 +++- src/cli/workflow.ts | 10 ++- src/node/services/ptc/quickjsRuntime.ts | 10 +-- src/node/services/ptc/runtime.ts | 13 ++-- src/node/services/ptc/toolBridge.ts | 4 +- src/node/services/ptc/types.ts | 64 ++++++++++++++++--- src/node/services/toolAssembly.test.ts | 16 +++++ src/node/services/toolAssembly.ts | 10 ++- .../services/tools/code_execution.test.ts | 40 ++++++++++++ .../extractToolMediaAsUserMessages.test.ts | 54 ++++++++++++++++ .../utils/messages/toolResultAttachments.ts | 39 +++++++++++ 11 files changed, 246 insertions(+), 27 deletions(-) diff --git a/src/cli/run.ts b/src/cli/run.ts index d20fe397bc..5729bc0fc8 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -80,7 +80,10 @@ import { createRuntime, runFullInit } from "../node/runtime/runtimeFactory"; import type { Runtime } from "../node/runtime/Runtime"; import { execSync } from "child_process"; import { getParseOptions } from "./argv"; -import { EXPERIMENT_IDS } from "../common/constants/experiments"; +import { + EXPERIMENT_IDS, + LEGACY_PTC_EXCLUSIVE_EXPERIMENT_ID, +} from "../common/constants/experiments"; import { getErrorMessage } from "@/common/utils/errors"; import { describeCliGoalStop, driveCliGoalUntilTerminal } from "./goalRunDriver"; import { @@ -267,7 +270,13 @@ function renderUnknown(value: unknown): string { const VALID_EXPERIMENT_IDS = new Set(Object.values(EXPERIMENT_IDS)); function collectExperiments(value: string, previous: string[]): string[] { - const experimentId = value.trim().toLowerCase(); + let experimentId = value.trim().toLowerCase(); + // Hidden compat alias: "PTC Exclusive Mode" merged into PTC, and the merged + // flag activates exactly the old exclusive posture — keep existing + // automation that passes the removed ID working instead of erroring. + if (experimentId === LEGACY_PTC_EXCLUSIVE_EXPERIMENT_ID) { + experimentId = EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING; + } if (!VALID_EXPERIMENT_IDS.has(experimentId)) { throw new Error( `Unknown experiment "${value}". Valid experiments: ${[...VALID_EXPERIMENT_IDS].join(", ")}` diff --git a/src/cli/workflow.ts b/src/cli/workflow.ts index d57970b129..213bb6c6ed 100644 --- a/src/cli/workflow.ts +++ b/src/cli/workflow.ts @@ -8,7 +8,7 @@ import * as path from "node:path"; import { Command } from "commander"; -import { EXPERIMENT_IDS } from "@/common/constants/experiments"; +import { EXPERIMENT_IDS, LEGACY_PTC_EXCLUSIVE_EXPERIMENT_ID } from "@/common/constants/experiments"; import type { ProjectConfig } from "@/common/types/project"; import { parseRuntimeModeAndHost, RUNTIME_MODE, type RuntimeConfig } from "@/common/types/runtime"; import { @@ -165,7 +165,13 @@ async function gatherStdin(): Promise { } function collectExperiments(value: string, previous: string[]): string[] { - const experimentId = value.trim().toLowerCase(); + let experimentId = value.trim().toLowerCase(); + // Hidden compat alias: "PTC Exclusive Mode" merged into PTC, and the merged + // flag activates exactly the old exclusive posture — keep existing + // automation that passes the removed ID working instead of erroring. + if (experimentId === LEGACY_PTC_EXCLUSIVE_EXPERIMENT_ID) { + experimentId = EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING; + } if (!VALID_EXPERIMENT_IDS.has(experimentId)) { throw new Error( `Unknown experiment "${value}". Valid experiments: ${[...VALID_EXPERIMENT_IDS].join(", ")}` diff --git a/src/node/services/ptc/quickjsRuntime.ts b/src/node/services/ptc/quickjsRuntime.ts index 675aca77e6..1b2a654bfb 100644 --- a/src/node/services/ptc/quickjsRuntime.ts +++ b/src/node/services/ptc/quickjsRuntime.ts @@ -619,10 +619,12 @@ export class QuickJSRuntime implements IJSRuntime { private boundCaptureResult(value: unknown, toolName: string): unknown { if (this.kernelRecordBounds === undefined) return value; - // Exempt records (persistence-critical tools, media containers) keep the - // full result: compaction and request-time extractors reconstruct - // context from them, so a bounded preview would silently lose it. - if (this.kernelRecordBounds.resultExempt?.(toolName, value) === true) return value; + // Retained records (persistence-critical tools, media containers) keep a + // possibly sanitized full result: compaction and request-time extractors + // reconstruct context from them, so a bounded preview would silently + // lose it. + const retained = this.kernelRecordBounds.captureRetained?.(toolName, value); + if (retained !== undefined) return retained; return this.boundCapture(value, this.kernelRecordBounds.resultCapBytes); } diff --git a/src/node/services/ptc/runtime.ts b/src/node/services/ptc/runtime.ts index 993a418ff3..f8879d29bb 100644 --- a/src/node/services/ptc/runtime.ts +++ b/src/node/services/ptc/runtime.ts @@ -140,13 +140,14 @@ export interface KernelRecordBounds { /** Max serialized bytes of `result` kept in a record/event. */ resultCapBytes: number; /** - * Skip RESULT bounding for records that must retain their full payload - * through capture (persistence-critical tools, media containers — see - * isKernelRecordResultExempt): post-eval compaction and request-time - * extractors need the original value. Args and errors stay bounded - * regardless. + * Capture-time retain override for records that must keep (a possibly + * sanitized form of) their full result — persistence-critical tools and + * extractable media containers (see retainExemptKernelRecordResult): + * post-eval compaction and request-time extractors need the payload. + * Returns the value to retain in the record, or undefined to apply normal + * result bounding. Args and errors stay bounded regardless. */ - resultExempt?: (toolName: string, result: unknown) => boolean; + captureRetained?: (toolName: string, result: unknown) => unknown | undefined; } /** diff --git a/src/node/services/ptc/toolBridge.ts b/src/node/services/ptc/toolBridge.ts index a0041a2b2f..19b01eb5b3 100644 --- a/src/node/services/ptc/toolBridge.ts +++ b/src/node/services/ptc/toolBridge.ts @@ -20,7 +20,7 @@ import { isBridgeToolGranted, type CapabilityGrants, } from "@/common/types/capabilityGrants"; -import { isKernelRecordResultExempt } from "./types"; +import { retainExemptKernelRecordResult } from "./types"; /** * Result shape of an AI SDK Schema's optional custom validator @@ -245,7 +245,7 @@ export class ToolBridge { ? { argsCapBytes: KERNEL_COMPACT_ARGS_CAP_BYTES, resultCapBytes: RESULT_HANDLE_OFFLOAD_THRESHOLD_BYTES, - resultExempt: isKernelRecordResultExempt, + captureRetained: retainExemptKernelRecordResult, } : undefined ); diff --git a/src/node/services/ptc/types.ts b/src/node/services/ptc/types.ts index adabb0aafb..d37ddc7357 100644 --- a/src/node/services/ptc/types.ts +++ b/src/node/services/ptc/types.ts @@ -115,6 +115,53 @@ export function isKernelRecordResultExempt(toolName: string, result: unknown): b return isPersistenceCriticalRecordToolName(toolName) || containsMediaContentPayload(result); } +/** + * Capture-time counterpart of isKernelRecordResultExempt (see + * KernelRecordBounds.captureRetained): returns the value the record should + * retain, or undefined to apply normal result bounding. Media containers are + * retained in SANITIZED form — unsupported media parts (audio/blobs, up to + * 8 MiB each with no aggregate cap) are replaced with bounded text + * placeholders BEFORE the record is retained and persisted, so a mixed + * container (image + audio) keeps only its extractable payload. + */ +export function retainExemptKernelRecordResult(toolName: string, result: unknown): unknown { + if (isPersistenceCriticalRecordToolName(toolName)) return result; + if (!containsMediaContentPayload(result)) return undefined; + return boundUnsupportedMediaPartsAtCapture(result); +} + +/** Media-part shape check shared by the container predicates below. */ +function asMediaPart(item: unknown): { data: string; mediaType?: string } | null { + if (typeof item !== "object" || item === null) return null; + const record = item as { type?: unknown; data?: unknown; mediaType?: unknown }; + if (record.type !== "media" || typeof record.data !== "string") return null; + return { + data: record.data, + ...(typeof record.mediaType === "string" ? { mediaType: record.mediaType } : {}), + }; +} + +/** See retainExemptKernelRecordResult: bound unsupported media parts inside an otherwise-retained container. */ +function boundUnsupportedMediaPartsAtCapture(result: unknown): unknown { + const container = result as { type: "content"; value: unknown[] }; + let changed = false; + const value = container.value.map((item) => { + const media = asMediaPart(item); + if ( + media === null || + (media.mediaType !== undefined && isSupportedAttachmentMediaType(media.mediaType)) + ) { + return item; + } + changed = true; + return { + type: "text", + text: `[media bounded at capture: ${media.mediaType ?? "unknown"}, ${media.data.length} base64 chars — not supported as a model attachment]`, + }; + }); + return changed ? { ...container, value } : result; +} + /** See isKernelRecordResultExempt (persistence-critical branch). */ export function isPersistenceCriticalRecordToolName(toolName: string): boolean { return ( @@ -136,13 +183,12 @@ export function containsMediaContentPayload(result: unknown): boolean { if (typeof result !== "object" || result === null) return false; const container = result as { type?: unknown; value?: unknown }; if (container.type !== "content" || !Array.isArray(container.value)) return false; - return container.value.some( - (item: unknown) => - typeof item === "object" && - item !== null && - (item as { type?: unknown }).type === "media" && - typeof (item as { data?: unknown }).data === "string" && - typeof (item as { mediaType?: unknown }).mediaType === "string" && - isSupportedAttachmentMediaType((item as { mediaType: string }).mediaType) - ); + return container.value.some((item: unknown) => { + const media = asMediaPart(item); + return ( + media !== null && + media.mediaType !== undefined && + isSupportedAttachmentMediaType(media.mediaType) + ); + }); } diff --git a/src/node/services/toolAssembly.test.ts b/src/node/services/toolAssembly.test.ts index ed080e2387..77bc97ebac 100644 --- a/src/node/services/toolAssembly.test.ts +++ b/src/node/services/toolAssembly.test.ts @@ -90,6 +90,22 @@ describe("applyToolPolicyAndExperiments", () => { expect(Object.keys(result)).toEqual([]); }); + test("an allowlist that re-enables code_execution keeps the exclusive entry point", async () => { + // [disable .*, enable code_execution] empties the base-tool record, but + // the last matching rule explicitly enables the synthesized entry point — + // it must not be misread as a no-tools contract. + const result = await applyToolPolicyAndExperiments({ + allTools: { bash: executableTool("Run a command") }, + effectiveToolPolicy: [ + { regex_match: ".*", action: "disable" }, + { regex_match: "code_execution", action: "enable" }, + ], + experiments: { programmaticToolCalling: true }, + emitNestedToolEvent: () => undefined, + }); + expect(Object.keys(result)).toEqual(["code_execution"]); + }); + test("policy-required bridgeable tools stay model-visible in the exclusive set", async () => { // "require" gates run completion on a TOP-LEVEL toolResult for that name // (StreamManager.createStopWhenCondition); a nested xum.* call never diff --git a/src/node/services/toolAssembly.ts b/src/node/services/toolAssembly.ts index efa2d9c38d..f19b7793b7 100644 --- a/src/node/services/toolAssembly.ts +++ b/src/node/services/toolAssembly.ts @@ -20,6 +20,7 @@ type SendMessageExperiments = SendMessageOptions["experiments"]; import { applyToolPolicy, + applyToolPolicyToNames, buildRequiredToolPatterns, type ToolPolicy, } from "@/common/utils/tools/toolPolicy"; @@ -203,8 +204,13 @@ export async function applyToolPolicyAndExperiments( // no-tools contract: synthesizing code_execution anyway would hand that // flow a tool it explicitly forbade. Checked on the PRE-grant policy // result so a least-privilege grants ceiling (which stubs, not disables) - // keeps code_execution as the mandatory bridge entry point. - const policyLeavesNoTools = Object.keys(policyFilteredPreGrant).length === 0; + // keeps code_execution as the mandatory bridge entry point. The synthesized + // name is probed explicitly: an allowlist like [disable .*, enable + // code_execution] empties the base record yet clearly intends the exclusive + // entry point to exist, so the base-tool record alone cannot decide. + const policyLeavesNoTools = + Object.keys(policyFilteredPreGrant).length === 0 && + applyToolPolicyToNames(["code_execution"], effectiveToolPolicy).length === 0; if (experiments?.programmaticToolCalling && !policyLeavesNoTools) { try { // Lazy-load PTC modules only when experiments are enabled diff --git a/src/node/services/tools/code_execution.test.ts b/src/node/services/tools/code_execution.test.ts index 09041cf1dd..ce29701a18 100644 --- a/src/node/services/tools/code_execution.test.ts +++ b/src/node/services/tools/code_execution.test.ts @@ -963,6 +963,46 @@ describe("createCodeExecutionTool", () => { await host.disposeScope("ws-exempt-bounds"); }); + it("bounds unsupported parts of mixed media containers at capture", async () => { + // A mixed container (image + audio) is retained for request-time image + // extraction, but the unsupported audio payload (up to 8 MiB per part) + // must not persist raw into the record/chat.jsonl — it is replaced with + // a bounded placeholder BEFORE retention. + using tmp = new DisposableTempDir("code-exec-mixed-media"); + const host = new SandboxHostService(); + const audioData = "d2F2".repeat(50); + const tools: Record = { + mcp__shots__take: createMockTool("mcp__shots__take", z.object({}), () => ({ + type: "content", + value: [ + { type: "media", mediaType: "image/png", data: "aGVsbG8=" }, + { type: "media", mediaType: "audio/wav", data: audioData }, + ], + })), + }; + const tool = await createCodeExecutionTool( + runtimeFactory, + new ToolBridge(tools), + undefined, + persistentRunner(host, "ws-mixed-media", tmp.path) + ); + + const result = (await tool.execute!( + { code: "mux.mcp__shots__take({}); return true;" }, + mockToolCallOptions + )) as PTCExecutionResult; + expect(result.success).toBe(true); + const record = result.toolCalls.find((r) => r.toolName === "mcp__shots__take"); + const value = ( + record?.result as { value?: Array<{ type?: string; data?: string; text?: string }> } + )?.value; + expect(value?.[0]?.data).toBe("aGVsbG8="); + expect(value?.[1]?.type).toBe("text"); + expect(value?.[1]?.text).toContain("media bounded at capture: audio/wav"); + expect(JSON.stringify(record)).not.toContain(audioData); + await host.disposeScope("ws-mixed-media"); + }); + it("does not exempt unsupported media (audio) from kernel record suppression", async () => { // Request-time extraction only consumes supported attachment types // (images/PDF); exempting audio/blob media would leave raw base64 in diff --git a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts index c75f85fb8b..5a90209406 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts @@ -219,6 +219,60 @@ describe("extractToolMediaAsUserMessages", () => { }); }); + it("redacts media containers copied into code_execution console output", async () => { + // `const image = xum.(...); console.log(image)` copies the + // container into consoleOutput args (classic console budget ~1MiB); + // request-time extraction must rewrite that copy too and dedupe it + // against the record's attachment. + const base64 = ( + await sharp({ + create: { width: 10, height: 10, channels: 3, background: { r: 1, g: 2, b: 3 } }, + }) + .png() + .toBuffer() + ).toString("base64"); + const mediaContainer = { + type: "content", + value: [{ type: "media", mediaType: "image/png", data: base64 }], + }; + + const input: MuxMessage[] = [ + { + id: "ce-console", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: "call1", + toolName: "code_execution", + input: { code: "const image = xum.mcp__shots__take({}); console.log(image);" }, + state: "output-available", + output: { + success: true, + toolCalls: [{ toolName: "mcp__shots__take", args: {}, result: mediaContainer }], + consoleOutput: [{ level: "log", args: [mediaContainer], timestamp: 1 }], + }, + }, + ], + metadata: { timestamp: 1 }, + }, + ]; + + const rewritten = await extractToolMediaAsUserMessages(input); + expect(rewritten).toHaveLength(2); + + const toolPart = rewritten[0].parts[0]; + if (toolPart.type !== "dynamic-tool" || toolPart.state !== "output-available") { + throw new Error("Expected an output-available dynamic-tool part"); + } + expect(JSON.stringify(toolPart.output)).not.toContain(base64); + + // Record copy + console copy dedupe into a single attachment. + const syntheticUser = rewritten[1]; + const fileParts = syntheticUser.parts.filter((part) => part.type === "file"); + expect(fileParts).toHaveLength(1); + }); + it("replaces unsupported media (audio/blobs) with bounded placeholders", async () => { // Unsupported media can be MiBs of base64 the model can never consume as // an attachment; it must never ride into the provider request as JSON diff --git a/src/node/utils/messages/toolResultAttachments.ts b/src/node/utils/messages/toolResultAttachments.ts index a45b27aa81..0f8cea6c0a 100644 --- a/src/node/utils/messages/toolResultAttachments.ts +++ b/src/node/utils/messages/toolResultAttachments.ts @@ -240,6 +240,44 @@ function extractAttachmentsFromNestedToolCalls( pushUnique(extractedOuter.attachments); } + // Console output too: `const image = xum.(...); console.log(image)` + // copies the media container into consoleOutput args, which would otherwise + // carry up to the classic console budget (~1MiB) of base64 into the next + // request as JSON despite the record/result rewrites above. + const consoleOutput = (output as { consoleOutput?: unknown }).consoleOutput; + let newConsoleOutput = consoleOutput; + if (Array.isArray(consoleOutput)) { + let consoleChanged = false; + const mapped = consoleOutput.map((record: unknown) => { + if (typeof record !== "object" || record === null) { + return record; + } + const args = (record as { args?: unknown }).args; + if (!Array.isArray(args)) { + return record; + } + let argsChanged = false; + const newArgs = args.map((arg: unknown) => { + const extracted = extractAttachmentsFromToolOutput(arg); + if (extracted == null) { + return arg; + } + argsChanged = true; + pushUnique(extracted.attachments); + return extracted.newOutput; + }); + if (!argsChanged) { + return record; + } + consoleChanged = true; + return { ...record, args: newArgs }; + }); + if (consoleChanged) { + didChange = true; + newConsoleOutput = mapped; + } + } + if (!didChange) { return null; } @@ -249,6 +287,7 @@ function extractAttachmentsFromNestedToolCalls( ...output, toolCalls: newToolCalls, ...(extractedOuter != null ? { result: extractedOuter.newOutput } : {}), + ...(newConsoleOutput !== consoleOutput ? { consoleOutput: newConsoleOutput } : {}), }, attachments, }; From 55f033be218d968824846e2154be4b8d68978000 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 19:19:15 +0000 Subject: [PATCH 08/40] Fix lint: redundant union constituent, prefer optional chain --- src/node/services/ptc/runtime.ts | 2 +- src/node/services/ptc/types.ts | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/node/services/ptc/runtime.ts b/src/node/services/ptc/runtime.ts index f8879d29bb..cf2a61039a 100644 --- a/src/node/services/ptc/runtime.ts +++ b/src/node/services/ptc/runtime.ts @@ -147,7 +147,7 @@ export interface KernelRecordBounds { * Returns the value to retain in the record, or undefined to apply normal * result bounding. Args and errors stay bounded regardless. */ - captureRetained?: (toolName: string, result: unknown) => unknown | undefined; + captureRetained?: (toolName: string, result: unknown) => unknown; } /** diff --git a/src/node/services/ptc/types.ts b/src/node/services/ptc/types.ts index d37ddc7357..7ad988b602 100644 --- a/src/node/services/ptc/types.ts +++ b/src/node/services/ptc/types.ts @@ -185,10 +185,6 @@ export function containsMediaContentPayload(result: unknown): boolean { if (container.type !== "content" || !Array.isArray(container.value)) return false; return container.value.some((item: unknown) => { const media = asMediaPart(item); - return ( - media !== null && - media.mediaType !== undefined && - isSupportedAttachmentMediaType(media.mediaType) - ); + return media?.mediaType !== undefined && isSupportedAttachmentMediaType(media.mediaType); }); } From 14f6d364785df60b0298bfb6ec7b33c2965f0f4f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 19:49:19 +0000 Subject: [PATCH 09/40] Address Codex review round 7: retry-snapshot experiment aliases, capture-bounded persistence shapes, aggregate media budget --- src/common/constants/experiments.ts | 29 +++++ src/common/orpc/schemas/stream.test.ts | 24 ++-- src/common/orpc/schemas/stream.ts | 47 +++++--- .../schemas/project.taskExperiments.test.ts | 37 ++++++- src/common/schemas/project.ts | 16 --- src/common/types/message.ts | 9 +- src/constants/kernelOutput.ts | 10 ++ src/node/services/agentSession.ts | 11 +- src/node/services/ptc/types.ts | 104 +++++++++++++++--- src/node/services/taskService.ts | 10 +- .../services/tools/code_execution.test.ts | 80 +++++++++++++- 11 files changed, 312 insertions(+), 65 deletions(-) diff --git a/src/common/constants/experiments.ts b/src/common/constants/experiments.ts index 1f92f76984..9b0e47f83c 100644 --- a/src/common/constants/experiments.ts +++ b/src/common/constants/experiments.ts @@ -39,6 +39,35 @@ export type ExperimentId = (typeof EXPERIMENT_IDS)[keyof typeof EXPERIMENT_IDS]; */ export const LEGACY_PTC_EXCLUSIVE_EXPERIMENT_ID = "programmatic-tool-calling-exclusive"; +/** + * Read-side alias for persisted experiment-flag objects (camelCase form used + * by taskExperiments snapshots and startup-retry send options): a legacy + * exclusive `true` opted into exactly the posture merged PTC activates, so it + * wins even over an explicit `programmaticToolCalling: false`. + */ +export function aliasLegacyPtcExclusive< + T extends { programmaticToolCalling?: boolean; programmaticToolCallingExclusive?: boolean }, +>( + experiments: T | undefined +): (Omit & { programmaticToolCalling?: boolean }) | undefined { + if (experiments?.programmaticToolCallingExclusive !== true) return experiments; + if (experiments.programmaticToolCalling === true) return experiments; + return { ...experiments, programmaticToolCalling: true }; +} + +/** + * Write-side mirror for persisted experiment-flag objects: an enabled merged + * PTC also stamps the legacy exclusive key so a downgraded build runs the + * exclusive posture instead of reading bare PTC as the removed (~2x cost) + * supplement mode. + */ +export function withLegacyPtcExclusiveMirror( + experiments: T | undefined +): (T & { programmaticToolCallingExclusive?: boolean }) | undefined { + if (experiments?.programmaticToolCalling !== true) return experiments; + return { ...experiments, programmaticToolCallingExclusive: true }; +} + export interface ExperimentDefinition { id: ExperimentId; name: string; diff --git a/src/common/orpc/schemas/stream.test.ts b/src/common/orpc/schemas/stream.test.ts index 7ac911fb60..9ee199b2cf 100644 --- a/src/common/orpc/schemas/stream.test.ts +++ b/src/common/orpc/schemas/stream.test.ts @@ -15,18 +15,28 @@ describe("SendMessageOptions experiments", () => { expect(parsed.experiments && "bogus" in parsed.experiments).toBe(false); }); - test("stale programmaticToolCallingExclusive payloads parse cleanly and drop the key", () => { - // The exclusive experiment was removed (PTC is exclusive-only now). Older - // clients/persisted payloads may still send the flag; it must be ignored, - // never rejected. + test("legacy programmaticToolCallingExclusive payloads parse cleanly and are retained", () => { + // The exclusive experiment merged into PTC (exclusive-only now). Persisted + // startup-retry snapshots may still carry the flag; it must parse cleanly + // and survive round-trips (downgrade-compat mirror), never be rejected. const parsed = SendMessageOptionsSchema.parse({ model: "anthropic:claude-sonnet-4-5", agentId: "exec", experiments: { programmaticToolCalling: true, programmaticToolCallingExclusive: true }, }); expect(parsed.experiments?.programmaticToolCalling).toBe(true); - expect(parsed.experiments && "programmaticToolCallingExclusive" in parsed.experiments).toBe( - false - ); + expect(parsed.experiments?.programmaticToolCallingExclusive).toBe(true); + }); + + test("an exclusive-only legacy snapshot aliases onto merged PTC, winning over explicit false", () => { + // Old builds could persist { programmaticToolCalling: false, + // programmaticToolCallingExclusive: true } — that posture is exactly what + // merged PTC activates, so startup retries must resume with PTC on. + const parsed = SendMessageOptionsSchema.parse({ + model: "anthropic:claude-sonnet-4-5", + agentId: "exec", + experiments: { programmaticToolCalling: false, programmaticToolCallingExclusive: true }, + }); + expect(parsed.experiments?.programmaticToolCalling).toBe(true); }); }); diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts index 28d1b34952..eb48c8864c 100644 --- a/src/common/orpc/schemas/stream.ts +++ b/src/common/orpc/schemas/stream.ts @@ -738,20 +738,39 @@ export const ToolPolicySchema = z.array(ToolPolicyFilterSchema).meta({ // Unknown keys (e.g. `goals` from older persisted send-options written // before the Goals experiment graduated to GA) are stripped by Zod's // default behavior, so we do not need to retain a deprecated field. -export const ExperimentsSchema = z.object({ - programmaticToolCalling: z.boolean().optional(), - /** - * RLM mode (sub-experiment of Programmatic Tool Calling): persistent - * sandbox kernel for code_execution. Inert unless a PTC flag is also on. - */ - rlm: z.boolean().optional(), - advisorTool: z.boolean().optional(), - dynamicWorkflows: z.boolean().optional(), - memory: z.boolean().optional(), - timeline: z.boolean().optional(), - workspaceHeartbeats: z.boolean().optional(), - toolSearch: z.boolean().optional(), -}); +export const ExperimentsSchema = z.preprocess( + // Legacy alias: startup-retry snapshots persisted by builds where "PTC + // Exclusive Mode" was a separate experiment may carry only the exclusive + // flag; the merged PTC experiment activates exactly that posture (`true` + // wins over an explicit programmaticToolCalling: false). + (value) => + typeof value === "object" && + value !== null && + (value as Record).programmaticToolCallingExclusive === true + ? { ...value, programmaticToolCalling: true } + : value, + z.object({ + programmaticToolCalling: z.boolean().optional(), + /** + * Downgrade-compat mirror (see withLegacyPtcExclusiveMirror): retained + * through parsing and stamped alongside programmaticToolCalling in + * persisted startup-retry snapshots so a downgraded build resumes in the + * exclusive posture instead of supplement mode. + */ + programmaticToolCallingExclusive: z.boolean().optional(), + /** + * RLM mode (sub-experiment of Programmatic Tool Calling): persistent + * sandbox kernel for code_execution. Inert unless a PTC flag is also on. + */ + rlm: z.boolean().optional(), + advisorTool: z.boolean().optional(), + dynamicWorkflows: z.boolean().optional(), + memory: z.boolean().optional(), + timeline: z.boolean().optional(), + workspaceHeartbeats: z.boolean().optional(), + toolSearch: z.boolean().optional(), + }) +); /** * `steer` is accepted for older clients, but the backend treats every manual diff --git a/src/common/schemas/project.taskExperiments.test.ts b/src/common/schemas/project.taskExperiments.test.ts index b80c7d8076..8a663d7f6d 100644 --- a/src/common/schemas/project.taskExperiments.test.ts +++ b/src/common/schemas/project.taskExperiments.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { toPersistedTaskExperiments, WorkspaceConfigSchema } from "./project"; +import { + aliasLegacyPtcExclusive, + withLegacyPtcExclusiveMirror, +} from "@/common/constants/experiments"; +import { WorkspaceConfigSchema } from "./project"; describe("WorkspaceConfig taskExperiments", () => { test("legacy programmaticToolCallingExclusive entries parse cleanly and are retained", () => { @@ -46,9 +50,9 @@ describe("WorkspaceConfig taskExperiments", () => { }); }); -describe("toPersistedTaskExperiments", () => { +describe("withLegacyPtcExclusiveMirror", () => { test("mirrors an enabled PTC onto the legacy exclusive key for downgrades", () => { - expect(toPersistedTaskExperiments({ programmaticToolCalling: true, rlm: true })).toEqual({ + expect(withLegacyPtcExclusiveMirror({ programmaticToolCalling: true, rlm: true })).toEqual({ programmaticToolCalling: true, rlm: true, programmaticToolCallingExclusive: true, @@ -56,9 +60,32 @@ describe("toPersistedTaskExperiments", () => { }); test("leaves PTC-off and undefined snapshots untouched", () => { - expect(toPersistedTaskExperiments({ programmaticToolCalling: false })).toEqual({ + expect(withLegacyPtcExclusiveMirror({ programmaticToolCalling: false })).toEqual({ programmaticToolCalling: false, }); - expect(toPersistedTaskExperiments(undefined)).toBeUndefined(); + expect(withLegacyPtcExclusiveMirror(undefined)).toBeUndefined(); + }); +}); + +describe("aliasLegacyPtcExclusive", () => { + test("legacy exclusive true activates merged PTC, winning over an explicit false", () => { + expect( + aliasLegacyPtcExclusive({ + programmaticToolCalling: false, + programmaticToolCallingExclusive: true, + rlm: true, + }) + ).toEqual({ + programmaticToolCalling: true, + programmaticToolCallingExclusive: true, + rlm: true, + }); + }); + + test("legacy exclusive false and absent flags pass through untouched", () => { + expect(aliasLegacyPtcExclusive({ programmaticToolCallingExclusive: false })).toEqual({ + programmaticToolCallingExclusive: false, + }); + expect(aliasLegacyPtcExclusive(undefined)).toBeUndefined(); }); }); diff --git a/src/common/schemas/project.ts b/src/common/schemas/project.ts index 5de052264a..ca76676bcc 100644 --- a/src/common/schemas/project.ts +++ b/src/common/schemas/project.ts @@ -331,19 +331,3 @@ export const ProjectConfigSchema = z.object({ export type WorktreeArchiveSnapshotProject = z.infer; export type WorktreeArchiveSnapshot = z.infer; - -/** - * Project runtime experiment flags onto the persisted taskExperiments - * snapshot. A downgraded build interprets a bare `programmaticToolCalling: - * true` as the removed supplement mode (~2x token cost), so an enabled PTC - * also stamps the legacy exclusive flag — the same new-to-legacy mirror the - * backend applies to feature_flags.json and the renderer applies to - * localStorage. Read-side aliases (schema preprocess above + the runtime - * config loader) handle the opposite direction. - */ -export function toPersistedTaskExperiments( - experiments: T | undefined -): (T & { programmaticToolCallingExclusive?: boolean }) | undefined { - if (experiments?.programmaticToolCalling !== true) return experiments; - return { ...experiments, programmaticToolCallingExclusive: true }; -} diff --git a/src/common/types/message.ts b/src/common/types/message.ts index 9b8b313859..13a59e369b 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -9,6 +9,7 @@ import type { } from "@/common/constants/contextBoundary"; import type { GoalSyntheticMessageKind } from "@/constants/goals"; import type { SendMessageOptions } from "@/common/orpc/types"; +import { withLegacyPtcExclusiveMirror } from "@/common/constants/experiments"; import type { z } from "zod"; import type { AgentMode } from "./mode"; import type { AgentSkillScope } from "./agentSkill"; @@ -77,7 +78,9 @@ export function pickPreservedSendOptions(options: SendMessageOptions): Preserved reasoningMode: options.reasoningMode, additionalSystemInstructions: options.additionalSystemInstructions, providerOptions: options.providerOptions, - experiments: options.experiments, + // Downgrade-compat (see withLegacyPtcExclusiveMirror): preserved options + // can persist across restarts and build versions. + experiments: withLegacyPtcExclusiveMirror(options.experiments), disableWorkspaceAgents: options.disableWorkspaceAgents, // Delegated turns with explicit agent overrides must stay loud across the // compaction replay too — dropping this would let the follow-up silently @@ -147,7 +150,9 @@ export function pickStartupRetrySendOptions( additionalSystemInstructions: options.additionalSystemInstructions, maxOutputTokens: options.maxOutputTokens, providerOptions: options.providerOptions, - experiments: options.experiments, + // Downgrade-compat: retry snapshots persist to chat.jsonl and cross build + // versions, so an enabled merged PTC also stamps the legacy exclusive key. + experiments: withLegacyPtcExclusiveMirror(options.experiments), disableWorkspaceAgents: options.disableWorkspaceAgents, // Keep explicit-agent turns loud across restart recovery (see pickPreservedSendOptions). strictAgentResolution: options.strictAgentResolution, diff --git a/src/constants/kernelOutput.ts b/src/constants/kernelOutput.ts index 5704b6ce0f..de000379a7 100644 --- a/src/constants/kernelOutput.ts +++ b/src/constants/kernelOutput.ts @@ -39,3 +39,13 @@ export const KERNEL_COMPACT_ARGS_CAP_BYTES = 2 * 1024; /** Bounded head shown for a mux.load ingestion ({key, bytes, lines, preview}). */ export const KERNEL_LOAD_PREVIEW_CHARS = 512; + +/** + * Aggregate base64 budget for supported media parts retained in ONE kernel + * record/event (see retainExemptKernelRecordResult). MCP applies only a + * per-part guard, so a tool returning many individually-allowed images could + * otherwise persist unbounded aggregate base64 into partial.json/chat.jsonl + * rows. Sized to fit a typical screenshot or two (base64 of a ~1MB PNG is + * ~1.4MB); parts beyond the budget become bounded placeholders. + */ +export const KERNEL_RETAINED_MEDIA_BUDGET_BYTES = 3 * 1024 * 1024; diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index fc4dac06bc..cc4ccb701b 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -166,7 +166,11 @@ import { SKILL_DYNAMIC_COMMAND_TIMEOUT_MS, SKILL_DYNAMIC_OUTPUT_CAP_BYTES, } from "@/node/services/agentSkills/skillDynamicContext"; -import { EXPERIMENT_IDS, type ExperimentId } from "@/common/constants/experiments"; +import { + aliasLegacyPtcExclusive, + EXPERIMENT_IDS, + type ExperimentId, +} from "@/common/constants/experiments"; import { awaitPendingBranchSummary, isRlmModeEnabled, @@ -1943,7 +1947,10 @@ export class AgentSession { : undefined; const persistedAllowAgentSetGoal = persistedRetrySendOptions?.allowAgentSetGoal; const persistedProviderOptions = persistedRetrySendOptions?.providerOptions; - const persistedExperiments = persistedRetrySendOptions?.experiments; + // History rows load as raw JSON (no schema parse), so the legacy exclusive + // alias must be applied here: an old snapshot may carry only the exclusive + // flag, which activates exactly the posture merged PTC now provides. + const persistedExperiments = aliasLegacyPtcExclusive(persistedRetrySendOptions?.experiments); const lastUserMuxMetadata = lastUserMessage?.metadata?.muxMetadata; if (isCompactionRequestMetadata(lastUserMuxMetadata)) { diff --git a/src/node/services/ptc/types.ts b/src/node/services/ptc/types.ts index 7ad988b602..2ec3fc4677 100644 --- a/src/node/services/ptc/types.ts +++ b/src/node/services/ptc/types.ts @@ -7,6 +7,12 @@ import { FILE_EDIT_TOOL_NAMES } from "@/common/types/tools"; import { isSupportedAttachmentMediaType } from "@/common/utils/attachments/supportedAttachmentMediaTypes"; +import { getToolOutputUiOnly } from "@/common/utils/tools/toolOutputUiOnly"; +import { MAX_FILE_CONTENT_SIZE } from "@/common/constants/attachments"; +import { + KERNEL_COMPACT_ARGS_CAP_BYTES, + KERNEL_RETAINED_MEDIA_BUDGET_BYTES, +} from "@/constants/kernelOutput"; /** * Event emitted when a tool call starts within the sandbox. @@ -118,16 +124,72 @@ export function isKernelRecordResultExempt(toolName: string, result: unknown): b /** * Capture-time counterpart of isKernelRecordResultExempt (see * KernelRecordBounds.captureRetained): returns the value the record should - * retain, or undefined to apply normal result bounding. Media containers are - * retained in SANITIZED form — unsupported media parts (audio/blobs, up to - * 8 MiB each with no aggregate cap) are replaced with bounded text - * placeholders BEFORE the record is retained and persisted, so a mixed - * container (image + audio) keeps only its extractable payload. + * retain, or undefined to apply normal result bounding. Retained values are + * SANITIZED, never raw: + * + * - Persistence-critical results are reduced to the bounded shape the + * extractors actually consume (see boundPersistenceCriticalResult) — the + * raw results are unbounded upstream (generateDiff has no cap), so a loop + * of large edits must not accumulate megabytes per execution in streamed + * events and persisted records. + * - Media containers keep supported parts under an aggregate budget; + * unsupported parts (audio/blobs) and over-budget parts become bounded + * text placeholders BEFORE the record is retained and persisted. */ export function retainExemptKernelRecordResult(toolName: string, result: unknown): unknown { - if (isPersistenceCriticalRecordToolName(toolName)) return result; + if (isPersistenceCriticalRecordToolName(toolName)) { + return boundPersistenceCriticalResult(toolName, result); + } if (!containsMediaContentPayload(result)) return undefined; - return boundUnsupportedMediaPartsAtCapture(result); + return sanitizeRetainedMediaContainer(result); +} + +/** + * Reduce a persistence-critical result to the bounded shape the + * post-compaction extractors consume: + * + * - file_edit_*: { success?, diff?, error? } with the diff capped at + * MAX_FILE_CONTENT_SIZE (+1 char so extractEditedFileDiffs still detects + * truncation via its `length > cap` check) — the ui_only diff variant is + * flattened onto `diff`, which the extractor reads as its fallback. + * - agent_skill_read: { success, skill } / { success, error } passes through + * when its serialized size fits the same cap; oversized packages fall back + * to normal bounding (undefined) and the snapshot degrades like any other + * bounded record. + */ +function boundPersistenceCriticalResult(toolName: string, result: unknown): unknown { + if (typeof result !== "object" || result === null) return undefined; + const record = result as { success?: unknown; error?: unknown }; + const success = typeof record.success === "boolean" ? { success: record.success } : {}; + const error = + typeof record.error === "string" + ? { error: record.error.slice(0, KERNEL_COMPACT_ARGS_CAP_BYTES) } + : {}; + + if (toolName === "agent_skill_read") { + const skill = (result as { skill?: unknown }).skill; + const reduced = { ...success, ...error, ...(skill !== undefined ? { skill } : {}) }; + try { + if (JSON.stringify(reduced).length > MAX_FILE_CONTENT_SIZE) return undefined; + } catch { + return undefined; + } + return reduced; + } + + const rawDiff = (result as { diff?: unknown }).diff; + const uiOnlyDiff = getToolOutputUiOnly(result)?.file_edit?.diff; + const diff = typeof uiOnlyDiff === "string" ? uiOnlyDiff : rawDiff; + return { + ...success, + ...error, + ...(typeof diff === "string" + ? { + diff: + diff.length > MAX_FILE_CONTENT_SIZE ? diff.slice(0, MAX_FILE_CONTENT_SIZE + 1) : diff, + } + : {}), + }; } /** Media-part shape check shared by the container predicates below. */ @@ -141,18 +203,34 @@ function asMediaPart(item: unknown): { data: string; mediaType?: string } | null }; } -/** See retainExemptKernelRecordResult: bound unsupported media parts inside an otherwise-retained container. */ -function boundUnsupportedMediaPartsAtCapture(result: unknown): unknown { +/** + * See retainExemptKernelRecordResult: sanitize an otherwise-retained media + * container. Unsupported media parts are always replaced with bounded + * placeholders, and supported parts are charged against an aggregate budget + * (KERNEL_RETAINED_MEDIA_BUDGET_BYTES) — MCP enforces only a per-part guard, + * so many individually-allowed images could otherwise persist unbounded + * aggregate base64 into events and chat.jsonl rows. + */ +function sanitizeRetainedMediaContainer(result: unknown): unknown { const container = result as { type: "content"; value: unknown[] }; let changed = false; + let retainedMediaBytes = 0; const value = container.value.map((item) => { const media = asMediaPart(item); - if ( - media === null || - (media.mediaType !== undefined && isSupportedAttachmentMediaType(media.mediaType)) - ) { + if (media === null) { return item; } + if (media.mediaType !== undefined && isSupportedAttachmentMediaType(media.mediaType)) { + if (retainedMediaBytes + media.data.length <= KERNEL_RETAINED_MEDIA_BUDGET_BYTES) { + retainedMediaBytes += media.data.length; + return item; + } + changed = true; + return { + type: "text", + text: `[media bounded at capture: ${media.mediaType}, ${media.data.length} base64 chars — aggregate media budget exceeded]`, + }; + } changed = true; return { type: "text", diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 319593218e..7e43d21fde 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -9,7 +9,9 @@ import { TASK_TERMINATION_STOP_STREAM_TIMEOUT_MS, TASK_TERMINATION_WORKSPACE_REMOVE_TIMEOUT_MS, } from "@/constants/terminationTimeouts"; -import { toPersistedTaskExperiments } from "@/common/schemas/project"; +// Persisted task snapshots stamp the legacy exclusive mirror so downgraded +// builds resume tasks in the exclusive posture (see withLegacyPtcExclusiveMirror). +import { withLegacyPtcExclusiveMirror } from "@/common/constants/experiments"; import { raceWithAbortAndTimeout } from "@/node/utils/concurrency/withTimeout"; import { MutexMap } from "@/node/utils/concurrency/mutexMap"; import { AsyncMutex } from "@/node/utils/concurrency/asyncMutex"; @@ -3445,7 +3447,7 @@ export class TaskService { taskModelString: plan.taskModelString, taskThinkingLevel: plan.effectiveThinkingLevel, taskOnRefusal: plan.onRefusal, - taskExperiments: toPersistedTaskExperiments(plan.experiments), + taskExperiments: withLegacyPtcExclusiveMirror(plan.experiments), taskIsolation: plan.sharedWorkspacePath != null ? "none" : undefined, taskAttentionPolicy: plan.attentionPolicy, projects: plan.parentMeta.projects, @@ -5218,7 +5220,7 @@ export class TaskService { taskModelString, taskThinkingLevel: effectiveThinkingLevel, taskOnRefusal: args.onRefusal, - taskExperiments: toPersistedTaskExperiments(args.experiments), + taskExperiments: withLegacyPtcExclusiveMirror(args.experiments), taskIsolation: useSharedWorkspace ? "none" : undefined, taskAttentionPolicy: args.attentionPolicy, projects: parentMeta.projects, @@ -5388,7 +5390,7 @@ export class TaskService { taskModelString, taskThinkingLevel: effectiveThinkingLevel, taskOnRefusal: args.onRefusal, - taskExperiments: toPersistedTaskExperiments(args.experiments), + taskExperiments: withLegacyPtcExclusiveMirror(args.experiments), taskIsolation: useSharedWorkspace ? "none" : undefined, taskAttentionPolicy: args.attentionPolicy, projects: inheritedProjects, diff --git a/src/node/services/tools/code_execution.test.ts b/src/node/services/tools/code_execution.test.ts index ce29701a18..ed92da92bb 100644 --- a/src/node/services/tools/code_execution.test.ts +++ b/src/node/services/tools/code_execution.test.ts @@ -15,6 +15,7 @@ import { DurableEventJournal } from "@/node/utils/journal/durableEventJournal"; import { createKernelFileLoader } from "@/node/services/tools/kernelFileLoad"; import { LocalRuntime } from "@/node/runtime/LocalRuntime"; import { RESULT_HANDLE_VARS_CAP_BYTES, VARS_SNAPSHOT_MAX_BYTES } from "@/constants/resultHandles"; +import { KERNEL_RETAINED_MEDIA_BUDGET_BYTES } from "@/constants/kernelOutput"; import * as fs from "node:fs/promises"; import * as nodePath from "node:path"; @@ -890,7 +891,7 @@ describe("createCodeExecutionTool", () => { z.object({ name: z.string() }), () => ({ success: true, - content: "---\nname: demo\n---\nBody", + skill: { frontmatter: { name: "demo" }, body: "Body" }, }) ), }; @@ -911,7 +912,8 @@ describe("createCodeExecutionTool", () => { const editRecord = result.toolCalls.find((r) => r.toolName === "file_edit_insert"); expect((editRecord?.result as { diff?: string })?.diff).toContain("+hello"); const skillRecord = result.toolCalls.find((r) => r.toolName === "agent_skill_read"); - expect((skillRecord?.result as { content?: string })?.content).toContain("name: demo"); + const skill = (skillRecord?.result as { skill?: { body?: string } })?.skill; + expect(skill?.body).toBe("Body"); await host.disposeScope("ws-persist-records"); }); @@ -963,6 +965,80 @@ describe("createCodeExecutionTool", () => { await host.disposeScope("ws-exempt-bounds"); }); + it("caps oversized persistence payloads at capture (diff keeps the downstream truncation signal)", async () => { + // generateDiff is unbounded upstream; a >50k diff must be sliced at + // capture (to cap+1 chars, so extractEditedFileDiffs' `length > cap` + // truncation check still fires) instead of persisting megabytes. + using tmp = new DisposableTempDir("code-exec-oversized-diff"); + const host = new SandboxHostService(); + const hugeDiff = `@@ -0,0 +1 @@\n+${"y".repeat(80_000)}`; + const tools: Record = { + file_edit_insert: createMockTool( + "file_edit_insert", + z.object({ path: z.string() }), + () => ({ + success: true, + diff: hugeDiff, + }) + ), + }; + const tool = await createCodeExecutionTool( + runtimeFactory, + new ToolBridge(tools), + undefined, + persistentRunner(host, "ws-oversized-diff", tmp.path) + ); + + const result = (await tool.execute!( + { code: 'mux.file_edit_insert({path: "/huge.ts"}); return true;' }, + mockToolCallOptions + )) as PTCExecutionResult; + expect(result.success).toBe(true); + const record = result.toolCalls.find((r) => r.toolName === "file_edit_insert"); + const diff = (record?.result as { diff?: string })?.diff; + expect(diff?.length).toBe(50_001); + expect(hugeDiff.startsWith(diff!)).toBe(true); + await host.disposeScope("ws-oversized-diff"); + }); + + it("charges retained media against an aggregate budget", async () => { + // MCP applies only a per-part guard: many individually-allowed images + // must not persist unbounded aggregate base64 into records/events. + using tmp = new DisposableTempDir("code-exec-media-budget"); + const host = new SandboxHostService(); + const bigImage = "A".repeat(KERNEL_RETAINED_MEDIA_BUDGET_BYTES - 100); + const secondImage = "B".repeat(500); + const tools: Record = { + mcp__shots__take: createMockTool("mcp__shots__take", z.object({}), () => ({ + type: "content", + value: [ + { type: "media", mediaType: "image/png", data: bigImage }, + { type: "media", mediaType: "image/png", data: secondImage }, + ], + })), + }; + const tool = await createCodeExecutionTool( + runtimeFactory, + new ToolBridge(tools), + undefined, + persistentRunner(host, "ws-media-budget", tmp.path) + ); + + const result = (await tool.execute!( + { code: "mux.mcp__shots__take({}); return true;" }, + mockToolCallOptions + )) as PTCExecutionResult; + expect(result.success).toBe(true); + const record = result.toolCalls.find((r) => r.toolName === "mcp__shots__take"); + const value = ( + record?.result as { value?: Array<{ type?: string; data?: string; text?: string }> } + )?.value; + expect(value?.[0]?.data).toBe(bigImage); + expect(value?.[1]?.type).toBe("text"); + expect(value?.[1]?.text).toContain("aggregate media budget exceeded"); + await host.disposeScope("ws-media-budget"); + }); + it("bounds unsupported parts of mixed media containers at capture", async () => { // A mixed container (image + audio) is retained for request-time image // extraction, but the unsupported audio payload (up to 8 MiB per part) From 746d04d59121da5e904cfb9046c0f27e9a946ec1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 20:37:28 +0000 Subject: [PATCH 10/40] review r8: capture-bounding hardening (args path retention, serialized media budget, skill body truncation, hunk-boundary diffs, classic-mode media budget) --- .../utils/messages/extractEditedFiles.test.ts | 28 +++ .../utils/messages/extractEditedFiles.ts | 28 ++- src/constants/kernelOutput.ts | 27 +- src/node/services/ptc/quickjsRuntime.ts | 44 +++- src/node/services/ptc/runtime.ts | 22 ++ src/node/services/ptc/toolBridge.test.ts | 1 + src/node/services/ptc/toolBridge.ts | 14 +- src/node/services/ptc/types.test.ts | 174 +++++++++++++ src/node/services/ptc/types.ts | 236 +++++++++++++++--- .../services/tools/code_execution.test.ts | 98 +++++++- .../services/workflows/WorkflowRunner.test.ts | 1 + 11 files changed, 606 insertions(+), 67 deletions(-) create mode 100644 src/node/services/ptc/types.test.ts diff --git a/src/common/utils/messages/extractEditedFiles.test.ts b/src/common/utils/messages/extractEditedFiles.test.ts index 09edb5d017..97860c43ef 100644 --- a/src/common/utils/messages/extractEditedFiles.test.ts +++ b/src/common/utils/messages/extractEditedFiles.test.ts @@ -152,6 +152,34 @@ describe("nested PTC edit records (exclusive posture)", () => { expect(extractEditedFileDiffs(messages)).toHaveLength(1); }); + it("propagates capture-time diff truncation to the combined diff", () => { + // A kernel-retained record whose oversized diff was hunk-bounded at + // capture (diffTruncated) makes every combined diff for that file + // incomplete — even when a later small edit combines cleanly, the result + // must not look like a complete original→final snapshot. + const laterDiff = makeDiff("/big.ts", "old", "new"); + const messages: MuxMessage[] = [ + createCodeExecutionMessage([ + { + toolName: "file_edit_replace_string", + args: { path: "/big.ts" }, + result: { success: true, diffTruncated: true }, + }, + { + toolName: "file_edit_replace_string", + args: { path: "/big.ts" }, + result: { success: true, diff: laterDiff }, + }, + ]), + ]; + + expect(extractEditedFilePaths(messages)).toEqual(["/big.ts"]); + const diffs = extractEditedFileDiffs(messages); + expect(diffs).toHaveLength(1); + expect(diffs[0].diff).toBe(laterDiff); + expect(diffs[0].truncated).toBe(true); + }); + it("kernel-compacted records surface the path but no diff", () => { // Current kernel compaction exempts file_edit_* records (results kept for // exactly this extractor), but result-less compact records still exist in diff --git a/src/common/utils/messages/extractEditedFiles.ts b/src/common/utils/messages/extractEditedFiles.ts index 8f2e953acf..85c0ce0f97 100644 --- a/src/common/utils/messages/extractEditedFiles.ts +++ b/src/common/utils/messages/extractEditedFiles.ts @@ -12,6 +12,13 @@ import { extractToolFilePath } from "@/common/utils/tools/toolInputFilePath"; interface FileEditToolOutput { success?: boolean; diff?: string; + /** + * Kernel-retained records bound oversized diffs at a hunk boundary at + * capture (see boundRetainedEditDiff in ptc/types.ts) and flag the loss + * here; it propagates to FileEditDiff.truncated so consumers know the + * combined diff is incomplete. + */ + diffTruncated?: boolean; } /** @@ -23,6 +30,8 @@ interface FileEditToolOutput { interface NestedEditRecord { filePath: string; diff?: string; + /** See FileEditToolOutput.diffTruncated. */ + diffTruncated?: boolean; } /** @@ -66,7 +75,14 @@ function collectNestedEditRecords(output: unknown): NestedEditRecord[] { result !== undefined ? (getToolOutputUiOnly(result)?.file_edit?.diff ?? result.diff) : undefined; - records.push({ filePath, ...(diff !== undefined ? { diff } : {}) }); + records.push({ + filePath, + ...(diff !== undefined ? { diff } : {}), + // Propagated even when the bounded diff itself was dropped (no hunk + // fit): a later small edit to the same file must still surface as an + // incomplete combined diff, not a complete-looking one. + ...(result?.diffTruncated === true ? { diffTruncated: true } : {}), + }); } return records; } @@ -254,6 +270,9 @@ export function extractEditedFileDiffs(messages: MuxMessage[]): FileEditDiff[] { // Collect all diffs per file path in chronological order const diffsByPath = new Map(); const editOrder: string[] = []; // Track order of last edit per file + // Paths whose kernel-retained diff was hunk-truncated at capture: the + // combined diff is incomplete no matter how the combination goes. + const captureTruncatedPaths = new Set(); const addDiff = (filePath: string, diff: string): void => { if (!diffsByPath.has(filePath)) { @@ -279,6 +298,9 @@ export function extractEditedFileDiffs(messages: MuxMessage[]): FileEditDiff[] { // diff); kernel-compacted records surface path-only edits and are // skipped here (no diff contents survive compaction of the record). for (const record of collectNestedEditRecords(part.output)) { + if (record.diffTruncated === true) { + captureTruncatedPaths.add(record.filePath); + } if (record.diff !== undefined && record.diff.length > 0) { addDiff(record.filePath, record.diff); } @@ -311,7 +333,9 @@ export function extractEditedFileDiffs(messages: MuxMessage[]): FileEditDiff[] { const combined = combineDiffs(filePath, diffs); if (combined) { - results.push(combined); + results.push( + captureTruncatedPaths.has(filePath) ? { ...combined, truncated: true } : combined + ); } } diff --git a/src/constants/kernelOutput.ts b/src/constants/kernelOutput.ts index de000379a7..c32df55ae9 100644 --- a/src/constants/kernelOutput.ts +++ b/src/constants/kernelOutput.ts @@ -41,11 +41,30 @@ export const KERNEL_COMPACT_ARGS_CAP_BYTES = 2 * 1024; export const KERNEL_LOAD_PREVIEW_CHARS = 512; /** - * Aggregate base64 budget for supported media parts retained in ONE kernel - * record/event (see retainExemptKernelRecordResult). MCP applies only a + * Aggregate serialized budget for parts retained in ONE media-container + * record/event (see sanitizeRetainedMediaContainer). MCP applies only a * per-part guard, so a tool returning many individually-allowed images could * otherwise persist unbounded aggregate base64 into partial.json/chat.jsonl - * rows. Sized to fit a typical screenshot or two (base64 of a ~1MB PNG is - * ~1.4MB); parts beyond the budget become bounded placeholders. + * rows. Charged against each part's FULL serialized size (metadata included: + * a crafted part can hide megabytes in mediaType with an empty data string). + * Sized to fit a typical screenshot or two (base64 of a ~1MB PNG is ~1.4MB); + * parts beyond the budget become bounded placeholders. */ export const KERNEL_RETAINED_MEDIA_BUDGET_BYTES = 3 * 1024 * 1024; + +/** + * Max parts retained in one sanitized media container (see + * sanitizeRetainedMediaContainer). Bounds the container's STRUCTURE: without + * it, a million zero-cost parts would each earn a placeholder object, growing + * the sanitized record without bound even when every payload is bounded. + */ +export const KERNEL_RETAINED_CONTAINER_MAX_PARTS = 64; + +/** + * Max chars of a validated tool-arg file path preserved on a __kernelBounded + * args marker (see retainPersistenceCriticalArgsFields). Covers Linux + * PATH_MAX (4096); longer strings cannot be real paths of successful edits, + * so they are dropped rather than truncated (a truncated path would + * misattribute the record to a nonexistent file). + */ +export const KERNEL_RETAINED_PATH_MAX_CHARS = 4096; diff --git a/src/node/services/ptc/quickjsRuntime.ts b/src/node/services/ptc/quickjsRuntime.ts index 1b2a654bfb..7e3ccd939e 100644 --- a/src/node/services/ptc/quickjsRuntime.ts +++ b/src/node/services/ptc/quickjsRuntime.ts @@ -182,6 +182,8 @@ export class QuickJSRuntime implements IJSRuntime { private pendingJobGate?: (run: () => void) => void; /** Kernel-mode caps on record/event capture; see IJSRuntime.setKernelRecordBounds. */ private kernelRecordBounds?: KernelRecordBounds; + /** Mode-independent record sanitizer; see IJSRuntime.setCaptureResultSanitizer. */ + private captureResultSanitizer?: (toolName: string, result: unknown) => unknown; /** Monotonic eval counter + the generation currently inside eval() (null * between evals). Distinguishes settlements arriving mid-eval (queued for * the eval's own drain points) from truly-late ones between evals (gated). @@ -297,7 +299,7 @@ export class QuickJSRuntime implements IJSRuntime { // Kernel mode bounds captured args/results at creation: records and // streamed events must never retain full guest payloads (host memory + // session history growth); the guest still receives full values. - const recordArgs = this.boundCaptureArgs(args[0]); + const recordArgs = this.boundCaptureArgs(args[0], name); // Emit start event this.eventHandler?.({ @@ -477,7 +479,7 @@ export class QuickJSRuntime implements IJSRuntime { const result = await fn(...args); const endTime = Date.now(); // Same creation-time bounding as synchronous bridges (kernel mode). - const recordArgs = this.boundCaptureArgs(args[0]); + const recordArgs = this.boundCaptureArgs(args[0], name); const recordResult = this.boundCaptureResult(result, name); toolCalls.push({ toolName: name, @@ -503,7 +505,7 @@ export class QuickJSRuntime implements IJSRuntime { const endTime = Date.now(); const errorStr = error instanceof Error ? error.message : String(error); const recordError = this.boundCaptureError(errorStr); - const recordArgs = this.boundCaptureArgs(args[0]); + const recordArgs = this.boundCaptureArgs(args[0], name); toolCalls.push({ toolName: name, args: recordArgs, @@ -565,6 +567,12 @@ export class QuickJSRuntime implements IJSRuntime { this.kernelRecordBounds = bounds; } + setCaptureResultSanitizer( + sanitizer: ((toolName: string, result: unknown) => unknown) | undefined + ): void { + this.captureResultSanitizer = sanitizer; + } + /** * Bound a guest-supplied value at record/event CREATION time (kernel mode * only). Records live in host memory for the whole eval and events land in @@ -594,10 +602,16 @@ export class QuickJSRuntime implements IJSRuntime { }; } - private boundCaptureArgs(value: unknown): unknown { - return this.kernelRecordBounds === undefined - ? value - : this.boundCapture(value, this.kernelRecordBounds.argsCapBytes); + private boundCaptureArgs(value: unknown, toolName: string): unknown { + if (this.kernelRecordBounds === undefined) return value; + const bounded = this.boundCapture(value, this.kernelRecordBounds.argsCapBytes); + if (bounded === value) return value; + // The marker replaced the args entirely: merge back attribution fields + // (e.g. the file path of a persistence-critical edit) so post-compaction + // extractors can still attribute the record. Marker fields are spread + // last so retained fields can never spoof __kernelBounded/bytes/preview. + const retained = this.kernelRecordBounds.captureArgsRetained?.(toolName, value); + return retained !== undefined ? { ...retained, ...(bounded as object) } : bounded; } /** @@ -618,14 +632,22 @@ export class QuickJSRuntime implements IJSRuntime { } private boundCaptureResult(value: unknown, toolName: string): unknown { - if (this.kernelRecordBounds === undefined) return value; + // The mode-independent sanitizer runs first (both classic and kernel + // mode): media containers are budgeted at capture because records/events + // persist into session history in every mode, and request-time + // attachment extraction rewrites only the provider copy. + const sanitized = + this.captureResultSanitizer !== undefined + ? this.captureResultSanitizer(toolName, value) + : value; + if (this.kernelRecordBounds === undefined) return sanitized; // Retained records (persistence-critical tools, media containers) keep a // possibly sanitized full result: compaction and request-time extractors // reconstruct context from them, so a bounded preview would silently // lose it. - const retained = this.kernelRecordBounds.captureRetained?.(toolName, value); + const retained = this.kernelRecordBounds.captureRetained?.(toolName, sanitized); if (retained !== undefined) return retained; - return this.boundCapture(value, this.kernelRecordBounds.resultCapBytes); + return this.boundCapture(sanitized, this.kernelRecordBounds.resultCapBytes); } setPendingJobGate(gate: (run: () => void) => void): void { @@ -790,7 +812,7 @@ export class QuickJSRuntime implements IJSRuntime { const callId = generateCallId(); // Same creation-time bounding as registerFunction (kernel mode). - const recordArgs = this.boundCaptureArgs(args[0]); + const recordArgs = this.boundCaptureArgs(args[0], methodName); // Emit start event this.eventHandler?.({ diff --git a/src/node/services/ptc/runtime.ts b/src/node/services/ptc/runtime.ts index cf2a61039a..6904b45b60 100644 --- a/src/node/services/ptc/runtime.ts +++ b/src/node/services/ptc/runtime.ts @@ -92,6 +92,20 @@ export interface IJSRuntime extends Disposable { */ setKernelRecordBounds(bounds: KernelRecordBounds | undefined): void; + /** + * Sanitize results captured into records/events at CREATION time in ALL + * modes (classic + kernel). Unlike setKernelRecordBounds this never bounds + * ordinary results — the non-RLM inline-results contract keeps them inline — + * it exists for media containers, whose aggregate base64 must be budgeted + * before events/records persist into session history (request-time + * attachment extraction rewrites only the provider copy, never + * partial.json/chat.jsonl). The guest-visible value is never sanitized. + * Pass undefined to disable. + */ + setCaptureResultSanitizer( + sanitizer: ((toolName: string, result: unknown) => unknown) | undefined + ): void; + /** * Route late guest-continuation execution through a host-provided gate. * When a fire-and-forget capability (registerPromiseFunction) settles after @@ -148,6 +162,14 @@ export interface KernelRecordBounds { * result bounding. Args and errors stay bounded regardless. */ captureRetained?: (toolName: string, result: unknown) => unknown; + /** + * Attribution fields merged onto a __kernelBounded ARGS marker when + * bounding replaces the args of a record (see + * retainPersistenceCriticalArgsFields): post-compaction extractors need the + * validated file path of an oversized file_edit_* call to attribute its + * retained diff. Marker fields win on key collisions. + */ + captureArgsRetained?: (toolName: string, args: unknown) => Record | undefined; } /** diff --git a/src/node/services/ptc/toolBridge.test.ts b/src/node/services/ptc/toolBridge.test.ts index e8fbb92696..813f59414b 100644 --- a/src/node/services/ptc/toolBridge.test.ts +++ b/src/node/services/ptc/toolBridge.test.ts @@ -29,6 +29,7 @@ function createMockRuntime(overrides: Partial = {}): IJSRuntime { registerSyncFunction: mock((_name: string, _fn: () => unknown) => undefined), setVarsProperty: mock((_key: string, _value: string) => undefined), setKernelRecordBounds: mock(() => undefined), + setCaptureResultSanitizer: mock(() => undefined), setPendingJobGate: mock((_gate: (run: () => void) => void) => undefined), setLimits: mock((_limits: RuntimeLimits) => undefined), onEvent: mock((_handler: (event: PTCEvent) => void) => undefined), diff --git a/src/node/services/ptc/toolBridge.ts b/src/node/services/ptc/toolBridge.ts index 19b01eb5b3..9fd2e608fc 100644 --- a/src/node/services/ptc/toolBridge.ts +++ b/src/node/services/ptc/toolBridge.ts @@ -20,7 +20,11 @@ import { isBridgeToolGranted, type CapabilityGrants, } from "@/common/types/capabilityGrants"; -import { retainExemptKernelRecordResult } from "./types"; +import { + retainExemptKernelRecordResult, + retainPersistenceCriticalArgsFields, + sanitizeMediaRecordCapture, +} from "./types"; /** * Result shape of an AI SDK Schema's optional custom validator @@ -246,9 +250,17 @@ export class ToolBridge { argsCapBytes: KERNEL_COMPACT_ARGS_CAP_BYTES, resultCapBytes: RESULT_HANDLE_OFFLOAD_THRESHOLD_BYTES, captureRetained: retainExemptKernelRecordResult, + captureArgsRetained: retainPersistenceCriticalArgsFields, } : undefined ); + // Media containers are budgeted at capture in BOTH modes: classic records + // keep full inline results by contract, but exclusive PTC makes the + // bridge the only route to executable MCP tools, and request-time + // attachment extraction rewrites only the provider copy — records/events + // persisted into partial.json/chat.jsonl need the budget regardless of + // kernel mode. + runtime.setCaptureResultSanitizer(sanitizeMediaRecordCapture); const xumObj: Record Promise> = {}; // Grant-denied tools get an explicit stub: the guest sees a clear diff --git a/src/node/services/ptc/types.test.ts b/src/node/services/ptc/types.test.ts new file mode 100644 index 0000000000..bc44c8aac7 --- /dev/null +++ b/src/node/services/ptc/types.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "bun:test"; +import { parsePatch } from "diff"; +import { AgentSkillPackageSchema } from "@/common/orpc/schemas/agentSkill"; +import { MAX_FILE_CONTENT_SIZE } from "@/common/constants/attachments"; +import { + KERNEL_RETAINED_CONTAINER_MAX_PARTS, + KERNEL_RETAINED_MEDIA_BUDGET_BYTES, +} from "@/constants/kernelOutput"; +import { + retainExemptKernelRecordResult, + retainPersistenceCriticalArgsFields, + sanitizeMediaRecordCapture, +} from "./types"; + +interface RetainedContainer { + type?: string; + value: Array<{ type?: string; text?: string; data?: string }>; +} + +describe("retainExemptKernelRecordResult", () => { + describe("agent_skill_read", () => { + const oversizedSkill = { + scope: "project", + directoryName: "demo", + frontmatter: { name: "demo", description: "a demo skill" }, + body: "B".repeat(MAX_FILE_CONTENT_SIZE), + }; + + it("truncates an oversized skill body into a schema-valid bounded package", () => { + // A valid skill near the 50k snapshot limit serializes above the + // retained-record cap once frontmatter overhead is added; discarding it + // would erase the skill instructions from every later turn — the body + // must degrade like createLoadedSkillSnapshot's own truncation. + const retained = retainExemptKernelRecordResult("agent_skill_read", { + success: true, + skill: oversizedSkill, + }) as { success?: boolean; skill?: { body?: string } }; + expect(retained?.success).toBe(true); + expect(retained?.skill?.body?.startsWith("BBB")).toBe(true); + expect( + retained?.skill?.body?.endsWith( + "[Skill body truncated at capture to fit the retained-record cap]" + ) + ).toBe(true); + expect(JSON.stringify(retained).length).toBeLessThanOrEqual(MAX_FILE_CONTENT_SIZE); + // The snapshot extractor's schema still accepts the bounded package. + expect(AgentSkillPackageSchema.safeParse(retained?.skill).success).toBe(true); + }); + + it("falls back to normal bounding for malformed oversized packages", () => { + const retained = retainExemptKernelRecordResult("agent_skill_read", { + success: true, + skill: "not-an-object".repeat(10_000), + }); + expect(retained).toBeUndefined(); + }); + }); + + describe("file_edit_* diff bounding", () => { + it("retains a parseable hunk-boundary prefix for oversized multi-hunk diffs", () => { + const hunk1 = `@@ -1,0 +1,1 @@\n+${"a".repeat(30_000)}\n`; + const diff = `Index: /x.ts\n===\n--- /x.ts\n+++ /x.ts\n${hunk1}@@ -9,0 +10,1 @@\n+${"b".repeat(30_000)}\n`; + const retained = retainExemptKernelRecordResult("file_edit_replace_string", { + success: true, + diff, + }) as { success?: boolean; diff?: string; diffTruncated?: boolean }; + expect(retained.success).toBe(true); + expect(retained.diffTruncated).toBe(true); + expect(retained.diff?.endsWith(hunk1)).toBe(true); + // combineDiffs must be able to parse and apply the retained prefix. + const patches = parsePatch(retained.diff!); + expect(patches[0]?.hunks.length).toBe(1); + }); + + it("drops the diff but keeps the truncation flag when no whole hunk fits", () => { + const retained = retainExemptKernelRecordResult("file_edit_insert", { + success: true, + diff: `@@ -1,0 +1,1 @@\n+${"g".repeat(60_000)}\n`, + }) as { success?: boolean; diff?: string; diffTruncated?: boolean }; + expect(retained.success).toBe(true); + expect(retained.diff).toBeUndefined(); + expect(retained.diffTruncated).toBe(true); + }); + }); + + describe("media container budgets", () => { + it("charges media metadata against the budget (empty-data mediaType attack)", () => { + // transformMCPResult copies server-controlled MIME types unchanged and + // the supported-type check accepts any image/ prefix, so a part hiding + // megabytes in mediaType with an EMPTY data string must still be + // charged — and the placeholder must not echo the junk label. + const junkType = `image/${"m".repeat(KERNEL_RETAINED_MEDIA_BUDGET_BYTES)}`; + const retained = retainExemptKernelRecordResult("mcp__shots__take", { + type: "content", + value: [ + { type: "media", mediaType: "image/png", data: "aGVsbG8=" }, + { type: "media", mediaType: junkType, data: "" }, + ], + }) as RetainedContainer; + expect(retained.value[0]?.data).toBe("aGVsbG8="); + expect(retained.value[1]?.type).toBe("text"); + expect(retained.value[1]?.text).toContain("aggregate media budget exceeded"); + expect(retained.value[1]?.text!.length).toBeLessThan(300); + }); + + it("charges non-media sibling parts against the budget", () => { + const retained = retainExemptKernelRecordResult("mcp__shots__take", { + type: "content", + value: [ + { type: "media", mediaType: "image/png", data: "aGVsbG8=" }, + { type: "text", text: "T".repeat(KERNEL_RETAINED_MEDIA_BUDGET_BYTES) }, + ], + }) as RetainedContainer; + expect(retained.value[0]?.data).toBe("aGVsbG8="); + expect(retained.value[1]?.text).toContain("part bounded at capture"); + }); + + it("caps the retained part count", () => { + const retained = retainExemptKernelRecordResult("mcp__shots__take", { + type: "content", + value: [ + { type: "media", mediaType: "image/png", data: "aGVsbG8=" }, + ...Array.from({ length: 200 }, (_, i) => ({ type: "text", text: `t${i}` })), + ], + }) as RetainedContainer; + expect(retained.value.length).toBe(KERNEL_RETAINED_CONTAINER_MAX_PARTS + 1); + expect(retained.value[KERNEL_RETAINED_CONTAINER_MAX_PARTS]?.text).toContain( + "container part limit exceeded" + ); + }); + }); +}); + +describe("sanitizeMediaRecordCapture", () => { + it("passes non-container results through untouched (non-RLM inline-results contract)", () => { + const result = { success: true, output: "x".repeat(100_000) }; + expect(sanitizeMediaRecordCapture("bash", result)).toBe(result); + }); + + it("bounds containers holding only unsupported media", () => { + // containsMediaContentPayload would NOT exempt this container (no + // supported media), but the mode-independent sanitizer must still bound + // it: classic mode would otherwise persist the raw base64 into records. + const sanitized = sanitizeMediaRecordCapture("mcp__rec__capture", { + type: "content", + value: [{ type: "media", mediaType: "audio/wav", data: "d2F2".repeat(50) }], + }) as RetainedContainer; + expect(sanitized.value[0]?.type).toBe("text"); + expect(sanitized.value[0]?.text).toContain("not supported as a model attachment"); + }); +}); + +describe("retainPersistenceCriticalArgsFields", () => { + it("preserves the validated path for file_edit tools", () => { + expect( + retainPersistenceCriticalArgsFields("file_edit_insert", { + path: "/a.ts", + content: "x".repeat(5_000), + }) + ).toEqual({ path: "/a.ts" }); + }); + + it("returns undefined for non-persistence-critical tools and unusable paths", () => { + expect(retainPersistenceCriticalArgsFields("bash", { path: "/a.ts" })).toBeUndefined(); + expect( + retainPersistenceCriticalArgsFields("file_edit_insert", "not-an-object") + ).toBeUndefined(); + // A path longer than any real filesystem path is guest junk: dropping it + // beats recording a truncated (wrong) attribution. + expect( + retainPersistenceCriticalArgsFields("file_edit_insert", { path: "p".repeat(5_000) }) + ).toBeUndefined(); + }); +}); diff --git a/src/node/services/ptc/types.ts b/src/node/services/ptc/types.ts index 2ec3fc4677..447b0b6074 100644 --- a/src/node/services/ptc/types.ts +++ b/src/node/services/ptc/types.ts @@ -8,10 +8,13 @@ import { FILE_EDIT_TOOL_NAMES } from "@/common/types/tools"; import { isSupportedAttachmentMediaType } from "@/common/utils/attachments/supportedAttachmentMediaTypes"; import { getToolOutputUiOnly } from "@/common/utils/tools/toolOutputUiOnly"; +import { extractToolFilePath } from "@/common/utils/tools/toolInputFilePath"; import { MAX_FILE_CONTENT_SIZE } from "@/common/constants/attachments"; import { KERNEL_COMPACT_ARGS_CAP_BYTES, + KERNEL_RETAINED_CONTAINER_MAX_PARTS, KERNEL_RETAINED_MEDIA_BUDGET_BYTES, + KERNEL_RETAINED_PATH_MAX_CHARS, } from "@/constants/kernelOutput"; /** @@ -148,14 +151,14 @@ export function retainExemptKernelRecordResult(toolName: string, result: unknown * Reduce a persistence-critical result to the bounded shape the * post-compaction extractors consume: * - * - file_edit_*: { success?, diff?, error? } with the diff capped at - * MAX_FILE_CONTENT_SIZE (+1 char so extractEditedFileDiffs still detects - * truncation via its `length > cap` check) — the ui_only diff variant is - * flattened onto `diff`, which the extractor reads as its fallback. + * - file_edit_*: { success?, diff?, diffTruncated?, error? } with oversized + * diffs truncated at a HUNK boundary (see boundRetainedEditDiff) — the + * ui_only diff variant is flattened onto `diff`, which the extractor reads + * as its fallback. * - agent_skill_read: { success, skill } / { success, error } passes through - * when its serialized size fits the same cap; oversized packages fall back - * to normal bounding (undefined) and the snapshot degrades like any other - * bounded record. + * when its serialized size fits MAX_FILE_CONTENT_SIZE; oversized packages + * keep a schema-valid skill with a truncated body (see + * boundOversizedSkillPackage) instead of being discarded. */ function boundPersistenceCriticalResult(toolName: string, result: unknown): unknown { if (typeof result !== "object" || result === null) return undefined; @@ -169,12 +172,10 @@ function boundPersistenceCriticalResult(toolName: string, result: unknown): unkn if (toolName === "agent_skill_read") { const skill = (result as { skill?: unknown }).skill; const reduced = { ...success, ...error, ...(skill !== undefined ? { skill } : {}) }; - try { - if (JSON.stringify(reduced).length > MAX_FILE_CONTENT_SIZE) return undefined; - } catch { - return undefined; - } - return reduced; + const reducedLength = serializedJsonLength(reduced); + if (reducedLength === undefined) return undefined; + if (reducedLength <= MAX_FILE_CONTENT_SIZE) return reduced; + return boundOversizedSkillPackage(skill, reducedLength, success, error); } const rawDiff = (result as { diff?: unknown }).diff; @@ -183,15 +184,88 @@ function boundPersistenceCriticalResult(toolName: string, result: unknown): unkn return { ...success, ...error, - ...(typeof diff === "string" - ? { - diff: - diff.length > MAX_FILE_CONTENT_SIZE ? diff.slice(0, MAX_FILE_CONTENT_SIZE + 1) : diff, - } - : {}), + ...(typeof diff === "string" ? boundRetainedEditDiff(diff) : {}), }; } +/** + * Note appended when a retained skill body is truncated at capture. Distinct + * from the snapshot-limit note in skillSnapshot.ts: this cut point depends on + * the package's serialized overhead, not MAX_AGENT_SKILL_SNAPSHOT_CHARS. + */ +const SKILL_BODY_CAPTURE_TRUNCATION_NOTE = + "\n\n[Skill body truncated at capture to fit the retained-record cap]"; + +/** + * Truncate an oversized skill package's BODY — the only unbounded field the + * snapshot extractor consumes — so the retained {success, skill} fits + * MAX_FILE_CONTENT_SIZE while AgentSkillPackageSchema still parses. A valid + * skill whose package serializes just above the cap (body near the separately + * supported 50k snapshot limit plus frontmatter overhead) must degrade to a + * bounded body like createLoadedSkillSnapshot does, not lose the whole + * package to a __kernelBounded marker (which compaction then drops entirely, + * erasing the skill instructions from every later turn). Serialized escape + * inflation only ever over-estimates the non-body overhead, so the sliced + * package is guaranteed to fit. Returns undefined (normal bounding) for + * malformed packages the extractor would reject anyway. + */ +function boundOversizedSkillPackage( + skill: unknown, + reducedLength: number, + success: { success?: boolean }, + error: { error?: string } +): unknown { + if (typeof skill !== "object" || skill === null) return undefined; + const body = (skill as { body?: unknown }).body; + if (typeof body !== "string") return undefined; + // Serialized note length minus the surrounding quotes. + const noteSerializedChars = JSON.stringify(SKILL_BODY_CAPTURE_TRUNCATION_NOTE).length - 2; + const budget = MAX_FILE_CONTENT_SIZE - (reducedLength - body.length) - noteSerializedChars; + if (budget <= 0) return undefined; + return { + ...success, + ...error, + skill: { ...skill, body: `${body.slice(0, budget)}${SKILL_BODY_CAPTURE_TRUNCATION_NOTE}` }, + }; +} + +/** + * Bound an oversized unified diff at a HUNK boundary. A mid-hunk slice is not + * parseable — parsePatch throws on it and applyPatch cannot apply it — so + * combineDiffs would fall back to ONLY the last diff for the file: a later + * small edit would erase the earlier large edit from post-compaction context + * entirely. Whole hunks form a valid, applicable prefix, and diffTruncated + * propagates the loss to FileEditDiff.truncated (see extractEditedFileDiffs). + * When not even the first hunk fits, only the flag is retained; the record + * still attributes the edit via success + path. + */ +function boundRetainedEditDiff(diff: string): { diff?: string; diffTruncated?: true } { + if (diff.length <= MAX_FILE_CONTENT_SIZE) return { diff }; + // Hunk headers are the only diff lines starting with "@@ " (body lines + // start with ' ', '+', '-', or '\'). + const hunkHeader = /^@@ /gm; + const hunkStarts: number[] = []; + let match; + while ((match = hunkHeader.exec(diff)) !== null) hunkStarts.push(match.index); + let end = 0; + for (let i = 0; i < hunkStarts.length; i++) { + const hunkEnd = i + 1 < hunkStarts.length ? hunkStarts[i + 1] : diff.length; + if (hunkEnd > MAX_FILE_CONTENT_SIZE) break; + end = hunkEnd; + } + if (end === 0) return { diffTruncated: true }; + return { diff: diff.slice(0, end), diffTruncated: true }; +} + +/** JSON.stringify length, or undefined when unserializable (cycles, BigInt). */ +function serializedJsonLength(value: unknown): number | undefined { + try { + return JSON.stringify(value)?.length; + } catch { + return undefined; + } +} + /** Media-part shape check shared by the container predicates below. */ function asMediaPart(item: unknown): { data: string; mediaType?: string } | null { if (typeof item !== "object" || item === null) return null; @@ -204,42 +278,122 @@ function asMediaPart(item: unknown): { data: string; mediaType?: string } | null } /** - * See retainExemptKernelRecordResult: sanitize an otherwise-retained media - * container. Unsupported media parts are always replaced with bounded - * placeholders, and supported parts are charged against an aggregate budget - * (KERNEL_RETAINED_MEDIA_BUDGET_BYTES) — MCP enforces only a per-part guard, - * so many individually-allowed images could otherwise persist unbounded - * aggregate base64 into events and chat.jsonl rows. + * Bounded label for placeholder text: mediaType is server-controlled and can + * itself be arbitrarily long ("image/" + megabytes still passes the + * supported-type prefix check), so never interpolate it raw. + */ +function boundedMediaTypeLabel(mediaType: string | undefined): string { + if (mediaType === undefined) return "unknown"; + return mediaType.length > 100 ? `${mediaType.slice(0, 100)}…` : mediaType; +} + +/** + * See retainExemptKernelRecordResult / sanitizeMediaRecordCapture: sanitize a + * retained media container. Unsupported media parts are always replaced with + * bounded placeholders, and every OTHER retained part — media or not — is + * charged its FULL serialized size (metadata and structure included) against + * an aggregate budget (KERNEL_RETAINED_MEDIA_BUDGET_BYTES): MCP enforces only + * a per-part data guard, so payloads could otherwise hide in mediaType + * strings, sibling fields, or many individually-allowed images and persist + * unbounded bytes into events and chat.jsonl rows. Part COUNT is bounded + * separately (KERNEL_RETAINED_CONTAINER_MAX_PARTS) so placeholder structure + * cannot grow without bound either. Idempotent: placeholders are small text + * parts that re-charge identically on a second pass. */ function sanitizeRetainedMediaContainer(result: unknown): unknown { const container = result as { type: "content"; value: unknown[] }; - let changed = false; - let retainedMediaBytes = 0; - const value = container.value.map((item) => { + const parts = container.value.slice(0, KERNEL_RETAINED_CONTAINER_MAX_PARTS); + let changed = parts.length < container.value.length; + let retainedBytes = 0; + const value: unknown[] = parts.map((item) => { const media = asMediaPart(item); - if (media === null) { - return item; - } - if (media.mediaType !== undefined && isSupportedAttachmentMediaType(media.mediaType)) { - if (retainedMediaBytes + media.data.length <= KERNEL_RETAINED_MEDIA_BUDGET_BYTES) { - retainedMediaBytes += media.data.length; - return item; - } + if ( + media !== null && + (media.mediaType === undefined || !isSupportedAttachmentMediaType(media.mediaType)) + ) { changed = true; return { type: "text", - text: `[media bounded at capture: ${media.mediaType}, ${media.data.length} base64 chars — aggregate media budget exceeded]`, + text: `[media bounded at capture: ${boundedMediaTypeLabel(media.mediaType)}, ${media.data.length} base64 chars — not supported as a model attachment]`, }; } + const serialized = serializedJsonLength(item); + if ( + serialized !== undefined && + retainedBytes + serialized <= KERNEL_RETAINED_MEDIA_BUDGET_BYTES + ) { + retainedBytes += serialized; + return item; + } changed = true; - return { - type: "text", - text: `[media bounded at capture: ${media.mediaType ?? "unknown"}, ${media.data.length} base64 chars — not supported as a model attachment]`, - }; + return media !== null + ? { + type: "text", + text: `[media bounded at capture: ${boundedMediaTypeLabel(media.mediaType)}, ${media.data.length} base64 chars — aggregate media budget exceeded]`, + } + : { + type: "text", + text: `[part bounded at capture: ${serialized ?? "unserializable"} serialized chars — aggregate media budget exceeded]`, + }; }); + if (parts.length < container.value.length) { + value.push({ + type: "text", + text: `[${container.value.length - parts.length} additional part(s) bounded at capture — container part limit exceeded]`, + }); + } return changed ? { ...container, value } : result; } +/** + * Mode-independent capture sanitizer (see IJSRuntime.setCaptureResultSanitizer), + * applied to results captured into records/events in BOTH classic and kernel + * mode. Classic (non-RLM) records keep full inline results by contract, but + * media containers are the exception: exclusive PTC makes the bridge the only + * route to executable MCP tools, records/events persist into + * partial.json/chat.jsonl, and request-time attachment extraction rewrites + * only the provider copy — without a capture budget a server returning many + * individually-allowed images would persist unbounded multi-megabyte records + * in default (non-RLM) PTC mode. The guest still receives the full value. + */ +export function sanitizeMediaRecordCapture(_toolName: string, result: unknown): unknown { + return isMediaContentContainer(result) ? sanitizeRetainedMediaContainer(result) : result; +} + +/** + * Any MCP-style content container carrying at least one media part — + * supported or not. Broader than containsMediaContentPayload (which gates the + * kernel retain EXEMPTION on extractable/supported media): the capture + * sanitizer must also bound containers holding only unsupported media + * (audio/blobs), which classic mode would otherwise persist raw. + */ +function isMediaContentContainer(result: unknown): boolean { + if (typeof result !== "object" || result === null) return false; + const container = result as { type?: unknown; value?: unknown }; + if (container.type !== "content" || !Array.isArray(container.value)) return false; + return container.value.some((item: unknown) => asMediaPart(item) !== null); +} + +/** + * Capture-time attribution fields merged onto a __kernelBounded ARGS marker + * (see KernelRecordBounds.captureArgsRetained): a file_edit_* whose args + * exceed the kernel args cap (e.g. inserting >2 KiB of content) would + * otherwise lose its `path`, and both post-compaction diff preservation and + * crash-safe edited-file tracking skip records they cannot attribute — + * silently dropping a successful edit whose bounded diff WAS retained. Only + * the validated path field is preserved; the marker's preview keeps the head + * of everything else. + */ +export function retainPersistenceCriticalArgsFields( + toolName: string, + args: unknown +): Record | undefined { + if (!isPersistenceCriticalRecordToolName(toolName)) return undefined; + const path = extractToolFilePath(args); + if (path === undefined || path.length > KERNEL_RETAINED_PATH_MAX_CHARS) return undefined; + return { path }; +} + /** See isKernelRecordResultExempt (persistence-critical branch). */ export function isPersistenceCriticalRecordToolName(toolName: string): boolean { return ( diff --git a/src/node/services/tools/code_execution.test.ts b/src/node/services/tools/code_execution.test.ts index ed92da92bb..683b8f2b37 100644 --- a/src/node/services/tools/code_execution.test.ts +++ b/src/node/services/tools/code_execution.test.ts @@ -965,13 +965,16 @@ describe("createCodeExecutionTool", () => { await host.disposeScope("ws-exempt-bounds"); }); - it("caps oversized persistence payloads at capture (diff keeps the downstream truncation signal)", async () => { - // generateDiff is unbounded upstream; a >50k diff must be sliced at - // capture (to cap+1 chars, so extractEditedFileDiffs' `length > cap` - // truncation check still fires) instead of persisting megabytes. + it("bounds oversized diffs at a hunk boundary at capture (parseable prefix + truncation flag)", async () => { + // generateDiff is unbounded upstream; a >50k diff must be bounded at + // capture — but a mid-hunk slice is unparseable (parsePatch throws, + // combineDiffs falls back to only the LAST diff, erasing this edit), so + // whole hunks are kept and diffTruncated carries the loss signal. using tmp = new DisposableTempDir("code-exec-oversized-diff"); const host = new SandboxHostService(); - const hugeDiff = `@@ -0,0 +1 @@\n+${"y".repeat(80_000)}`; + const hunk1 = `@@ -1,0 +1,1 @@\n+${"a".repeat(30_000)}\n`; + const hunk2 = `@@ -5,0 +7,1 @@\n+${"b".repeat(30_000)}\n`; + const hugeDiff = `${hunk1}${hunk2}`; const tools: Record = { file_edit_insert: createMockTool( "file_edit_insert", @@ -995,12 +998,91 @@ describe("createCodeExecutionTool", () => { )) as PTCExecutionResult; expect(result.success).toBe(true); const record = result.toolCalls.find((r) => r.toolName === "file_edit_insert"); - const diff = (record?.result as { diff?: string })?.diff; - expect(diff?.length).toBe(50_001); - expect(hugeDiff.startsWith(diff!)).toBe(true); + const retained = record?.result as { diff?: string; diffTruncated?: boolean }; + // The first whole hunk is retained; the second (which would cross the + // cap) is dropped, and the truncation is flagged for the extractor. + expect(retained?.diff).toBe(hunk1); + expect(retained?.diffTruncated).toBe(true); await host.disposeScope("ws-oversized-diff"); }); + it("preserves the edit path on a bounded-args marker", async () => { + // Inserting >2KiB of content bounds the record's ARGS to a + // __kernelBounded marker; without the merged-back path, extractors + // could not attribute the retained diff and would silently drop the + // successful edit from post-compaction context (round 8, P1). + using tmp = new DisposableTempDir("code-exec-bounded-edit-args"); + const host = new SandboxHostService(); + const tools: Record = { + file_edit_insert: createMockTool( + "file_edit_insert", + z.object({ path: z.string(), content: z.string() }), + () => ({ success: true, diff: "@@ -1,0 +1,1 @@\n+hello\n" }) + ), + }; + const tool = await createCodeExecutionTool( + runtimeFactory, + new ToolBridge(tools), + undefined, + persistentRunner(host, "ws-bounded-edit-args", tmp.path) + ); + + const result = (await tool.execute!( + { + code: 'mux.file_edit_insert({path: "/kept.ts", content: "x".repeat(5000)}); return true;', + }, + mockToolCallOptions + )) as PTCExecutionResult; + expect(result.success).toBe(true); + const record = result.toolCalls.find((r) => r.toolName === "file_edit_insert"); + const args = record?.args as { + __kernelBounded?: boolean; + path?: string; + preview?: string; + }; + expect(args.__kernelBounded).toBe(true); + expect(args.path).toBe("/kept.ts"); + // The oversized content itself stays bounded to the marker preview. + expect(JSON.stringify(args).length).toBeLessThan(4 * 1024); + expect((record?.result as { diff?: string })?.diff).toContain("+hello"); + await host.disposeScope("ws-bounded-edit-args"); + }); + + it("budgets media containers at capture in classic (non-kernel) mode too", async () => { + // Exclusive PTC makes the bridge the only route to executable MCP + // tools even without RLM, and records/events persist into session + // history in every mode while request-time extraction rewrites only + // the provider copy — so the aggregate media budget must apply to + // ephemeral registrations as well (round 8). + const bigImage = "A".repeat(2 * 1024 * 1024); + const tools: Record = { + mcp__shots__take: createMockTool("mcp__shots__take", z.object({}), () => ({ + type: "content", + value: [ + { type: "media", mediaType: "image/png", data: bigImage }, + { type: "media", mediaType: "image/png", data: bigImage }, + ], + })), + }; + const tool = await createCodeExecutionTool(runtimeFactory, new ToolBridge(tools)); + + const result = (await tool.execute!( + // The guest still receives the full container: only the RECORD is + // sanitized. + { code: "const r = mux.mcp__shots__take({}); return r.value[1].data.length;" }, + mockToolCallOptions + )) as PTCExecutionResult; + expect(result.success).toBe(true); + expect(result.result).toBe(bigImage.length); + const record = result.toolCalls.find((r) => r.toolName === "mcp__shots__take"); + const value = ( + record?.result as { value?: Array<{ type?: string; data?: string; text?: string }> } + )?.value; + expect(value?.[0]?.data).toBe(bigImage); + expect(value?.[1]?.type).toBe("text"); + expect(value?.[1]?.text).toContain("aggregate media budget exceeded"); + }); + it("charges retained media against an aggregate budget", async () => { // MCP applies only a per-part guard: many individually-allowed images // must not persist unbounded aggregate base64 into records/events. diff --git a/src/node/services/workflows/WorkflowRunner.test.ts b/src/node/services/workflows/WorkflowRunner.test.ts index ff49deb3f1..ceb2035252 100644 --- a/src/node/services/workflows/WorkflowRunner.test.ts +++ b/src/node/services/workflows/WorkflowRunner.test.ts @@ -3230,6 +3230,7 @@ describe("WorkflowRunner", () => { registerSyncFunction: noop, setVarsProperty: noop, setKernelRecordBounds: noop, + setCaptureResultSanitizer: noop, setPendingJobGate: noop, onEvent: noop, abort: noop, From c7eb4e338d822d861ee436c87f5b5199363c104a Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 20:58:09 +0000 Subject: [PATCH 11/40] review r9: sanitize classic outer return + console media at capture; charge media budget in UTF-8 bytes --- src/node/services/ptc/quickjsRuntime.ts | 12 ++++- src/node/services/ptc/types.test.ts | 16 ++++++ src/node/services/ptc/types.ts | 40 +++++++++++++-- .../services/tools/code_execution.test.ts | 51 +++++++++++++++++++ src/node/services/tools/code_execution.ts | 13 ++++- 5 files changed, 125 insertions(+), 7 deletions(-) diff --git a/src/node/services/ptc/quickjsRuntime.ts b/src/node/services/ptc/quickjsRuntime.ts index 7e3ccd939e..81af63f4c8 100644 --- a/src/node/services/ptc/quickjsRuntime.ts +++ b/src/node/services/ptc/quickjsRuntime.ts @@ -1328,11 +1328,19 @@ export class QuickJSRuntime implements IJSRuntime { } budget.retainedBytes += size; - attribution.consoleOutput.push({ level, args, timestamp }); + // Media containers are budgeted at capture like tool-call records + // (see setCaptureResultSanitizer): console events stream into session + // history immediately, so any later sanitization would miss the + // streamed copy. Budget accounting stays on the raw size above — + // sanitization only shrinks, never grows. + const sanitizer = this.captureResultSanitizer; + const captured = + sanitizer !== undefined ? args.map((arg) => sanitizer("console", arg)) : args; + attribution.consoleOutput.push({ level, args: captured, timestamp }); attribution.eventHandler?.({ type: "console", level, - args, + args: captured, timestamp, }); }); diff --git a/src/node/services/ptc/types.test.ts b/src/node/services/ptc/types.test.ts index bc44c8aac7..a9fb0ad8a3 100644 --- a/src/node/services/ptc/types.test.ts +++ b/src/node/services/ptc/types.test.ts @@ -103,6 +103,22 @@ describe("retainExemptKernelRecordResult", () => { expect(retained.value[1]?.text!.length).toBeLessThan(300); }); + it("charges the budget in UTF-8 bytes, not UTF-16 code units", () => { + // ~1.5M CJK chars ≈ 4.5 MiB in UTF-8 history — a character-based check + // would retain this part under the 3 MiB budget. + const junkType = `image/${"画".repeat(1_500_000)}`; + const retained = retainExemptKernelRecordResult("mcp__shots__take", { + type: "content", + value: [ + { type: "media", mediaType: "image/png", data: "aGVsbG8=" }, + { type: "media", mediaType: junkType, data: "" }, + ], + }) as RetainedContainer; + expect(retained.value[0]?.data).toBe("aGVsbG8="); + expect(retained.value[1]?.type).toBe("text"); + expect(retained.value[1]?.text).toContain("aggregate media budget exceeded"); + }); + it("charges non-media sibling parts against the budget", () => { const retained = retainExemptKernelRecordResult("mcp__shots__take", { type: "content", diff --git a/src/node/services/ptc/types.ts b/src/node/services/ptc/types.ts index 447b0b6074..d13eaba412 100644 --- a/src/node/services/ptc/types.ts +++ b/src/node/services/ptc/types.ts @@ -257,7 +257,13 @@ function boundRetainedEditDiff(diff: string): { diff?: string; diffTruncated?: t return { diff: diff.slice(0, end), diffTruncated: true }; } -/** JSON.stringify length, or undefined when unserializable (cycles, BigInt). */ +/** + * JSON.stringify length in CHARACTERS, or undefined when unserializable + * (cycles, BigInt). Used for the persistence-critical caps, which are char + * caps by design: MAX_FILE_CONTENT_SIZE bounds repo-controlled diff/skill + * text everywhere else in the codebase via .length/.slice(), and the + * downstream extractors compare the same way. + */ function serializedJsonLength(value: unknown): number | undefined { try { return JSON.stringify(value)?.length; @@ -266,6 +272,21 @@ function serializedJsonLength(value: unknown): number | undefined { } } +/** + * JSON.stringify length in UTF-8 BYTES, or undefined when unserializable. + * The media budget is a byte bound on persisted history: charging chars + * would let server-controlled multibyte metadata (e.g. "image/" + millions + * of CJK characters) occupy ~3x the documented budget on disk. + */ +function serializedJsonByteLength(value: unknown): number | undefined { + try { + const serialized = JSON.stringify(value); + return serialized === undefined ? undefined : Buffer.byteLength(serialized, "utf8"); + } catch { + return undefined; + } +} + /** Media-part shape check shared by the container predicates below. */ function asMediaPart(item: unknown): { data: string; mediaType?: string } | null { if (typeof item !== "object" || item === null) return null; @@ -317,7 +338,7 @@ function sanitizeRetainedMediaContainer(result: unknown): unknown { text: `[media bounded at capture: ${boundedMediaTypeLabel(media.mediaType)}, ${media.data.length} base64 chars — not supported as a model attachment]`, }; } - const serialized = serializedJsonLength(item); + const serialized = serializedJsonByteLength(item); if ( serialized !== undefined && retainedBytes + serialized <= KERNEL_RETAINED_MEDIA_BUDGET_BYTES @@ -333,7 +354,7 @@ function sanitizeRetainedMediaContainer(result: unknown): unknown { } : { type: "text", - text: `[part bounded at capture: ${serialized ?? "unserializable"} serialized chars — aggregate media budget exceeded]`, + text: `[part bounded at capture: ${serialized ?? "unserializable"} serialized bytes — aggregate media budget exceeded]`, }; }); if (parts.length < container.value.length) { @@ -357,7 +378,18 @@ function sanitizeRetainedMediaContainer(result: unknown): unknown { * in default (non-RLM) PTC mode. The guest still receives the full value. */ export function sanitizeMediaRecordCapture(_toolName: string, result: unknown): unknown { - return isMediaContentContainer(result) ? sanitizeRetainedMediaContainer(result) : result; + return sanitizeCapturedMediaValue(result); +} + +/** + * Tool-name-free form of sanitizeMediaRecordCapture for values that are not + * nested tool records: the classic execution's outer return value and console + * args also persist into the code_execution history row (see + * createCodeExecutionTool), and `return xum.(...)` / + * `console.log(...)` would otherwise carry the raw unbudgeted container. + */ +export function sanitizeCapturedMediaValue(value: unknown): unknown { + return isMediaContentContainer(value) ? sanitizeRetainedMediaContainer(value) : value; } /** diff --git a/src/node/services/tools/code_execution.test.ts b/src/node/services/tools/code_execution.test.ts index 683b8f2b37..82f8e85203 100644 --- a/src/node/services/tools/code_execution.test.ts +++ b/src/node/services/tools/code_execution.test.ts @@ -1083,6 +1083,57 @@ describe("createCodeExecutionTool", () => { expect(value?.[1]?.text).toContain("aggregate media budget exceeded"); }); + it("sanitizes returned and console-logged media containers in classic mode", async () => { + // Classic executions have no offload stage: `return xum.()` + // assigns the guest value directly to the outer PTCExecutionResult + // result, which persists into partial.json/chat.jsonl — the + // record-level sanitizer alone leaves that copy unbudgeted (round 9). + // Console args are sanitized at CAPTURE (streamed events included); + // over-budget console records are separately dropped whole by the + // console capture budget, so the observable case here is unsupported + // media riding under that budget. + const bigImage = "A".repeat(2 * 1024 * 1024); + const audioData = "d2F2".repeat(50); + const tools: Record = { + mcp__shots__take: createMockTool("mcp__shots__take", z.object({}), () => ({ + type: "content", + value: [ + { type: "media", mediaType: "image/png", data: bigImage }, + { type: "media", mediaType: "image/png", data: bigImage }, + ], + })), + mcp__rec__capture: createMockTool("mcp__rec__capture", z.object({}), () => ({ + type: "content", + value: [{ type: "media", mediaType: "audio/wav", data: audioData }], + })), + }; + const tool = await createCodeExecutionTool(runtimeFactory, new ToolBridge(tools)); + + const result = (await tool.execute!( + { + code: "const r = mux.mcp__shots__take({}); console.log(mux.mcp__rec__capture({})); return r;", + }, + mockToolCallOptions + )) as PTCExecutionResult; + expect(result.success).toBe(true); + + const outer = ( + result.result as { value?: Array<{ type?: string; data?: string; text?: string }> } + )?.value; + expect(outer?.[0]?.data).toBe(bigImage); + expect(outer?.[1]?.type).toBe("text"); + expect(outer?.[1]?.text).toContain("aggregate media budget exceeded"); + + const consoleArg = ( + result.consoleOutput[0]?.args[0] as { + value?: Array<{ type?: string; text?: string }>; + } + )?.value; + expect(consoleArg?.[0]?.type).toBe("text"); + expect(consoleArg?.[0]?.text).toContain("not supported as a model attachment"); + expect(JSON.stringify(result.consoleOutput)).not.toContain(audioData); + }); + it("charges retained media against an aggregate budget", async () => { // MCP applies only a per-part guard: many individually-allowed images // must not persist unbounded aggregate base64 into records/events. diff --git a/src/node/services/tools/code_execution.ts b/src/node/services/tools/code_execution.ts index c1ab4d81ff..179ed79246 100644 --- a/src/node/services/tools/code_execution.ts +++ b/src/node/services/tools/code_execution.ts @@ -32,7 +32,7 @@ import { } from "@/constants/resultHandles"; import { KERNEL_COMPACT_ARGS_CAP_BYTES, KERNEL_CONSOLE_CAP_BYTES } from "@/constants/kernelOutput"; import { sliceUtf8Bytes } from "@/common/utils/sliceUtf8Bytes"; -import { isKernelRecordResultExempt } from "@/node/services/ptc/types"; +import { isKernelRecordResultExempt, sanitizeCapturedMediaValue } from "@/node/services/ptc/types"; // Default limits const DEFAULT_MEMORY_BYTES = 64 * 1024 * 1024; // 64MB @@ -671,6 +671,17 @@ ${xumTypes} if (mount?.lifetime === "persistent") { compactKernelToolCallRecords(result, loadActive); capKernelConsoleOutput(result); + } else { + // Classic executions have no offload stage, so a guest that + // RETURNS a bridged media container assigns the raw multi-image + // payload directly to the outer result, which persists into this + // record's history row — the capture sanitizer only covers nested + // tool-call records and console args, and request-time attachment + // extraction rewrites only the provider copy, never + // partial.json/chat.jsonl. Budget it at the same boundary. Kernel + // mode is excluded on purpose: its outer result feeds vars-handle + // offloading, which must store full fidelity for the guest. + result.result = sanitizeCapturedMediaValue(result.result); } // RLM return-value offloading BEFORE the vars snapshot below, so the From 247bd12fc7b8d3719aeacf9544af2d0e4f022139 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 21:22:04 +0000 Subject: [PATCH 12/40] review r10: alias legacy PTC in follow-up dispatch; deep-walk outer media sanitization; execution-wide retained-result budget --- src/constants/kernelOutput.ts | 13 ++++ ...gentSession.continueMessageAgentId.test.ts | 31 ++++++++ src/node/services/agentSession.ts | 8 ++- src/node/services/ptc/quickjsRuntime.ts | 58 +++++++++++++-- src/node/services/ptc/types.test.ts | 50 +++++++++++++ src/node/services/ptc/types.ts | 71 ++++++++++++++++--- .../services/tools/code_execution.test.ts | 53 +++++++++++++- 7 files changed, 266 insertions(+), 18 deletions(-) diff --git a/src/constants/kernelOutput.ts b/src/constants/kernelOutput.ts index c32df55ae9..2316054c46 100644 --- a/src/constants/kernelOutput.ts +++ b/src/constants/kernelOutput.ts @@ -60,6 +60,19 @@ export const KERNEL_RETAINED_MEDIA_BUDGET_BYTES = 3 * 1024 * 1024; */ export const KERNEL_RETAINED_CONTAINER_MAX_PARTS = 64; +/** + * Execution-wide byte budget for RETAINED kernel record results (see + * QuickJSRuntime.boundCaptureResult). Retained results (persistence-critical + * diffs/skills, media containers) legitimately bypass the per-record 16KiB + * kernel bound, but each is only individually capped (~50k chars / 3MiB + * media) — a loop of retained calls would otherwise append megabytes per + * call to toolCalls and streamed history without limit. This bounds their + * SUM per execution; on exhaustion, further oversized results fall back to + * normal bounding (honest-size markers) while small results pass unaffected. + * Sized for several full media containers plus hundreds of bounded diffs. + */ +export const KERNEL_RETAINED_EXECUTION_BUDGET_BYTES = 4 * KERNEL_RETAINED_MEDIA_BUDGET_BYTES; + /** * Max chars of a validated tool-arg file path preserved on a __kernelBounded * args marker (see retainPersistenceCriticalArgsFields). Covers Linux diff --git a/src/node/services/agentSession.continueMessageAgentId.test.ts b/src/node/services/agentSession.continueMessageAgentId.test.ts index 09532400fb..214166e3a0 100644 --- a/src/node/services/agentSession.continueMessageAgentId.test.ts +++ b/src/node/services/agentSession.continueMessageAgentId.test.ts @@ -204,6 +204,37 @@ describe("AgentSession continue-message agentId fallback", () => { expect(dispatchedInternal?.synthetic).toBe(true); }); + test("dispatchPendingFollowUp aliases legacy exclusive-PTC experiments", async () => { + // An older build can persist {programmaticToolCalling: false, + // programmaticToolCallingExclusive: true}; dispatch copies raw persisted + // JSON into the next send, and the explicit false would otherwise win + // over backend overrides while the removed legacy field is ignored — + // silently downgrading the crash-safe follow-up to PTC-off (and making + // its rlm flag inert). + let dispatchedOptions: SendOptions | undefined; + const { internals } = await createSession([ + compactionSummaryMessage("summary-legacy-ptc", { + text: "continue after compaction", + model: "openai:gpt-4o", + agentId: "exec", + experiments: { + programmaticToolCalling: false, + programmaticToolCallingExclusive: true, + rlm: true, + }, + }), + ]); + internals.sendMessage = mock((_message: string, options?: SendOptions) => { + dispatchedOptions = options; + return Promise.resolve({ success: true as const }); + }); + + await internals.dispatchPendingFollowUp(); + + expect(dispatchedOptions?.experiments?.programmaticToolCalling).toBe(true); + expect(dispatchedOptions?.experiments?.rlm).toBe(true); + }); + test("dispatchPendingFollowUp preserves agent-initiated attribution", async () => { let dispatchedInternal: { synthetic?: boolean; agentInitiated?: boolean } | undefined; const { internals } = await createSession([ diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index cc4ccb701b..d74c227a8e 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -7130,7 +7130,13 @@ export class AgentSession { reasoningMode: followUp.reasoningMode, additionalSystemInstructions: followUp.additionalSystemInstructions, providerOptions: followUp.providerOptions, - experiments: followUp.experiments, + // Raw JSON boundary (same as the startup-retry snapshot read above): an + // older build may have persisted {programmaticToolCalling: false, + // programmaticToolCallingExclusive: true}, and the explicit false would + // otherwise win over backend overrides while the removed legacy field + // is ignored — silently downgrading the crash-safe follow-up to + // PTC-off (and making its rlm flag inert). + experiments: aliasLegacyPtcExclusive(followUp.experiments), allowAgentSetGoal: followUp.allowAgentSetGoal, disableWorkspaceAgents: followUp.disableWorkspaceAgents, // Explicit-agent turns stay loud on the resumed turn too: the requested agent diff --git a/src/node/services/ptc/quickjsRuntime.ts b/src/node/services/ptc/quickjsRuntime.ts index 81af63f4c8..2be1fdb775 100644 --- a/src/node/services/ptc/quickjsRuntime.ts +++ b/src/node/services/ptc/quickjsRuntime.ts @@ -14,7 +14,10 @@ import { QuickJSAsyncFFI } from "@jitl/quickjs-wasmfile-release-asyncify/ffi"; import crypto from "crypto"; import type { IJSRuntime, IJSRuntimeFactory, KernelRecordBounds, RuntimeLimits } from "./runtime"; import type { PTCEvent, PTCExecutionResult, PTCToolCallRecord, PTCConsoleRecord } from "./types"; -import { CONSOLE_CAPTURE_BUDGET_BYTES } from "@/constants/kernelOutput"; +import { + CONSOLE_CAPTURE_BUDGET_BYTES, + KERNEL_RETAINED_EXECUTION_BUDGET_BYTES, +} from "@/constants/kernelOutput"; import { sliceUtf8Bytes } from "@/common/utils/sliceUtf8Bytes"; /** Capture-time console retention accounting for one eval (see setupConsole). */ @@ -184,6 +187,15 @@ export class QuickJSRuntime implements IJSRuntime { private kernelRecordBounds?: KernelRecordBounds; /** Mode-independent record sanitizer; see IJSRuntime.setCaptureResultSanitizer. */ private captureResultSanitizer?: (toolName: string, result: unknown) => unknown; + /** Per-execution byte budgets for RETAINED record results, keyed by the + * attribution's record array like consoleBudgets (fresh array per eval; + * late fire-and-forget settlements share their originating eval's budget). + * Retained results bypass the per-record kernel bound by design, so their + * SUM must be bounded here (see boundCaptureResult). */ + private readonly retainedResultBudgets = new WeakMap< + PTCToolCallRecord[], + { remainingBytes: number } + >(); /** Monotonic eval counter + the generation currently inside eval() (null * between evals). Distinguishes settlements arriving mid-eval (queued for * the eval's own drain points) from truly-late ones between evals (gated). @@ -314,7 +326,7 @@ export class QuickJSRuntime implements IJSRuntime { const result = await fn(...args); const endTime = Date.now(); const duration_ms = endTime - startTime; - const recordResult = this.boundCaptureResult(result, name); + const recordResult = this.boundCaptureResult(result, name, this.toolCalls); // Record tool call this.toolCalls.push({ @@ -480,7 +492,7 @@ export class QuickJSRuntime implements IJSRuntime { const endTime = Date.now(); // Same creation-time bounding as synchronous bridges (kernel mode). const recordArgs = this.boundCaptureArgs(args[0], name); - const recordResult = this.boundCaptureResult(result, name); + const recordResult = this.boundCaptureResult(result, name, toolCalls); toolCalls.push({ toolName: name, args: recordArgs, @@ -631,7 +643,11 @@ export class QuickJSRuntime implements IJSRuntime { return `${sliceUtf8Bytes(errorStr, capBytes)}…[${bytes} bytes total; truncated]`; } - private boundCaptureResult(value: unknown, toolName: string): unknown { + private boundCaptureResult( + value: unknown, + toolName: string, + toolCalls: PTCToolCallRecord[] + ): unknown { // The mode-independent sanitizer runs first (both classic and kernel // mode): media containers are budgeted at capture because records/events // persist into session history in every mode, and request-time @@ -646,10 +662,40 @@ export class QuickJSRuntime implements IJSRuntime { // reconstruct context from them, so a bounded preview would silently // lose it. const retained = this.kernelRecordBounds.captureRetained?.(toolName, sanitized); - if (retained !== undefined) return retained; + if (retained !== undefined) { + // Execution-wide budget on retained results: each is individually + // bounded, but retention bypasses the per-record kernel cap by design, + // so a loop of retained calls would otherwise append ~3MiB containers + // or 50k-char persistence records without limit. Unserializable + // values count as overflow — never retain for free. + let size: number; + try { + size = Buffer.byteLength(JSON.stringify(retained) ?? "", "utf8"); + } catch { + size = Number.POSITIVE_INFINITY; + } + const budget = this.retainedBudgetFor(toolCalls); + if (size <= budget.remainingBytes) { + budget.remainingBytes -= size; + return retained; + } + // Budget exhausted: fall through to normal bounding — oversized + // results become honest-size markers, small results still pass inline. + } return this.boundCapture(sanitized, this.kernelRecordBounds.resultCapBytes); } + /** Get-or-create the retained-result budget for one attribution's record + * array (keying mirrors consoleBudgetFor). */ + private retainedBudgetFor(toolCalls: PTCToolCallRecord[]): { remainingBytes: number } { + let budget = this.retainedResultBudgets.get(toolCalls); + if (!budget) { + budget = { remainingBytes: KERNEL_RETAINED_EXECUTION_BUDGET_BYTES }; + this.retainedResultBudgets.set(toolCalls, budget); + } + return budget; + } + setPendingJobGate(gate: (run: () => void) => void): void { this.pendingJobGate = gate; } @@ -827,7 +873,7 @@ export class QuickJSRuntime implements IJSRuntime { const result = await fn(...args); const endTime = Date.now(); const duration_ms = endTime - startTime; - const recordResult = this.boundCaptureResult(result, methodName); + const recordResult = this.boundCaptureResult(result, methodName, this.toolCalls); // Record tool call this.toolCalls.push({ diff --git a/src/node/services/ptc/types.test.ts b/src/node/services/ptc/types.test.ts index a9fb0ad8a3..2630dfb12e 100644 --- a/src/node/services/ptc/types.test.ts +++ b/src/node/services/ptc/types.test.ts @@ -9,6 +9,7 @@ import { import { retainExemptKernelRecordResult, retainPersistenceCriticalArgsFields, + sanitizeCapturedMediaValue, sanitizeMediaRecordCapture, } from "./types"; @@ -153,6 +154,55 @@ describe("sanitizeMediaRecordCapture", () => { expect(sanitizeMediaRecordCapture("bash", result)).toBe(result); }); + it("sanitizes containers wrapped inside return values", () => { + // Guests can wrap bridged results (`return { image: xum.mcp(...) }`); + // a root-only check would pass the wrapper through raw and persist + // unbudgeted base64 (round 10). + const audio = { type: "media", mediaType: "audio/wav", data: "d2F2".repeat(50) }; + const wrapped = { image: { type: "content", value: [audio] }, note: "kept" }; + const sanitized = sanitizeCapturedMediaValue(wrapped) as { + image: RetainedContainer; + note: string; + }; + expect(sanitized.note).toBe("kept"); + expect(sanitized.image.value[0]?.type).toBe("text"); + expect(sanitized.image.value[0]?.text).toContain("not supported as a model attachment"); + }); + + it("shares one aggregate budget across all containers in a value", () => { + // Wrapping N containers must not multiply the bound: two ~2MiB images in + // separate wrapped containers exceed one shared 3MiB budget. + const bigImage = "A".repeat(2 * 1024 * 1024); + const container = () => ({ + type: "content", + value: [{ type: "media", mediaType: "image/png", data: bigImage }], + }); + const sanitized = sanitizeCapturedMediaValue({ a: container(), b: container() }) as { + a: RetainedContainer; + b: RetainedContainer; + }; + expect(sanitized.a.value[0]?.data).toBe(bigImage); + expect(sanitized.b.value[0]?.type).toBe("text"); + expect(sanitized.b.value[0]?.text).toContain("aggregate media budget exceeded"); + }); + + it("bounds cyclic and overly deep values instead of hanging or leaking", () => { + const audio = { type: "media", mediaType: "audio/wav", data: "d2F2" }; + const cyclic: Record = { container: { type: "content", value: [audio] } }; + cyclic.self = cyclic; + const sanitizedCycle = sanitizeCapturedMediaValue(cyclic) as Record; + expect(sanitizedCycle.self).toBe("[cyclic value bounded at capture]"); + expect((sanitizedCycle.container as RetainedContainer).value[0]?.type).toBe("text"); + + // A media container buried past the depth cap must fail CLOSED: the + // subtree becomes a placeholder rather than passing through unsanitized. + let deep: unknown = { type: "content", value: [audio] }; + for (let i = 0; i < 300; i++) deep = { next: deep }; + const sanitizedDeep = JSON.stringify(sanitizeCapturedMediaValue(deep)); + expect(sanitizedDeep).toContain("nesting depth limit exceeded"); + expect(sanitizedDeep).not.toContain("d2F2"); + }); + it("bounds containers holding only unsupported media", () => { // containsMediaContentPayload would NOT exempt this container (no // supported media), but the mode-independent sanitizer must still bound diff --git a/src/node/services/ptc/types.ts b/src/node/services/ptc/types.ts index d13eaba412..69ebc7aaf7 100644 --- a/src/node/services/ptc/types.ts +++ b/src/node/services/ptc/types.ts @@ -321,11 +321,13 @@ function boundedMediaTypeLabel(mediaType: string | undefined): string { * cannot grow without bound either. Idempotent: placeholders are small text * parts that re-charge identically on a second pass. */ -function sanitizeRetainedMediaContainer(result: unknown): unknown { +function sanitizeRetainedMediaContainer( + result: unknown, + budget: { remainingBytes: number } = { remainingBytes: KERNEL_RETAINED_MEDIA_BUDGET_BYTES } +): unknown { const container = result as { type: "content"; value: unknown[] }; const parts = container.value.slice(0, KERNEL_RETAINED_CONTAINER_MAX_PARTS); let changed = parts.length < container.value.length; - let retainedBytes = 0; const value: unknown[] = parts.map((item) => { const media = asMediaPart(item); if ( @@ -339,11 +341,8 @@ function sanitizeRetainedMediaContainer(result: unknown): unknown { }; } const serialized = serializedJsonByteLength(item); - if ( - serialized !== undefined && - retainedBytes + serialized <= KERNEL_RETAINED_MEDIA_BUDGET_BYTES - ) { - retainedBytes += serialized; + if (serialized !== undefined && serialized <= budget.remainingBytes) { + budget.remainingBytes -= serialized; return item; } changed = true; @@ -381,15 +380,71 @@ export function sanitizeMediaRecordCapture(_toolName: string, result: unknown): return sanitizeCapturedMediaValue(result); } +/** + * Recursion depth cap for the media value-graph walk. JSON persistence + * tolerates deeper nesting, but a guest-built ladder past this depth is not a + * plausible legitimate return shape — fail CLOSED (bounded placeholder) so + * depth can never be used to smuggle an unsanitized container past the walk. + */ +const MAX_MEDIA_SANITIZE_DEPTH = 256; + /** * Tool-name-free form of sanitizeMediaRecordCapture for values that are not * nested tool records: the classic execution's outer return value and console * args also persist into the code_execution history row (see * createCodeExecutionTool), and `return xum.(...)` / * `console.log(...)` would otherwise carry the raw unbudgeted container. + * + * Walks the ENTIRE value graph, not just the root: guests can wrap bridged + * results arbitrarily (`return { image: xum.mcp__shots__take({}) }`), and a + * root-only check would pass the wrapper through raw. All containers found in + * one value share a single aggregate budget, so wrapping N containers cannot + * multiply the bound. Object identity is memoized (shared subtrees stay + * linear; cycle back-edges resolve to a bounded placeholder — cyclic values + * cannot JSON-persist anyway). */ export function sanitizeCapturedMediaValue(value: unknown): unknown { - return isMediaContentContainer(value) ? sanitizeRetainedMediaContainer(value) : value; + const budget = { remainingBytes: KERNEL_RETAINED_MEDIA_BUDGET_BYTES }; + return sanitizeMediaValueGraph(value, budget, new Map(), 0); +} + +function sanitizeMediaValueGraph( + value: unknown, + budget: { remainingBytes: number }, + memo: Map, + depth: number +): unknown { + if (typeof value !== "object" || value === null) return value; + const existing = memo.get(value); + if (existing !== undefined) return existing; + if (depth >= MAX_MEDIA_SANITIZE_DEPTH) { + return "[value bounded at capture: nesting depth limit exceeded]"; + } + // Pre-seed so a cycle back-edge encountered while this node is still being + // processed resolves to a placeholder instead of recursing forever. + memo.set(value, "[cyclic value bounded at capture]"); + + let result: unknown; + if (isMediaContentContainer(value)) { + // Container parts are charged their FULL serialized size (nested payloads + // included), so there is no need to descend into a sanitized container. + result = sanitizeRetainedMediaContainer(value, budget); + } else if (Array.isArray(value)) { + const mapped = value.map((item) => sanitizeMediaValueGraph(item, budget, memo, depth + 1)); + result = mapped.some((item, index) => item !== value[index]) ? mapped : value; + } else { + const record = value as Record; + let changed = false; + const mapped: Record = {}; + for (const [key, item] of Object.entries(record)) { + const sanitized = sanitizeMediaValueGraph(item, budget, memo, depth + 1); + mapped[key] = sanitized; + if (sanitized !== item) changed = true; + } + result = changed ? mapped : value; + } + memo.set(value, result); + return result; } /** diff --git a/src/node/services/tools/code_execution.test.ts b/src/node/services/tools/code_execution.test.ts index 82f8e85203..1a9e5fe23a 100644 --- a/src/node/services/tools/code_execution.test.ts +++ b/src/node/services/tools/code_execution.test.ts @@ -1083,6 +1083,49 @@ describe("createCodeExecutionTool", () => { expect(value?.[1]?.text).toContain("aggregate media budget exceeded"); }); + it("charges retained results against one execution-wide budget", async () => { + // Retention bypasses the per-record 16KiB kernel cap by design, but a + // loop of retained calls (each up to ~3MiB of media) must not grow + // toolCalls and streamed history without limit (round 10): once the + // execution-wide budget is exhausted, further oversized results fall + // back to normal bounding and compaction drops them with honest sizes. + using tmp = new DisposableTempDir("code-exec-exec-budget"); + const host = new SandboxHostService(); + const imageData = "A".repeat(KERNEL_RETAINED_MEDIA_BUDGET_BYTES - 1024); + const tools: Record = { + mcp__shots__take: createMockTool("mcp__shots__take", z.object({}), () => ({ + type: "content", + value: [{ type: "media", mediaType: "image/png", data: imageData }], + })), + }; + const tool = await createCodeExecutionTool( + runtimeFactory, + new ToolBridge(tools), + undefined, + persistentRunner(host, "ws-exec-budget", tmp.path) + ); + + const result = (await tool.execute!( + { code: "for (let i = 0; i < 5; i++) { mux.mcp__shots__take({}); } return true;" }, + mockToolCallOptions + )) as PTCExecutionResult; + expect(result.success).toBe(true); + const records = result.toolCalls.filter((r) => r.toolName === "mcp__shots__take"); + expect(records).toHaveLength(5); + // First four ~3MiB containers fit the 12MiB execution budget and are + // retained; the fifth exceeds it, gets normally bounded at capture, and + // compaction drops the non-exempt marker while reporting the true size. + for (const record of records.slice(0, 4)) { + const value = (record.result as { value?: Array<{ data?: string }> })?.value; + expect(value?.[0]?.data).toBe(imageData); + } + const overflow = records[4]; + expect(overflow.result).toBeUndefined(); + expect(overflow.ok).toBe(true); + expect(overflow.bytes).toBeGreaterThan(3_000_000); + await host.disposeScope("ws-exec-budget"); + }); + it("sanitizes returned and console-logged media containers in classic mode", async () => { // Classic executions have no offload stage: `return xum.()` // assigns the guest value directly to the outer PTCExecutionResult @@ -1111,15 +1154,19 @@ describe("createCodeExecutionTool", () => { const result = (await tool.execute!( { - code: "const r = mux.mcp__shots__take({}); console.log(mux.mcp__rec__capture({})); return r;", + // Wrapped, not returned at the root: the sanitizer must walk the + // whole value graph (round 10), a root-only check would miss this. + code: "const r = mux.mcp__shots__take({}); console.log(mux.mcp__rec__capture({})); return { wrapped: r };", }, mockToolCallOptions )) as PTCExecutionResult; expect(result.success).toBe(true); const outer = ( - result.result as { value?: Array<{ type?: string; data?: string; text?: string }> } - )?.value; + result.result as { + wrapped?: { value?: Array<{ type?: string; data?: string; text?: string }> }; + } + )?.wrapped?.value; expect(outer?.[0]?.data).toBe(bigImage); expect(outer?.[1]?.type).toBe("text"); expect(outer?.[1]?.text).toContain("aggregate media budget exceeded"); From f6cbc41201a106e0f4fdbe7db5c89c66252a315c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 21:39:05 +0000 Subject: [PATCH 13/40] review r11: validate media types (well-formed + length) in isSupportedAttachmentMediaType; bound metadata labels in provider placeholders --- .../supportedAttachmentMediaTypes.ts | 10 ++++ src/node/services/ptc/types.test.ts | 39 +++++++++++---- .../extractToolMediaAsUserMessages.test.ts | 47 +++++++++++++++++++ .../utils/messages/toolResultAttachments.ts | 20 ++++++-- 4 files changed, 105 insertions(+), 11 deletions(-) diff --git a/src/common/utils/attachments/supportedAttachmentMediaTypes.ts b/src/common/utils/attachments/supportedAttachmentMediaTypes.ts index 5f068b97ee..ab70948c5d 100644 --- a/src/common/utils/attachments/supportedAttachmentMediaTypes.ts +++ b/src/common/utils/attachments/supportedAttachmentMediaTypes.ts @@ -43,6 +43,16 @@ export function getAttachmentMediaTypeFromExtension(filename: string): string | export function isSupportedAttachmentMediaType(mediaType: string): boolean { const normalized = normalizeAttachmentMediaType(mediaType); + // Media types are attacker-influencable metadata (e.g. MCP servers copy + // them verbatim into media parts), and every consumer of this predicate + // sits on a trust boundary that retains or interpolates the value + // (capture retention, request extraction, provider output sanitization). + // Require a well-formed type/subtype within a plausible length — a bare + // "image/" prefix check would qualify "image/" + megabytes of junk as a + // supported attachment type. + if (normalized.length > MAX_STAGED_MEDIA_TYPE_LENGTH || !MEDIA_TYPE_PATTERN.test(normalized)) { + return false; + } return normalized.startsWith("image/") || normalized === PDF_MEDIA_TYPE; } diff --git a/src/node/services/ptc/types.test.ts b/src/node/services/ptc/types.test.ts index 2630dfb12e..00fecd6d0a 100644 --- a/src/node/services/ptc/types.test.ts +++ b/src/node/services/ptc/types.test.ts @@ -85,17 +85,41 @@ describe("retainExemptKernelRecordResult", () => { }); describe("media container budgets", () => { - it("charges media metadata against the budget (empty-data mediaType attack)", () => { - // transformMCPResult copies server-controlled MIME types unchanged and - // the supported-type check accepts any image/ prefix, so a part hiding - // megabytes in mediaType with an EMPTY data string must still be - // charged — and the placeholder must not echo the junk label. + it("rejects junk media types at validation instead of retaining them as supported", () => { + // transformMCPResult copies server-controlled MIME types unchanged; an + // "image/" + megabytes string must fail isSupportedAttachmentMediaType + // (well-formed type/subtype within the RFC-plausible length), or one + // retained part could bloat every later provider request through + // placeholder interpolation (round 11). The placeholder label itself + // stays bounded. const junkType = `image/${"m".repeat(KERNEL_RETAINED_MEDIA_BUDGET_BYTES)}`; const retained = retainExemptKernelRecordResult("mcp__shots__take", { type: "content", value: [ { type: "media", mediaType: "image/png", data: "aGVsbG8=" }, - { type: "media", mediaType: junkType, data: "" }, + { type: "media", mediaType: junkType, data: "aGVsbG8=" }, + ], + }) as RetainedContainer; + expect(retained.value[0]?.data).toBe("aGVsbG8="); + expect(retained.value[1]?.type).toBe("text"); + expect(retained.value[1]?.text).toContain("not supported as a model attachment"); + expect(retained.value[1]?.text!.length).toBeLessThan(300); + }); + + it("charges media metadata against the budget (empty-data filename attack)", () => { + // Payload hidden in a sibling metadata field of a well-formed part: the + // serialized-size charge is the backstop for metadata the validator + // cannot reject — and the placeholder must not echo the junk label. + const retained = retainExemptKernelRecordResult("mcp__shots__take", { + type: "content", + value: [ + { type: "media", mediaType: "image/png", data: "aGVsbG8=" }, + { + type: "media", + mediaType: "image/png", + data: "", + filename: "m".repeat(KERNEL_RETAINED_MEDIA_BUDGET_BYTES), + }, ], }) as RetainedContainer; expect(retained.value[0]?.data).toBe("aGVsbG8="); @@ -107,12 +131,11 @@ describe("retainExemptKernelRecordResult", () => { it("charges the budget in UTF-8 bytes, not UTF-16 code units", () => { // ~1.5M CJK chars ≈ 4.5 MiB in UTF-8 history — a character-based check // would retain this part under the 3 MiB budget. - const junkType = `image/${"画".repeat(1_500_000)}`; const retained = retainExemptKernelRecordResult("mcp__shots__take", { type: "content", value: [ { type: "media", mediaType: "image/png", data: "aGVsbG8=" }, - { type: "media", mediaType: junkType, data: "" }, + { type: "media", mediaType: "image/png", data: "", filename: "画".repeat(1_500_000) }, ], }) as RetainedContainer; expect(retained.value[0]?.data).toBe("aGVsbG8="); diff --git a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts index 5a90209406..7ffbdb7839 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts @@ -325,6 +325,53 @@ describe("extractToolMediaAsUserMessages", () => { expect(fileParts).toHaveLength(1); }); + it("bounds junk media-type and filename metadata in provider-visible placeholders", async () => { + // MCP servers copy MIME/filename metadata verbatim; a persisted part + // carrying megabytes there must not be re-interpolated into placeholder + // text on every later request (round 11). The junk type also fails + // isSupportedAttachmentMediaType (well-formed + length validation), so it + // can never become a supported attachment either. + const junkType = `image/${"m".repeat(500_000)}`; + const junkName = `${"n".repeat(500_000)}.png`; + + const input: MuxMessage[] = [ + { + id: "a-junk-meta", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: "call1", + toolName: "mcp__shots__take", + input: {}, + state: "output-available", + output: { + type: "content", + value: [ + { type: "media", mediaType: junkType, data: "aGVsbG8=" }, + { type: "media", mediaType: "audio/wav", data: "d2F2", filename: junkName }, + ], + }, + }, + ], + metadata: { timestamp: 1 }, + }, + ]; + + const rewritten = await extractToolMediaAsUserMessages(input); + const toolPart = rewritten[0].parts[0]; + if (toolPart.type !== "dynamic-tool" || toolPart.state !== "output-available") { + throw new Error("Expected an output-available dynamic-tool part"); + } + const outputText = JSON.stringify(toolPart.output); + // Both parts degrade to placeholders with BOUNDED labels: the junk + // metadata strings must not survive into the provider copy. + expect(outputText.length).toBeLessThan(2_000); + expect(outputText).toContain("[Media omitted from provider request:"); + // No synthetic attachment is created from the junk-typed part. + expect(rewritten).toHaveLength(1); + }); + it("self-heals oversized raster tool attachments by downscaling them for provider requests", async () => { const oversizedPng = await sharp({ create: { diff --git a/src/node/utils/messages/toolResultAttachments.ts b/src/node/utils/messages/toolResultAttachments.ts index 0f8cea6c0a..c87c83b3d6 100644 --- a/src/node/utils/messages/toolResultAttachments.ts +++ b/src/node/utils/messages/toolResultAttachments.ts @@ -81,11 +81,25 @@ function isMediaPart(value: unknown): value is AISDKMediaPart { function normalizeOptionalFilename(filename: string | undefined): string | undefined { const trimmed = filename?.trim(); - return trimmed != null && trimmed.length > 0 ? trimmed : undefined; + if (trimmed == null || trimmed.length === 0) return undefined; + // Filenames are attacker-influencable metadata like media types: they ride + // into provider-visible placeholders and attachment file parts, so an + // unbounded value persisted by an MCP tool would bloat every later request. + return boundMetadataLabel(trimmed, 200); +} + +/** + * Bound provider-visible metadata interpolation. Media types and filenames + * come from tool results (MCP servers copy them verbatim), and placeholder + * text persists in the provider copy of every later request — never + * interpolate them raw. + */ +function boundMetadataLabel(text: string, max: number): string { + return text.length > max ? `${text.slice(0, max)}…` : text; } function buildAttachmentPlaceholder(item: AISDKMediaPart): AISDKTextPart { - const normalizedMediaType = normalizeAttachmentMediaType(item.mediaType); + const normalizedMediaType = boundMetadataLabel(normalizeAttachmentMediaType(item.mediaType), 100); const filename = normalizeOptionalFilename(item.filename); const label = filename != null ? `${filename} (${normalizedMediaType})` : normalizedMediaType; return { @@ -95,7 +109,7 @@ function buildAttachmentPlaceholder(item: AISDKMediaPart): AISDKTextPart { } function buildUnsupportedMediaPlaceholder(item: AISDKMediaPart): AISDKTextPart { - const normalizedMediaType = normalizeAttachmentMediaType(item.mediaType); + const normalizedMediaType = boundMetadataLabel(normalizeAttachmentMediaType(item.mediaType), 100); const filename = normalizeOptionalFilename(item.filename); const label = filename != null ? `${filename} (${normalizedMediaType})` : normalizedMediaType; return { From 7d7b2aa725f8147311dd4f02c90770afe6ae0780 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 21:58:10 +0000 Subject: [PATCH 14/40] review r12: depth-bound nested tool-record extraction; compact capture-bounded markers normally (preserve path attribution + failed-edit success bit) --- src/node/services/ptc/quickjsRuntime.ts | 19 ++++++++- .../services/tools/code_execution.test.ts | 34 +++++++++++++++- src/node/services/tools/code_execution.ts | 13 ++++++- .../extractToolMediaAsUserMessages.test.ts | 39 +++++++++++++++++++ .../utils/messages/toolResultAttachments.ts | 34 ++++++++++++---- 5 files changed, 128 insertions(+), 11 deletions(-) diff --git a/src/node/services/ptc/quickjsRuntime.ts b/src/node/services/ptc/quickjsRuntime.ts index 2be1fdb775..b405ab51ff 100644 --- a/src/node/services/ptc/quickjsRuntime.ts +++ b/src/node/services/ptc/quickjsRuntime.ts @@ -679,8 +679,23 @@ export class QuickJSRuntime implements IJSRuntime { budget.remainingBytes -= size; return retained; } - // Budget exhausted: fall through to normal bounding — oversized - // results become honest-size markers, small results still pass inline. + // Budget exhausted: fall back to normal bounding — oversized results + // become honest-size markers, small results still pass inline. A + // boolean success bit is preserved onto the marker: compaction folds + // result.success===false into the compact ok bit, and a FAILED edit + // misattributed as ok:true would advertise a never-applied path in + // crash-safe edited-file tracking. + const bounded = this.boundCapture(sanitized, this.kernelRecordBounds.resultCapBytes); + const success = (retained as { success?: unknown }).success; + if ( + typeof success === "boolean" && + typeof bounded === "object" && + bounded !== null && + (bounded as { __kernelBounded?: boolean }).__kernelBounded === true + ) { + return { ...bounded, success }; + } + return bounded; } return this.boundCapture(sanitized, this.kernelRecordBounds.resultCapBytes); } diff --git a/src/node/services/tools/code_execution.test.ts b/src/node/services/tools/code_execution.test.ts index 1a9e5fe23a..98380e95d1 100644 --- a/src/node/services/tools/code_execution.test.ts +++ b/src/node/services/tools/code_execution.test.ts @@ -1092,11 +1092,25 @@ describe("createCodeExecutionTool", () => { using tmp = new DisposableTempDir("code-exec-exec-budget"); const host = new SandboxHostService(); const imageData = "A".repeat(KERNEL_RETAINED_MEDIA_BUDGET_BYTES - 1024); + const bigDiff = `@@ -1,0 +1,1 @@\n+${"d".repeat(30_000)}\n@@ -9,0 +10,1 @@\n+${"e".repeat(30_000)}\n`; const tools: Record = { mcp__shots__take: createMockTool("mcp__shots__take", z.object({}), () => ({ type: "content", value: [{ type: "media", mediaType: "image/png", data: imageData }], })), + file_edit_insert: createMockTool( + "file_edit_insert", + z.object({ path: z.string() }), + () => ({ + success: true, + diff: bigDiff, + }) + ), + file_edit_replace_string: createMockTool( + "file_edit_replace_string", + z.object({ path: z.string() }), + () => ({ success: false, diff: bigDiff }) + ), }; const tool = await createCodeExecutionTool( runtimeFactory, @@ -1106,7 +1120,12 @@ describe("createCodeExecutionTool", () => { ); const result = (await tool.execute!( - { code: "for (let i = 0; i < 5; i++) { mux.mcp__shots__take({}); } return true;" }, + { + code: + "for (let i = 0; i < 5; i++) { mux.mcp__shots__take({}); } " + + 'mux.file_edit_insert({path: "/after-budget.ts"}); ' + + 'mux.file_edit_replace_string({path: "/after-budget-failed.ts"}); return true;', + }, mockToolCallOptions )) as PTCExecutionResult; expect(result.success).toBe(true); @@ -1123,6 +1142,19 @@ describe("createCodeExecutionTool", () => { expect(overflow.result).toBeUndefined(); expect(overflow.ok).toBe(true); expect(overflow.bytes).toBeGreaterThan(3_000_000); + + // Persistence-critical records after overflow: the name-based + // exemption must not preserve the __kernelBounded marker as a result — + // compaction emits the normal {ok, bytes} summary so edit extractors + // keep PATH attribution (round 12), and a FAILED edit's success bit + // survives through the marker instead of misreporting ok:true. + const editOk = result.toolCalls.find((r) => r.toolName === "file_edit_insert"); + expect(editOk?.result).toBeUndefined(); + expect(editOk?.ok).toBe(true); + expect((editOk?.args as { path?: string })?.path).toBe("/after-budget.ts"); + const editFailed = result.toolCalls.find((r) => r.toolName === "file_edit_replace_string"); + expect(editFailed?.result).toBeUndefined(); + expect(editFailed?.ok).toBe(false); await host.disposeScope("ws-exec-budget"); }); diff --git a/src/node/services/tools/code_execution.ts b/src/node/services/tools/code_execution.ts index 179ed79246..043555672c 100644 --- a/src/node/services/tools/code_execution.ts +++ b/src/node/services/tools/code_execution.ts @@ -302,6 +302,17 @@ function compactKernelToolCallRecords(result: PTCExecutionResult, loadActive: bo ...(record.error !== undefined ? { error: boundCompactRecordError(record.error) } : {}), }; } + // A result already replaced by a __kernelBounded marker at capture + // (execution-wide retained budget exhausted) carries no extractable + // payload: the name-based persistence exemption below would keep the + // marker as the record's result, and edit extractors would then see a + // result without success:true and drop the record's PATH attribution + // too. Compact it normally instead — the result-less {ok, bytes} summary + // keeps crash-safe edited-file tracking and reports the honest size. + const captureBounded = + typeof record.result === "object" && + record.result !== null && + (record.result as { __kernelBounded?: boolean }).__kernelBounded === true; // Exempt records also keep their result (see isKernelRecordResultExempt; // creation-time capture bounding applies the same predicate, so the full // payload actually reaches this point): persistence extractors mine @@ -309,7 +320,7 @@ function compactKernelToolCallRecords(result: PTCExecutionResult, loadActive: bo // after compaction, and media containers from bridged MCP tools must // reach request-time attachment extraction or RLM users could never see // bridged screenshots/images. - if (isKernelRecordResultExempt(record.toolName, record.result)) { + if (!captureBounded && isKernelRecordResultExempt(record.toolName, record.result)) { return { ...record, args: boundCompactRecordArgs(record.args), diff --git a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts index 7ffbdb7839..84bf476f24 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts @@ -372,6 +372,45 @@ describe("extractToolMediaAsUserMessages", () => { expect(rewritten).toHaveLength(1); }); + it("bounds recursion through corrupt deeply-nested tool records instead of overflowing the stack", async () => { + // History rows are untrusted persisted JSON (self-healing rule): a + // syntactically valid row nesting {toolCalls:[{result: …}]} deep enough + // would otherwise stack-overflow while preparing provider messages — + // and extraction runs on EVERY request, so one corrupt row would brick + // the workspace. Over-deep values must degrade to no extraction. + let deep: Record = { toolCalls: [] }; + for (let i = 0; i < 50_000; i++) { + deep = { toolCalls: [{ toolName: "bash", result: deep }] }; + } + + const input: MuxMessage[] = [ + { + id: "a-deep", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: "call1", + toolName: "code_execution", + input: { code: "..." }, + state: "output-available", + output: deep, + }, + ], + metadata: { timestamp: 1 }, + }, + ]; + + const rewritten = await extractToolMediaAsUserMessages(input); + // No throw, no synthetic attachments — the row passes through unrewritten. + expect(rewritten).toHaveLength(1); + const toolPart = rewritten[0].parts[0]; + if (toolPart.type !== "dynamic-tool" || toolPart.state !== "output-available") { + throw new Error("Expected an output-available dynamic-tool part"); + } + expect(toolPart.output).toBe(deep); + }); + it("self-heals oversized raster tool attachments by downscaling them for provider requests", async () => { const oversizedPng = await sharp({ create: { diff --git a/src/node/utils/messages/toolResultAttachments.ts b/src/node/utils/messages/toolResultAttachments.ts index c87c83b3d6..3326af203d 100644 --- a/src/node/utils/messages/toolResultAttachments.ts +++ b/src/node/utils/messages/toolResultAttachments.ts @@ -130,11 +130,27 @@ function buildDisplayOnlyFilePlaceholder(item: DisplayOnlyFilePart): AISDKTextPa }; } +/** + * Depth bound for the mutual recursion between extractAttachmentsFromToolOutput + * and extractAttachmentsFromNestedToolCalls (json wrappers count too). History + * rows are untrusted persisted JSON: a syntactically valid row nesting + * {toolCalls:[{result: …}]} chains deep enough would overflow the stack while + * preparing provider messages, and since extraction runs on EVERY request, + * one malformed row would brick the workspace (self-healing rule). Real + * nesting is 1–2 levels (code_execution → bridged tool results); over-deep + * values are left unrewritten instead of recursed into. + */ +const MAX_NESTED_TOOL_EXTRACTION_DEPTH = 64; + export function extractAttachmentsFromToolOutput( - output: unknown + output: unknown, + depth = 0 ): { newOutput: unknown; attachments: ExtractedToolAttachment[] } | null { + if (depth > MAX_NESTED_TOOL_EXTRACTION_DEPTH) { + return null; + } if (isJsonContainer(output)) { - const extracted = extractAttachmentsFromToolOutput(output.value); + const extracted = extractAttachmentsFromToolOutput(output.value, depth + 1); if (extracted == null) { return null; } @@ -146,7 +162,7 @@ export function extractAttachmentsFromToolOutput( } if (!isContentContainer(output)) { - return extractAttachmentsFromNestedToolCalls(output); + return extractAttachmentsFromNestedToolCalls(output, depth + 1); } const attachments: ExtractedToolAttachment[] = []; @@ -211,7 +227,8 @@ export function extractAttachmentsFromToolOutput( * places is deduplicated into a single attachment. */ function extractAttachmentsFromNestedToolCalls( - output: unknown + output: unknown, + depth: number ): { newOutput: unknown; attachments: ExtractedToolAttachment[] } | null { if (typeof output !== "object" || output === null) { return null; @@ -238,7 +255,10 @@ function extractAttachmentsFromNestedToolCalls( if (typeof record !== "object" || record === null) { return record; } - const extracted = extractAttachmentsFromToolOutput((record as { result?: unknown }).result); + const extracted = extractAttachmentsFromToolOutput( + (record as { result?: unknown }).result, + depth + 1 + ); if (extracted == null) { return record; } @@ -248,7 +268,7 @@ function extractAttachmentsFromNestedToolCalls( }); const outerResult = (output as { result?: unknown }).result; - const extractedOuter = extractAttachmentsFromToolOutput(outerResult); + const extractedOuter = extractAttachmentsFromToolOutput(outerResult, depth + 1); if (extractedOuter != null) { didChange = true; pushUnique(extractedOuter.attachments); @@ -272,7 +292,7 @@ function extractAttachmentsFromNestedToolCalls( } let argsChanged = false; const newArgs = args.map((arg: unknown) => { - const extracted = extractAttachmentsFromToolOutput(arg); + const extracted = extractAttachmentsFromToolOutput(arg, depth + 1); if (extracted == null) { return arg; } From c5a7d9c7aa9ac015ccd6b6903f84cb29612d6824 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 22:08:31 +0000 Subject: [PATCH 15/40] review r13: replace over-depth nested tool subtrees with a bounded placeholder in provider requests --- .../extractToolMediaAsUserMessages.test.ts | 25 +++++++++++++------ .../utils/messages/toolResultAttachments.ts | 15 ++++++++--- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts index 84bf476f24..1c89db8617 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts @@ -372,13 +372,22 @@ describe("extractToolMediaAsUserMessages", () => { expect(rewritten).toHaveLength(1); }); - it("bounds recursion through corrupt deeply-nested tool records instead of overflowing the stack", async () => { + it("replaces corrupt deeply-nested tool records with a bounded placeholder instead of overflowing the stack", async () => { // History rows are untrusted persisted JSON (self-healing rule): a // syntactically valid row nesting {toolCalls:[{result: …}]} deep enough - // would otherwise stack-overflow while preparing provider messages — - // and extraction runs on EVERY request, so one corrupt row would brick - // the workspace. Over-deep values must degrade to no extraction. - let deep: Record = { toolCalls: [] }; + // would otherwise stack-overflow while preparing provider messages — and + // extraction runs on EVERY request, so one corrupt row would brick the + // workspace. The over-deep subtree must also be REPLACED, not retained: + // a payload hiding at the leaf would otherwise keep shipping as raw JSON + // on every later request (round 13). + const leafPayload = "aGlkZGVu".repeat(100); + let deep: Record = { + toolCalls: [], + result: { + type: "content", + value: [{ type: "media", mediaType: "image/png", data: leafPayload }], + }, + }; for (let i = 0; i < 50_000; i++) { deep = { toolCalls: [{ toolName: "bash", result: deep }] }; } @@ -402,13 +411,15 @@ describe("extractToolMediaAsUserMessages", () => { ]; const rewritten = await extractToolMediaAsUserMessages(input); - // No throw, no synthetic attachments — the row passes through unrewritten. + // No throw and no synthetic attachment from the buried payload. expect(rewritten).toHaveLength(1); const toolPart = rewritten[0].parts[0]; if (toolPart.type !== "dynamic-tool" || toolPart.state !== "output-available") { throw new Error("Expected an output-available dynamic-tool part"); } - expect(toolPart.output).toBe(deep); + const outputText = JSON.stringify(toolPart.output); + expect(outputText).toContain("nested tool-record depth limit exceeded"); + expect(outputText).not.toContain(leafPayload); }); it("self-heals oversized raster tool attachments by downscaling them for provider requests", async () => { diff --git a/src/node/utils/messages/toolResultAttachments.ts b/src/node/utils/messages/toolResultAttachments.ts index 3326af203d..4fe448dc6b 100644 --- a/src/node/utils/messages/toolResultAttachments.ts +++ b/src/node/utils/messages/toolResultAttachments.ts @@ -137,17 +137,26 @@ function buildDisplayOnlyFilePlaceholder(item: DisplayOnlyFilePart): AISDKTextPa * {toolCalls:[{result: …}]} chains deep enough would overflow the stack while * preparing provider messages, and since extraction runs on EVERY request, * one malformed row would brick the workspace (self-healing rule). Real - * nesting is 1–2 levels (code_execution → bridged tool results); over-deep - * values are left unrewritten instead of recursed into. + * nesting is 1–2 levels (code_execution → bridged tool results). + * + * Over-deep subtrees are REPLACED with a bounded placeholder in the provider + * copy, not retained: descent only follows tool-output-shaped wrappers, so + * anything past the cap is malformed by construction, and retaining it would + * keep shipping whatever payload hides at the leaf (e.g. raw base64) on every + * later request — trading the stack overflow for context-limit failures. + * Persisted history itself is never mutated. */ const MAX_NESTED_TOOL_EXTRACTION_DEPTH = 64; +const OVER_DEPTH_PLACEHOLDER = + "[tool output omitted from provider request: nested tool-record depth limit exceeded]"; + export function extractAttachmentsFromToolOutput( output: unknown, depth = 0 ): { newOutput: unknown; attachments: ExtractedToolAttachment[] } | null { if (depth > MAX_NESTED_TOOL_EXTRACTION_DEPTH) { - return null; + return { newOutput: OVER_DEPTH_PLACEHOLDER, attachments: [] }; } if (isJsonContainer(output)) { const extracted = extractAttachmentsFromToolOutput(output.value, depth + 1); From df522f03d5dcdcb1730db23d7cc3047139f94dcc Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 22:22:54 +0000 Subject: [PATCH 16/40] review r14: admit only string diffs from untrusted history (nested records + direct parts) --- .../utils/messages/extractEditedFiles.test.ts | 45 +++++++++++++++++++ .../utils/messages/extractEditedFiles.ts | 13 ++++-- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/common/utils/messages/extractEditedFiles.test.ts b/src/common/utils/messages/extractEditedFiles.test.ts index 97860c43ef..d090a0d07f 100644 --- a/src/common/utils/messages/extractEditedFiles.test.ts +++ b/src/common/utils/messages/extractEditedFiles.test.ts @@ -180,6 +180,51 @@ describe("nested PTC edit records (exclusive posture)", () => { expect(diffs[0].truncated).toBe(true); }); + it("skips non-string diffs (nested and direct) instead of throwing in parsePatch", () => { + // Untrusted persisted JSON: a successful record can carry an array (or + // object) diff, which passes truthiness/length checks and would throw in + // parsePatch/applyPatch on every compaction/recovery pass (round 14). + // The edit still counts for path tracking; only the diff is dropped. + const goodDiff = makeDiff("/good.ts", "old", "new"); + const messages: MuxMessage[] = [ + createCodeExecutionMessage([ + { + toolName: "file_edit_insert", + args: { path: "/array-diff.ts" }, + result: { success: true, diff: ["not", "a", "string"] }, + }, + { + toolName: "file_edit_insert", + args: { path: "/good.ts" }, + result: { success: true, diff: goodDiff }, + }, + ]), + { + id: "msg-direct-corrupt-diff", + role: "assistant", + parts: [ + { + type: "dynamic-tool" as const, + toolCallId: "tc-direct-corrupt-diff", + toolName: "file_edit_replace_string", + state: "output-available" as const, + input: { path: "/direct-array-diff.ts" }, + output: { success: true, diff: { corrupt: true } }, + }, + ], + }, + ]; + + expect(extractEditedFilePaths(messages)).toEqual([ + "/direct-array-diff.ts", + "/good.ts", + "/array-diff.ts", + ]); + const diffs = extractEditedFileDiffs(messages); + expect(diffs).toHaveLength(1); + expect(diffs[0].path).toBe("/good.ts"); + }); + it("kernel-compacted records surface the path but no diff", () => { // Current kernel compaction exempts file_edit_* records (results kept for // exactly this extractor), but result-less compact records still exist in diff --git a/src/common/utils/messages/extractEditedFiles.ts b/src/common/utils/messages/extractEditedFiles.ts index 85c0ce0f97..078d115658 100644 --- a/src/common/utils/messages/extractEditedFiles.ts +++ b/src/common/utils/messages/extractEditedFiles.ts @@ -71,10 +71,15 @@ function collectNestedEditRecords(output: unknown): NestedEditRecord[] { if (result !== undefined && result.success !== true) continue; const filePath = extractToolFilePath(record.args); if (!filePath) continue; - const diff = + const rawDiff = result !== undefined ? (getToolOutputUiOnly(result)?.file_edit?.diff ?? result.diff) : undefined; + // Untrusted persisted JSON again: a malformed row can carry a non-string + // diff (array/object) that would pass truthiness/length checks and then + // throw inside parsePatch/applyPatch on every compaction and recovery + // pass — admit strings only (the path-only edit record still counts). + const diff = typeof rawDiff === "string" ? rawDiff : undefined; records.push({ filePath, ...(diff !== undefined ? { diff } : {}), @@ -315,8 +320,10 @@ export function extractEditedFileDiffs(messages: MuxMessage[]): FileEditDiff[] { if (!output?.success) continue; const uiOnly = getToolOutputUiOnly(output); - const diff = uiOnly?.file_edit?.diff ?? output.diff; - if (!diff) continue; + const rawPartDiff = uiOnly?.file_edit?.diff ?? output.diff; + // Same untrusted-JSON guard as collectNestedEditRecords: strings only. + const diff = typeof rawPartDiff === "string" && rawPartDiff.length > 0 ? rawPartDiff : null; + if (diff === null) continue; const filePath = extractToolFilePath(part.input); if (!filePath) continue; From badc35ed67094992dc8b4934f9864fa245e24aee Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 22:37:01 +0000 Subject: [PATCH 17/40] review r15: deep-walk wrapper objects in request-time media extraction (wrapped outer results and console args) --- .../extractToolMediaAsUserMessages.test.ts | 66 +++++++++++++++++++ .../utils/messages/toolResultAttachments.ts | 45 ++++++++++++- 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts index 1c89db8617..d4da09d0e2 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts @@ -219,6 +219,72 @@ describe("extractToolMediaAsUserMessages", () => { }); }); + it("rewrites media containers wrapped inside outer results and console args", async () => { + // `return { image: xum.mcp(...) }` wraps the container in a plain object: + // capture-time sanitization intentionally retains supported containers + // under its budget, so the provider copy must deep-walk arbitrary + // wrappers — a root-only outer check would ship the screenshot as BOTH an + // attachment (from the nested record) and raw JSON on every later + // request (round 15). + const base64 = ( + await sharp({ + create: { + width: 10, + height: 10, + channels: 3, + background: { r: 0, g: 0, b: 255 }, + }, + }) + .png() + .toBuffer() + ).toString("base64"); + + const mediaContainer = { + type: "content", + value: [{ type: "media", mediaType: "image/png", data: base64 }], + }; + const input: MuxMessage[] = [ + { + id: "ce-wrapped", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: "call1", + toolName: "code_execution", + input: { code: "const r = xum.mcp__shots__take({}); return { image: r };" }, + state: "output-available", + output: { + success: true, + result: { image: mediaContainer, note: "kept" }, + toolCalls: [{ toolName: "mcp__shots__take", args: {}, result: mediaContainer }], + consoleOutput: [{ level: "log", args: [{ wrapped: mediaContainer }], timestamp: 1 }], + }, + }, + ], + metadata: { timestamp: 1 }, + }, + ]; + + const rewritten = await extractToolMediaAsUserMessages(input); + expect(rewritten).toHaveLength(2); + + const toolPart = rewritten[0].parts[0]; + if (toolPart.type !== "dynamic-tool" || toolPart.state !== "output-available") { + throw new Error("Expected an output-available dynamic-tool part"); + } + const outputText = JSON.stringify(toolPart.output); + // All three copies (record, wrapped outer result, wrapped console arg) + // are replaced; sibling wrapper fields survive untouched. + expect(outputText).not.toContain(base64); + expect(outputText).toContain('"note":"kept"'); + + // Identical media across all copies dedupes into ONE attachment. + const syntheticUser = rewritten[1]; + const fileParts = syntheticUser.parts.filter((part) => part.type === "file"); + expect(fileParts).toHaveLength(1); + }); + it("redacts media containers copied into code_execution console output", async () => { // `const image = xum.(...); console.log(image)` copies the // container into consoleOutput args (classic console budget ~1MiB); diff --git a/src/node/utils/messages/toolResultAttachments.ts b/src/node/utils/messages/toolResultAttachments.ts index 4fe448dc6b..7cf5a7664a 100644 --- a/src/node/utils/messages/toolResultAttachments.ts +++ b/src/node/utils/messages/toolResultAttachments.ts @@ -171,7 +171,11 @@ export function extractAttachmentsFromToolOutput( } if (!isContentContainer(output)) { - return extractAttachmentsFromNestedToolCalls(output, depth + 1); + const nested = extractAttachmentsFromNestedToolCalls(output, depth + 1); + if (nested != null) { + return nested; + } + return extractAttachmentsFromWrapperValue(output, depth + 1); } const attachments: ExtractedToolAttachment[] = []; @@ -336,6 +340,45 @@ function extractAttachmentsFromNestedToolCalls( }; } +/** + * Deep-walk arbitrary wrapper objects/arrays for media content containers. + * Sandbox code can wrap bridged results (`return { image: xum.mcp(...) }`), + * and capture-time sanitization intentionally RETAINS supported containers + * under its budget — so the provider copy must rewrite them into + * attachments/placeholders wherever they sit, or a normal screenshot ships as + * both an attachment (from the duplicate nested record) and megabytes of raw + * JSON (from the wrapped outer result) on every later request. Children route + * back through extractAttachmentsFromToolOutput, so the shared depth cap + * bounds the stack (cycles terminate because depth grows on each revisit) and + * over-deep subtrees degrade to the bounded placeholder. + */ +function extractAttachmentsFromWrapperValue( + value: unknown, + depth: number +): { newOutput: unknown; attachments: ExtractedToolAttachment[] } | null { + if (typeof value !== "object" || value === null) { + return null; + } + const attachments: ExtractedToolAttachment[] = []; + let didChange = false; + const rewrite = (item: unknown): unknown => { + const extracted = extractAttachmentsFromToolOutput(item, depth + 1); + if (extracted == null) { + return item; + } + didChange = true; + attachments.push(...extracted.attachments); + return extracted.newOutput; + }; + const newValue = Array.isArray(value) + ? value.map(rewrite) + : Object.fromEntries(Object.entries(value).map(([key, item]) => [key, rewrite(item)])); + if (!didChange) { + return null; + } + return { newOutput: newValue, attachments }; +} + type ProviderReadyToolAttachment = | { type: "attachment"; attachment: ExtractedToolAttachment } | { type: "text"; text: string }; From f98b424d953c0de7ce8acc75f23c9d2360368d40 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 22:53:44 +0000 Subject: [PATCH 18/40] review r16: keep media-free deep JSON untouched in extraction; require positive success bit for result-less edit/read records --- .../utils/messages/extractEditedFiles.test.ts | 15 ++++++++ .../utils/messages/extractEditedFiles.ts | 9 +++-- .../utils/messages/extractReadFiles.test.ts | 11 +++++- src/common/utils/messages/extractReadFiles.ts | 5 +++ .../extractToolMediaAsUserMessages.test.ts | 36 +++++++++++++++++++ .../utils/messages/toolResultAttachments.ts | 25 +++++++++---- 6 files changed, 91 insertions(+), 10 deletions(-) diff --git a/src/common/utils/messages/extractEditedFiles.test.ts b/src/common/utils/messages/extractEditedFiles.test.ts index d090a0d07f..f054686d1c 100644 --- a/src/common/utils/messages/extractEditedFiles.test.ts +++ b/src/common/utils/messages/extractEditedFiles.test.ts @@ -225,6 +225,21 @@ describe("nested PTC edit records (exclusive posture)", () => { expect(diffs[0].path).toBe("/good.ts"); }); + it("does not report records lacking both a result and an ok bit as edits", () => { + // Success must be positive: kernel-compacted records always carry an + // explicit boolean ok and classic records carry a result, so a malformed + // row with neither must not be reported by crash-safe tracking as a + // completed edit (round 16). + const messages: MuxMessage[] = [ + createCodeExecutionMessage([ + { toolName: "file_edit_insert", args: { path: "/unmodified.ts" } }, + { toolName: "file_edit_insert", args: { path: "/kernel-ok.ts" }, ok: true }, + ]), + ]; + + expect(extractEditedFilePaths(messages)).toEqual(["/kernel-ok.ts"]); + }); + it("kernel-compacted records surface the path but no diff", () => { // Current kernel compaction exempts file_edit_* records (results kept for // exactly this extractor), but result-less compact records still exist in diff --git a/src/common/utils/messages/extractEditedFiles.ts b/src/common/utils/messages/extractEditedFiles.ts index 078d115658..e96b9af758 100644 --- a/src/common/utils/messages/extractEditedFiles.ts +++ b/src/common/utils/messages/extractEditedFiles.ts @@ -65,9 +65,12 @@ function collectNestedEditRecords(output: unknown): NestedEditRecord[] { continue; } const result = record.result as FileEditToolOutput | undefined; - // Classic records retain the full result: edits resolve with - // {success: false} instead of throwing, so require an explicit success. - // Kernel-compacted records carry no result; their ok bit above decides. + // Success must be POSITIVE, never inferred from absence: classic records + // retain the full result (edits resolve with {success: false} instead of + // throwing), and kernel-compacted result-less records always carry an + // explicit boolean ok — a malformed row with neither must not be + // reported by crash-safe tracking as a completed edit. + if (result === undefined && record.ok !== true) continue; if (result !== undefined && result.success !== true) continue; const filePath = extractToolFilePath(record.args); if (!filePath) continue; diff --git a/src/common/utils/messages/extractReadFiles.test.ts b/src/common/utils/messages/extractReadFiles.test.ts index 9fb1418cb7..498d5fcbb0 100644 --- a/src/common/utils/messages/extractReadFiles.test.ts +++ b/src/common/utils/messages/extractReadFiles.test.ts @@ -97,8 +97,17 @@ describe("extractReadFilePaths", () => { success: true, toolCalls: [ { toolName: "file_read", args: { path: "/nested-read.ts" }, ok: true, bytes: 10 }, - { toolName: "load", args: { path: "/loaded.jsonl", key: "data" } }, + // loadActive compaction keeps the load result (no ok bit). + { + toolName: "load", + args: { path: "/loaded.jsonl", key: "data" }, + result: { key: "data", bytes: 9, lines: 1 }, + }, // Failures and non-read nested calls are ignored. + // A malformed row with neither result nor ok must not be + // advertised as read (round 16): success is never inferred + // from absence. + { toolName: "file_read", args: { path: "/never-read.ts" } }, { toolName: "file_read", args: { path: "/nested-failed.ts" }, error: "denied" }, { toolName: "load", args: { path: "/load-failed.txt", key: "x" }, error: "missing" }, { toolName: "bash", args: { path: "/not-a-read.sh" }, ok: true }, diff --git a/src/common/utils/messages/extractReadFiles.ts b/src/common/utils/messages/extractReadFiles.ts index a51f5bb4f6..bc56ae5f62 100644 --- a/src/common/utils/messages/extractReadFiles.ts +++ b/src/common/utils/messages/extractReadFiles.ts @@ -40,6 +40,11 @@ function collectNestedReadPaths(output: unknown): string[] { // instead of throwing, so a missing error does not mean the read // succeeded. (Kernel-compacted records fold this into the ok bit.) const result = (record as { result?: unknown }).result; + // Same positive-success rule as collectNestedEditRecords: a result-less + // record must carry an explicit ok === true (all kernel-compacted records + // do) — a malformed row with neither result nor ok must not advertise a + // never-read path. + if (result === undefined && record.ok !== true) continue; if ( typeof result === "object" && result !== null && diff --git a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts index d4da09d0e2..404b5fa98d 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts @@ -488,6 +488,42 @@ describe("extractToolMediaAsUserMessages", () => { expect(outputText).not.toContain(leafPayload); }); + it("leaves media-free deep JSON outputs untouched", async () => { + // The wrapper walk visits arbitrary objects; legitimate deep JSON with no + // media or tool-record shapes must pass through unchanged — only + // tool-output-shaped chains earn the over-depth placeholder (round 16). + let deep: Record = { leaf: "value" }; + for (let i = 0; i < 200; i++) { + deep = { next: deep }; + } + + const input: MuxMessage[] = [ + { + id: "a-deep-json", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: "call1", + toolName: "mcp__api__query", + input: {}, + state: "output-available", + output: deep, + }, + ], + metadata: { timestamp: 1 }, + }, + ]; + + const rewritten = await extractToolMediaAsUserMessages(input); + expect(rewritten).toHaveLength(1); + const toolPart = rewritten[0].parts[0]; + if (toolPart.type !== "dynamic-tool" || toolPart.state !== "output-available") { + throw new Error("Expected an output-available dynamic-tool part"); + } + expect(toolPart.output).toBe(deep); + }); + it("self-heals oversized raster tool attachments by downscaling them for provider requests", async () => { const oversizedPng = await sharp({ create: { diff --git a/src/node/utils/messages/toolResultAttachments.ts b/src/node/utils/messages/toolResultAttachments.ts index 7cf5a7664a..6c5b495e71 100644 --- a/src/node/utils/messages/toolResultAttachments.ts +++ b/src/node/utils/messages/toolResultAttachments.ts @@ -139,12 +139,15 @@ function buildDisplayOnlyFilePlaceholder(item: DisplayOnlyFilePart): AISDKTextPa * one malformed row would brick the workspace (self-healing rule). Real * nesting is 1–2 levels (code_execution → bridged tool results). * - * Over-deep subtrees are REPLACED with a bounded placeholder in the provider - * copy, not retained: descent only follows tool-output-shaped wrappers, so - * anything past the cap is malformed by construction, and retaining it would - * keep shipping whatever payload hides at the leaf (e.g. raw base64) on every - * later request — trading the stack overflow for context-limit failures. - * Persisted history itself is never mutated. + * Over-deep TOOL-OUTPUT-SHAPED subtrees (json wrappers, toolCalls record + * chains) are REPLACED with a bounded placeholder in the provider copy, not + * retained: those shapes are malformed by construction past the cap, and + * retaining them would keep shipping whatever payload hides at the leaf + * (e.g. raw base64) on every later request — trading the stack overflow for + * context-limit failures. GENERIC wrapper descent instead stops at the cap + * and leaves the subtree unchanged (see extractAttachmentsFromWrapperValue): + * media-free deep JSON is plausibly legitimate output and must not be + * silently truncated. Persisted history itself is never mutated. */ const MAX_NESTED_TOOL_EXTRACTION_DEPTH = 64; @@ -359,6 +362,16 @@ function extractAttachmentsFromWrapperValue( if (typeof value !== "object" || value === null) { return null; } + // Generic wrappers past the cap are plausibly LEGITIMATE deep JSON (a + // media-free API tree), unlike tool-output-shaped chains: stop descending + // and leave the subtree unchanged instead of substituting the placeholder. + // The stack stays bounded because no recursion continues from here, and + // children below are invoked at depth + 1 ≤ cap, so the placeholder branch + // in extractAttachmentsFromToolOutput is reachable only through + // json/toolCalls edges that add further depth. + if (depth >= MAX_NESTED_TOOL_EXTRACTION_DEPTH) { + return null; + } const attachments: ExtractedToolAttachment[] = []; let didChange = false; const rewrite = (item: unknown): unknown => { From 01b36daa75908e4f1075c28e0f0a12766d9a0a37 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 22:58:13 +0000 Subject: [PATCH 19/40] review r16b: walk wrapper siblings alongside nested tool-record extraction --- .../extractToolMediaAsUserMessages.test.ts | 9 +++++++-- .../utils/messages/toolResultAttachments.ts | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts index 404b5fa98d..d142ddbb03 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts @@ -259,6 +259,10 @@ describe("extractToolMediaAsUserMessages", () => { result: { image: mediaContainer, note: "kept" }, toolCalls: [{ toolName: "mcp__shots__take", args: {}, result: mediaContainer }], consoleOutput: [{ level: "log", args: [{ wrapped: mediaContainer }], timestamp: 1 }], + // Sibling field beside the toolCalls-shaped structure: the + // nested rewrite must walk it too, not return early (round 16 + // security straggler). + sibling: { stashed: mediaContainer }, }, }, ], @@ -274,10 +278,11 @@ describe("extractToolMediaAsUserMessages", () => { throw new Error("Expected an output-available dynamic-tool part"); } const outputText = JSON.stringify(toolPart.output); - // All three copies (record, wrapped outer result, wrapped console arg) - // are replaced; sibling wrapper fields survive untouched. + // All four copies (record, wrapped outer result, wrapped console arg, + // sibling field) are replaced; non-media wrapper fields survive. expect(outputText).not.toContain(base64); expect(outputText).toContain('"note":"kept"'); + expect(outputText).toContain('"stashed"'); // Identical media across all copies dedupes into ONE attachment. const syntheticUser = rewritten[1]; diff --git a/src/node/utils/messages/toolResultAttachments.ts b/src/node/utils/messages/toolResultAttachments.ts index 6c5b495e71..1285ab1d55 100644 --- a/src/node/utils/messages/toolResultAttachments.ts +++ b/src/node/utils/messages/toolResultAttachments.ts @@ -328,6 +328,25 @@ function extractAttachmentsFromNestedToolCalls( } } + // Sibling wrapper fields beyond toolCalls/result/consoleOutput: classic + // sandbox code can return a wrapper holding BOTH a toolCalls-shaped + // structure and other media-bearing fields, and returning after only the + // nested rewrite would leave sibling base64 in request-ready JSON (capture + // retains supported media under its budget by design). + const siblingRewrites: Record = {}; + for (const [key, item] of Object.entries(output as Record)) { + if (key === "toolCalls" || key === "result" || key === "consoleOutput") { + continue; + } + const extracted = extractAttachmentsFromToolOutput(item, depth + 1); + if (extracted == null) { + continue; + } + didChange = true; + pushUnique(extracted.attachments); + siblingRewrites[key] = extracted.newOutput; + } + if (!didChange) { return null; } @@ -338,6 +357,7 @@ function extractAttachmentsFromNestedToolCalls( toolCalls: newToolCalls, ...(extractedOuter != null ? { result: extractedOuter.newOutput } : {}), ...(newConsoleOutput !== consoleOutput ? { consoleOutput: newConsoleOutput } : {}), + ...siblingRewrites, }, attachments, }; From 8f86bde69258aa7acdbf130b55a19903155eb979 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Aug 2026 07:26:31 +0000 Subject: [PATCH 20/40] review r17: scan generic wrapper spans iteratively so deep-wrapped media is always extracted The generic wrapper walk recursed through extractAttachmentsFromToolOutput, consuming 2 depth units per wrapper level and abandoning the scan at the 64-level cap - a supported media container below ~32 plain wrappers kept shipping raw base64 in every provider request while capture-time sanitization intentionally retains containers far deeper. Replace the recursive walk with an explicit-stack post-order traversal that rewrites media at any wrapper depth, preserves identity for media-free JSON, and keeps recursion (and the shared depth cap) only for tool-output-shaped edges (json/content/toolCalls chains). --- .../extractToolMediaAsUserMessages.test.ts | 61 ++++++++ .../utils/messages/toolResultAttachments.ts | 130 +++++++++++++----- 2 files changed, 159 insertions(+), 32 deletions(-) diff --git a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts index d142ddbb03..25e6677678 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts @@ -529,6 +529,67 @@ describe("extractToolMediaAsUserMessages", () => { expect(toolPart.output).toBe(deep); }); + it("extracts media buried under deep generic wrapper chains", async () => { + // Capture-time sanitization retains supported containers under its budget + // regardless of wrapper depth, so the request-time scan must not abandon + // deep generic spans: a container hidden below more plain wrappers than a + // recursive depth budget allows would otherwise ship raw base64 in every + // provider request (round 17). + const base64 = ( + await sharp({ + create: { width: 10, height: 10, channels: 3, background: { r: 7, g: 8, b: 9 } }, + }) + .png() + .toBuffer() + ).toString("base64"); + + let deep: Record = { + image: { + type: "content", + value: [{ type: "media", mediaType: "image/png", data: base64 }], + }, + leaf: "kept", + }; + for (let i = 0; i < 200; i++) { + deep = { next: deep }; + } + + const input: MuxMessage[] = [ + { + id: "a-deep-media", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: "call1", + toolName: "code_execution", + input: { code: "..." }, + state: "output-available", + output: deep, + }, + ], + metadata: { timestamp: 1 }, + }, + ]; + + const rewritten = await extractToolMediaAsUserMessages(input); + expect(rewritten).toHaveLength(2); + const toolPart = rewritten[0].parts[0]; + if (toolPart.type !== "dynamic-tool" || toolPart.state !== "output-available") { + throw new Error("Expected an output-available dynamic-tool part"); + } + const outputText = JSON.stringify(toolPart.output); + // The buried container is rewritten to a placeholder while the media-free + // wrapper structure survives untouched. + expect(outputText).not.toContain(base64); + expect(outputText).toContain("[Attachment attached:"); + expect(outputText).toContain('"leaf":"kept"'); + + const syntheticUser = rewritten[1]; + const fileParts = syntheticUser.parts.filter((part) => part.type === "file"); + expect(fileParts).toHaveLength(1); + }); + it("self-heals oversized raster tool attachments by downscaling them for provider requests", async () => { const oversizedPng = await sharp({ create: { diff --git a/src/node/utils/messages/toolResultAttachments.ts b/src/node/utils/messages/toolResultAttachments.ts index 1285ab1d55..74f16b3739 100644 --- a/src/node/utils/messages/toolResultAttachments.ts +++ b/src/node/utils/messages/toolResultAttachments.ts @@ -144,10 +144,11 @@ function buildDisplayOnlyFilePlaceholder(item: DisplayOnlyFilePart): AISDKTextPa * retained: those shapes are malformed by construction past the cap, and * retaining them would keep shipping whatever payload hides at the leaf * (e.g. raw base64) on every later request — trading the stack overflow for - * context-limit failures. GENERIC wrapper descent instead stops at the cap - * and leaves the subtree unchanged (see extractAttachmentsFromWrapperValue): - * media-free deep JSON is plausibly legitimate output and must not be - * silently truncated. Persisted history itself is never mutated. + * context-limit failures. GENERIC wrapper descent does NOT consume this cap: + * it is scanned iteratively without recursion (see + * extractAttachmentsFromWrapperValue), so media is rewritten at any wrapper + * depth while media-free deep JSON — plausibly legitimate output — passes + * through unchanged. Persisted history itself is never mutated. */ const MAX_NESTED_TOOL_EXTRACTION_DEPTH = 64; @@ -363,6 +364,21 @@ function extractAttachmentsFromNestedToolCalls( }; } +/** + * Tool-output-shaped values route through the recursive shape handlers in + * extractAttachmentsFromToolOutput (which consume the shared depth cap); + * everything else is a generic wrapper scanned iteratively below. + */ +function isToolOutputShaped(value: unknown): boolean { + return ( + isJsonContainer(value) || + isContentContainer(value) || + (typeof value === "object" && + value !== null && + Array.isArray((value as { toolCalls?: unknown }).toolCalls)) + ); +} + /** * Deep-walk arbitrary wrapper objects/arrays for media content containers. * Sandbox code can wrap bridged results (`return { image: xum.mcp(...) }`), @@ -370,10 +386,20 @@ function extractAttachmentsFromNestedToolCalls( * under its budget — so the provider copy must rewrite them into * attachments/placeholders wherever they sit, or a normal screenshot ships as * both an attachment (from the duplicate nested record) and megabytes of raw - * JSON (from the wrapped outer result) on every later request. Children route - * back through extractAttachmentsFromToolOutput, so the shared depth cap - * bounds the stack (cycles terminate because depth grows on each revisit) and - * over-deep subtrees degrade to the bounded placeholder. + * JSON (from the wrapped outer result) on every later request. + * + * Generic wrapper spans are traversed ITERATIVELY (explicit stack, post-order + * copy-on-write rebuild) rather than recursively: wrapper shapes are + * model/attacker-authored, and abandoning the scan at a fixed depth would let + * a media container hidden below that many plain wrappers keep shipping raw + * base64 in every provider request, while recursing per wrapper level would + * trade that for stack overflow (capture-time sanitization retains supported + * containers far deeper than any safe recursion budget). Media-free deep JSON + * still passes through unchanged (null ⇒ caller keeps the original value). + * Recursion continues only through tool-output-shaped children + * (json/content/toolCalls edges), which stay bounded by the shared depth cap; + * in-memory cycles are skipped via the visiting/processed sets, and cyclic + * back-edges are kept as-is (persisted JSON history cannot contain them). */ function extractAttachmentsFromWrapperValue( value: unknown, @@ -382,34 +408,74 @@ function extractAttachmentsFromWrapperValue( if (typeof value !== "object" || value === null) { return null; } - // Generic wrappers past the cap are plausibly LEGITIMATE deep JSON (a - // media-free API tree), unlike tool-output-shaped chains: stop descending - // and leave the subtree unchanged instead of substituting the placeholder. - // The stack stays bounded because no recursion continues from here, and - // children below are invoked at depth + 1 ≤ cap, so the placeholder branch - // in extractAttachmentsFromToolOutput is reachable only through - // json/toolCalls edges that add further depth. - if (depth >= MAX_NESTED_TOOL_EXTRACTION_DEPTH) { - return null; - } const attachments: ExtractedToolAttachment[] = []; - let didChange = false; - const rewrite = (item: unknown): unknown => { - const extracted = extractAttachmentsFromToolOutput(item, depth + 1); - if (extracted == null) { - return item; + // Copy-on-write rebuilds keyed by original node identity; nodes absent from + // this map are unchanged and reused as-is (preserves identity for the + // media-free case). + const changed = new Map(); + const processed = new Set(); + const visiting = new Set(); + const stack: Array<{ node: object; entered: boolean }> = [{ node: value, entered: false }]; + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + const node = frame.node; + if (!frame.entered) { + // Duplicate frames (shared children pushed by several parents) and + // cycle back-edges are dropped before descending. + if (processed.has(node) || visiting.has(node)) { + stack.pop(); + continue; + } + frame.entered = true; + visiting.add(node); + // Descend generic object/array children first (post-order rebuild); + // tool-output-shaped children are handled at exit via the recursive + // shape handlers instead. + const children: unknown[] = Array.isArray(node) + ? node + : Object.values(node as Record); + for (const child of children) { + if (typeof child !== "object" || child === null) continue; + if (processed.has(child) || visiting.has(child)) continue; + if (isToolOutputShaped(child)) continue; + stack.push({ node: child, entered: false }); + } + continue; } - didChange = true; - attachments.push(...extracted.attachments); - return extracted.newOutput; - }; - const newValue = Array.isArray(value) - ? value.map(rewrite) - : Object.fromEntries(Object.entries(value).map(([key, item]) => [key, rewrite(item)])); - if (!didChange) { + stack.pop(); + visiting.delete(node); + processed.add(node); + let nodeChanged = false; + const rewriteChild = (child: unknown): unknown => { + if (typeof child !== "object" || child === null) { + return child; + } + if (isToolOutputShaped(child)) { + const extracted = extractAttachmentsFromToolOutput(child, depth + 1); + if (extracted == null) { + return child; + } + nodeChanged = true; + attachments.push(...extracted.attachments); + return extracted.newOutput; + } + if (changed.has(child)) { + nodeChanged = true; + return changed.get(child); + } + return child; + }; + const rebuilt = Array.isArray(node) + ? node.map(rewriteChild) + : Object.fromEntries(Object.entries(node).map(([key, child]) => [key, rewriteChild(child)])); + if (nodeChanged) { + changed.set(node, rebuilt); + } + } + if (!changed.has(value)) { return null; } - return { newOutput: newValue, attachments }; + return { newOutput: changed.get(value), attachments }; } type ProviderReadyToolAttachment = From e5051883f0228e1c12c1c1f6d1352a061b6c83ae Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Aug 2026 07:54:52 +0000 Subject: [PATCH 21/40] review r18: reject nested agent_skill_read records with explicit ok:false Nested history is untrusted: a contradictory row carrying ok:false plus a schema-valid {success:true, skill} result was still promoted into post-compaction skill snapshots. Other nested extractors treat ok:false as authoritative failure - do the same here so malformed state cannot inject a skill snapshot into later provider requests. --- .../agentSkills/loadedSkillSnapshots.test.ts | 19 +++++++++++++++++++ .../agentSkills/loadedSkillSnapshots.ts | 5 +++++ 2 files changed, 24 insertions(+) diff --git a/src/node/services/agentSkills/loadedSkillSnapshots.test.ts b/src/node/services/agentSkills/loadedSkillSnapshots.test.ts index 0e178d7ad4..e3cb868477 100644 --- a/src/node/services/agentSkills/loadedSkillSnapshots.test.ts +++ b/src/node/services/agentSkills/loadedSkillSnapshots.test.ts @@ -102,6 +102,25 @@ describe("extractLoadedSkillSnapshotsFromMessages", () => { // Failed and kernel-compacted records (no full result) yield nothing. { toolName: "agent_skill_read", args: { name: "failed-skill" }, error: "denied" }, { toolName: "agent_skill_read", args: { name: "kernel-skill" }, ok: true, bytes: 9 }, + // Contradictory untrusted row: explicit ok:false is authoritative + // failure even when a schema-valid result rides alongside (r18). + { + toolName: "agent_skill_read", + args: { name: "contradictory-skill" }, + ok: false, + result: { + success: true, + skill: { + scope: "project", + directoryName: "contradictory-skill", + frontmatter: { + name: "contradictory-skill", + description: "contradictory description", + }, + body: "Contradictory body", + }, + }, + }, { toolName: "bash", args: { script: "true" }, result: { success: true } }, ], }, diff --git a/src/node/services/agentSkills/loadedSkillSnapshots.ts b/src/node/services/agentSkills/loadedSkillSnapshots.ts index 4f566b07ff..b42d697a7d 100644 --- a/src/node/services/agentSkills/loadedSkillSnapshots.ts +++ b/src/node/services/agentSkills/loadedSkillSnapshots.ts @@ -186,6 +186,11 @@ function extractLoadedSkillSnapshotsFromCodeExecutionOutput( for (const record of toolCalls as Array>) { if (typeof record !== "object" || record === null) continue; if (record.toolName !== "agent_skill_read" || record.error !== undefined) continue; + // Nested history is untrusted: an explicit ok:false marks the call failed + // even when a schema-valid result rides alongside (r18). Other nested + // extractors treat ok:false as authoritative failure — do the same here so + // contradictory rows cannot inject a skill snapshot into later requests. + if (record.ok === false) continue; const snapshot = extractLoadedSkillSnapshotFromToolOutput(record.result); if (snapshot) { snapshots.push(snapshot); From a36d1448e171e8e90d9c4ea3295a44975f055a47 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Aug 2026 08:14:56 +0000 Subject: [PATCH 22/40] review r18 retry: traverse non-media content parts for nested media containers The content-container branch extracted immediate media parts but retained custom non-media parts unchanged; a media container nested inside such a part (retained whole at capture within the aggregate budget) kept shipping raw base64 in every provider request. Route non-media parts through the shared extractor so nested containers are rewritten into attachments at any position. --- .../extractToolMediaAsUserMessages.test.ts | 68 +++++++++++++++++++ .../utils/messages/toolResultAttachments.ts | 14 ++++ 2 files changed, 82 insertions(+) diff --git a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts index 25e6677678..65f470eba6 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts @@ -529,6 +529,74 @@ describe("extractToolMediaAsUserMessages", () => { expect(toolPart.output).toBe(deep); }); + it("extracts media containers nested inside non-media content parts", async () => { + // A content container can hold a custom non-media part that itself wraps + // another media container. Capture retains such parts whole while within + // the aggregate budget, so request-time extraction must traverse them — + // otherwise the nested base64 rides as raw JSON in every later provider + // request (r18 retry). + const makeBase64 = async (r: number) => + ( + await sharp({ + create: { width: 10, height: 10, channels: 3, background: { r, g: 0, b: 0 } }, + }) + .png() + .toBuffer() + ).toString("base64"); + const directBase64 = await makeBase64(10); + const nestedBase64 = await makeBase64(200); + + const input: MuxMessage[] = [ + { + id: "a-nested-part", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: "call1", + toolName: "mcp__shots__take", + input: {}, + state: "output-available", + output: { + type: "content", + value: [ + { type: "media", mediaType: "image/png", data: directBase64 }, + { + type: "custom", + payload: { + inner: { + type: "content", + value: [{ type: "media", mediaType: "image/png", data: nestedBase64 }], + }, + note: "kept", + }, + }, + ], + }, + }, + ], + metadata: { timestamp: 1 }, + }, + ]; + + const rewritten = await extractToolMediaAsUserMessages(input); + expect(rewritten).toHaveLength(2); + const toolPart = rewritten[0].parts[0]; + if (toolPart.type !== "dynamic-tool" || toolPart.state !== "output-available") { + throw new Error("Expected an output-available dynamic-tool part"); + } + const outputText = JSON.stringify(toolPart.output); + // Both the immediate media part AND the container nested inside the + // custom part are rewritten; non-media custom fields survive. + expect(outputText).not.toContain(directBase64); + expect(outputText).not.toContain(nestedBase64); + expect(outputText).toContain('"note":"kept"'); + + const syntheticUser = rewritten[1]; + const fileParts = syntheticUser.parts.filter((part) => part.type === "file"); + expect(fileParts).toHaveLength(2); + }); + it("extracts media buried under deep generic wrapper chains", async () => { // Capture-time sanitization retains supported containers under its budget // regardless of wrapper depth, so the request-time scan must not abandon diff --git a/src/node/utils/messages/toolResultAttachments.ts b/src/node/utils/messages/toolResultAttachments.ts index 74f16b3739..a77583335e 100644 --- a/src/node/utils/messages/toolResultAttachments.ts +++ b/src/node/utils/messages/toolResultAttachments.ts @@ -215,6 +215,20 @@ export function extractAttachmentsFromToolOutput( continue; } + // Non-media parts can nest their own media containers (e.g. a custom part + // wrapping another MCP-style container). Capture-time sanitization retains + // such parts whole while within the aggregate budget (charged at full + // serialized size), so the provider copy must traverse them like any other + // wrapper — otherwise the nested base64 rides as raw JSON text in every + // later request (r18 retry). + const nested = extractAttachmentsFromToolOutput(item, depth + 1); + if (nested != null) { + didChange = true; + attachments.push(...nested.attachments); + newValue.push(nested.newOutput as AISDKContent); + continue; + } + newValue.push(item); } From 6c86bc8c230e2a3a27bfa0fd0367ceed79e76d88 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Aug 2026 08:35:35 +0000 Subject: [PATCH 23/40] review r19: deep media exemption search + shared classic-mode capture budget 1. containsMediaContentPayload now recurses through non-media parts (depth-capped, fail-closed): a supported image nested inside a custom wrapper part exempts the container, so captureRetained keeps the sanitized payload instead of collapsing it to a __kernelBounded marker the request-time extractor cannot use. Retention stays bounded because sanitizeRetainedMediaContainer charges every part its full serialized size. 2. Classic (non-kernel) mode shares ONE capture-sanitizer media budget per execution: the early return skipped the execution-wide retained budget and each call minted a fresh 3MiB allowance, so a model-authored loop of bridged media calls could persist unbounded multi-megabyte records. Kernel mode keeps per-call allowances (already bounded by the retained execution budget). --- src/node/services/ptc/quickjsRuntime.ts | 46 +++++++++++++-- src/node/services/ptc/runtime.ts | 4 +- src/node/services/ptc/types.test.ts | 59 +++++++++++++++++++ src/node/services/ptc/types.ts | 47 ++++++++++++--- .../services/tools/code_execution.test.ts | 32 ++++++++++ 5 files changed, 175 insertions(+), 13 deletions(-) diff --git a/src/node/services/ptc/quickjsRuntime.ts b/src/node/services/ptc/quickjsRuntime.ts index b405ab51ff..bf15535c94 100644 --- a/src/node/services/ptc/quickjsRuntime.ts +++ b/src/node/services/ptc/quickjsRuntime.ts @@ -17,6 +17,7 @@ import type { PTCEvent, PTCExecutionResult, PTCToolCallRecord, PTCConsoleRecord import { CONSOLE_CAPTURE_BUDGET_BYTES, KERNEL_RETAINED_EXECUTION_BUDGET_BYTES, + KERNEL_RETAINED_MEDIA_BUDGET_BYTES, } from "@/constants/kernelOutput"; import { sliceUtf8Bytes } from "@/common/utils/sliceUtf8Bytes"; @@ -186,7 +187,23 @@ export class QuickJSRuntime implements IJSRuntime { /** Kernel-mode caps on record/event capture; see IJSRuntime.setKernelRecordBounds. */ private kernelRecordBounds?: KernelRecordBounds; /** Mode-independent record sanitizer; see IJSRuntime.setCaptureResultSanitizer. */ - private captureResultSanitizer?: (toolName: string, result: unknown) => unknown; + private captureResultSanitizer?: ( + toolName: string, + result: unknown, + budget?: { remainingBytes: number } + ) => unknown; + /** Per-execution shared media budget for the capture sanitizer in CLASSIC + * (non-kernel) mode, keyed like retainedResultBudgets. Classic records keep + * full inline results and have no kernel caps, so without sharing, every + * call would mint a fresh per-value media allowance and a model-authored + * loop of bridged media calls could persist unbounded multi-megabyte + * records into partial/final history (r19). Kernel mode keeps per-call + * allowances — its cross-call growth is already bounded by + * retainedResultBudgets. */ + private readonly classicSanitizerBudgets = new WeakMap< + PTCToolCallRecord[], + { remainingBytes: number } + >(); /** Per-execution byte budgets for RETAINED record results, keyed by the * attribution's record array like consoleBudgets (fresh array per eval; * late fire-and-forget settlements share their originating eval's budget). @@ -580,7 +597,9 @@ export class QuickJSRuntime implements IJSRuntime { } setCaptureResultSanitizer( - sanitizer: ((toolName: string, result: unknown) => unknown) | undefined + sanitizer: + | ((toolName: string, result: unknown, budget?: { remainingBytes: number }) => unknown) + | undefined ): void { this.captureResultSanitizer = sanitizer; } @@ -651,10 +670,18 @@ export class QuickJSRuntime implements IJSRuntime { // The mode-independent sanitizer runs first (both classic and kernel // mode): media containers are budgeted at capture because records/events // persist into session history in every mode, and request-time - // attachment extraction rewrites only the provider copy. + // attachment extraction rewrites only the provider copy. Classic mode + // shares ONE sanitizer budget per execution (see classicSanitizerBudgets); + // kernel mode keeps per-call allowances backed by the retained budget. const sanitized = this.captureResultSanitizer !== undefined - ? this.captureResultSanitizer(toolName, value) + ? this.captureResultSanitizer( + toolName, + value, + this.kernelRecordBounds === undefined + ? this.classicSanitizerBudgetFor(toolCalls) + : undefined + ) : value; if (this.kernelRecordBounds === undefined) return sanitized; // Retained records (persistence-critical tools, media containers) keep a @@ -711,6 +738,17 @@ export class QuickJSRuntime implements IJSRuntime { return budget; } + /** Get-or-create the classic-mode shared sanitizer budget for one + * attribution's record array (see classicSanitizerBudgets). */ + private classicSanitizerBudgetFor(toolCalls: PTCToolCallRecord[]): { remainingBytes: number } { + let budget = this.classicSanitizerBudgets.get(toolCalls); + if (!budget) { + budget = { remainingBytes: KERNEL_RETAINED_MEDIA_BUDGET_BYTES }; + this.classicSanitizerBudgets.set(toolCalls, budget); + } + return budget; + } + setPendingJobGate(gate: (run: () => void) => void): void { this.pendingJobGate = gate; } diff --git a/src/node/services/ptc/runtime.ts b/src/node/services/ptc/runtime.ts index 6904b45b60..7fbc9f8339 100644 --- a/src/node/services/ptc/runtime.ts +++ b/src/node/services/ptc/runtime.ts @@ -103,7 +103,9 @@ export interface IJSRuntime extends Disposable { * Pass undefined to disable. */ setCaptureResultSanitizer( - sanitizer: ((toolName: string, result: unknown) => unknown) | undefined + sanitizer: + | ((toolName: string, result: unknown, budget?: { remainingBytes: number }) => unknown) + | undefined ): void; /** diff --git a/src/node/services/ptc/types.test.ts b/src/node/services/ptc/types.test.ts index 00fecd6d0a..9ec059967d 100644 --- a/src/node/services/ptc/types.test.ts +++ b/src/node/services/ptc/types.test.ts @@ -85,6 +85,48 @@ describe("retainExemptKernelRecordResult", () => { }); describe("media container budgets", () => { + it("exempts containers whose only supported media is nested inside a custom part", () => { + // A shallow immediate-part check would decline the exemption and + // collapse the whole result to a __kernelBounded marker, so the + // request-time traversal would never see a payload to extract (r19). + const nested = { + type: "content", + value: [ + { + type: "custom", + payload: { + inner: { + type: "content", + value: [{ type: "media", mediaType: "image/png", data: "aGVsbG8=" }], + }, + }, + }, + ], + }; + const retained = retainExemptKernelRecordResult("mcp__shots__take", nested) as { + value: Array<{ type?: string }>; + }; + expect(retained).toBeDefined(); + expect(retained.value[0]?.type).toBe("custom"); + + // Unsupported nested media alone still does NOT exempt. + const unsupportedNested = { + type: "content", + value: [ + { + type: "custom", + payload: { + inner: { + type: "content", + value: [{ type: "media", mediaType: "audio/wav", data: "d2F2" }], + }, + }, + }, + ], + }; + expect(retainExemptKernelRecordResult("mcp__shots__take", unsupportedNested)).toBeUndefined(); + }); + it("rejects junk media types at validation instead of retaining them as supported", () => { // transformMCPResult copies server-controlled MIME types unchanged; an // "image/" + megabytes string must fail isSupportedAttachmentMediaType @@ -192,6 +234,23 @@ describe("sanitizeMediaRecordCapture", () => { expect(sanitized.image.value[0]?.text).toContain("not supported as a model attachment"); }); + it("shares a caller-provided budget across separate captures", () => { + // Classic mode passes ONE budget per execution (r19): without sharing, + // each call would mint a fresh allowance and a loop of bridged media + // calls could persist unbounded multi-megabyte records. + const bigImage = "A".repeat(2 * 1024 * 1024); + const container = () => ({ + type: "content", + value: [{ type: "media", mediaType: "image/png", data: bigImage }], + }); + const shared = { remainingBytes: KERNEL_RETAINED_MEDIA_BUDGET_BYTES }; + const first = sanitizeCapturedMediaValue(container(), shared) as RetainedContainer; + const second = sanitizeCapturedMediaValue(container(), shared) as RetainedContainer; + expect(first.value[0]?.data).toBe(bigImage); + expect(second.value[0]?.type).toBe("text"); + expect(second.value[0]?.text).toContain("aggregate media budget exceeded"); + }); + it("shares one aggregate budget across all containers in a value", () => { // Wrapping N containers must not multiply the bound: two ~2MiB images in // separate wrapped containers exceed one shared 3MiB budget. diff --git a/src/node/services/ptc/types.ts b/src/node/services/ptc/types.ts index 69ebc7aaf7..1acc2e47d6 100644 --- a/src/node/services/ptc/types.ts +++ b/src/node/services/ptc/types.ts @@ -376,8 +376,12 @@ function sanitizeRetainedMediaContainer( * individually-allowed images would persist unbounded multi-megabyte records * in default (non-RLM) PTC mode. The guest still receives the full value. */ -export function sanitizeMediaRecordCapture(_toolName: string, result: unknown): unknown { - return sanitizeCapturedMediaValue(result); +export function sanitizeMediaRecordCapture( + _toolName: string, + result: unknown, + budget?: { remainingBytes: number } +): unknown { + return sanitizeCapturedMediaValue(result, budget); } /** @@ -403,8 +407,13 @@ const MAX_MEDIA_SANITIZE_DEPTH = 256; * linear; cycle back-edges resolve to a bounded placeholder — cyclic values * cannot JSON-persist anyway). */ -export function sanitizeCapturedMediaValue(value: unknown): unknown { - const budget = { remainingBytes: KERNEL_RETAINED_MEDIA_BUDGET_BYTES }; +export function sanitizeCapturedMediaValue( + value: unknown, + // A caller-shared budget bounds media across MULTIPLE captures (classic + // mode shares one per execution — see QuickJSRuntime.boundCaptureResult); + // absent, each call gets the standalone per-value allowance. + budget: { remainingBytes: number } = { remainingBytes: KERNEL_RETAINED_MEDIA_BUDGET_BYTES } +): unknown { return sanitizeMediaValueGraph(value, budget, new Map(), 0); } @@ -497,13 +506,35 @@ export function isPersistenceCriticalRecordToolName(toolName: string): boolean { * not justify exempting the record from kernel bounding; extraction replaces * any unsupported parts that ride along in an exempted container with bounded * placeholders at request time. + * + * The search recurses through non-media parts (r19): a supported image nested + * inside a custom wrapper part must still exempt the container, or + * captureRetained declines and the whole result collapses to a + * __kernelBounded marker before request-time extraction ever sees a payload. + * Retention stays bounded either way — sanitizeRetainedMediaContainer charges + * every retained part its FULL serialized size (nested payloads included). */ export function containsMediaContentPayload(result: unknown): boolean { if (typeof result !== "object" || result === null) return false; const container = result as { type?: unknown; value?: unknown }; if (container.type !== "content" || !Array.isArray(container.value)) return false; - return container.value.some((item: unknown) => { - const media = asMediaPart(item); - return media?.mediaType !== undefined && isSupportedAttachmentMediaType(media.mediaType); - }); + return container.value.some((item: unknown) => containsSupportedMediaValue(item, 0)); +} + +/** Bounded deep search for a supported media part inside arbitrary wrapper + * values. Depth-capped like the sanitizer walk (guest values are JSON + * round-tripped so cycles are unreachable; the cap fails CLOSED — a ladder + * deeper than any plausible legitimate shape simply loses the exemption and + * falls back to normal bounding). */ +function containsSupportedMediaValue(value: unknown, depth: number): boolean { + if (typeof value !== "object" || value === null) return false; + const media = asMediaPart(value); + if (media !== null) { + return media.mediaType !== undefined && isSupportedAttachmentMediaType(media.mediaType); + } + if (depth >= MAX_MEDIA_SANITIZE_DEPTH) return false; + const children: unknown[] = Array.isArray(value) + ? value + : Object.values(value as Record); + return children.some((child) => containsSupportedMediaValue(child, depth + 1)); } diff --git a/src/node/services/tools/code_execution.test.ts b/src/node/services/tools/code_execution.test.ts index 15238e8e8b..5051692ced 100644 --- a/src/node/services/tools/code_execution.test.ts +++ b/src/node/services/tools/code_execution.test.ts @@ -1106,6 +1106,38 @@ describe("createCodeExecutionTool", () => { expect(value?.[1]?.text).toContain("aggregate media budget exceeded"); }); + it("shares one classic-mode capture budget across calls in an execution", async () => { + // Classic mode has no kernel caps and no retained-result budget, so a + // fresh per-call media allowance would let a model-authored loop of + // bridged media calls persist unbounded multi-megabyte records (r19). + // One shared per-execution budget bounds the sum: later calls' media + // degrade to placeholders. + const image = "A".repeat(1_300_000); + const tools: Record = { + mcp__shots__take: createMockTool("mcp__shots__take", z.object({}), () => ({ + type: "content", + value: [{ type: "media", mediaType: "image/png", data: image }], + })), + }; + const tool = await createCodeExecutionTool(runtimeFactory, new ToolBridge(tools)); + + const result = (await tool.execute!( + { code: "for (let i = 0; i < 3; i++) { mux.mcp__shots__take({}); } return true;" }, + mockToolCallOptions + )) as PTCExecutionResult; + expect(result.success).toBe(true); + const records = result.toolCalls.filter((r) => r.toolName === "mcp__shots__take"); + expect(records).toHaveLength(3); + const partOf = (record: (typeof records)[number]) => + (record.result as { value?: Array<{ type?: string; data?: string; text?: string }> }) + ?.value?.[0]; + // Two ~1.3MB images fit the shared 3MiB budget; the third exceeds it. + expect(partOf(records[0])?.data).toBe(image); + expect(partOf(records[1])?.data).toBe(image); + expect(partOf(records[2])?.type).toBe("text"); + expect(partOf(records[2])?.text).toContain("aggregate media budget exceeded"); + }); + it("charges retained results against one execution-wide budget", async () => { // Retention bypasses the per-record 16KiB kernel cap by design, but a // loop of retained calls (each up to ~3MiB of media) must not grow From 77479e9569f5f3cf5952d1b59d2505da423aa4cc Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Aug 2026 08:49:11 +0000 Subject: [PATCH 24/40] review r20: restrict media exemption to nested extractable content containers The r19 deep search accepted raw {type:"media"} leaves outside any content container, but both the capture sanitizer and the request-time extractor consume container shapes exclusively (bridged MCP results always arrive as containers) - exempting for a bare leaf would retain base64 that nothing downstream budgets or rewrites. The predicate now counts only nested containers holding an immediate supported media child; raw leaves are guest-authored arbitrary JSON, the same class as any guest-returned string. --- src/node/services/ptc/types.test.ts | 15 ++++++++ src/node/services/ptc/types.ts | 56 ++++++++++++++++++----------- 2 files changed, 50 insertions(+), 21 deletions(-) diff --git a/src/node/services/ptc/types.test.ts b/src/node/services/ptc/types.test.ts index 9ec059967d..bc5cfd141c 100644 --- a/src/node/services/ptc/types.test.ts +++ b/src/node/services/ptc/types.test.ts @@ -125,6 +125,21 @@ describe("retainExemptKernelRecordResult", () => { ], }; expect(retainExemptKernelRecordResult("mcp__shots__take", unsupportedNested)).toBeUndefined(); + + // A raw {type:"media"} leaf OUTSIDE any nested content container does + // not exempt either (r20): the sanitizer and extractor only consume + // container shapes, so retaining for a bare leaf would persist base64 + // nothing downstream budgets or rewrites. + const rawLeaf = { + type: "content", + value: [ + { + type: "custom", + payload: { leaf: { type: "media", mediaType: "image/png", data: "aGVsbG8=" } }, + }, + ], + }; + expect(retainExemptKernelRecordResult("mcp__shots__take", rawLeaf)).toBeUndefined(); }); it("rejects junk media types at validation instead of retaining them as supported", () => { diff --git a/src/node/services/ptc/types.ts b/src/node/services/ptc/types.ts index 1acc2e47d6..ad115bd76b 100644 --- a/src/node/services/ptc/types.ts +++ b/src/node/services/ptc/types.ts @@ -507,34 +507,48 @@ export function isPersistenceCriticalRecordToolName(toolName: string): boolean { * any unsupported parts that ride along in an exempted container with bounded * placeholders at request time. * - * The search recurses through non-media parts (r19): a supported image nested - * inside a custom wrapper part must still exempt the container, or - * captureRetained declines and the whole result collapses to a - * __kernelBounded marker before request-time extraction ever sees a payload. - * Retention stays bounded either way — sanitizeRetainedMediaContainer charges - * every retained part its FULL serialized size (nested payloads included). + * The search recurses through non-media parts (r19), but only NESTED CONTENT + * CONTAINERS with an immediate supported media child count (r20): the capture + * sanitizer (isMediaContentContainer) and the request-time extractor both + * consume container shapes exclusively — bridged MCP results always arrive as + * containers (transformMCPResult) — so exempting for a raw {type:"media"} + * leaf outside any container would retain base64 that nothing downstream + * budgets or rewrites. Such leaves are guest-authored arbitrary JSON, the + * same class as any guest-returned string. Retention of exempted containers + * stays bounded — sanitizeRetainedMediaContainer charges every retained part + * its FULL serialized size (nested payloads included). */ export function containsMediaContentPayload(result: unknown): boolean { - if (typeof result !== "object" || result === null) return false; - const container = result as { type?: unknown; value?: unknown }; - if (container.type !== "content" || !Array.isArray(container.value)) return false; - return container.value.some((item: unknown) => containsSupportedMediaValue(item, 0)); + if (!isContentContainerShape(result)) return false; + if (hasImmediateSupportedMedia(result)) return true; + return result.value.some((item: unknown) => containsNestedSupportedContainer(item, 0)); } -/** Bounded deep search for a supported media part inside arbitrary wrapper - * values. Depth-capped like the sanitizer walk (guest values are JSON - * round-tripped so cycles are unreachable; the cap fails CLOSED — a ladder - * deeper than any plausible legitimate shape simply loses the exemption and - * falls back to normal bounding). */ -function containsSupportedMediaValue(value: unknown, depth: number): boolean { +function isContentContainerShape(value: unknown): value is { type: "content"; value: unknown[] } { if (typeof value !== "object" || value === null) return false; - const media = asMediaPart(value); - if (media !== null) { - return media.mediaType !== undefined && isSupportedAttachmentMediaType(media.mediaType); - } + const container = value as { type?: unknown; value?: unknown }; + return container.type === "content" && Array.isArray(container.value); +} + +/** Immediate children only: the shape the sanitizer and extractor consume. */ +function hasImmediateSupportedMedia(container: { value: unknown[] }): boolean { + return container.value.some((item: unknown) => { + const media = asMediaPart(item); + return media?.mediaType !== undefined && isSupportedAttachmentMediaType(media.mediaType); + }); +} + +/** Bounded deep search for a nested content container holding an immediate + * supported media part. Depth-capped like the sanitizer walk (guest values + * are JSON round-tripped so cycles are unreachable; the cap fails CLOSED — a + * ladder deeper than any plausible legitimate shape simply loses the + * exemption and falls back to normal bounding). */ +function containsNestedSupportedContainer(value: unknown, depth: number): boolean { + if (typeof value !== "object" || value === null) return false; + if (isContentContainerShape(value) && hasImmediateSupportedMedia(value)) return true; if (depth >= MAX_MEDIA_SANITIZE_DEPTH) return false; const children: unknown[] = Array.isArray(value) ? value : Object.values(value as Record); - return children.some((child) => containsSupportedMediaValue(child, depth + 1)); + return children.some((child) => containsNestedSupportedContainer(child, depth + 1)); } From 1879aee39a21674a5014943584796ad688e91cb2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Aug 2026 09:09:59 +0000 Subject: [PATCH 25/40] review r21: compute skill-body truncation budget in serialized space Subtracting the RAW body length from the package's serialized length treated the body's own escape inflation (newlines, quotes, backslashes serialize to 2+ chars) as fixed overhead: an escape-heavy 50k body drove the budget negative, boundOversizedSkillPackage returned undefined, and the whole package collapsed to a kernel marker - compaction then erased the skill snapshot even though a shorter serialized prefix fits. Compute the true non-body overhead from the body's serialized form and binary- search the largest raw prefix whose serialized length fits the budget (surrogate-pair safe). --- src/node/services/ptc/types.test.ts | 20 ++++++++++++ src/node/services/ptc/types.ts | 47 ++++++++++++++++++++++++----- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/node/services/ptc/types.test.ts b/src/node/services/ptc/types.test.ts index bc5cfd141c..b4db4a5cff 100644 --- a/src/node/services/ptc/types.test.ts +++ b/src/node/services/ptc/types.test.ts @@ -55,6 +55,26 @@ describe("retainExemptKernelRecordResult", () => { }); expect(retained).toBeUndefined(); }); + + it("truncates escape-heavy bodies by serialized budget instead of dropping them", () => { + // An all-newline body serializes at ~2x its raw length; a raw-length + // budget treated that inflation as fixed overhead, went negative, and + // lost the whole package to a marker even though a shorter serialized + // prefix fits (r21). + const retained = retainExemptKernelRecordResult("agent_skill_read", { + success: true, + skill: { ...oversizedSkill, body: "\n".repeat(MAX_FILE_CONTENT_SIZE) }, + }) as { success?: boolean; skill?: { body?: string } }; + expect(retained?.success).toBe(true); + expect(retained?.skill?.body?.startsWith("\n\n\n")).toBe(true); + expect( + retained?.skill?.body?.endsWith( + "[Skill body truncated at capture to fit the retained-record cap]" + ) + ).toBe(true); + expect(JSON.stringify(retained).length).toBeLessThanOrEqual(MAX_FILE_CONTENT_SIZE); + expect(AgentSkillPackageSchema.safeParse(retained?.skill).success).toBe(true); + }); }); describe("file_edit_* diff bounding", () => { diff --git a/src/node/services/ptc/types.ts b/src/node/services/ptc/types.ts index ad115bd76b..472547eeb1 100644 --- a/src/node/services/ptc/types.ts +++ b/src/node/services/ptc/types.ts @@ -204,10 +204,15 @@ const SKILL_BODY_CAPTURE_TRUNCATION_NOTE = * supported 50k snapshot limit plus frontmatter overhead) must degrade to a * bounded body like createLoadedSkillSnapshot does, not lose the whole * package to a __kernelBounded marker (which compaction then drops entirely, - * erasing the skill instructions from every later turn). Serialized escape - * inflation only ever over-estimates the non-body overhead, so the sliced - * package is guaranteed to fit. Returns undefined (normal bounding) for - * malformed packages the extractor would reject anyway. + * erasing the skill instructions from every later turn). + * + * The budget is computed in SERIALIZED space (r21): subtracting the RAW body + * length from the package's serialized length treated the body's own escape + * inflation (newlines, quotes, backslashes serialize to 2+ chars) as fixed + * overhead, so an escape-heavy body drove the budget negative and lost the + * whole package even though a shorter serialized prefix fits. Returns + * undefined (normal bounding) for malformed packages the extractor would + * reject anyway, or when the true non-body overhead alone exceeds the cap. */ function boundOversizedSkillPackage( skill: unknown, @@ -220,12 +225,40 @@ function boundOversizedSkillPackage( if (typeof body !== "string") return undefined; // Serialized note length minus the surrounding quotes. const noteSerializedChars = JSON.stringify(SKILL_BODY_CAPTURE_TRUNCATION_NOTE).length - 2; - const budget = MAX_FILE_CONTENT_SIZE - (reducedLength - body.length) - noteSerializedChars; - if (budget <= 0) return undefined; + // True non-body overhead: package serialized length minus the body's + // SERIALIZED content chars (between its quotes). + const serializedBodyChars = JSON.stringify(body).length - 2; + const overhead = reducedLength - serializedBodyChars; + const serializedBudget = MAX_FILE_CONTENT_SIZE - overhead - noteSerializedChars; + if (serializedBudget <= 0) return undefined; + // Largest raw-body prefix whose serialized form fits the budget. Escape + // expansion is per-character, so the predicate is monotonic in prefix + // length (the one exception — a lone trailing high surrogate escaping to 6 + // chars where the completed pair costs 2 — can only make the search settle + // on a slightly shorter prefix; `low` is only ever advanced to a value that + // TESTED as fitting, so the result always fits). + let low = 0; + let high = body.length; + while (low < high) { + const mid = Math.ceil((low + high) / 2); + const midSerializedChars = JSON.stringify(body.slice(0, mid)).length - 2; + if (midSerializedChars <= serializedBudget) { + low = mid; + } else { + high = mid - 1; + } + } + // Do not end the retained body on a split surrogate pair: dropping a + // trailing lone HIGH surrogate strictly shrinks the serialized form. + if (low > 0) { + const lastCode = body.charCodeAt(low - 1); + if (lastCode >= 0xd800 && lastCode <= 0xdbff) low -= 1; + } + if (low <= 0) return undefined; return { ...success, ...error, - skill: { ...skill, body: `${body.slice(0, budget)}${SKILL_BODY_CAPTURE_TRUNCATION_NOTE}` }, + skill: { ...skill, body: `${body.slice(0, low)}${SKILL_BODY_CAPTURE_TRUNCATION_NOTE}` }, }; } From b4fc1ec466a84ce61fa5c2e68e48e6df70f1ac35 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Aug 2026 09:29:52 +0000 Subject: [PATCH 26/40] review r22: code-point-boundary skill truncation search + classic args media bounding 1. boundOversizedSkillPackage searches prefixes at CODE-POINT boundaries: serialized length is not monotonic in code units across a surrogate pair (a lone high surrogate escapes to 6 chars while the completed pair serializes as 2), so a code-unit midpoint inside a leading emoji could reject a tiny budget the whole pair fits and drop the package. 2. Classic-mode record ARGS are sanitized against the shared per-execution capture budget: media containers passed into another bridged tool ({payload: image}) previously copied unbudgeted base64 into every start/end event and record. Request-time extraction now also rewrites nested-call args media into attachments, mirroring result handling. --- src/node/services/ptc/quickjsRuntime.ts | 26 +++++++-- src/node/services/ptc/types.test.ts | 25 ++++++++ src/node/services/ptc/types.ts | 41 ++++++++----- .../services/tools/code_execution.test.ts | 42 ++++++++++++++ .../extractToolMediaAsUserMessages.test.ts | 58 +++++++++++++++++++ .../utils/messages/toolResultAttachments.ts | 24 ++++++-- 6 files changed, 190 insertions(+), 26 deletions(-) diff --git a/src/node/services/ptc/quickjsRuntime.ts b/src/node/services/ptc/quickjsRuntime.ts index bf15535c94..5e2aee8b2d 100644 --- a/src/node/services/ptc/quickjsRuntime.ts +++ b/src/node/services/ptc/quickjsRuntime.ts @@ -328,7 +328,7 @@ export class QuickJSRuntime implements IJSRuntime { // Kernel mode bounds captured args/results at creation: records and // streamed events must never retain full guest payloads (host memory + // session history growth); the guest still receives full values. - const recordArgs = this.boundCaptureArgs(args[0], name); + const recordArgs = this.boundCaptureArgs(args[0], name, this.toolCalls); // Emit start event this.eventHandler?.({ @@ -508,7 +508,7 @@ export class QuickJSRuntime implements IJSRuntime { const result = await fn(...args); const endTime = Date.now(); // Same creation-time bounding as synchronous bridges (kernel mode). - const recordArgs = this.boundCaptureArgs(args[0], name); + const recordArgs = this.boundCaptureArgs(args[0], name, toolCalls); const recordResult = this.boundCaptureResult(result, name, toolCalls); toolCalls.push({ toolName: name, @@ -534,7 +534,7 @@ export class QuickJSRuntime implements IJSRuntime { const endTime = Date.now(); const errorStr = error instanceof Error ? error.message : String(error); const recordError = this.boundCaptureError(errorStr); - const recordArgs = this.boundCaptureArgs(args[0], name); + const recordArgs = this.boundCaptureArgs(args[0], name, toolCalls); toolCalls.push({ toolName: name, args: recordArgs, @@ -633,8 +633,22 @@ export class QuickJSRuntime implements IJSRuntime { }; } - private boundCaptureArgs(value: unknown, toolName: string): unknown { - if (this.kernelRecordBounds === undefined) return value; + private boundCaptureArgs( + value: unknown, + toolName: string, + toolCalls: PTCToolCallRecord[] + ): unknown { + if (this.kernelRecordBounds === undefined) { + // Classic mode has no args cap, but media containers passed AS + // ARGUMENTS to another bridged tool (e.g. {payload: image}) would + // otherwise copy unbudgeted base64 into every start/end event and + // record (r22) — the result sanitizer only covers the producing call. + // Sanitize against the same shared per-execution budget; the guest + // still passes the full value to the tool. + return this.captureResultSanitizer !== undefined + ? this.captureResultSanitizer(toolName, value, this.classicSanitizerBudgetFor(toolCalls)) + : value; + } const bounded = this.boundCapture(value, this.kernelRecordBounds.argsCapBytes); if (bounded === value) return value; // The marker replaced the args entirely: merge back attribution fields @@ -911,7 +925,7 @@ export class QuickJSRuntime implements IJSRuntime { const callId = generateCallId(); // Same creation-time bounding as registerFunction (kernel mode). - const recordArgs = this.boundCaptureArgs(args[0], methodName); + const recordArgs = this.boundCaptureArgs(args[0], methodName, this.toolCalls); // Emit start event this.eventHandler?.({ diff --git a/src/node/services/ptc/types.test.ts b/src/node/services/ptc/types.test.ts index b4db4a5cff..3467f8e530 100644 --- a/src/node/services/ptc/types.test.ts +++ b/src/node/services/ptc/types.test.ts @@ -56,6 +56,31 @@ describe("retainExemptKernelRecordResult", () => { expect(retained).toBeUndefined(); }); + it("keeps emoji-leading bodies when the budget is below a lone-surrogate escape", () => { + // Serialized length is NOT monotonic in code units across a surrogate + // pair: a code-unit midpoint inside a leading emoji serializes to a + // 6-char \udXXX escape and would reject a budget the whole 2-char pair + // fits, losing the package entirely — the search must test complete + // code points (r22). Budget is pinned to 3 chars via a pad field the + // schema strips. + const note = "\n\n[Skill body truncated at capture to fit the retained-record cap]"; + const noteChars = JSON.stringify(note).length - 2; + const emptyBodySkill = { ...oversizedSkill, body: "", pad: "" }; + const overheadEmpty = JSON.stringify({ success: true, skill: emptyBodySkill }).length; + const pad = "p".repeat(MAX_FILE_CONTENT_SIZE - overheadEmpty - noteChars - 3); + const retained = retainExemptKernelRecordResult("agent_skill_read", { + success: true, + skill: { ...oversizedSkill, body: "🎉".repeat(60), pad }, + }) as { success?: boolean; skill?: { body?: string } }; + expect(retained?.success).toBe(true); + // Exactly one whole emoji fits the 3-char budget (2 serialized chars). + expect(retained?.skill?.body?.startsWith("🎉")).toBe(true); + expect(retained?.skill?.body?.startsWith("🎉🎉")).toBe(false); + expect(retained?.skill?.body?.endsWith(note)).toBe(true); + expect(JSON.stringify(retained).length).toBeLessThanOrEqual(MAX_FILE_CONTENT_SIZE); + expect(AgentSkillPackageSchema.safeParse(retained?.skill).success).toBe(true); + }); + it("truncates escape-heavy bodies by serialized budget instead of dropping them", () => { // An all-newline body serializes at ~2x its raw length; a raw-length // budget treated that inflation as fixed overhead, went negative, and diff --git a/src/node/services/ptc/types.ts b/src/node/services/ptc/types.ts index 472547eeb1..1da2d6844d 100644 --- a/src/node/services/ptc/types.ts +++ b/src/node/services/ptc/types.ts @@ -231,34 +231,43 @@ function boundOversizedSkillPackage( const overhead = reducedLength - serializedBodyChars; const serializedBudget = MAX_FILE_CONTENT_SIZE - overhead - noteSerializedChars; if (serializedBudget <= 0) return undefined; - // Largest raw-body prefix whose serialized form fits the budget. Escape - // expansion is per-character, so the predicate is monotonic in prefix - // length (the one exception — a lone trailing high surrogate escaping to 6 - // chars where the completed pair costs 2 — can only make the search settle - // on a slightly shorter prefix; `low` is only ever advanced to a value that - // TESTED as fitting, so the result always fits). + // Largest raw-body prefix whose serialized form fits the budget, searched + // over CODE-POINT boundaries (r22): serialized length is NOT monotonic in + // code units across a surrogate pair — a lone high surrogate escapes to 6 + // chars while the completed pair serializes as 2, so a code-unit midpoint + // landing inside a leading emoji could reject a budget the whole pair fits. + // Boundary prefixes never split pairs, and appending one code point (or one + // unpaired surrogate, kept as its own unit) strictly grows the serialized + // form, restoring monotonicity for the search. + const boundaries: number[] = [0]; + for (let i = 0; i < body.length; ) { + const code = body.charCodeAt(i); + const isPair = + code >= 0xd800 && + code <= 0xdbff && + i + 1 < body.length && + body.charCodeAt(i + 1) >= 0xdc00 && + body.charCodeAt(i + 1) <= 0xdfff; + i += isPair ? 2 : 1; + boundaries.push(i); + } let low = 0; - let high = body.length; + let high = boundaries.length - 1; while (low < high) { const mid = Math.ceil((low + high) / 2); - const midSerializedChars = JSON.stringify(body.slice(0, mid)).length - 2; + const midSerializedChars = JSON.stringify(body.slice(0, boundaries[mid])).length - 2; if (midSerializedChars <= serializedBudget) { low = mid; } else { high = mid - 1; } } - // Do not end the retained body on a split surrogate pair: dropping a - // trailing lone HIGH surrogate strictly shrinks the serialized form. - if (low > 0) { - const lastCode = body.charCodeAt(low - 1); - if (lastCode >= 0xd800 && lastCode <= 0xdbff) low -= 1; - } - if (low <= 0) return undefined; + const cut = boundaries[low]; + if (cut <= 0) return undefined; return { ...success, ...error, - skill: { ...skill, body: `${body.slice(0, low)}${SKILL_BODY_CAPTURE_TRUNCATION_NOTE}` }, + skill: { ...skill, body: `${body.slice(0, cut)}${SKILL_BODY_CAPTURE_TRUNCATION_NOTE}` }, }; } diff --git a/src/node/services/tools/code_execution.test.ts b/src/node/services/tools/code_execution.test.ts index 5051692ced..8527de3982 100644 --- a/src/node/services/tools/code_execution.test.ts +++ b/src/node/services/tools/code_execution.test.ts @@ -1138,6 +1138,48 @@ describe("createCodeExecutionTool", () => { expect(partOf(records[2])?.text).toContain("aggregate media budget exceeded"); }); + it("sanitizes media containers passed as args to another bridged call in classic mode", async () => { + // `const img = mux.({}); mux.({payload: img})` + // copies the container into the consumer record's ARGS and start/end + // events; without bounding, repeated passes persist unbudgeted base64 + // (r22). Args share the same per-execution capture budget as results. + const image = "A".repeat(1_300_000); + const tools: Record = { + mcp__shots__take: createMockTool("mcp__shots__take", z.object({}), () => ({ + type: "content", + value: [{ type: "media", mediaType: "image/png", data: image }], + })), + mcp__sink__send: createMockTool("mcp__sink__send", z.object({}).passthrough(), () => ({ + ok: true, + })), + }; + const tool = await createCodeExecutionTool(runtimeFactory, new ToolBridge(tools)); + + const result = (await tool.execute!( + { + code: + "const img = mux.mcp__shots__take({}); " + + "mux.mcp__sink__send({payload: img}); " + + "mux.mcp__sink__send({payload: img}); return true;", + }, + mockToolCallOptions + )) as PTCExecutionResult; + expect(result.success).toBe(true); + const sinkRecords = result.toolCalls.filter((r) => r.toolName === "mcp__sink__send"); + expect(sinkRecords).toHaveLength(2); + const argPart = (record: (typeof sinkRecords)[number]) => + ( + record.args as { + payload?: { value?: Array<{ type?: string; data?: string; text?: string }> }; + } + )?.payload?.value?.[0]; + // Result (1.3MB) + first args copy (1.3MB) fit the shared 3MiB budget; + // the second args copy exceeds it and degrades to a placeholder. + expect(argPart(sinkRecords[0])?.data).toBe(image); + expect(argPart(sinkRecords[1])?.type).toBe("text"); + expect(argPart(sinkRecords[1])?.text).toContain("aggregate media budget exceeded"); + }); + it("charges retained results against one execution-wide budget", async () => { // Retention bypasses the per-record 16KiB kernel cap by design, but a // loop of retained calls (each up to ~3MiB of media) must not grow diff --git a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts index 65f470eba6..0bf380ad26 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts @@ -529,6 +529,64 @@ describe("extractToolMediaAsUserMessages", () => { expect(toolPart.output).toBe(deep); }); + it("redacts media containers passed as nested tool-call args", async () => { + // Sandbox code can pass a bridged media result into another tool + // ({payload: image}); classic capture retains the args copy under the + // shared budget, so request-time extraction must rewrite it like result + // media (r22). + const base64 = ( + await sharp({ + create: { width: 10, height: 10, channels: 3, background: { r: 3, g: 1, b: 4 } }, + }) + .png() + .toBuffer() + ).toString("base64"); + const mediaContainer = { + type: "content", + value: [{ type: "media", mediaType: "image/png", data: base64 }], + }; + + const input: MuxMessage[] = [ + { + id: "ce-args", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: "call1", + toolName: "code_execution", + input: { code: "mux.mcp__sink__send({payload: img});" }, + state: "output-available", + output: { + success: true, + result: null, + toolCalls: [ + { + toolName: "mcp__sink__send", + args: { payload: mediaContainer, note: "kept" }, + result: { ok: true }, + }, + ], + }, + }, + ], + metadata: { timestamp: 1 }, + }, + ]; + + const rewritten = await extractToolMediaAsUserMessages(input); + expect(rewritten).toHaveLength(2); + const toolPart = rewritten[0].parts[0]; + if (toolPart.type !== "dynamic-tool" || toolPart.state !== "output-available") { + throw new Error("Expected an output-available dynamic-tool part"); + } + const outputText = JSON.stringify(toolPart.output); + expect(outputText).not.toContain(base64); + expect(outputText).toContain('"note":"kept"'); + const fileParts = rewritten[1].parts.filter((part) => part.type === "file"); + expect(fileParts).toHaveLength(1); + }); + it("extracts media containers nested inside non-media content parts", async () => { // A content container can hold a custom non-media part that itself wraps // another media container. Capture retains such parts whole while within diff --git a/src/node/utils/messages/toolResultAttachments.ts b/src/node/utils/messages/toolResultAttachments.ts index a77583335e..b9ad71569c 100644 --- a/src/node/utils/messages/toolResultAttachments.ts +++ b/src/node/utils/messages/toolResultAttachments.ts @@ -286,16 +286,32 @@ function extractAttachmentsFromNestedToolCalls( if (typeof record !== "object" || record === null) { return record; } - const extracted = extractAttachmentsFromToolOutput( + const rewrites: Record = {}; + const extractedResult = extractAttachmentsFromToolOutput( (record as { result?: unknown }).result, depth + 1 ); - if (extracted == null) { + if (extractedResult != null) { + pushUnique(extractedResult.attachments); + rewrites.result = extractedResult.newOutput; + } + // Nested-call ARGS too (r22): sandbox code can pass a bridged media + // result into another tool ({payload: image}); classic capture retains + // the copy under the shared budget, so the provider copy must rewrite it + // like result media or the base64 rides as JSON in every later request. + const extractedArgs = extractAttachmentsFromToolOutput( + (record as { args?: unknown }).args, + depth + 1 + ); + if (extractedArgs != null) { + pushUnique(extractedArgs.attachments); + rewrites.args = extractedArgs.newOutput; + } + if (extractedResult == null && extractedArgs == null) { return record; } didChange = true; - pushUnique(extracted.attachments); - return { ...record, result: extracted.newOutput }; + return { ...record, ...rewrites }; }); const outerResult = (output as { result?: unknown }).result; From c38ee0b28f38d0304f08b6afb5c9e8b597aae482 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Aug 2026 09:50:03 +0000 Subject: [PATCH 27/40] review r23: retain empty-prefix skill packages + sanitize standalone media leaves 1. boundOversizedSkillPackage keeps the package with an empty body prefix plus the truncation note when the positive budget is smaller than the body's first code point - serializedBudget > 0 already proves overhead + note fit, and dropping the package would erase the skill snapshot from post-compaction context. 2. Standalone {type:"media"} leaves outside content containers are now sanitized at capture (charged full serialized size against the shared budget, unsupported media always replaced) and rewritten into attachments at request time - guest code plucking a part out of a container and returning/logging/passing it as another tool's argument previously persisted and resent unbudgeted base64 on every call. --- src/node/services/ptc/types.test.ts | 45 +++++++++++++++ src/node/services/ptc/types.ts | 39 ++++++++++++- .../extractToolMediaAsUserMessages.test.ts | 55 +++++++++++++++++++ .../utils/messages/toolResultAttachments.ts | 27 ++++++++- 4 files changed, 163 insertions(+), 3 deletions(-) diff --git a/src/node/services/ptc/types.test.ts b/src/node/services/ptc/types.test.ts index 3467f8e530..1748636b8e 100644 --- a/src/node/services/ptc/types.test.ts +++ b/src/node/services/ptc/types.test.ts @@ -81,6 +81,26 @@ describe("retainExemptKernelRecordResult", () => { expect(AgentSkillPackageSchema.safeParse(retained?.skill).success).toBe(true); }); + it("retains an empty body prefix when the budget is below the first code point", () => { + // Budget of 1 char cannot fit the leading emoji (2 serialized chars), + // but the empty prefix + truncation note is schema-valid and fits — + // dropping the package entirely would erase the skill from + // post-compaction context (r23). + const note = "\n\n[Skill body truncated at capture to fit the retained-record cap]"; + const noteChars = JSON.stringify(note).length - 2; + const emptyBodySkill = { ...oversizedSkill, body: "", pad: "" }; + const overheadEmpty = JSON.stringify({ success: true, skill: emptyBodySkill }).length; + const pad = "p".repeat(MAX_FILE_CONTENT_SIZE - overheadEmpty - noteChars - 1); + const retained = retainExemptKernelRecordResult("agent_skill_read", { + success: true, + skill: { ...oversizedSkill, body: "🎉".repeat(60), pad }, + }) as { success?: boolean; skill?: { body?: string } }; + expect(retained?.success).toBe(true); + expect(retained?.skill?.body).toBe(note); + expect(JSON.stringify(retained).length).toBeLessThanOrEqual(MAX_FILE_CONTENT_SIZE); + expect(AgentSkillPackageSchema.safeParse(retained?.skill).success).toBe(true); + }); + it("truncates escape-heavy bodies by serialized budget instead of dropping them", () => { // An all-newline body serializes at ~2x its raw length; a raw-length // budget treated that inflation as fixed overhead, went negative, and @@ -294,6 +314,31 @@ describe("sanitizeMediaRecordCapture", () => { expect(sanitized.image.value[0]?.text).toContain("not supported as a model attachment"); }); + it("sanitizes standalone media leaves outside containers", () => { + // Guest code can pluck a part out of a container (`image.value[0]`) and + // return/log/pass it; container-only recognition would persist that copy + // unbudgeted on every call (r23). + const bigImage = "A".repeat(2 * 1024 * 1024); + const leaf = () => ({ type: "media", mediaType: "image/png", data: bigImage }); + const shared = { remainingBytes: KERNEL_RETAINED_MEDIA_BUDGET_BYTES }; + const first = sanitizeCapturedMediaValue({ payload: leaf() }, shared) as { + payload: { type?: string; data?: string; text?: string }; + }; + const second = sanitizeCapturedMediaValue({ payload: leaf() }, shared) as { + payload: { type?: string; text?: string }; + }; + expect(first.payload.data).toBe(bigImage); + expect(second.payload.type).toBe("text"); + expect(second.payload.text).toContain("aggregate media budget exceeded"); + + // Unsupported standalone leaves are always replaced. + const audio = sanitizeCapturedMediaValue({ + payload: { type: "media", mediaType: "audio/wav", data: "d2F2".repeat(50) }, + }) as { payload: { type?: string; text?: string } }; + expect(audio.payload.type).toBe("text"); + expect(audio.payload.text).toContain("not supported as a model attachment"); + }); + it("shares a caller-provided budget across separate captures", () => { // Classic mode passes ONE budget per execution (r19): without sharing, // each call would mint a fresh allowance and a loop of bridged media diff --git a/src/node/services/ptc/types.ts b/src/node/services/ptc/types.ts index 1da2d6844d..83e4a4898f 100644 --- a/src/node/services/ptc/types.ts +++ b/src/node/services/ptc/types.ts @@ -262,8 +262,13 @@ function boundOversizedSkillPackage( high = mid - 1; } } + // cut === 0 (budget positive but smaller than the first code point) still + // retains the package with an empty body prefix + note (r23): the empty + // prefix is schema-valid and fits (serializedBudget > 0 established that + // overhead + note fit under the cap), while returning undefined would + // collapse the whole package to a kernel marker and erase the skill from + // post-compaction context. const cut = boundaries[low]; - if (cut <= 0) return undefined; return { ...success, ...error, @@ -480,6 +485,12 @@ function sanitizeMediaValueGraph( // Container parts are charged their FULL serialized size (nested payloads // included), so there is no need to descend into a sanitized container. result = sanitizeRetainedMediaContainer(value, budget); + } else if (asMediaPart(value) !== null) { + // STANDALONE media leaves too (r23): guest code can pluck a part out of + // a container (`const part = image.value[0]`) and return it, log it, or + // pass it as another tool's argument — container-only recognition would + // let that copy persist unbudgeted base64 on every call. + result = sanitizeStandaloneMediaPart(value, budget); } else if (Array.isArray(value)) { const mapped = value.map((item) => sanitizeMediaValueGraph(item, budget, memo, depth + 1)); result = mapped.some((item, index) => item !== value[index]) ? mapped : value; @@ -498,6 +509,32 @@ function sanitizeMediaValueGraph( return result; } +/** + * Standalone {type:"media"} leaf outside any content container: unsupported + * media is always replaced, supported media is charged its full serialized + * size against the shared budget — mirroring sanitizeRetainedMediaContainer's + * per-part handling so plucked parts cost the same as containered ones. + */ +function sanitizeStandaloneMediaPart(value: object, budget: { remainingBytes: number }): unknown { + const media = asMediaPart(value); + if (media === null) return value; + if (media.mediaType === undefined || !isSupportedAttachmentMediaType(media.mediaType)) { + return { + type: "text", + text: `[media bounded at capture: ${boundedMediaTypeLabel(media.mediaType)}, ${media.data.length} base64 chars — not supported as a model attachment]`, + }; + } + const serialized = serializedJsonByteLength(value); + if (serialized !== undefined && serialized <= budget.remainingBytes) { + budget.remainingBytes -= serialized; + return value; + } + return { + type: "text", + text: `[media bounded at capture: ${boundedMediaTypeLabel(media.mediaType)}, ${media.data.length} base64 chars — aggregate media budget exceeded]`, + }; +} + /** * Any MCP-style content container carrying at least one media part — * supported or not. Broader than containsMediaContentPayload (which gates the diff --git a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts index 0bf380ad26..fb955987cb 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts @@ -587,6 +587,61 @@ describe("extractToolMediaAsUserMessages", () => { expect(fileParts).toHaveLength(1); }); + it("redacts standalone media leaves plucked out of containers", async () => { + // `const part = image.value[0]; mux.sink({payload: part})` copies a BARE + // media part (no surrounding container) into args; capture retains it + // under the shared budget, so extraction must rewrite the leaf too (r23). + const base64 = ( + await sharp({ + create: { width: 10, height: 10, channels: 3, background: { r: 9, g: 9, b: 9 } }, + }) + .png() + .toBuffer() + ).toString("base64"); + + const input: MuxMessage[] = [ + { + id: "ce-leaf", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: "call1", + toolName: "code_execution", + input: { code: "mux.mcp__sink__send({payload: img.value[0]});" }, + state: "output-available", + output: { + success: true, + // Bare leaf in the outer result too. + result: { type: "media", mediaType: "image/png", data: base64 }, + toolCalls: [ + { + toolName: "mcp__sink__send", + args: { payload: { type: "media", mediaType: "image/png", data: base64 } }, + result: { ok: true }, + }, + ], + }, + }, + ], + metadata: { timestamp: 1 }, + }, + ]; + + const rewritten = await extractToolMediaAsUserMessages(input); + expect(rewritten).toHaveLength(2); + const toolPart = rewritten[0].parts[0]; + if (toolPart.type !== "dynamic-tool" || toolPart.state !== "output-available") { + throw new Error("Expected an output-available dynamic-tool part"); + } + const outputText = JSON.stringify(toolPart.output); + expect(outputText).not.toContain(base64); + expect(outputText).toContain("[Attachment attached:"); + // Identical leaf in args and outer result dedupes into ONE attachment. + const fileParts = rewritten[1].parts.filter((part) => part.type === "file"); + expect(fileParts).toHaveLength(1); + }); + it("extracts media containers nested inside non-media content parts", async () => { // A content container can hold a custom non-media part that itself wraps // another media container. Capture retains such parts whole while within diff --git a/src/node/utils/messages/toolResultAttachments.ts b/src/node/utils/messages/toolResultAttachments.ts index b9ad71569c..80f447346e 100644 --- a/src/node/utils/messages/toolResultAttachments.ts +++ b/src/node/utils/messages/toolResultAttachments.ts @@ -175,6 +175,27 @@ export function extractAttachmentsFromToolOutput( } if (!isContentContainer(output)) { + // Standalone media leaf (r23): sandbox code can pluck a part out of a + // container (`const part = image.value[0]`) and return it or pass it as + // another tool's argument; capture retains it under the shared budget, + // so the provider copy must rewrite it like containered parts. + if (isMediaPart(output)) { + if (isSupportedAttachmentMediaType(output.mediaType)) { + return { + newOutput: buildAttachmentPlaceholder(output), + attachments: [ + { + data: output.data, + mediaType: normalizeAttachmentMediaType(output.mediaType), + ...(normalizeOptionalFilename(output.filename) + ? { filename: normalizeOptionalFilename(output.filename) } + : {}), + }, + ], + }; + } + return { newOutput: buildUnsupportedMediaPlaceholder(output), attachments: [] }; + } const nested = extractAttachmentsFromNestedToolCalls(output, depth + 1); if (nested != null) { return nested; @@ -467,7 +488,9 @@ function extractAttachmentsFromWrapperValue( for (const child of children) { if (typeof child !== "object" || child === null) continue; if (processed.has(child) || visiting.has(child)) continue; - if (isToolOutputShaped(child)) continue; + // Media leaves and tool-output shapes are handled at the parent's + // exit phase via the recursive shape handlers, not span descent. + if (isMediaPart(child) || isToolOutputShaped(child)) continue; stack.push({ node: child, entered: false }); } continue; @@ -480,7 +503,7 @@ function extractAttachmentsFromWrapperValue( if (typeof child !== "object" || child === null) { return child; } - if (isToolOutputShaped(child)) { + if (isMediaPart(child) || isToolOutputShaped(child)) { const extracted = extractAttachmentsFromToolOutput(child, depth + 1); if (extracted == null) { return child; From 2b551d67550610ee29da62c60b7ee315b90b8745 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Aug 2026 09:54:37 +0000 Subject: [PATCH 28/40] lint: accept unknown in sanitizeStandaloneMediaPart (no-object-parameters) --- src/node/services/ptc/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/node/services/ptc/types.ts b/src/node/services/ptc/types.ts index 83e4a4898f..bda87b92c4 100644 --- a/src/node/services/ptc/types.ts +++ b/src/node/services/ptc/types.ts @@ -515,7 +515,7 @@ function sanitizeMediaValueGraph( * size against the shared budget — mirroring sanitizeRetainedMediaContainer's * per-part handling so plucked parts cost the same as containered ones. */ -function sanitizeStandaloneMediaPart(value: object, budget: { remainingBytes: number }): unknown { +function sanitizeStandaloneMediaPart(value: unknown, budget: { remainingBytes: number }): unknown { const media = asMediaPart(value); if (media === null) return value; if (media.mediaType === undefined || !isSupportedAttachmentMediaType(media.mediaType)) { From bb8f6aa1813e9a8be6612bb8d1882f51a6704c51 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Aug 2026 10:14:27 +0000 Subject: [PATCH 29/40] review r24: zero-budget skill retention, metadata-agnostic leaf recognition, sanitized-value cap, leaf/wrapper kernel exemption 1. boundOversizedSkillPackage accepts a zero body budget: overhead + note at exactly MAX_FILE_CONTENT_SIZE still fits with an empty prefix; only negative budgets are unretainable. 2. Request-time isMediaPart no longer gates on optional filename metadata: capture recognition (asMediaPart) ignores it, so a retained leaf with filename:null must be extracted, not left as provider JSON. Malformed filenames are dropped by normalizeOptionalFilename. 3. sanitizeCapturedMediaValue applies a final serialized-output cap (KERNEL_SANITIZED_MEDIA_VALUE_MAX_BYTES) to media-bearing values: placeholders never consume the media budget, so a flood of media nodes could otherwise append placeholder structures without bound. 4. containsMediaContentPayload accepts the exact shapes capture bounds and extraction consumes - containers with immediate supported media AND standalone supported leaves anywhere in the graph (r20's container-only restriction predates r23's leaf support); retention now routes through the budgeted graph sanitizer, which also handles non-container shapes. --- src/constants/kernelOutput.ts | 12 +++ src/node/services/ptc/types.test.ts | 63 +++++++++++-- src/node/services/ptc/types.ts | 89 ++++++++++++------- .../extractToolMediaAsUserMessages.test.ts | 47 ++++++++++ .../utils/messages/toolResultAttachments.ts | 8 +- 5 files changed, 178 insertions(+), 41 deletions(-) diff --git a/src/constants/kernelOutput.ts b/src/constants/kernelOutput.ts index 2316054c46..32e46ecc10 100644 --- a/src/constants/kernelOutput.ts +++ b/src/constants/kernelOutput.ts @@ -73,6 +73,18 @@ export const KERNEL_RETAINED_CONTAINER_MAX_PARTS = 64; */ export const KERNEL_RETAINED_EXECUTION_BUDGET_BYTES = 4 * KERNEL_RETAINED_MEDIA_BUDGET_BYTES; +/** + * Final serialized-size cap on ONE media-bearing value after the capture + * sanitizer's graph walk. Placeholders replacing unsupported/over-budget + * media do not consume the media budget (they must always be emitted for + * safety), so a value flooding thousands of media nodes could otherwise + * append placeholder structures without bound; a sanitized value that still + * serializes above this cap collapses to a single bounded marker. 2x the + * media budget leaves ample room for legitimately retained media plus + * non-media siblings and placeholder overhead. + */ +export const KERNEL_SANITIZED_MEDIA_VALUE_MAX_BYTES = 2 * KERNEL_RETAINED_MEDIA_BUDGET_BYTES; + /** * Max chars of a validated tool-arg file path preserved on a __kernelBounded * args marker (see retainPersistenceCriticalArgsFields). Covers Linux diff --git a/src/node/services/ptc/types.test.ts b/src/node/services/ptc/types.test.ts index 1748636b8e..3a2517307d 100644 --- a/src/node/services/ptc/types.test.ts +++ b/src/node/services/ptc/types.test.ts @@ -81,6 +81,30 @@ describe("retainExemptKernelRecordResult", () => { expect(AgentSkillPackageSchema.safeParse(retained?.skill).success).toBe(true); }); + it("retains the package when the body budget is exactly zero", () => { + // overhead + note == MAX_FILE_CONTENT_SIZE exactly: the empty prefix + + // note fits the cap exactly, so zero is a valid budget — only negative + // budgets (overhead + note alone exceed the cap) are unretainable (r24). + const note = "\n\n[Skill body truncated at capture to fit the retained-record cap]"; + const noteChars = JSON.stringify(note).length - 2; + const emptyBodySkill = { ...oversizedSkill, body: "", pad: "" }; + const overheadEmpty = JSON.stringify({ success: true, skill: emptyBodySkill }).length; + const zeroPad = "p".repeat(MAX_FILE_CONTENT_SIZE - overheadEmpty - noteChars); + const retained = retainExemptKernelRecordResult("agent_skill_read", { + success: true, + skill: { ...oversizedSkill, body: "🎉".repeat(60), pad: zeroPad }, + }) as { success?: boolean; skill?: { body?: string } }; + expect(retained?.skill?.body).toBe(note); + expect(JSON.stringify(retained).length).toBe(MAX_FILE_CONTENT_SIZE); + + // One char more overhead → negative budget → unretainable. + const negative = retainExemptKernelRecordResult("agent_skill_read", { + success: true, + skill: { ...oversizedSkill, body: "🎉".repeat(60), pad: `${zeroPad}p` }, + }); + expect(negative).toBeUndefined(); + }); + it("retains an empty body prefix when the budget is below the first code point", () => { // Budget of 1 char cannot fit the leading emoji (2 serialized chars), // but the empty prefix + truncation note is schema-valid and fits — @@ -191,10 +215,9 @@ describe("retainExemptKernelRecordResult", () => { }; expect(retainExemptKernelRecordResult("mcp__shots__take", unsupportedNested)).toBeUndefined(); - // A raw {type:"media"} leaf OUTSIDE any nested content container does - // not exempt either (r20): the sanitizer and extractor only consume - // container shapes, so retaining for a bare leaf would persist base64 - // nothing downstream budgets or rewrites. + // Standalone leaves and wrapper shapes exempt too (r24): capture + // sanitization bounds them and request-time extraction rewrites them, + // so declining would collapse an extractable payload to a marker. const rawLeaf = { type: "content", value: [ @@ -204,7 +227,22 @@ describe("retainExemptKernelRecordResult", () => { }, ], }; - expect(retainExemptKernelRecordResult("mcp__shots__take", rawLeaf)).toBeUndefined(); + const retainedLeaf = retainExemptKernelRecordResult("mcp__shots__take", rawLeaf); + expect(retainedLeaf).toBeDefined(); + const bareLeaf = retainExemptKernelRecordResult("mcp__shots__take", { + type: "media", + mediaType: "image/png", + data: "aGVsbG8=", + }) as { data?: string }; + expect(bareLeaf?.data).toBe("aGVsbG8="); + // Unsupported bare leaves still do not exempt. + expect( + retainExemptKernelRecordResult("mcp__shots__take", { + type: "media", + mediaType: "audio/wav", + data: "d2F2", + }) + ).toBeUndefined(); }); it("rejects junk media types at validation instead of retaining them as supported", () => { @@ -314,6 +352,21 @@ describe("sanitizeMediaRecordCapture", () => { expect(sanitized.image.value[0]?.text).toContain("not supported as a model attachment"); }); + it("collapses placeholder floods to a single bounded marker", () => { + // Placeholders never consume the media budget (they must always be + // emitted), so a value flooding many media nodes could otherwise append + // placeholder structures without bound (r24): the sanitized value gets a + // final serialized cap. + const leaves = Array.from({ length: 60_000 }, () => ({ + type: "media", + mediaType: "audio/wav", + data: "d2F2", + })); + const sanitized = sanitizeCapturedMediaValue({ payload: leaves }); + expect(typeof sanitized).toBe("string"); + expect(sanitized as string).toContain("exceed the sanitized-value cap"); + }); + it("sanitizes standalone media leaves outside containers", () => { // Guest code can pluck a part out of a container (`image.value[0]`) and // return/log/pass it; container-only recognition would persist that copy diff --git a/src/node/services/ptc/types.ts b/src/node/services/ptc/types.ts index bda87b92c4..8a5ea60fc8 100644 --- a/src/node/services/ptc/types.ts +++ b/src/node/services/ptc/types.ts @@ -14,6 +14,7 @@ import { KERNEL_COMPACT_ARGS_CAP_BYTES, KERNEL_RETAINED_CONTAINER_MAX_PARTS, KERNEL_RETAINED_MEDIA_BUDGET_BYTES, + KERNEL_SANITIZED_MEDIA_VALUE_MAX_BYTES, KERNEL_RETAINED_PATH_MAX_CHARS, } from "@/constants/kernelOutput"; @@ -144,7 +145,10 @@ export function retainExemptKernelRecordResult(toolName: string, result: unknown return boundPersistenceCriticalResult(toolName, result); } if (!containsMediaContentPayload(result)) return undefined; - return sanitizeRetainedMediaContainer(result); + // The budgeted graph walk handles every exempt shape (containers, bare + // leaves, wrappers around either — r24); the container sanitizer alone + // would crash on non-container shapes the exemption now accepts. + return sanitizeCapturedMediaValue(result); } /** @@ -229,8 +233,11 @@ function boundOversizedSkillPackage( // SERIALIZED content chars (between its quotes). const serializedBodyChars = JSON.stringify(body).length - 2; const overhead = reducedLength - serializedBodyChars; + // Zero is a VALID budget (r24): the empty body prefix + note then fits the + // cap exactly; only a negative budget (overhead + note alone exceed the + // cap) makes the package unretainable. const serializedBudget = MAX_FILE_CONTENT_SIZE - overhead - noteSerializedChars; - if (serializedBudget <= 0) return undefined; + if (serializedBudget < 0) return undefined; // Largest raw-body prefix whose serialized form fits the budget, searched // over CODE-POINT boundaries (r22): serialized length is NOT monotonic in // code units across a surrogate pair — a lone high surrogate escapes to 6 @@ -461,14 +468,27 @@ export function sanitizeCapturedMediaValue( // absent, each call gets the standalone per-value allowance. budget: { remainingBytes: number } = { remainingBytes: KERNEL_RETAINED_MEDIA_BUDGET_BYTES } ): unknown { - return sanitizeMediaValueGraph(value, budget, new Map(), 0); + const state = { sawMedia: false }; + const sanitized = sanitizeMediaValueGraph(value, budget, new Map(), 0, state); + if (!state.sawMedia) return sanitized; + // Final serialized-output cap (r24): placeholders replacing unsupported or + // over-budget media never consume the media budget (they must always be + // emitted for safety), so a value flooding many media nodes could append + // placeholder structures without bound. Media-free values are untouched + // (classic mode keeps full inline results/args by contract). + const bytes = serializedJsonByteLength(sanitized); + if (bytes === undefined || bytes > KERNEL_SANITIZED_MEDIA_VALUE_MAX_BYTES) { + return `[value bounded at capture: ${bytes ?? "unserializable"} serialized bytes after media sanitization exceed the sanitized-value cap]`; + } + return sanitized; } function sanitizeMediaValueGraph( value: unknown, budget: { remainingBytes: number }, memo: Map, - depth: number + depth: number, + state: { sawMedia: boolean } ): unknown { if (typeof value !== "object" || value === null) return value; const existing = memo.get(value); @@ -484,22 +504,26 @@ function sanitizeMediaValueGraph( if (isMediaContentContainer(value)) { // Container parts are charged their FULL serialized size (nested payloads // included), so there is no need to descend into a sanitized container. + state.sawMedia = true; result = sanitizeRetainedMediaContainer(value, budget); } else if (asMediaPart(value) !== null) { // STANDALONE media leaves too (r23): guest code can pluck a part out of // a container (`const part = image.value[0]`) and return it, log it, or // pass it as another tool's argument — container-only recognition would // let that copy persist unbudgeted base64 on every call. + state.sawMedia = true; result = sanitizeStandaloneMediaPart(value, budget); } else if (Array.isArray(value)) { - const mapped = value.map((item) => sanitizeMediaValueGraph(item, budget, memo, depth + 1)); + const mapped = value.map((item) => + sanitizeMediaValueGraph(item, budget, memo, depth + 1, state) + ); result = mapped.some((item, index) => item !== value[index]) ? mapped : value; } else { const record = value as Record; let changed = false; const mapped: Record = {}; for (const [key, item] of Object.entries(record)) { - const sanitized = sanitizeMediaValueGraph(item, budget, memo, depth + 1); + const sanitized = sanitizeMediaValueGraph(item, budget, memo, depth + 1, state); mapped[key] = sanitized; if (sanitized !== item) changed = true; } @@ -586,21 +610,33 @@ export function isPersistenceCriticalRecordToolName(toolName: string): boolean { * any unsupported parts that ride along in an exempted container with bounded * placeholders at request time. * - * The search recurses through non-media parts (r19), but only NESTED CONTENT - * CONTAINERS with an immediate supported media child count (r20): the capture - * sanitizer (isMediaContentContainer) and the request-time extractor both - * consume container shapes exclusively — bridged MCP results always arrive as - * containers (transformMCPResult) — so exempting for a raw {type:"media"} - * leaf outside any container would retain base64 that nothing downstream - * budgets or rewrites. Such leaves are guest-authored arbitrary JSON, the - * same class as any guest-returned string. Retention of exempted containers - * stays bounded — sanitizeRetainedMediaContainer charges every retained part - * its FULL serialized size (nested payloads included). + * The search recurses through wrappers and non-media parts (r19) and accepts + * the exact shapes the capture sanitizer bounds and the request-time + * extractor consumes: content containers with an immediate supported media + * child, and standalone supported media leaves (r24 — r20 restricted the + * predicate to containers when downstream was container-only; r23 taught + * both the sanitizer and the extractor to bound/extract standalone leaves, + * so declining the exemption for them now just collapses an extractable + * payload to a __kernelBounded marker). Retention stays bounded: + * retainExemptKernelRecordResult sanitizes through the same budgeted graph + * walk before the record is kept. */ export function containsMediaContentPayload(result: unknown): boolean { - if (!isContentContainerShape(result)) return false; - if (hasImmediateSupportedMedia(result)) return true; - return result.value.some((item: unknown) => containsNestedSupportedContainer(item, 0)); + return containsExtractableMediaValue(result, 0); +} + +function containsExtractableMediaValue(value: unknown, depth: number): boolean { + if (typeof value !== "object" || value === null) return false; + const media = asMediaPart(value); + if (media !== null) { + return media.mediaType !== undefined && isSupportedAttachmentMediaType(media.mediaType); + } + if (isContentContainerShape(value) && hasImmediateSupportedMedia(value)) return true; + if (depth >= MAX_MEDIA_SANITIZE_DEPTH) return false; + const children: unknown[] = Array.isArray(value) + ? value + : Object.values(value as Record); + return children.some((child) => containsExtractableMediaValue(child, depth + 1)); } function isContentContainerShape(value: unknown): value is { type: "content"; value: unknown[] } { @@ -616,18 +652,3 @@ function hasImmediateSupportedMedia(container: { value: unknown[] }): boolean { return media?.mediaType !== undefined && isSupportedAttachmentMediaType(media.mediaType); }); } - -/** Bounded deep search for a nested content container holding an immediate - * supported media part. Depth-capped like the sanitizer walk (guest values - * are JSON round-tripped so cycles are unreachable; the cap fails CLOSED — a - * ladder deeper than any plausible legitimate shape simply loses the - * exemption and falls back to normal bounding). */ -function containsNestedSupportedContainer(value: unknown, depth: number): boolean { - if (typeof value !== "object" || value === null) return false; - if (isContentContainerShape(value) && hasImmediateSupportedMedia(value)) return true; - if (depth >= MAX_MEDIA_SANITIZE_DEPTH) return false; - const children: unknown[] = Array.isArray(value) - ? value - : Object.values(value as Record); - return children.some((child) => containsNestedSupportedContainer(child, depth + 1)); -} diff --git a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts index fb955987cb..24be59087b 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts @@ -587,6 +587,53 @@ describe("extractToolMediaAsUserMessages", () => { expect(fileParts).toHaveLength(1); }); + it("extracts media leaves whose optional metadata is malformed", async () => { + // Capture recognition ignores optional metadata (asMediaPart), so a + // retained leaf with filename:null must not be rejected by a stricter + // request-time predicate — that would leave the retained base64 in + // provider JSON (r24). The malformed filename is dropped, not sent. + const base64 = ( + await sharp({ + create: { width: 10, height: 10, channels: 3, background: { r: 5, g: 5, b: 5 } }, + }) + .png() + .toBuffer() + ).toString("base64"); + + const input: MuxMessage[] = [ + { + id: "ce-nullname", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: "call1", + toolName: "code_execution", + input: { code: "..." }, + state: "output-available", + output: { + success: true, + result: { + wrapped: { type: "media", mediaType: "image/png", data: base64, filename: null }, + }, + toolCalls: [], + }, + }, + ], + metadata: { timestamp: 1 }, + }, + ]; + + const rewritten = await extractToolMediaAsUserMessages(input); + expect(rewritten).toHaveLength(2); + const outputText = JSON.stringify( + (rewritten[0].parts[0] as { output?: unknown }).output ?? rewritten[0].parts[0] + ); + expect(outputText).not.toContain(base64); + const fileParts = rewritten[1].parts.filter((part) => part.type === "file"); + expect(fileParts).toHaveLength(1); + }); + it("redacts standalone media leaves plucked out of containers", async () => { // `const part = image.value[0]; mux.sink({payload: part})` copies a BARE // media part (no surrounding container) into args; capture retains it diff --git a/src/node/utils/messages/toolResultAttachments.ts b/src/node/utils/messages/toolResultAttachments.ts index 80f447346e..11f0f7a64e 100644 --- a/src/node/utils/messages/toolResultAttachments.ts +++ b/src/node/utils/messages/toolResultAttachments.ts @@ -71,11 +71,15 @@ function isMediaPart(value: unknown): value is AISDKMediaPart { } const record = value as Record; + // Optional metadata must not gate recognition (r24): capture retains a + // leaf with e.g. filename:null (asMediaPart ignores filename), so a + // stricter predicate here would leave that retained base64 in + // provider-visible JSON. Malformed filenames are dropped downstream by + // normalizeOptionalFilename (null/non-string → no filename). return ( record.type === "media" && typeof record.data === "string" && - typeof record.mediaType === "string" && - (record.filename === undefined || typeof record.filename === "string") + typeof record.mediaType === "string" ); } From ffbe42b9c0ca35e3f4ede024a64e66f471288d70 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Aug 2026 10:33:03 +0000 Subject: [PATCH 30/40] review r25: type-check optional filenames + iterative media graph walk 1. normalizeOptionalFilename validates typeof filename === 'string' before trimming: recognition ignores optional metadata (r24), so a persisted leaf carrying filename:123 would throw during provider-request preparation and brick the workspace (self-healing rule). The media-part type now declares filename as unknown to keep the guard honest. 2. sanitizeMediaValueGraph traverses iteratively (explicit stack, post-order copy-on-write) instead of recursing with a depth cap: media-free deep JSON passes through with identity preserved (classic full-inline contract) while media containers and leaves are sanitized at any depth - the cap previously replaced legitimate deep non-media subtrees and was the only thing depth-bounding the walk. --- src/node/services/ptc/types.test.ts | 17 ++- src/node/services/ptc/types.ts | 129 +++++++++++++----- .../extractToolMediaAsUserMessages.test.ts | 44 ++++++ .../utils/messages/toolResultAttachments.ts | 16 ++- 4 files changed, 161 insertions(+), 45 deletions(-) diff --git a/src/node/services/ptc/types.test.ts b/src/node/services/ptc/types.test.ts index 3a2517307d..5b895ae498 100644 --- a/src/node/services/ptc/types.test.ts +++ b/src/node/services/ptc/types.test.ts @@ -426,7 +426,7 @@ describe("sanitizeMediaRecordCapture", () => { expect(sanitized.b.value[0]?.text).toContain("aggregate media budget exceeded"); }); - it("bounds cyclic and overly deep values instead of hanging or leaking", () => { + it("bounds cyclic and deeply nested media values instead of hanging or leaking", () => { const audio = { type: "media", mediaType: "audio/wav", data: "d2F2" }; const cyclic: Record = { container: { type: "content", value: [audio] } }; cyclic.self = cyclic; @@ -434,15 +434,24 @@ describe("sanitizeMediaRecordCapture", () => { expect(sanitizedCycle.self).toBe("[cyclic value bounded at capture]"); expect((sanitizedCycle.container as RetainedContainer).value[0]?.type).toBe("text"); - // A media container buried past the depth cap must fail CLOSED: the - // subtree becomes a placeholder rather than passing through unsanitized. + // A media container buried under many wrapper levels is still found and + // sanitized — the iterative walk has no depth limit to smuggle past (r25). let deep: unknown = { type: "content", value: [audio] }; for (let i = 0; i < 300; i++) deep = { next: deep }; const sanitizedDeep = JSON.stringify(sanitizeCapturedMediaValue(deep)); - expect(sanitizedDeep).toContain("nesting depth limit exceeded"); + expect(sanitizedDeep).toContain("not supported as a model attachment"); expect(sanitizedDeep).not.toContain("d2F2"); }); + it("preserves media-free deep values with identity intact", () => { + // Classic mode keeps full inline results/args by contract: depth-based + // replacement of legitimate media-free deep JSON silently truncated real + // output (r25). + let deep: Record = { leaf: "value" }; + for (let i = 0; i < 300; i++) deep = { next: deep }; + expect(sanitizeCapturedMediaValue(deep)).toBe(deep); + }); + it("bounds containers holding only unsupported media", () => { // containsMediaContentPayload would NOT exempt this container (no // supported media), but the mode-independent sanitizer must still bound diff --git a/src/node/services/ptc/types.ts b/src/node/services/ptc/types.ts index 8a5ea60fc8..d33e7d71c1 100644 --- a/src/node/services/ptc/types.ts +++ b/src/node/services/ptc/types.ts @@ -469,7 +469,7 @@ export function sanitizeCapturedMediaValue( budget: { remainingBytes: number } = { remainingBytes: KERNEL_RETAINED_MEDIA_BUDGET_BYTES } ): unknown { const state = { sawMedia: false }; - const sanitized = sanitizeMediaValueGraph(value, budget, new Map(), 0, state); + const sanitized = sanitizeMediaValueGraph(value, budget, state); if (!state.sawMedia) return sanitized; // Final serialized-output cap (r24): placeholders replacing unsupported or // over-budget media never consume the media budget (they must always be @@ -483,54 +483,109 @@ export function sanitizeCapturedMediaValue( return sanitized; } +/** + * Iterative (explicit stack, post-order copy-on-write) traversal of the + * value graph: media containers and standalone leaves are sanitized wherever + * they sit, while media-free spans pass through with IDENTITY preserved — + * classic mode keeps full inline results/args by contract, so depth-based + * replacement of legitimate deep JSON is not acceptable (r25); iteration + * removes the need for any depth bound. In-memory cycle back-edges resolve + * to a bounded placeholder (cyclic values cannot JSON-persist anyway); + * shared subtrees are processed once and reused. + */ function sanitizeMediaValueGraph( - value: unknown, + root: unknown, budget: { remainingBytes: number }, - memo: Map, - depth: number, state: { sawMedia: boolean } ): unknown { - if (typeof value !== "object" || value === null) return value; - const existing = memo.get(value); - if (existing !== undefined) return existing; - if (depth >= MAX_MEDIA_SANITIZE_DEPTH) { - return "[value bounded at capture: nesting depth limit exceeded]"; - } - // Pre-seed so a cycle back-edge encountered while this node is still being - // processed resolves to a placeholder instead of recursing forever. - memo.set(value, "[cyclic value bounded at capture]"); - - let result: unknown; - if (isMediaContentContainer(value)) { - // Container parts are charged their FULL serialized size (nested payloads - // included), so there is no need to descend into a sanitized container. + if (typeof root !== "object" || root === null) return root; + if (isMediaContentContainer(root)) { state.sawMedia = true; - result = sanitizeRetainedMediaContainer(value, budget); - } else if (asMediaPart(value) !== null) { + return sanitizeRetainedMediaContainer(root, budget); + } + if (asMediaPart(root) !== null) { // STANDALONE media leaves too (r23): guest code can pluck a part out of // a container (`const part = image.value[0]`) and return it, log it, or // pass it as another tool's argument — container-only recognition would // let that copy persist unbudgeted base64 on every call. state.sawMedia = true; - result = sanitizeStandaloneMediaPart(value, budget); - } else if (Array.isArray(value)) { - const mapped = value.map((item) => - sanitizeMediaValueGraph(item, budget, memo, depth + 1, state) - ); - result = mapped.some((item, index) => item !== value[index]) ? mapped : value; - } else { - const record = value as Record; - let changed = false; - const mapped: Record = {}; - for (const [key, item] of Object.entries(record)) { - const sanitized = sanitizeMediaValueGraph(item, budget, memo, depth + 1, state); - mapped[key] = sanitized; - if (sanitized !== item) changed = true; + return sanitizeStandaloneMediaPart(root, budget); + } + // Copy-on-write rebuilds keyed by original node identity; nodes absent + // from this map are unchanged and reused as-is. + const changed = new Map(); + const processed = new Set(); + const visiting = new Set(); + const stack: Array<{ node: object; entered: boolean }> = [{ node: root, entered: false }]; + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + const node = frame.node; + if (!frame.entered) { + if (processed.has(node) || visiting.has(node)) { + stack.pop(); + continue; + } + frame.entered = true; + visiting.add(node); + const children: unknown[] = Array.isArray(node) + ? node + : Object.values(node as Record); + for (const child of children) { + if (typeof child !== "object" || child === null) continue; + if (processed.has(child) || visiting.has(child)) continue; + // Media shapes are handled atomically at the parent's exit phase + // (container parts are charged their full serialized size, so there + // is no need to descend into them). + if (isMediaContentContainer(child) || asMediaPart(child) !== null) continue; + stack.push({ node: child, entered: false }); + } + continue; + } + stack.pop(); + let nodeChanged = false; + const rewriteChild = (child: unknown): unknown => { + if (typeof child !== "object" || child === null) return child; + if (isMediaContentContainer(child)) { + state.sawMedia = true; + const sanitized = sanitizeRetainedMediaContainer(child, budget); + if (sanitized !== child) nodeChanged = true; + return sanitized; + } + if (asMediaPart(child) !== null) { + state.sawMedia = true; + const sanitized = sanitizeStandaloneMediaPart(child, budget); + if (sanitized !== child) nodeChanged = true; + return sanitized; + } + // Back-edge to a node still being processed (self/ancestor cycle): + // bounded placeholder instead of infinite structure. + if (visiting.has(child)) { + nodeChanged = true; + return "[cyclic value bounded at capture]"; + } + if (changed.has(child)) { + nodeChanged = true; + return changed.get(child); + } + return child; + }; + // Rebuild while this node is still in `visiting` so self-references are + // detected as cycles. + const rebuilt = Array.isArray(node) + ? node.map(rewriteChild) + : Object.fromEntries( + Object.entries(node as Record).map(([key, child]) => [ + key, + rewriteChild(child), + ]) + ); + visiting.delete(node); + processed.add(node); + if (nodeChanged) { + changed.set(node, rebuilt); } - result = changed ? mapped : value; } - memo.set(value, result); - return result; + return changed.has(root) ? changed.get(root) : root; } /** diff --git a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts index 24be59087b..a8ed6c0f03 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts @@ -587,6 +587,50 @@ describe("extractToolMediaAsUserMessages", () => { expect(fileParts).toHaveLength(1); }); + it("drops non-string filename metadata instead of throwing during extraction", async () => { + // filename: 123 passes leaf recognition (optional metadata is ignored), + // and .trim() on it would throw while preparing EVERY later provider + // request — one malformed persisted row must not brick the workspace + // (r25, self-healing rule). + const base64 = ( + await sharp({ + create: { width: 10, height: 10, channels: 3, background: { r: 8, g: 8, b: 8 } }, + }) + .png() + .toBuffer() + ).toString("base64"); + + const input: MuxMessage[] = [ + { + id: "ce-numname", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: "call1", + toolName: "mcp__shots__take", + input: {}, + state: "output-available", + output: { + type: "content", + value: [{ type: "media", mediaType: "image/png", data: base64, filename: 123 }], + }, + }, + ], + metadata: { timestamp: 1 }, + }, + ]; + + const rewritten = await extractToolMediaAsUserMessages(input); + expect(rewritten).toHaveLength(2); + const outputText = JSON.stringify((rewritten[0].parts[0] as { output?: unknown }).output); + expect(outputText).not.toContain(base64); + const fileParts = rewritten[1].parts.filter((part) => part.type === "file"); + expect(fileParts).toHaveLength(1); + // The malformed filename is dropped, not sent. + expect(fileParts[0]).not.toHaveProperty("filename", 123); + }); + it("extracts media leaves whose optional metadata is malformed", async () => { // Capture recognition ignores optional metadata (asMediaPart), so a // retained leaf with filename:null must not be rejected by a stricter diff --git a/src/node/utils/messages/toolResultAttachments.ts b/src/node/utils/messages/toolResultAttachments.ts index 11f0f7a64e..186df777a9 100644 --- a/src/node/utils/messages/toolResultAttachments.ts +++ b/src/node/utils/messages/toolResultAttachments.ts @@ -23,7 +23,10 @@ interface AISDKMediaPart { type: "media"; data: string; mediaType: string; - filename?: string; + /** Untrusted optional metadata: persisted rows may carry any shape here — + * recognition ignores it (r24) and normalizeOptionalFilename drops + * non-strings (r25). */ + filename?: unknown; } interface AISDKTextPart { @@ -83,9 +86,14 @@ function isMediaPart(value: unknown): value is AISDKMediaPart { ); } -function normalizeOptionalFilename(filename: string | undefined): string | undefined { - const trimmed = filename?.trim(); - if (trimmed == null || trimmed.length === 0) return undefined; +function normalizeOptionalFilename(filename: unknown): string | undefined { + // History rows are untrusted: recognition ignores optional metadata (r24), + // so a persisted leaf can carry filename: 123 — calling .trim() on it would + // throw during provider-request preparation and brick the workspace (r25, + // self-healing rule). Non-string metadata is dropped, never thrown on. + if (typeof filename !== "string") return undefined; + const trimmed = filename.trim(); + if (trimmed.length === 0) return undefined; // Filenames are attacker-influencable metadata like media types: they ride // into provider-visible placeholders and attachment file parts, so an // unbounded value persisted by an MCP tool would bloat every later request. From 13411f8fffc0207931bc638f7169e0d784f98355 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Aug 2026 10:49:35 +0000 Subject: [PATCH 31/40] review r26: unbounded exemption scan, result-less edit truncation signal, serialized-byte path bound --- .../utils/messages/extractEditedFiles.test.ts | 24 +++++++ .../utils/messages/extractEditedFiles.ts | 11 +++- src/constants/kernelOutput.ts | 15 +++-- src/node/services/ptc/types.test.ts | 24 +++++++ src/node/services/ptc/types.ts | 64 +++++++++++-------- 5 files changed, 105 insertions(+), 33 deletions(-) diff --git a/src/common/utils/messages/extractEditedFiles.test.ts b/src/common/utils/messages/extractEditedFiles.test.ts index f054686d1c..1ef10cd48a 100644 --- a/src/common/utils/messages/extractEditedFiles.test.ts +++ b/src/common/utils/messages/extractEditedFiles.test.ts @@ -254,6 +254,30 @@ describe("nested PTC edit records (exclusive posture)", () => { expect(extractEditedFilePaths(messages)).toEqual(["/kernel.ts"]); expect(extractEditedFileDiffs(messages)).toEqual([]); }); + + it("marks the combined diff truncated when a result-less edit's diff did not survive", () => { + // A kernel execution that exhausts the retained-result budget compacts a + // later successful edit to a result-less {ok: true} record: the earlier + // retained diff no longer describes the final file content, so the + // surviving combined diff must not present itself as complete (round 26). + const earlierDiff = makeDiff("/kernel.ts", "old", "mid"); + const messages: MuxMessage[] = [ + createCodeExecutionMessage([ + { + toolName: "file_edit_replace_string", + args: { path: "/kernel.ts" }, + result: { success: true, diff: earlierDiff }, + }, + { toolName: "file_edit_replace_string", args: { path: "/kernel.ts" }, ok: true, bytes: 9 }, + ]), + ]; + + const diffs = extractEditedFileDiffs(messages); + expect(diffs).toHaveLength(1); + expect(diffs[0].path).toBe("/kernel.ts"); + expect(diffs[0].diff).toBe(earlierDiff); + expect(diffs[0].truncated).toBe(true); + }); }); describe("extractEditedFilePaths", () => { diff --git a/src/common/utils/messages/extractEditedFiles.ts b/src/common/utils/messages/extractEditedFiles.ts index e96b9af758..40dcf4bdc0 100644 --- a/src/common/utils/messages/extractEditedFiles.ts +++ b/src/common/utils/messages/extractEditedFiles.ts @@ -303,14 +303,21 @@ export function extractEditedFileDiffs(messages: MuxMessage[]): FileEditDiff[] { if (part.toolName === "code_execution") { // Classic PTC records retain the full nested result (including the - // diff); kernel-compacted records surface path-only edits and are - // skipped here (no diff contents survive compaction of the record). + // diff); kernel-compacted records surface path-only edits whose diff + // contents did not survive compaction. for (const record of collectNestedEditRecords(part.output)) { if (record.diffTruncated === true) { captureTruncatedPaths.add(record.filePath); } if (record.diff !== undefined && record.diff.length > 0) { addDiff(record.filePath, record.diff); + } else if (record.diff === undefined) { + // A successful result-less (kernel-compacted) edit landed without + // any retained diff: diffs from the file's OTHER retained edits no + // longer describe the final content, so any surviving combined + // diff must surface as incomplete rather than complete-looking + // (r26 — mirrors the dropped-bounded-diff diffTruncated signal). + captureTruncatedPaths.add(record.filePath); } } continue; diff --git a/src/constants/kernelOutput.ts b/src/constants/kernelOutput.ts index 32e46ecc10..e2ef26f9a6 100644 --- a/src/constants/kernelOutput.ts +++ b/src/constants/kernelOutput.ts @@ -86,10 +86,13 @@ export const KERNEL_RETAINED_EXECUTION_BUDGET_BYTES = 4 * KERNEL_RETAINED_MEDIA_ export const KERNEL_SANITIZED_MEDIA_VALUE_MAX_BYTES = 2 * KERNEL_RETAINED_MEDIA_BUDGET_BYTES; /** - * Max chars of a validated tool-arg file path preserved on a __kernelBounded - * args marker (see retainPersistenceCriticalArgsFields). Covers Linux - * PATH_MAX (4096); longer strings cannot be real paths of successful edits, - * so they are dropped rather than truncated (a truncated path would - * misattribute the record to a nonexistent file). + * Max serialized-JSON UTF-8 bytes of a validated tool-arg file path preserved + * on a __kernelBounded args marker (see retainPersistenceCriticalArgsFields). + * Covers Linux PATH_MAX (4096 bytes); longer strings cannot be real paths of + * successful edits, so they are dropped rather than truncated (a truncated + * path would misattribute the record to a nonexistent file). Measured in + * serialized bytes rather than UTF-16 code units (r26): JSON escaping can + * expand a 4096-code-unit multibyte/lone-surrogate string to ~24 KiB, far + * past the 2 KiB args marker this field merges onto. */ -export const KERNEL_RETAINED_PATH_MAX_CHARS = 4096; +export const KERNEL_RETAINED_PATH_MAX_BYTES = 4096; diff --git a/src/node/services/ptc/types.test.ts b/src/node/services/ptc/types.test.ts index 5b895ae498..80064ae757 100644 --- a/src/node/services/ptc/types.test.ts +++ b/src/node/services/ptc/types.test.ts @@ -245,6 +245,18 @@ describe("retainExemptKernelRecordResult", () => { ).toBeUndefined(); }); + it("exempts media below arbitrarily deep wrappers (no depth cap)", () => { + // The retention sanitizer walks iteratively without a depth bound + // (r25); the exemption predicate must match (r26), or deep-wrapped + // media would be compacted away before request-time extraction — which + // is also unbounded over wrappers — could attach it. + let deep: unknown = { type: "media", mediaType: "image/png", data: "aGVsbG8=" }; + for (let i = 0; i < 300; i++) deep = { next: deep }; + const retained = retainExemptKernelRecordResult("mcp__shots__take", deep); + expect(retained).toBeDefined(); + expect(JSON.stringify(retained)).toContain("aGVsbG8="); + }); + it("rejects junk media types at validation instead of retaining them as supported", () => { // transformMCPResult copies server-controlled MIME types unchanged; an // "image/" + megabytes string must fail isSupportedAttachmentMediaType @@ -486,4 +498,16 @@ describe("retainPersistenceCriticalArgsFields", () => { retainPersistenceCriticalArgsFields("file_edit_insert", { path: "p".repeat(5_000) }) ).toBeUndefined(); }); + + it("bounds retained paths by serialized bytes, not UTF-16 code units", () => { + // 2000 three-byte code points sit under a 4096 code-unit count but + // serialize to ~6 KB, and JSON escaping expands lone surrogates 6x — a + // code-unit cap would merge ~24 KiB onto the 2 KiB args marker (r26). + expect( + retainPersistenceCriticalArgsFields("file_edit_insert", { path: "\u96EA".repeat(2_000) }) + ).toBeUndefined(); + expect( + retainPersistenceCriticalArgsFields("file_edit_insert", { path: "\uD800".repeat(1_000) }) + ).toBeUndefined(); + }); }); diff --git a/src/node/services/ptc/types.ts b/src/node/services/ptc/types.ts index d33e7d71c1..85abed2253 100644 --- a/src/node/services/ptc/types.ts +++ b/src/node/services/ptc/types.ts @@ -15,7 +15,7 @@ import { KERNEL_RETAINED_CONTAINER_MAX_PARTS, KERNEL_RETAINED_MEDIA_BUDGET_BYTES, KERNEL_SANITIZED_MEDIA_VALUE_MAX_BYTES, - KERNEL_RETAINED_PATH_MAX_CHARS, + KERNEL_RETAINED_PATH_MAX_BYTES, } from "@/constants/kernelOutput"; /** @@ -438,14 +438,6 @@ export function sanitizeMediaRecordCapture( return sanitizeCapturedMediaValue(result, budget); } -/** - * Recursion depth cap for the media value-graph walk. JSON persistence - * tolerates deeper nesting, but a guest-built ladder past this depth is not a - * plausible legitimate return shape — fail CLOSED (bounded placeholder) so - * depth can never be used to smuggle an unsanitized container past the walk. - */ -const MAX_MEDIA_SANITIZE_DEPTH = 256; - /** * Tool-name-free form of sanitizeMediaRecordCapture for values that are not * nested tool records: the classic execution's outer return value and console @@ -644,7 +636,15 @@ export function retainPersistenceCriticalArgsFields( ): Record | undefined { if (!isPersistenceCriticalRecordToolName(toolName)) return undefined; const path = extractToolFilePath(args); - if (path === undefined || path.length > KERNEL_RETAINED_PATH_MAX_CHARS) return undefined; + if (path === undefined) return undefined; + // Bound by SERIALIZED bytes, not UTF-16 code units (r26): JSON escaping can + // expand a code-unit-capped multibyte/lone-surrogate string ~6x (to ~24 + // KiB), and this field merges onto a 2 KiB args marker — repeated oversized + // records would persist and stream far past the advertised per-record cap. + const serializedBytes = serializedJsonByteLength(path); + if (serializedBytes === undefined || serializedBytes > KERNEL_RETAINED_PATH_MAX_BYTES) { + return undefined; + } return { path }; } @@ -665,7 +665,7 @@ export function isPersistenceCriticalRecordToolName(toolName: string): boolean { * any unsupported parts that ride along in an exempted container with bounded * placeholders at request time. * - * The search recurses through wrappers and non-media parts (r19) and accepts + * The search walks through wrappers and non-media parts (r19) and accepts * the exact shapes the capture sanitizer bounds and the request-time * extractor consumes: content containers with an immediate supported media * child, and standalone supported media leaves (r24 — r20 restricted the @@ -677,21 +677,35 @@ export function isPersistenceCriticalRecordToolName(toolName: string): boolean { * walk before the record is kept. */ export function containsMediaContentPayload(result: unknown): boolean { - return containsExtractableMediaValue(result, 0); -} - -function containsExtractableMediaValue(value: unknown, depth: number): boolean { - if (typeof value !== "object" || value === null) return false; - const media = asMediaPart(value); - if (media !== null) { - return media.mediaType !== undefined && isSupportedAttachmentMediaType(media.mediaType); + // Iterative, unbounded scan aligned with sanitizeMediaValueGraph (r26): the + // retention sanitizer preserves media below ANY wrapper depth, so a + // depth-capped predicate here would decline the exemption for payloads the + // bounded retention walk can handle — kernel compaction would then drop an + // extractable result before request-time extraction could attach it. The + // visited set keeps shared subtrees linear and terminates cycle back-edges. + const stack: unknown[] = [result]; + const visited = new Set(); + while (stack.length > 0) { + const value = stack.pop(); + if (typeof value !== "object" || value === null) continue; + if (visited.has(value)) continue; + visited.add(value); + const media = asMediaPart(value); + if (media !== null) { + if (media.mediaType !== undefined && isSupportedAttachmentMediaType(media.mediaType)) { + return true; + } + // Media leaves are terminal for the sanitizer/extractor — their fields + // are never scanned for deeper payloads. + continue; + } + if (isContentContainerShape(value) && hasImmediateSupportedMedia(value)) return true; + const children: unknown[] = Array.isArray(value) + ? value + : Object.values(value as Record); + for (const child of children) stack.push(child); } - if (isContentContainerShape(value) && hasImmediateSupportedMedia(value)) return true; - if (depth >= MAX_MEDIA_SANITIZE_DEPTH) return false; - const children: unknown[] = Array.isArray(value) - ? value - : Object.values(value as Record); - return children.some((child) => containsExtractableMediaValue(child, depth + 1)); + return false; } function isContentContainerShape(value: unknown): value is { type: "content"; value: unknown[] } { From f5668ef008c0c45c15ca5b57a69c3e227d154d5b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Aug 2026 11:03:15 +0000 Subject: [PATCH 32/40] review r27 (security): charge media-bearing sanitized values against the shared execution budget --- src/constants/kernelOutput.ts | 19 ++++++----- src/node/services/ptc/quickjsRuntime.ts | 13 +++---- src/node/services/ptc/runtime.ts | 4 +-- src/node/services/ptc/types.test.ts | 28 +++++++++++++-- src/node/services/ptc/types.ts | 45 +++++++++++++++++++++---- 5 files changed, 84 insertions(+), 25 deletions(-) diff --git a/src/constants/kernelOutput.ts b/src/constants/kernelOutput.ts index e2ef26f9a6..d3a2501881 100644 --- a/src/constants/kernelOutput.ts +++ b/src/constants/kernelOutput.ts @@ -74,14 +74,17 @@ export const KERNEL_RETAINED_CONTAINER_MAX_PARTS = 64; export const KERNEL_RETAINED_EXECUTION_BUDGET_BYTES = 4 * KERNEL_RETAINED_MEDIA_BUDGET_BYTES; /** - * Final serialized-size cap on ONE media-bearing value after the capture - * sanitizer's graph walk. Placeholders replacing unsupported/over-budget - * media do not consume the media budget (they must always be emitted for - * safety), so a value flooding thousands of media nodes could otherwise - * append placeholder structures without bound; a sanitized value that still - * serializes above this cap collapses to a single bounded marker. 2x the - * media budget leaves ample room for legitimately retained media plus - * non-media siblings and placeholder overhead. + * Serialized-size allowance for media-BEARING values after the capture + * sanitizer's graph walk, shared across every capture charging the same + * budget (execution-wide in classic mode — see CaptureSanitizerBudget). + * Placeholders replacing unsupported/over-budget media do not consume the + * media budget (they must always be emitted for safety), so a value flooding + * thousands of media nodes — or a loop of calls each returning another + * placeholder-flooded record (r27 security) — could otherwise persist + * unbounded bytes; media-bearing sanitized values debit this allowance and + * collapse to a single bounded marker once it is spent. 2x the media budget + * leaves ample room for legitimately retained media plus non-media siblings + * and placeholder overhead. */ export const KERNEL_SANITIZED_MEDIA_VALUE_MAX_BYTES = 2 * KERNEL_RETAINED_MEDIA_BUDGET_BYTES; diff --git a/src/node/services/ptc/quickjsRuntime.ts b/src/node/services/ptc/quickjsRuntime.ts index 5e2aee8b2d..8ea20467ab 100644 --- a/src/node/services/ptc/quickjsRuntime.ts +++ b/src/node/services/ptc/quickjsRuntime.ts @@ -14,10 +14,11 @@ import { QuickJSAsyncFFI } from "@jitl/quickjs-wasmfile-release-asyncify/ffi"; import crypto from "crypto"; import type { IJSRuntime, IJSRuntimeFactory, KernelRecordBounds, RuntimeLimits } from "./runtime"; import type { PTCEvent, PTCExecutionResult, PTCToolCallRecord, PTCConsoleRecord } from "./types"; +import type { CaptureSanitizerBudget } from "./types"; +import { createCaptureSanitizerBudget } from "./types"; import { CONSOLE_CAPTURE_BUDGET_BYTES, KERNEL_RETAINED_EXECUTION_BUDGET_BYTES, - KERNEL_RETAINED_MEDIA_BUDGET_BYTES, } from "@/constants/kernelOutput"; import { sliceUtf8Bytes } from "@/common/utils/sliceUtf8Bytes"; @@ -190,7 +191,7 @@ export class QuickJSRuntime implements IJSRuntime { private captureResultSanitizer?: ( toolName: string, result: unknown, - budget?: { remainingBytes: number } + budget?: CaptureSanitizerBudget ) => unknown; /** Per-execution shared media budget for the capture sanitizer in CLASSIC * (non-kernel) mode, keyed like retainedResultBudgets. Classic records keep @@ -202,7 +203,7 @@ export class QuickJSRuntime implements IJSRuntime { * retainedResultBudgets. */ private readonly classicSanitizerBudgets = new WeakMap< PTCToolCallRecord[], - { remainingBytes: number } + CaptureSanitizerBudget >(); /** Per-execution byte budgets for RETAINED record results, keyed by the * attribution's record array like consoleBudgets (fresh array per eval; @@ -598,7 +599,7 @@ export class QuickJSRuntime implements IJSRuntime { setCaptureResultSanitizer( sanitizer: - | ((toolName: string, result: unknown, budget?: { remainingBytes: number }) => unknown) + | ((toolName: string, result: unknown, budget?: CaptureSanitizerBudget) => unknown) | undefined ): void { this.captureResultSanitizer = sanitizer; @@ -754,10 +755,10 @@ export class QuickJSRuntime implements IJSRuntime { /** Get-or-create the classic-mode shared sanitizer budget for one * attribution's record array (see classicSanitizerBudgets). */ - private classicSanitizerBudgetFor(toolCalls: PTCToolCallRecord[]): { remainingBytes: number } { + private classicSanitizerBudgetFor(toolCalls: PTCToolCallRecord[]): CaptureSanitizerBudget { let budget = this.classicSanitizerBudgets.get(toolCalls); if (!budget) { - budget = { remainingBytes: KERNEL_RETAINED_MEDIA_BUDGET_BYTES }; + budget = createCaptureSanitizerBudget(); this.classicSanitizerBudgets.set(toolCalls, budget); } return budget; diff --git a/src/node/services/ptc/runtime.ts b/src/node/services/ptc/runtime.ts index 7fbc9f8339..faf203f7a9 100644 --- a/src/node/services/ptc/runtime.ts +++ b/src/node/services/ptc/runtime.ts @@ -5,7 +5,7 @@ * but designed to allow future migration to libbun or other runtimes. */ -import type { PTCEvent, PTCExecutionResult } from "./types"; +import type { CaptureSanitizerBudget, PTCEvent, PTCExecutionResult } from "./types"; /** * Resource limits for sandbox execution. @@ -104,7 +104,7 @@ export interface IJSRuntime extends Disposable { */ setCaptureResultSanitizer( sanitizer: - | ((toolName: string, result: unknown, budget?: { remainingBytes: number }) => unknown) + | ((toolName: string, result: unknown, budget?: CaptureSanitizerBudget) => unknown) | undefined ): void; diff --git a/src/node/services/ptc/types.test.ts b/src/node/services/ptc/types.test.ts index 80064ae757..81db9f2c12 100644 --- a/src/node/services/ptc/types.test.ts +++ b/src/node/services/ptc/types.test.ts @@ -7,6 +7,7 @@ import { KERNEL_RETAINED_MEDIA_BUDGET_BYTES, } from "@/constants/kernelOutput"; import { + createCaptureSanitizerBudget, retainExemptKernelRecordResult, retainPersistenceCriticalArgsFields, sanitizeCapturedMediaValue, @@ -376,7 +377,28 @@ describe("sanitizeMediaRecordCapture", () => { })); const sanitized = sanitizeCapturedMediaValue({ payload: leaves }); expect(typeof sanitized).toBe("string"); - expect(sanitized as string).toContain("exceed the sanitized-value cap"); + expect(sanitized as string).toContain("exceed the remaining sanitized-value budget"); + }); + + it("charges media-bearing sanitized values against the shared execution budget", () => { + // Placeholders and non-media siblings never debit the media allowance, so + // a per-value-only cap let a loop of calls retain another multi-megabyte + // media-bearing record per call (r27 security): the sanitized bytes now + // debit the shared budget and later values collapse to a small marker. + const bigSibling = "x".repeat(4 * 1024 * 1024); + const value = () => ({ + note: bigSibling, + media: { type: "media", mediaType: "audio/wav", data: "d2F2" }, + }); + const shared = createCaptureSanitizerBudget(); + const first = sanitizeCapturedMediaValue(value(), shared); + expect(typeof first).toBe("object"); + const second = sanitizeCapturedMediaValue(value(), shared); + expect(typeof second).toBe("string"); + expect(second as string).toContain("exceed the remaining sanitized-value budget"); + // Media-free values stay uncharged and untouched (classic contract). + const mediaFree = { note: bigSibling }; + expect(sanitizeCapturedMediaValue(mediaFree, shared)).toBe(mediaFree); }); it("sanitizes standalone media leaves outside containers", () => { @@ -385,7 +407,7 @@ describe("sanitizeMediaRecordCapture", () => { // unbudgeted on every call (r23). const bigImage = "A".repeat(2 * 1024 * 1024); const leaf = () => ({ type: "media", mediaType: "image/png", data: bigImage }); - const shared = { remainingBytes: KERNEL_RETAINED_MEDIA_BUDGET_BYTES }; + const shared = createCaptureSanitizerBudget(); const first = sanitizeCapturedMediaValue({ payload: leaf() }, shared) as { payload: { type?: string; data?: string; text?: string }; }; @@ -413,7 +435,7 @@ describe("sanitizeMediaRecordCapture", () => { type: "content", value: [{ type: "media", mediaType: "image/png", data: bigImage }], }); - const shared = { remainingBytes: KERNEL_RETAINED_MEDIA_BUDGET_BYTES }; + const shared = createCaptureSanitizerBudget(); const first = sanitizeCapturedMediaValue(container(), shared) as RetainedContainer; const second = sanitizeCapturedMediaValue(container(), shared) as RetainedContainer; expect(first.value[0]?.data).toBe(bigImage); diff --git a/src/node/services/ptc/types.ts b/src/node/services/ptc/types.ts index 85abed2253..a95c3d4f73 100644 --- a/src/node/services/ptc/types.ts +++ b/src/node/services/ptc/types.ts @@ -433,11 +433,39 @@ function sanitizeRetainedMediaContainer( export function sanitizeMediaRecordCapture( _toolName: string, result: unknown, - budget?: { remainingBytes: number } + budget?: CaptureSanitizerBudget ): unknown { return sanitizeCapturedMediaValue(result, budget); } +/** + * Aggregate allowances shared across every capture that sanitizes against the + * same budget object (classic mode shares ONE per execution — see + * QuickJSRuntime.classicSanitizerBudgets; kernel mode gets a fresh one per + * call, its cross-call growth being bounded by the retained-result budget). + */ +export interface CaptureSanitizerBudget { + /** Bytes left for RETAINED supported media parts (and their retained + * container siblings) — see KERNEL_RETAINED_MEDIA_BUDGET_BYTES. */ + remainingBytes: number; + /** Serialized bytes left for media-BEARING sanitized values as a whole, + * placeholders and non-media siblings included — see + * KERNEL_SANITIZED_MEDIA_VALUE_MAX_BYTES. Placeholders are emitted for + * safety and never debit the media allowance above, so without this + * charge a loop of calls returning unsupported media would retain another + * placeholder-flooded record per call without bound (r27 security). + * Media-free values stay uncharged (classic keeps them inline by + * contract). */ + remainingSanitizedBytes: number; +} + +export function createCaptureSanitizerBudget(): CaptureSanitizerBudget { + return { + remainingBytes: KERNEL_RETAINED_MEDIA_BUDGET_BYTES, + remainingSanitizedBytes: KERNEL_SANITIZED_MEDIA_VALUE_MAX_BYTES, + }; +} + /** * Tool-name-free form of sanitizeMediaRecordCapture for values that are not * nested tool records: the classic execution's outer return value and console @@ -458,7 +486,7 @@ export function sanitizeCapturedMediaValue( // A caller-shared budget bounds media across MULTIPLE captures (classic // mode shares one per execution — see QuickJSRuntime.boundCaptureResult); // absent, each call gets the standalone per-value allowance. - budget: { remainingBytes: number } = { remainingBytes: KERNEL_RETAINED_MEDIA_BUDGET_BYTES } + budget: CaptureSanitizerBudget = createCaptureSanitizerBudget() ): unknown { const state = { sawMedia: false }; const sanitized = sanitizeMediaValueGraph(value, budget, state); @@ -466,12 +494,17 @@ export function sanitizeCapturedMediaValue( // Final serialized-output cap (r24): placeholders replacing unsupported or // over-budget media never consume the media budget (they must always be // emitted for safety), so a value flooding many media nodes could append - // placeholder structures without bound. Media-free values are untouched - // (classic mode keeps full inline results/args by contract). + // placeholder structures without bound. The cap DEBITS the shared budget + // (r27 security): a per-value-only cap would let a loop of calls retain + // another placeholder-flooded multi-megabyte record per call — once the + // execution-wide allowance is spent, media-bearing values collapse to this + // small marker. Media-free values are untouched and uncharged (classic + // mode keeps full inline results/args by contract). const bytes = serializedJsonByteLength(sanitized); - if (bytes === undefined || bytes > KERNEL_SANITIZED_MEDIA_VALUE_MAX_BYTES) { - return `[value bounded at capture: ${bytes ?? "unserializable"} serialized bytes after media sanitization exceed the sanitized-value cap]`; + if (bytes === undefined || bytes > budget.remainingSanitizedBytes) { + return `[value bounded at capture: ${bytes ?? "unserializable"} serialized bytes after media sanitization exceed the remaining sanitized-value budget]`; } + budget.remainingSanitizedBytes -= bytes; return sanitized; } From 463b7a91c0ae77f8a1055f3c6f91debc251234fc Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Aug 2026 11:10:40 +0000 Subject: [PATCH 33/40] review r27: recency bump for result-less edits, media exemption beats spoofed __kernelBounded --- .../utils/messages/extractEditedFiles.test.ts | 29 ++++++++++++++++ .../utils/messages/extractEditedFiles.ts | 18 +++++++--- .../services/tools/code_execution.test.ts | 34 +++++++++++++++++++ src/node/services/tools/code_execution.ts | 15 ++++++-- 4 files changed, 89 insertions(+), 7 deletions(-) diff --git a/src/common/utils/messages/extractEditedFiles.test.ts b/src/common/utils/messages/extractEditedFiles.test.ts index 1ef10cd48a..c59f7f184b 100644 --- a/src/common/utils/messages/extractEditedFiles.test.ts +++ b/src/common/utils/messages/extractEditedFiles.test.ts @@ -278,6 +278,35 @@ describe("nested PTC edit records (exclusive posture)", () => { expect(diffs[0].diff).toBe(earlierDiff); expect(diffs[0].truncated).toBe(true); }); + + it("moves recency to a later result-less edit so the file keeps its rank", () => { + // The result-less record is the file's LATEST edit: without a recency + // bump it stays ranked by its older retained diff and can fall off the + // MAX_EDITED_FILES cut once enough other files are edited in between + // (round 27). + const aDiff = makeDiff("/a.ts", "old", "new"); + const bDiff = makeDiff("/b.ts", "old", "new"); + const messages: MuxMessage[] = [ + createCodeExecutionMessage([ + { + toolName: "file_edit_replace_string", + args: { path: "/a.ts" }, + result: { success: true, diff: aDiff }, + }, + { + toolName: "file_edit_replace_string", + args: { path: "/b.ts" }, + result: { success: true, diff: bDiff }, + }, + { toolName: "file_edit_replace_string", args: { path: "/a.ts" }, ok: true, bytes: 9 }, + ]), + ]; + + const diffs = extractEditedFileDiffs(messages); + expect(diffs.map((d) => d.path)).toEqual(["/a.ts", "/b.ts"]); + expect(diffs[0].truncated).toBe(true); + expect(diffs[1].truncated).toBe(false); + }); }); describe("extractEditedFilePaths", () => { diff --git a/src/common/utils/messages/extractEditedFiles.ts b/src/common/utils/messages/extractEditedFiles.ts index 40dcf4bdc0..1cfea90abd 100644 --- a/src/common/utils/messages/extractEditedFiles.ts +++ b/src/common/utils/messages/extractEditedFiles.ts @@ -282,16 +282,19 @@ export function extractEditedFileDiffs(messages: MuxMessage[]): FileEditDiff[] { // combined diff is incomplete no matter how the combination goes. const captureTruncatedPaths = new Set(); + // Update edit order (move to end if already exists). + const bumpRecency = (filePath: string): void => { + const idx = editOrder.indexOf(filePath); + if (idx !== -1) editOrder.splice(idx, 1); + editOrder.push(filePath); + }; + const addDiff = (filePath: string, diff: string): void => { if (!diffsByPath.has(filePath)) { diffsByPath.set(filePath, []); } diffsByPath.get(filePath)!.push(diff); - - // Update edit order (move to end if already exists) - const idx = editOrder.indexOf(filePath); - if (idx !== -1) editOrder.splice(idx, 1); - editOrder.push(filePath); + bumpRecency(filePath); }; for (const message of messages) { @@ -318,6 +321,11 @@ export function extractEditedFileDiffs(messages: MuxMessage[]): FileEditDiff[] { // diff must surface as incomplete rather than complete-looking // (r26 — mirrors the dropped-bounded-diff diffTruncated signal). captureTruncatedPaths.add(record.filePath); + // It is also the file's LATEST edit: recency must move with it + // (r27), or a recently edited file still ranked by its older + // retained diff could fall off the MAX_EDITED_FILES cut entirely + // once enough other files were edited in between. + if (diffsByPath.has(record.filePath)) bumpRecency(record.filePath); } } continue; diff --git a/src/node/services/tools/code_execution.test.ts b/src/node/services/tools/code_execution.test.ts index 8527de3982..2874f5c99f 100644 --- a/src/node/services/tools/code_execution.test.ts +++ b/src/node/services/tools/code_execution.test.ts @@ -1348,6 +1348,40 @@ describe("createCodeExecutionTool", () => { await host.disposeScope("ws-media-budget"); }); + it("keeps media exempt when a server result spoofs the __kernelBounded field", async () => { + // The overflow marker's boolean is unnamespaced: a bridged server + // result can carry its own __kernelBounded field alongside real media, + // and marker-first compaction would drop the retained payload before + // request-time extraction could attach it (r27). Genuine markers never + // contain extractable media, so the media exemption wins. + using tmp = new DisposableTempDir("code-exec-marker-spoof"); + const host = new SandboxHostService(); + const image = "A".repeat(500); + const tools: Record = { + mcp__shots__take: createMockTool("mcp__shots__take", z.object({}), () => ({ + __kernelBounded: true, + type: "content", + value: [{ type: "media", mediaType: "image/png", data: image }], + })), + }; + const tool = await createCodeExecutionTool( + runtimeFactory, + new ToolBridge(tools), + undefined, + persistentRunner(host, "ws-marker-spoof", tmp.path) + ); + + const result = (await tool.execute!( + { code: "mux.mcp__shots__take({}); return true;" }, + mockToolCallOptions + )) as PTCExecutionResult; + expect(result.success).toBe(true); + const record = result.toolCalls.find((r) => r.toolName === "mcp__shots__take"); + const value = (record?.result as { value?: Array<{ data?: string }> })?.value; + expect(value?.[0]?.data).toBe(image); + await host.disposeScope("ws-marker-spoof"); + }); + it("bounds unsupported parts of mixed media containers at capture", async () => { // A mixed container (image + audio) is retained for request-time image // extraction, but the unsupported audio payload (up to 8 MiB per part) diff --git a/src/node/services/tools/code_execution.ts b/src/node/services/tools/code_execution.ts index a768e4900b..59de8c8b15 100644 --- a/src/node/services/tools/code_execution.ts +++ b/src/node/services/tools/code_execution.ts @@ -32,7 +32,11 @@ import { } from "@/constants/resultHandles"; import { KERNEL_COMPACT_ARGS_CAP_BYTES, KERNEL_CONSOLE_CAP_BYTES } from "@/constants/kernelOutput"; import { sliceUtf8Bytes } from "@/common/utils/sliceUtf8Bytes"; -import { isKernelRecordResultExempt, sanitizeCapturedMediaValue } from "@/node/services/ptc/types"; +import { + containsMediaContentPayload, + isKernelRecordResultExempt, + sanitizeCapturedMediaValue, +} from "@/node/services/ptc/types"; import { jsonSafeClone } from "@/common/utils/jsonSafeClone"; // Default limits @@ -313,7 +317,14 @@ function compactKernelToolCallRecords(result: PTCExecutionResult, loadActive: bo const captureBounded = typeof record.result === "object" && record.result !== null && - (record.result as { __kernelBounded?: boolean }).__kernelBounded === true; + (record.result as { __kernelBounded?: boolean }).__kernelBounded === true && + // The boolean is unnamespaced, so a bridged server's result can carry + // its own __kernelBounded field alongside real media. A GENUINE runtime + // marker ({__kernelBounded, bytes, preview}) never contains extractable + // media, so the media exemption takes priority — otherwise a retained, + // sanitized media payload would be compacted away and the model would + // never receive the attachment (r27). + !containsMediaContentPayload(record.result); // Exempt records also keep their result (see isKernelRecordResultExempt; // creation-time capture bounding applies the same predicate, so the full // payload actually reaches this point): persistence extractors mine From fc85b8109e2bb98a8d2806bf16a10931dc24ab1d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Aug 2026 11:43:22 +0000 Subject: [PATCH 34/40] review r28: charge overflow markers to the shared budget, request-wide extracted-media cap --- src/common/constants/imageAttachments.ts | 11 ++ src/node/services/ptc/types.test.ts | 14 +++ src/node/services/ptc/types.ts | 9 +- .../extractToolMediaAsUserMessages.test.ts | 54 ++++++++- .../extractToolMediaAsUserMessages.ts | 58 +++++++-- ...oolMediaAsUserMessagesFromModelMessages.ts | 112 +++++++++++------- .../utils/messages/toolResultAttachments.ts | 17 ++- 7 files changed, 222 insertions(+), 53 deletions(-) diff --git a/src/common/constants/imageAttachments.ts b/src/common/constants/imageAttachments.ts index 944d54790e..954af0a3e5 100644 --- a/src/common/constants/imageAttachments.ts +++ b/src/common/constants/imageAttachments.ts @@ -8,3 +8,14 @@ export const MAX_SVG_TEXT_CHARS = 50_000; // OpenAI caps at 2000px always; Anthropic caps at 2000px for many-image (>20) requests. // Resize at attach-time to avoid provider rejections that persist in history. export const MAX_IMAGE_DIMENSION = 2000; + +// Request-wide cap on media items extracted out of tool results into +// synthetic user messages. Capture-time bounding caps BYTES (execution-wide +// retained budget) and parts per container, but not distinct records across +// a transcript: a looped bridged media tool could otherwise fan out tens of +// thousands of synthetic provider parts that every later request re-processes +// (r28 security). The newest attachments are kept (the model usually needs +// its latest screenshot, not the oldest); older overflow collapses into one +// bounded placeholder. 64 stays under Anthropic's 100-images-per-request +// rejection threshold. +export const MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST = 64; diff --git a/src/node/services/ptc/types.test.ts b/src/node/services/ptc/types.test.ts index 81db9f2c12..61a1265727 100644 --- a/src/node/services/ptc/types.test.ts +++ b/src/node/services/ptc/types.test.ts @@ -401,6 +401,20 @@ describe("sanitizeMediaRecordCapture", () => { expect(sanitizeCapturedMediaValue(mediaFree, shared)).toBe(mediaFree); }); + it("charges overflow markers so exhausted captures cannot accumulate free bytes", () => { + // After exhaustion, a media-bearing capture still emits a bounded marker; + // the marker debits the shared budget too (r28), so the accounting covers + // every byte a call loop can persist — nothing is emitted for free. + const shared = createCaptureSanitizerBudget(); + shared.remainingSanitizedBytes = 4; + const out = sanitizeCapturedMediaValue( + { media: { type: "media", mediaType: "image/png", data: "aGVsbG8=" } }, + shared + ); + expect(typeof out).toBe("string"); + expect(shared.remainingSanitizedBytes).toBeLessThan(4); + }); + it("sanitizes standalone media leaves outside containers", () => { // Guest code can pluck a part out of a container (`image.value[0]`) and // return/log/pass it; container-only recognition would persist that copy diff --git a/src/node/services/ptc/types.ts b/src/node/services/ptc/types.ts index a95c3d4f73..f02e8ebfa7 100644 --- a/src/node/services/ptc/types.ts +++ b/src/node/services/ptc/types.ts @@ -502,7 +502,14 @@ export function sanitizeCapturedMediaValue( // mode keeps full inline results/args by contract). const bytes = serializedJsonByteLength(sanitized); if (bytes === undefined || bytes > budget.remainingSanitizedBytes) { - return `[value bounded at capture: ${bytes ?? "unserializable"} serialized bytes after media sanitization exceed the remaining sanitized-value budget]`; + const marker = `[value bounded at capture: ${bytes ?? "unserializable"} serialized bytes after media sanitization exceed the remaining sanitized-value budget]`; + // The marker is charged too (r28): it is the only payload a media-bearing + // capture emits after exhaustion, so leaving it free would let a call + // loop append one uncharged marker record per call. The budget may go + // negative — every capture must still yield a bounded replacement — but + // the accounting reflects every emitted byte. + budget.remainingSanitizedBytes -= serializedJsonByteLength(marker) ?? marker.length; + return marker; } budget.remainingSanitizedBytes -= bytes; return sanitized; diff --git a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts index a8ed6c0f03..841ab632d5 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "@jest/globals"; import sharp from "sharp"; -import { MAX_IMAGE_DIMENSION } from "@/common/constants/imageAttachments"; +import { + MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST, + MAX_IMAGE_DIMENSION, +} from "@/common/constants/imageAttachments"; import type { MuxMessage } from "@/common/types/message"; import { expectContentOutputValue } from "./testToolOutputHelpers"; import { extractToolMediaAsUserMessages } from "./extractToolMediaAsUserMessages"; @@ -920,6 +923,55 @@ describe("extractToolMediaAsUserMessages", () => { expect(resizedBase64).not.toBe(base64); }); + it("caps extracted media parts per request and keeps the newest attachments", async () => { + // Capture bounds bytes and per-container parts, not distinct records: a + // looped media tool could otherwise fan out tens of thousands of + // synthetic provider parts re-processed by every request (r28 security). + // Overflow is omitted OLDEST-first so the model keeps its latest + // screenshots, replaced by one bounded placeholder. + const svg = (marker: string) => + Buffer.from( + `${marker}` + ).toString("base64"); + const total = MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST + 2; + const value = Array.from({ length: total }, (_, i) => ({ + type: "media", + mediaType: "image/svg+xml", + data: svg(`marker-${String(i).padStart(2, "0")}`), + })); + const input: MuxMessage[] = [ + { + id: "a1", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: "call1", + toolName: "mcp__shots__take", + input: {}, + state: "output-available", + output: { type: "content", value }, + }, + ], + metadata: { timestamp: 1 }, + }, + ]; + + const rewritten = await extractToolMediaAsUserMessages(input); + expect(rewritten).toHaveLength(2); + const synthetic = rewritten[1]; + expect(synthetic.role).toBe("user"); + // 1 summary + capped inlined attachments + 1 omission placeholder. + expect(synthetic.parts).toHaveLength(2 + MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST); + const text = JSON.stringify(synthetic.parts); + expect(text).toContain(`[Attached ${total} attachment(s) from tool output]`); + expect(text).toContain("2 extracted media attachment(s) omitted"); + expect(text).not.toContain("marker-00"); + expect(text).not.toContain("marker-01"); + expect(text).toContain("marker-02"); + expect(text).toContain(`marker-${total - 1}`); + }); + it("rewrites attach_file PDF output into a synthetic user file part", async () => { const base64 = Buffer.from("%PDF-1.7").toString("base64"); diff --git a/src/node/utils/messages/extractToolMediaAsUserMessages.ts b/src/node/utils/messages/extractToolMediaAsUserMessages.ts index 6182f5e129..8430907dcf 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessages.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessages.ts @@ -1,7 +1,9 @@ import type { MuxMessage } from "@/common/types/message"; +import { MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST } from "@/common/constants/imageAttachments"; import { sanitizeAnthropicDocumentFilename } from "@/node/utils/messages/sanitizeAnthropicDocumentFilename"; import { createDataUrlForExtractedAttachment, + createOmittedToolAttachmentText, createToolAttachmentSummaryText, extractAttachmentsFromToolOutput, prepareExtractedToolAttachmentForProvider, @@ -26,7 +28,34 @@ import { export async function extractToolMediaAsUserMessages( messages: MuxMessage[] ): Promise { - let didChangeAnyMessage = false; + // Pass 1 — extract once per tool part. The request-wide media cap needs the + // TOTAL before any emission so the NEWEST attachments survive (the model + // usually needs its latest screenshot, not its oldest), and the extraction + // walk must not run twice per part. + const extractionsByPart = new Map< + MuxMessage["parts"][number], + NonNullable> + >(); + let totalAttachments = 0; + for (const message of messages) { + if (message.role !== "assistant") continue; + for (const part of message.parts) { + if (part.type !== "dynamic-tool" || part.state !== "output-available") continue; + const extracted = extractAttachmentsFromToolOutput(part.output); + if (extracted == null) continue; + extractionsByPart.set(part, extracted); + totalAttachments += extracted.attachments.length; + } + } + if (extractionsByPart.size === 0) return messages; + + // Capture-time bounding caps bytes and per-container parts, not distinct + // records across a transcript: a looped bridged media tool could otherwise + // fan out tens of thousands of synthetic provider parts that every later + // request re-processes (r28 security). Chronologically OLDEST overflow is + // omitted (replaced with one bounded placeholder per synthetic message). + let omitRemaining = Math.max(0, totalAttachments - MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST); + const result: MuxMessage[] = []; for (const message of messages) { @@ -45,9 +74,8 @@ export async function extractToolMediaAsUserMessages( newParts.push(part); continue; } - - const extracted = extractAttachmentsFromToolOutput(part.output); - if (extracted == null) { + const extracted = extractionsByPart.get(part); + if (extracted === undefined) { newParts.push(part); continue; } @@ -56,7 +84,16 @@ export async function extractToolMediaAsUserMessages( extractedAttachmentCount += extracted.attachments.length; const nextExtractedUserParts: MuxMessage["parts"] = []; + let omittedHere = 0; for (const attachment of extracted.attachments) { + if (omitRemaining > 0) { + // Over the request-wide cap: the payload was already replaced with + // a placeholder in the tool output; skipping BEFORE provider + // preparation also avoids the resize/data-url work. + omitRemaining--; + omittedHere++; + continue; + } const providerReadyAttachment = await prepareExtractedToolAttachmentForProvider(attachment); if (providerReadyAttachment.type === "text") { nextExtractedUserParts.push({ @@ -82,6 +119,12 @@ export async function extractToolMediaAsUserMessages( }); } + if (omittedHere > 0) { + nextExtractedUserParts.push({ + type: "text", + text: createOmittedToolAttachmentText(omittedHere), + }); + } extractedUserParts = [...extractedUserParts, ...nextExtractedUserParts]; newParts.push({ ...part, @@ -92,13 +135,9 @@ export async function extractToolMediaAsUserMessages( const rewrittenMessage = changedMessage ? ({ ...message, parts: newParts } satisfies MuxMessage) : message; - if (changedMessage) { - didChangeAnyMessage = true; - } result.push(rewrittenMessage); if (extractedUserParts.length > 0) { - didChangeAnyMessage = true; const timestamp = message.metadata?.timestamp ?? Date.now(); result.push({ id: `tool-media-${message.id}`, @@ -118,5 +157,6 @@ export async function extractToolMediaAsUserMessages( } } - return didChangeAnyMessage ? result : messages; + // extractionsByPart is non-empty here, so at least one message changed. + return result; } diff --git a/src/node/utils/messages/extractToolMediaAsUserMessagesFromModelMessages.ts b/src/node/utils/messages/extractToolMediaAsUserMessagesFromModelMessages.ts index 1091fb3ff2..f59096a0a1 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessagesFromModelMessages.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessagesFromModelMessages.ts @@ -1,6 +1,8 @@ import type { FilePart, ImagePart, ModelMessage, TextPart, ToolResultPart } from "ai"; +import { MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST } from "@/common/constants/imageAttachments"; import { sanitizeAnthropicDocumentFilename } from "@/node/utils/messages/sanitizeAnthropicDocumentFilename"; import { + createOmittedToolAttachmentText, createToolAttachmentSummaryText, extractAttachmentsFromToolOutput, prepareExtractedToolAttachmentForProvider, @@ -26,7 +28,36 @@ type ToolResultOutput = ToolResultPart["output"]; export async function extractToolMediaAsUserMessagesFromModelMessages( messages: ModelMessage[] ): Promise { - let didChange = false; + // Pass 1 — extract once per tool-result part. The request-wide media cap + // needs the TOTAL before any emission so the NEWEST attachments survive, + // and the extraction walk must not run twice per part (see the MuxMessage + // variant in extractToolMediaAsUserMessages.ts). + const extractionsByPart = new Map< + object, + NonNullable> + >(); + let totalAttachments = 0; + for (const message of messages) { + if (message.role !== "assistant" && message.role !== "tool") continue; + if (!Array.isArray(message.content)) continue; + for (const part of message.content) { + if (part.type !== "tool-result") continue; + const extracted = extractAttachmentsFromToolOutput(part.output as unknown); + if (extracted == null) continue; + extractionsByPart.set(part, extracted); + totalAttachments += extracted.attachments.length; + } + } + if (extractionsByPart.size === 0) return messages; + + // Request-wide cap (r28 security): capture bounds bytes and per-container + // parts, not distinct records, so a looped bridged media tool could + // otherwise fan out tens of thousands of synthetic provider parts. + // Chronologically OLDEST overflow is omitted behind a bounded placeholder. + const capState = { + omitRemaining: Math.max(0, totalAttachments - MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST), + }; + const result: ModelMessage[] = []; for (const message of messages) { @@ -34,54 +65,23 @@ export async function extractToolMediaAsUserMessagesFromModelMessages( result.push(message); continue; } - - let extractedAttachments: ExtractedToolAttachment[] = []; - let changedMessage = false; - - if (message.role === "tool") { - const newContent = message.content.map((part) => { - if (part.type !== "tool-result") { - return part; - } - - const extracted = extractAttachmentsFromToolOutput(part.output as unknown); - if (extracted == null) { - return part; - } - - didChange = true; - changedMessage = true; - extractedAttachments = [...extractedAttachments, ...extracted.attachments]; - - return { - ...part, - output: extracted.newOutput as ToolResultOutput, - }; - }); - - result.push(changedMessage ? { ...message, content: newContent } : message); - if (extractedAttachments.length > 0) { - result.push(await createSyntheticUserMessage(extractedAttachments)); - } - continue; - } - if (!Array.isArray(message.content)) { result.push(message); continue; } + let extractedAttachments: ExtractedToolAttachment[] = []; + let changedMessage = false; + const newContent = message.content.map((part) => { if (part.type !== "tool-result") { return part; } - - const extracted = extractAttachmentsFromToolOutput(part.output as unknown); - if (extracted == null) { + const extracted = extractionsByPart.get(part); + if (extracted === undefined) { return part; } - didChange = true; changedMessage = true; extractedAttachments = [...extractedAttachments, ...extracted.attachments]; @@ -91,17 +91,32 @@ export async function extractToolMediaAsUserMessagesFromModelMessages( }; }); - result.push(changedMessage ? { ...message, content: newContent } : message); + // The content arrays are structurally identical for both roles, but the + // union needs the role-specific rebuild. + result.push( + changedMessage + ? message.role === "tool" + ? { + ...message, + content: newContent as Extract["content"], + } + : { + ...message, + content: newContent as Extract["content"], + } + : message + ); if (extractedAttachments.length > 0) { - result.push(await createSyntheticUserMessage(extractedAttachments)); + result.push(await createSyntheticUserMessage(extractedAttachments, capState)); } } - return didChange ? result : messages; + return result; } async function createSyntheticUserMessage( - attachments: ExtractedToolAttachment[] + attachments: ExtractedToolAttachment[], + capState: { omitRemaining: number } ): Promise { const content: Array = [ { @@ -110,7 +125,16 @@ async function createSyntheticUserMessage( }, ]; + let omittedHere = 0; for (const attachment of attachments) { + if (capState.omitRemaining > 0) { + // Over the request-wide cap: the payload was already replaced with a + // placeholder in the tool output; skipping BEFORE provider preparation + // also avoids the resize work. + capState.omitRemaining--; + omittedHere++; + continue; + } const providerReadyAttachment = await prepareExtractedToolAttachmentForProvider(attachment); if (providerReadyAttachment.type === "text") { content.push({ @@ -141,6 +165,12 @@ async function createSyntheticUserMessage( : {}), }); } + if (omittedHere > 0) { + content.splice(1, 0, { + type: "text", + text: createOmittedToolAttachmentText(omittedHere), + }); + } return { role: "user", diff --git a/src/node/utils/messages/toolResultAttachments.ts b/src/node/utils/messages/toolResultAttachments.ts index 186df777a9..1a325e7549 100644 --- a/src/node/utils/messages/toolResultAttachments.ts +++ b/src/node/utils/messages/toolResultAttachments.ts @@ -3,7 +3,11 @@ import { isDisplayOnlyFilePart, type DisplayOnlyFilePart, } from "@/common/utils/attachments/displayOnlyFileParts"; -import { MAX_SVG_TEXT_CHARS, SVG_MEDIA_TYPE } from "@/common/constants/imageAttachments"; +import { + MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST, + MAX_SVG_TEXT_CHARS, + SVG_MEDIA_TYPE, +} from "@/common/constants/imageAttachments"; import { isSupportedAttachmentMediaType, normalizeAttachmentMediaType, @@ -598,6 +602,17 @@ export function createToolAttachmentSummaryText(count: number): string { return `[Attached ${count} attachment(s) from tool output]`; } +/** + * Placeholder for extracted attachments dropped by the request-wide media cap + * (see MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST): capture bounds bytes and + * per-container parts, not distinct records across a transcript, so a looped + * media tool could otherwise fan out tens of thousands of synthetic provider + * parts (r28 security). The newest attachments are kept. + */ +export function createOmittedToolAttachmentText(omitted: number): string { + return `[${omitted} extracted media attachment(s) omitted: request-wide cap of ${MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST} media parts reached; newest attachments are kept]`; +} + export function createDataUrlForExtractedAttachment(attachment: ExtractedToolAttachment): string { if (attachment.mediaType === SVG_MEDIA_TYPE) { const svgText = Buffer.from(attachment.data, "base64").toString("utf8"); From 456898213843dad00c0bf327d22f231af804bcb4 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Aug 2026 12:11:43 +0000 Subject: [PATCH 35/40] review r29: shared budget for outer return/console args, success bit on all bounded results, schema-valid over-depth parts, coalesced omitted placeholders --- src/node/services/ptc/quickjsRuntime.ts | 73 ++++++++++++----- src/node/services/ptc/runtime.ts | 16 +++- .../services/tools/code_execution.test.ts | 46 ++++++++++- src/node/services/tools/code_execution.ts | 12 ++- .../extractToolMediaAsUserMessages.test.ts | 80 ++++++++++++++++++ .../extractToolMediaAsUserMessages.ts | 26 +++--- ...oolMediaAsUserMessagesFromModelMessages.ts | 47 ++++++----- .../utils/messages/toolResultAttachments.ts | 81 ++++++++++++++++++- 8 files changed, 322 insertions(+), 59 deletions(-) diff --git a/src/node/services/ptc/quickjsRuntime.ts b/src/node/services/ptc/quickjsRuntime.ts index 8ea20467ab..c2e4cd5458 100644 --- a/src/node/services/ptc/quickjsRuntime.ts +++ b/src/node/services/ptc/quickjsRuntime.ts @@ -647,7 +647,7 @@ export class QuickJSRuntime implements IJSRuntime { // Sanitize against the same shared per-execution budget; the guest // still passes the full value to the tool. return this.captureResultSanitizer !== undefined - ? this.captureResultSanitizer(toolName, value, this.classicSanitizerBudgetFor(toolCalls)) + ? this.captureResultSanitizer(toolName, value, this.classicCaptureBudgetFor(toolCalls)) : value; } const bounded = this.boundCapture(value, this.kernelRecordBounds.argsCapBytes); @@ -694,7 +694,7 @@ export class QuickJSRuntime implements IJSRuntime { toolName, value, this.kernelRecordBounds === undefined - ? this.classicSanitizerBudgetFor(toolCalls) + ? this.classicCaptureBudgetFor(toolCalls) : undefined ) : value; @@ -722,24 +722,36 @@ export class QuickJSRuntime implements IJSRuntime { return retained; } // Budget exhausted: fall back to normal bounding — oversized results - // become honest-size markers, small results still pass inline. A - // boolean success bit is preserved onto the marker: compaction folds - // result.success===false into the compact ok bit, and a FAILED edit - // misattributed as ok:true would advertise a never-applied path in - // crash-safe edited-file tracking. - const bounded = this.boundCapture(sanitized, this.kernelRecordBounds.resultCapBytes); - const success = (retained as { success?: unknown }).success; - if ( - typeof success === "boolean" && - typeof bounded === "object" && - bounded !== null && - (bounded as { __kernelBounded?: boolean }).__kernelBounded === true - ) { - return { ...bounded, success }; - } - return bounded; + // become honest-size markers, small results still pass inline. + return QuickJSRuntime.preserveSuccessBit( + this.boundCapture(sanitized, this.kernelRecordBounds.resultCapBytes), + retained + ); + } + return QuickJSRuntime.preserveSuccessBit( + this.boundCapture(sanitized, this.kernelRecordBounds.resultCapBytes), + sanitized + ); + } + + /** A boolean success bit is preserved onto EVERY __kernelBounded result + * marker (r29 — not just the retained-budget-exhausted branch): compaction + * folds result.success===false into the compact ok bit, and a FAILED call + * whose oversized result was replaced by a marker would otherwise be + * misreported as ok:true — advertising a never-applied edit path or a + * never-read file in crash-safe attachment tracking. */ + private static preserveSuccessBit(bounded: unknown, source: unknown): unknown { + if (typeof source !== "object" || source === null) return bounded; + const success = (source as { success?: unknown }).success; + if ( + typeof success === "boolean" && + typeof bounded === "object" && + bounded !== null && + (bounded as { __kernelBounded?: boolean }).__kernelBounded === true + ) { + return { ...bounded, success }; } - return this.boundCapture(sanitized, this.kernelRecordBounds.resultCapBytes); + return bounded; } /** Get-or-create the retained-result budget for one attribution's record @@ -754,8 +766,10 @@ export class QuickJSRuntime implements IJSRuntime { } /** Get-or-create the classic-mode shared sanitizer budget for one - * attribution's record array (see classicSanitizerBudgets). */ - private classicSanitizerBudgetFor(toolCalls: PTCToolCallRecord[]): CaptureSanitizerBudget { + * attribution's record array (see classicSanitizerBudgets). Public because + * the classic outer return value persists into the same history row and + * must draw from the same allowance (r29; see IJSRuntime). */ + classicCaptureBudgetFor(toolCalls: PTCToolCallRecord[]): CaptureSanitizerBudget { let budget = this.classicSanitizerBudgets.get(toolCalls); if (!budget) { budget = createCaptureSanitizerBudget(); @@ -1449,7 +1463,22 @@ export class QuickJSRuntime implements IJSRuntime { // sanitization only shrinks, never grows. const sanitizer = this.captureResultSanitizer; const captured = - sanitizer !== undefined ? args.map((arg) => sanitizer("console", arg)) : args; + sanitizer !== undefined + ? args.map((arg) => + sanitizer( + "console", + arg, + // Classic mode draws from the same execution allowance as + // nested records and the outer return (r29): console-arg + // media must not mint a fresh per-value budget. Kernel mode + // keeps per-call allowances (cross-call growth is bounded + // by the retained-result budget and this console budget). + this.kernelRecordBounds === undefined + ? this.classicCaptureBudgetFor(attribution.toolCalls) + : undefined + ) + ) + : args; attribution.consoleOutput.push({ level, args: captured, timestamp }); attribution.eventHandler?.({ type: "console", diff --git a/src/node/services/ptc/runtime.ts b/src/node/services/ptc/runtime.ts index faf203f7a9..6aa0ddffa4 100644 --- a/src/node/services/ptc/runtime.ts +++ b/src/node/services/ptc/runtime.ts @@ -5,7 +5,12 @@ * but designed to allow future migration to libbun or other runtimes. */ -import type { CaptureSanitizerBudget, PTCEvent, PTCExecutionResult } from "./types"; +import type { + CaptureSanitizerBudget, + PTCEvent, + PTCExecutionResult, + PTCToolCallRecord, +} from "./types"; /** * Resource limits for sandbox execution. @@ -108,6 +113,15 @@ export interface IJSRuntime extends Disposable { | undefined ): void; + /** + * Shared per-execution capture-sanitizer budget for a classic (non-kernel) + * execution's record array. The outer return value persists into the same + * history row as the nested records, so it must draw from the SAME + * allowance (r29) — sanitizing it against a fresh per-value budget would + * let one execution retain roughly twice the intended media bound. + */ + classicCaptureBudgetFor?(toolCalls: PTCToolCallRecord[]): CaptureSanitizerBudget; + /** * Route late guest-continuation execution through a host-provided gate. * When a fire-and-forget capability (registerPromiseFunction) settles after diff --git a/src/node/services/tools/code_execution.test.ts b/src/node/services/tools/code_execution.test.ts index 2874f5c99f..08412cc5ce 100644 --- a/src/node/services/tools/code_execution.test.ts +++ b/src/node/services/tools/code_execution.test.ts @@ -1296,9 +1296,19 @@ describe("createCodeExecutionTool", () => { wrapped?: { value?: Array<{ type?: string; data?: string; text?: string }> }; } )?.wrapped?.value; - expect(outer?.[0]?.data).toBe(bigImage); + // The outer return draws from the SAME execution allowance as the + // nested record (r29): the record capture already retained the first + // ~2MiB image out of the 3MiB budget, so the returned copy of the same + // container cannot retain it AGAIN — both copies exceeding the shared + // remainder collapse to placeholders instead of doubling the bound. + expect(outer?.[0]?.type).toBe("text"); + expect(outer?.[0]?.text).toContain("aggregate media budget exceeded"); expect(outer?.[1]?.type).toBe("text"); expect(outer?.[1]?.text).toContain("aggregate media budget exceeded"); + // The payload itself is not lost: the nested record retains it. + const shotsRecord = result.toolCalls.find((r) => r.toolName === "mcp__shots__take"); + const recordValue = (shotsRecord?.result as { value?: Array<{ data?: string }> })?.value; + expect(recordValue?.[0]?.data).toBe(bigImage); const consoleArg = ( result.consoleOutput[0]?.args[0] as { @@ -1488,6 +1498,40 @@ describe("createCodeExecutionTool", () => { await host.disposeScope("ws-result-failure"); }); + it("keeps failure status when the failing result is oversized and capture-bounded", async () => { + // A failing result over the kernel result cap is replaced with a + // __kernelBounded marker at creation; the success bit must survive onto + // EVERY marker (r29 — not just the retained-budget branch), or + // compaction would report the failed call ok:true and read tracking + // would advertise a never-read path. + using tmp = new DisposableTempDir("code-exec-oversized-failure"); + const host = new SandboxHostService(); + const failingReadTools: Record = { + file_read: createMockTool("file_read", z.object({ path: z.string() }), () => ({ + success: false, + error: `Backend failure: ${"x".repeat(64 * 1024)}`, + })), + }; + const tool = await createCodeExecutionTool( + runtimeFactory, + new ToolBridge(failingReadTools), + undefined, + persistentRunner(host, "ws-oversized-failure", tmp.path) + ); + + const result = (await tool.execute!( + { code: 'const r = mux.file_read({path: "/missing.txt"}); return r.success;' }, + mockToolCallOptions + )) as PTCExecutionResult; + expect(result.success).toBe(true); + expect(result.result).toBe(false); + const record = result.toolCalls[0]; + expect(record.toolName).toBe("file_read"); + expect(record.error).toBeUndefined(); + expect(record.ok).toBe(false); + await host.disposeScope("ws-oversized-failure"); + }); + it("bounds nested-call args/results at creation: emitted events never carry full payloads", async () => { // Post-eval compaction cannot protect the stream path: nested events // land in partial/final session history via the stream manager, so a diff --git a/src/node/services/tools/code_execution.ts b/src/node/services/tools/code_execution.ts index 59de8c8b15..a0d8ac6528 100644 --- a/src/node/services/tools/code_execution.ts +++ b/src/node/services/tools/code_execution.ts @@ -701,10 +701,16 @@ ${xumTypes} // record's history row — the capture sanitizer only covers nested // tool-call records and console args, and request-time attachment // extraction rewrites only the provider copy, never - // partial.json/chat.jsonl. Budget it at the same boundary. Kernel - // mode is excluded on purpose: its outer result feeds vars-handle + // partial.json/chat.jsonl. Budget it at the same boundary, using + // the SAME execution allowance as the nested records (r29): a + // fresh per-value budget would retain the payload twice (record + + // outer result), doubling the intended media bound. Kernel mode + // is excluded on purpose: its outer result feeds vars-handle // offloading, which must store full fidelity for the guest. - result.result = sanitizeCapturedMediaValue(result.result); + result.result = sanitizeCapturedMediaValue( + result.result, + runtime.classicCaptureBudgetFor?.(result.toolCalls) + ); } // RLM return-value offloading BEFORE the vars snapshot below, so the diff --git a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts index 841ab632d5..b60e261b61 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts @@ -970,6 +970,86 @@ describe("extractToolMediaAsUserMessages", () => { expect(text).not.toContain("marker-01"); expect(text).toContain("marker-02"); expect(text).toContain(`marker-${total - 1}`); + + // The rewritten TOOL OUTPUT coalesces the per-item placeholders too (r29 + // security): a flooded transcript would otherwise keep tens of thousands + // of `[Attachment attached…]` parts — megabytes of provider JSON — even + // after the attachment cap. + const toolPart = rewritten[0].parts[0]; + expect(toolPart.type).toBe("dynamic-tool"); + if (toolPart.type === "dynamic-tool" && toolPart.state === "output-available") { + const outputValue = (toolPart.output as { value?: unknown[] }).value; + expect(outputValue).toHaveLength(1); + const coalesced = outputValue?.[0] as { type?: string; text?: string }; + expect(coalesced.type).toBe("text"); + expect(coalesced.text).toContain(`${total} attachments attached from tool output`); + } + }); + + it("keeps over-depth replacements schema-valid inside content containers", async () => { + // The over-depth replacement is a bare string; inserted raw into a + // content container's value array it would make the container malformed + // (AI SDK content entries must be typed parts) and fail model-message + // conversion on every later request (r29). + let deepItem: unknown = { note: "leaf" }; + for (let i = 0; i < 70; i++) deepItem = { type: "json", value: deepItem }; + let output: unknown = { + type: "content", + value: [ + deepItem, + { type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "shot.png" }, + ], + }; + for (let i = 0; i < 64; i++) output = { type: "json", value: output }; + + const input: MuxMessage[] = [ + { + id: "a1", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: "call1", + toolName: "mcp__shots__take", + input: {}, + state: "output-available", + output, + }, + ], + metadata: { timestamp: 1 }, + }, + ]; + + const rewritten = await extractToolMediaAsUserMessages(input); + expect(rewritten).toHaveLength(2); + const toolPart = rewritten[0].parts[0]; + expect(toolPart.type).toBe("dynamic-tool"); + if (toolPart.type === "dynamic-tool" && toolPart.state === "output-available") { + // Unwrap the 64 json layers back to the content container. + let current: unknown = toolPart.output; + for (let i = 0; i < 64; i++) { + current = (current as { value: unknown }).value; + } + const container = current as { type?: string; value?: unknown[] }; + expect(container.type).toBe("content"); + for (const entry of container.value ?? []) { + expect(typeof entry).toBe("object"); + expect(typeof (entry as { type?: unknown }).type).toBe("string"); + } + const overDepth = (container.value ?? []).find( + (entry) => + typeof (entry as { text?: unknown }).text === "string" && + (entry as { text: string }).text.includes("depth limit exceeded") + ) as { type?: string } | undefined; + expect(overDepth?.type).toBe("text"); + } + // The media item beside the over-depth entry still extracted (the fake + // PNG bytes then fail provider preparation, which is fine — extraction + // reached it). + const synthetic = rewritten[1]; + expect(JSON.stringify(synthetic.parts)).toContain( + "[Attached 1 attachment(s) from tool output]" + ); }); it("rewrites attach_file PDF output into a synthetic user file part", async () => { diff --git a/src/node/utils/messages/extractToolMediaAsUserMessages.ts b/src/node/utils/messages/extractToolMediaAsUserMessages.ts index 8430907dcf..cd50fa5b07 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessages.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessages.ts @@ -2,6 +2,7 @@ import type { MuxMessage } from "@/common/types/message"; import { MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST } from "@/common/constants/imageAttachments"; import { sanitizeAnthropicDocumentFilename } from "@/node/utils/messages/sanitizeAnthropicDocumentFilename"; import { + coalesceAttachmentPlaceholders, createDataUrlForExtractedAttachment, createOmittedToolAttachmentText, createToolAttachmentSummaryText, @@ -83,17 +84,14 @@ export async function extractToolMediaAsUserMessages( changedMessage = true; extractedAttachmentCount += extracted.attachments.length; + // Over the request-wide cap: chronologically OLDEST attachments are + // omitted. Skipping BEFORE provider preparation avoids the + // resize/data-url work for payloads the request will never carry. + const omittedHere = Math.min(omitRemaining, extracted.attachments.length); + omitRemaining -= omittedHere; + const nextExtractedUserParts: MuxMessage["parts"] = []; - let omittedHere = 0; - for (const attachment of extracted.attachments) { - if (omitRemaining > 0) { - // Over the request-wide cap: the payload was already replaced with - // a placeholder in the tool output; skipping BEFORE provider - // preparation also avoids the resize/data-url work. - omitRemaining--; - omittedHere++; - continue; - } + for (const attachment of extracted.attachments.slice(omittedHere)) { const providerReadyAttachment = await prepareExtractedToolAttachmentForProvider(attachment); if (providerReadyAttachment.type === "text") { nextExtractedUserParts.push({ @@ -128,7 +126,13 @@ export async function extractToolMediaAsUserMessages( extractedUserParts = [...extractedUserParts, ...nextExtractedUserParts]; newParts.push({ ...part, - output: extracted.newOutput, + // Omitted attachments also leave per-item placeholders behind in the + // rewritten output; coalesce them so a flooded transcript cannot keep + // megabytes of placeholder JSON in every request (r29 security). + output: + omittedHere > 0 + ? coalesceAttachmentPlaceholders(extracted.newOutput) + : extracted.newOutput, }); } diff --git a/src/node/utils/messages/extractToolMediaAsUserMessagesFromModelMessages.ts b/src/node/utils/messages/extractToolMediaAsUserMessagesFromModelMessages.ts index f59096a0a1..ac76aef691 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessagesFromModelMessages.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessagesFromModelMessages.ts @@ -2,6 +2,7 @@ import type { FilePart, ImagePart, ModelMessage, TextPart, ToolResultPart } from import { MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST } from "@/common/constants/imageAttachments"; import { sanitizeAnthropicDocumentFilename } from "@/node/utils/messages/sanitizeAnthropicDocumentFilename"; import { + coalesceAttachmentPlaceholders, createOmittedToolAttachmentText, createToolAttachmentSummaryText, extractAttachmentsFromToolOutput, @@ -70,7 +71,9 @@ export async function extractToolMediaAsUserMessagesFromModelMessages( continue; } - let extractedAttachments: ExtractedToolAttachment[] = []; + let keptAttachments: ExtractedToolAttachment[] = []; + let totalExtracted = 0; + let omittedForMessage = 0; let changedMessage = false; const newContent = message.content.map((part) => { @@ -83,11 +86,21 @@ export async function extractToolMediaAsUserMessagesFromModelMessages( } changedMessage = true; - extractedAttachments = [...extractedAttachments, ...extracted.attachments]; + totalExtracted += extracted.attachments.length; + // Over the request-wide cap: chronologically OLDEST attachments are + // omitted, and their per-item output placeholders are coalesced so a + // flooded transcript cannot keep megabytes of placeholder JSON in + // every request (r29 security). + const omittedHere = Math.min(capState.omitRemaining, extracted.attachments.length); + capState.omitRemaining -= omittedHere; + omittedForMessage += omittedHere; + keptAttachments = [...keptAttachments, ...extracted.attachments.slice(omittedHere)]; return { ...part, - output: extracted.newOutput as ToolResultOutput, + output: (omittedHere > 0 + ? coalesceAttachmentPlaceholders(extracted.newOutput) + : extracted.newOutput) as ToolResultOutput, }; }); @@ -106,8 +119,10 @@ export async function extractToolMediaAsUserMessagesFromModelMessages( } : message ); - if (extractedAttachments.length > 0) { - result.push(await createSyntheticUserMessage(extractedAttachments, capState)); + if (totalExtracted > 0) { + result.push( + await createSyntheticUserMessage(keptAttachments, omittedForMessage, totalExtracted) + ); } } @@ -115,26 +130,18 @@ export async function extractToolMediaAsUserMessagesFromModelMessages( } async function createSyntheticUserMessage( - attachments: ExtractedToolAttachment[], - capState: { omitRemaining: number } + keptAttachments: ExtractedToolAttachment[], + omittedCount: number, + totalExtracted: number ): Promise { const content: Array = [ { type: "text", - text: createToolAttachmentSummaryText(attachments.length), + text: createToolAttachmentSummaryText(totalExtracted), }, ]; - let omittedHere = 0; - for (const attachment of attachments) { - if (capState.omitRemaining > 0) { - // Over the request-wide cap: the payload was already replaced with a - // placeholder in the tool output; skipping BEFORE provider preparation - // also avoids the resize work. - capState.omitRemaining--; - omittedHere++; - continue; - } + for (const attachment of keptAttachments) { const providerReadyAttachment = await prepareExtractedToolAttachmentForProvider(attachment); if (providerReadyAttachment.type === "text") { content.push({ @@ -165,10 +172,10 @@ async function createSyntheticUserMessage( : {}), }); } - if (omittedHere > 0) { + if (omittedCount > 0) { content.splice(1, 0, { type: "text", - text: createOmittedToolAttachmentText(omittedHere), + text: createOmittedToolAttachmentText(omittedCount), }); } diff --git a/src/node/utils/messages/toolResultAttachments.ts b/src/node/utils/messages/toolResultAttachments.ts index 1a325e7549..8d89ef7acf 100644 --- a/src/node/utils/messages/toolResultAttachments.ts +++ b/src/node/utils/messages/toolResultAttachments.ts @@ -262,7 +262,15 @@ export function extractAttachmentsFromToolOutput( if (nested != null) { didChange = true; attachments.push(...nested.attachments); - newValue.push(nested.newOutput as AISDKContent); + // Content-container entries must stay typed content parts (AI SDK + // schema): an over-depth replacement is a bare string, and inserting it + // raw would make the container malformed and fail model-message + // conversion or provider validation on every later request (r29). + newValue.push( + typeof nested.newOutput === "string" + ? { type: "text", text: nested.newOutput } + : (nested.newOutput as AISDKContent) + ); continue; } @@ -613,6 +621,77 @@ export function createOmittedToolAttachmentText(omitted: number): string { return `[${omitted} extracted media attachment(s) omitted: request-wide cap of ${MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST} media parts reached; newest attachments are kept]`; } +const ATTACHMENT_PLACEHOLDER_PREFIX = "[Attachment attached"; + +/** + * Coalesce runs of per-item attachment placeholders inside a rewritten tool + * output. Applied only to outputs whose attachments were (partly) omitted by + * the request-wide media cap: each media item still leaves a per-item + * `[Attachment attached: …]` text part behind, so a flooded transcript could + * carry tens of thousands of placeholder parts (megabytes of provider JSON) + * even after the attachment cap (r29 security). Consecutive placeholder parts + * collapse into one bounded summary; single placeholders stay individual. + * The walk mirrors extraction's shapes and depth bound — the value was + * already rebuilt (and depth-bounded) by extraction. + */ +export function coalesceAttachmentPlaceholders(output: unknown): unknown { + return coalescePlaceholderWalk(output, 0); +} + +function coalescePlaceholderWalk(value: unknown, depth: number): unknown { + if (depth > MAX_NESTED_TOOL_EXTRACTION_DEPTH) return value; + if (Array.isArray(value)) { + const next: unknown[] = []; + let changed = false; + const run: unknown[] = []; + const flushRun = (): void => { + if (run.length === 0) return; + if (run.length === 1) { + next.push(run[0]); + } else { + changed = true; + next.push({ + type: "text", + text: `[${run.length} attachments attached from tool output (per-item placeholders coalesced: request-wide media cap reached)]`, + }); + } + run.length = 0; + }; + for (const item of value) { + if (isAttachmentPlaceholderPart(item)) { + run.push(item); + continue; + } + flushRun(); + const walked = coalescePlaceholderWalk(item, depth + 1); + if (walked !== item) changed = true; + next.push(walked); + } + flushRun(); + return changed ? next : value; + } + if (typeof value === "object" && value !== null) { + let changed = false; + const entries = Object.entries(value as Record).map(([key, child]) => { + const walked = coalescePlaceholderWalk(child, depth + 1); + if (walked !== child) changed = true; + return [key, walked] as const; + }); + return changed ? Object.fromEntries(entries) : value; + } + return value; +} + +function isAttachmentPlaceholderPart(value: unknown): boolean { + if (typeof value !== "object" || value === null) return false; + const part = value as { type?: unknown; text?: unknown }; + return ( + part.type === "text" && + typeof part.text === "string" && + part.text.startsWith(ATTACHMENT_PLACEHOLDER_PREFIX) + ); +} + export function createDataUrlForExtractedAttachment(attachment: ExtractedToolAttachment): string { if (attachment.mediaType === SVG_MEDIA_TYPE) { const svgText = Buffer.from(attachment.data, "base64").toString("utf8"); From 8f0637c9f464605833889fafc66359f023b5a58d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Aug 2026 12:29:11 +0000 Subject: [PATCH 36/40] review r30: global placeholder coalescing, exhaustion stub stops retention, init-time PTC downgrade mirror --- src/node/services/experimentsService.test.ts | 25 +++++ src/node/services/experimentsService.ts | 36 ++++++-- src/node/services/ptc/types.test.ts | 19 +++- src/node/services/ptc/types.ts | 33 ++++--- .../extractToolMediaAsUserMessages.test.ts | 57 ++++++++++++ .../utils/messages/toolResultAttachments.ts | 91 +++++++++++++------ 6 files changed, 213 insertions(+), 48 deletions(-) diff --git a/src/node/services/experimentsService.test.ts b/src/node/services/experimentsService.test.ts index a400a4d9b5..ef9efd3ae8 100644 --- a/src/node/services/experimentsService.test.ts +++ b/src/node/services/experimentsService.test.ts @@ -173,6 +173,31 @@ describe("ExperimentsService", () => { }); }); + test("initialization persists the downgrade mirror for a bare ptc:true file", async () => { + // A pre-merge file can carry ptc:true without the legacy exclusive key + // (setOverride is the only other writer): a user who upgrades and never + // touches a setting must still downgrade into the exclusive posture, not + // the removed supplement mode (r30). + await fs.writeFile( + path.join(tempDir, OVERRIDES_FILE), + JSON.stringify({ + version: 1, + experiments: {}, + overrides: { "programmatic-tool-calling": true }, + }), + "utf-8" + ); + + const { telemetryService } = createTelemetryService(); + const service = new ExperimentsService({ telemetryService, xumHome: tempDir }); + await service.initialize(); + + expect((await readOverridesFile()).overrides).toEqual({ + [EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING]: true, + "programmatic-tool-calling-exclusive": true, + }); + }); + test("a disabled legacy exclusive override stays ignored", async () => { await fs.writeFile( path.join(tempDir, OVERRIDES_FILE), diff --git a/src/node/services/experimentsService.ts b/src/node/services/experimentsService.ts index a8dd7de195..b46871de3b 100644 --- a/src/node/services/experimentsService.ts +++ b/src/node/services/experimentsService.ts @@ -32,19 +32,25 @@ function isRecord(value: unknown): value is Record { } /** Parse the persisted overrides file contents (shared by the service and CLI reads). */ -async function readOverridesFile(filePath: string): Promise> { +async function readOverridesFile(filePath: string): Promise<{ + overrides: Map; + /** True when the persisted file already carries the enabled legacy + * exclusive mirror (see LEGACY_PTC_EXCLUSIVE_EXPERIMENT_ID). */ + hasLegacyPtcMirror: boolean; +}> { const overrides = new Map(); + let hasLegacyPtcMirror = false; try { const raw = await fs.readFile(filePath, "utf-8"); const parsed = JSON.parse(raw) as unknown; if (!isRecord(parsed) || parsed.version !== OVERRIDES_FILE_VERSION) { - return overrides; + return { overrides, hasLegacyPtcMirror }; } const persisted = parsed.overrides; if (!isRecord(persisted)) { - return overrides; + return { overrides, hasLegacyPtcMirror }; } for (const [key, value] of Object.entries(persisted)) { @@ -62,11 +68,12 @@ async function readOverridesFile(filePath: string): Promise { - for (const [experimentId, enabled] of await readOverridesFile(this.overridesFilePath)) { + /** Returns true when the persisted file enables PTC without the legacy + * downgrade mirror and needs a rewrite (see initialize). */ + private async loadOverridesFromDisk(): Promise { + const { overrides, hasLegacyPtcMirror } = await readOverridesFile(this.overridesFilePath); + for (const [experimentId, enabled] of overrides) { this.overrides.set(experimentId, enabled); } + return overrides.get(EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING) === true && !hasLegacyPtcMirror; } private async writeOverridesToDisk(): Promise { diff --git a/src/node/services/ptc/types.test.ts b/src/node/services/ptc/types.test.ts index 61a1265727..aa02d5d63a 100644 --- a/src/node/services/ptc/types.test.ts +++ b/src/node/services/ptc/types.test.ts @@ -12,6 +12,7 @@ import { retainPersistenceCriticalArgsFields, sanitizeCapturedMediaValue, sanitizeMediaRecordCapture, + SANITIZER_BUDGET_EXHAUSTED_STUB, } from "./types"; interface RetainedContainer { @@ -402,9 +403,9 @@ describe("sanitizeMediaRecordCapture", () => { }); it("charges overflow markers so exhausted captures cannot accumulate free bytes", () => { - // After exhaustion, a media-bearing capture still emits a bounded marker; - // the marker debits the shared budget too (r28), so the accounting covers - // every byte a call loop can persist — nothing is emitted for free. + // A media-bearing capture over the remaining allowance emits a bounded + // marker; the marker debits the shared budget too (r28), so total marker + // bytes stay bounded by the initial allowance. const shared = createCaptureSanitizerBudget(); shared.remainingSanitizedBytes = 4; const out = sanitizeCapturedMediaValue( @@ -413,6 +414,18 @@ describe("sanitizeMediaRecordCapture", () => { ); expect(typeof out).toBe("string"); expect(shared.remainingSanitizedBytes).toBeLessThan(4); + + // Once the counter is spent, retention STOPS (r30): every further + // media-bearing capture returns one constant stub with no further debit, + // so a fast call loop cannot accumulate size-annotated markers or drive + // the counter unboundedly negative. + const exhausted = shared.remainingSanitizedBytes; + const second = sanitizeCapturedMediaValue( + { media: { type: "media", mediaType: "image/png", data: "aGVsbG8=" } }, + shared + ); + expect(second).toBe(SANITIZER_BUDGET_EXHAUSTED_STUB); + expect(shared.remainingSanitizedBytes).toBe(exhausted); }); it("sanitizes standalone media leaves outside containers", () => { diff --git a/src/node/services/ptc/types.ts b/src/node/services/ptc/types.ts index f02e8ebfa7..ebf46c13b5 100644 --- a/src/node/services/ptc/types.ts +++ b/src/node/services/ptc/types.ts @@ -466,6 +466,12 @@ export function createCaptureSanitizerBudget(): CaptureSanitizerBudget { }; } +/** Constant replacement for media-bearing values captured after the shared + * sanitized-value allowance is spent (r30): retention stops entirely rather + * than emitting another size-annotated marker per call. */ +export const SANITIZER_BUDGET_EXHAUSTED_STUB = + "[media-bearing value omitted: capture sanitizer budget exhausted]"; + /** * Tool-name-free form of sanitizeMediaRecordCapture for values that are not * nested tool records: the classic execution's outer return value and console @@ -501,18 +507,23 @@ export function sanitizeCapturedMediaValue( // small marker. Media-free values are untouched and uncharged (classic // mode keeps full inline results/args by contract). const bytes = serializedJsonByteLength(sanitized); - if (bytes === undefined || bytes > budget.remainingSanitizedBytes) { - const marker = `[value bounded at capture: ${bytes ?? "unserializable"} serialized bytes after media sanitization exceed the remaining sanitized-value budget]`; - // The marker is charged too (r28): it is the only payload a media-bearing - // capture emits after exhaustion, so leaving it free would let a call - // loop append one uncharged marker record per call. The budget may go - // negative — every capture must still yield a bounded replacement — but - // the accounting reflects every emitted byte. - budget.remainingSanitizedBytes -= serializedJsonByteLength(marker) ?? marker.length; - return marker; + if (bytes !== undefined && bytes <= budget.remainingSanitizedBytes) { + budget.remainingSanitizedBytes -= bytes; + return sanitized; + } + // Over the remaining allowance (or unserializable). + if (budget.remainingSanitizedBytes <= 0) { + // Exhausted (r30): retention STOPS — a constant stub with no further + // debit, so a fast call loop cannot keep accumulating size-annotated + // markers nor drive the counter unboundedly negative. Per-record + // structural overhead is all that remains, same as any non-media loop. + return SANITIZER_BUDGET_EXHAUSTED_STUB; } - budget.remainingSanitizedBytes -= bytes; - return sanitized; + const marker = `[value bounded at capture: ${bytes ?? "unserializable"} serialized bytes after media sanitization exceed the remaining sanitized-value budget]`; + // The marker is charged too (r28): total marker bytes stay bounded by the + // initial allowance, after which the exhausted branch above takes over. + budget.remainingSanitizedBytes -= serializedJsonByteLength(marker) ?? marker.length; + return marker; } /** diff --git a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts index b60e261b61..25374439ad 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts @@ -986,6 +986,63 @@ describe("extractToolMediaAsUserMessages", () => { } }); + it("coalesces omitted placeholders across separate nested records", async () => { + // One small image per bridged call leaves a SINGLETON placeholder in each + // record's value array — run-based coalescing would keep every one, so + // thousands of looped records would still carry megabytes of placeholder + // JSON after the attachment cap (r30). Coalescing is global across the + // whole tool output. + const svg = (i: number) => + Buffer.from( + `rec-${String(i).padStart(2, "0")}` + ).toString("base64"); + const total = MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST + 2; + const toolCalls = Array.from({ length: total }, (_, i) => ({ + toolName: "mcp__shots__take", + args: {}, + result: { + type: "content", + value: [{ type: "media", mediaType: "image/svg+xml", data: svg(i) }], + }, + })); + const input: MuxMessage[] = [ + { + id: "a1", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: "call1", + toolName: "code_execution", + input: { code: "..." }, + state: "output-available", + output: { success: true, toolCalls }, + }, + ], + metadata: { timestamp: 1 }, + }, + ]; + + const rewritten = await extractToolMediaAsUserMessages(input); + expect(rewritten).toHaveLength(2); + const toolPart = rewritten[0].parts[0]; + expect(toolPart.type).toBe("dynamic-tool"); + if (toolPart.type === "dynamic-tool" && toolPart.state === "output-available") { + const outputJson = JSON.stringify(toolPart.output); + // No per-record singleton placeholders survive; one bounded summary does. + expect(outputJson).not.toContain("[Attachment attached"); + expect(outputJson.split("placeholders coalesced").length - 1).toBe(1); + expect(outputJson).toContain(`${total} attachments attached from tool output`); + } + // Newest attachments still ride in the synthetic message; the two oldest + // were omitted by the request-wide cap. + const syntheticJson = JSON.stringify(rewritten[1].parts); + expect(syntheticJson).toContain("rec-02"); + expect(syntheticJson).toContain(`rec-${total - 1}`); + expect(syntheticJson).not.toContain("rec-00"); + expect(syntheticJson).not.toContain("rec-01"); + }); + it("keeps over-depth replacements schema-valid inside content containers", async () => { // The over-depth replacement is a bare string; inserted raw into a // content container's value array it would make the container malformed diff --git a/src/node/utils/messages/toolResultAttachments.ts b/src/node/utils/messages/toolResultAttachments.ts index 8d89ef7acf..e4a2713023 100644 --- a/src/node/utils/messages/toolResultAttachments.ts +++ b/src/node/utils/messages/toolResultAttachments.ts @@ -623,57 +623,89 @@ export function createOmittedToolAttachmentText(omitted: number): string { const ATTACHMENT_PLACEHOLDER_PREFIX = "[Attachment attached"; +/** See coalesceAttachmentPlaceholders: replacement for every placeholder + * after the first — constant so a flooded transcript's coalesced records + * carry only per-record structural overhead. */ +const COALESCED_PLACEHOLDER_STUB = { type: "text", text: "[attachment placeholder coalesced]" }; + /** - * Coalesce runs of per-item attachment placeholders inside a rewritten tool - * output. Applied only to outputs whose attachments were (partly) omitted by - * the request-wide media cap: each media item still leaves a per-item + * Coalesce per-item attachment placeholders inside a rewritten tool output. + * Applied only to outputs whose attachments were (partly) omitted by the + * request-wide media cap: each media item still leaves a per-item * `[Attachment attached: …]` text part behind, so a flooded transcript could * carry tens of thousands of placeholder parts (megabytes of provider JSON) - * even after the attachment cap (r29 security). Consecutive placeholder parts - * collapse into one bounded summary; single placeholders stay individual. + * even after the attachment cap (r29 security). Coalescing is GLOBAL across + * the whole output, not per-array (r30): one small image per nested record + * leaves singleton placeholders in separate `value` arrays, so run-based + * coalescing would preserve every one of them. The FIRST placeholder becomes + * a bounded summary carrying the total; later ones are dropped from arrays + * and stubbed in non-array positions. A single placeholder stays individual. * The walk mirrors extraction's shapes and depth bound — the value was * already rebuilt (and depth-bounded) by extraction. */ export function coalesceAttachmentPlaceholders(output: unknown): unknown { - return coalescePlaceholderWalk(output, 0); + const total = countAttachmentPlaceholders(output, 0); + if (total <= 1) return output; + const state = { total, replacedSummary: false }; + return coalescePlaceholderWalk(output, 0, state); } -function coalescePlaceholderWalk(value: unknown, depth: number): unknown { +function countAttachmentPlaceholders(value: unknown, depth: number): number { + if (depth > MAX_NESTED_TOOL_EXTRACTION_DEPTH) return 0; + if (isAttachmentPlaceholderPart(value)) return 1; + if (Array.isArray(value)) { + let count = 0; + for (const item of value) count += countAttachmentPlaceholders(item, depth + 1); + return count; + } + if (typeof value === "object" && value !== null) { + let count = 0; + for (const child of Object.values(value as Record)) { + count += countAttachmentPlaceholders(child, depth + 1); + } + return count; + } + return 0; +} + +function coalescePlaceholderWalk( + value: unknown, + depth: number, + state: { total: number; replacedSummary: boolean } +): unknown { if (depth > MAX_NESTED_TOOL_EXTRACTION_DEPTH) return value; + if (isAttachmentPlaceholderPart(value)) { + // Non-array position (e.g. a nested record's whole result): cannot be + // dropped structurally, so stub it after the first summary. + if (!state.replacedSummary) { + state.replacedSummary = true; + return buildCoalescedPlaceholderSummary(state.total); + } + return COALESCED_PLACEHOLDER_STUB; + } if (Array.isArray(value)) { const next: unknown[] = []; let changed = false; - const run: unknown[] = []; - const flushRun = (): void => { - if (run.length === 0) return; - if (run.length === 1) { - next.push(run[0]); - } else { - changed = true; - next.push({ - type: "text", - text: `[${run.length} attachments attached from tool output (per-item placeholders coalesced: request-wide media cap reached)]`, - }); - } - run.length = 0; - }; for (const item of value) { if (isAttachmentPlaceholderPart(item)) { - run.push(item); + changed = true; + if (!state.replacedSummary) { + state.replacedSummary = true; + next.push(buildCoalescedPlaceholderSummary(state.total)); + } + // Later placeholders are dropped from arrays entirely. continue; } - flushRun(); - const walked = coalescePlaceholderWalk(item, depth + 1); + const walked = coalescePlaceholderWalk(item, depth + 1, state); if (walked !== item) changed = true; next.push(walked); } - flushRun(); return changed ? next : value; } if (typeof value === "object" && value !== null) { let changed = false; const entries = Object.entries(value as Record).map(([key, child]) => { - const walked = coalescePlaceholderWalk(child, depth + 1); + const walked = coalescePlaceholderWalk(child, depth + 1, state); if (walked !== child) changed = true; return [key, walked] as const; }); @@ -682,6 +714,13 @@ function coalescePlaceholderWalk(value: unknown, depth: number): unknown { return value; } +function buildCoalescedPlaceholderSummary(total: number): { type: "text"; text: string } { + return { + type: "text", + text: `[${total} attachments attached from tool output (per-item placeholders coalesced: request-wide media cap reached)]`, + }; +} + function isAttachmentPlaceholderPart(value: unknown): boolean { if (typeof value !== "object" || value === null) return false; const part = value as { type?: unknown; text?: unknown }; From c126c9289772aa80db0616edb34dbe84aa42882d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Aug 2026 12:48:46 +0000 Subject: [PATCH 37/40] review r31: excess-placeholder coalescing (dedup case), existing conversation media counts against extraction allowance --- .../extractToolMediaAsUserMessages.test.ts | 113 +++++++++++++++++- .../extractToolMediaAsUserMessages.ts | 28 +++-- ...oolMediaAsUserMessagesFromModelMessages.ts | 27 ++++- .../utils/messages/toolResultAttachments.ts | 36 +++--- 4 files changed, 175 insertions(+), 29 deletions(-) diff --git a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts index 25374439ad..b46f61dc90 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts @@ -730,7 +730,9 @@ describe("extractToolMediaAsUserMessages", () => { } const outputText = JSON.stringify(toolPart.output); expect(outputText).not.toContain(base64); - expect(outputText).toContain("[Attachment attached:"); + // The dedup leaves 2 placeholders for 1 emitted attachment, so the + // excess coalesces into one bounded summary (r31). + expect(outputText).toContain("2 attachments attached from tool output"); // Identical leaf in args and outer result dedupes into ONE attachment. const fileParts = rewritten[1].parts.filter((part) => part.type === "file"); expect(fileParts).toHaveLength(1); @@ -1043,6 +1045,115 @@ describe("extractToolMediaAsUserMessages", () => { expect(syntheticJson).not.toContain("rec-01"); }); + it("coalesces placeholders when dedup leaves excess without any cap omission", async () => { + // pushUnique collapses repeated payloads into ONE attachment while every + // occurrence still leaves a placeholder: with omission at zero, the + // omitted-count trigger alone would keep every per-item placeholder in + // provider JSON (r31). Coalescing keys off placeholder count vs emitted + // attachments instead. + const svgData = Buffer.from( + 'dedup-marker' + ).toString("base64"); + const toolCalls = Array.from({ length: 5 }, () => ({ + toolName: "mcp__shots__take", + args: {}, + result: { + type: "content", + value: [{ type: "media", mediaType: "image/svg+xml", data: svgData }], + }, + })); + const input: MuxMessage[] = [ + { + id: "a1", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: "call1", + toolName: "code_execution", + input: { code: "..." }, + state: "output-available", + output: { success: true, toolCalls }, + }, + ], + metadata: { timestamp: 1 }, + }, + ]; + + const rewritten = await extractToolMediaAsUserMessages(input); + const toolPart = rewritten[0].parts[0]; + expect(toolPart.type).toBe("dynamic-tool"); + if (toolPart.type === "dynamic-tool" && toolPart.state === "output-available") { + const outputJson = JSON.stringify(toolPart.output); + expect(outputJson).not.toContain("[Attachment attached"); + expect(outputJson).toContain("5 attachments attached from tool output"); + } + // Dedup emitted exactly one attachment. + const syntheticJson = JSON.stringify(rewritten[1].parts); + expect(syntheticJson).toContain("[Attached 1 attachment(s) from tool output]"); + expect(syntheticJson.split("dedup-marker").length - 1).toBe(1); + }); + + it("counts existing conversation media parts against the extraction allowance", async () => { + // messagePipeline runs this transform after ordinary attachments are + // already in the request, and providers cap TOTAL media parts: existing + // images must consume the extraction allowance or 50 user images plus a + // full 64-part extraction would exceed a ~100-image provider limit (r31). + const svg = (i: number) => + Buffer.from( + `marker-${String(i).padStart(2, "0")}` + ).toString("base64"); + const existingFileParts = Array.from( + { length: MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST - 1 }, + (_, i) => + ({ + type: "file", + mediaType: "image/png", + url: `data:image/png;base64,QUJD${i}`, + }) as const + ); + const input: MuxMessage[] = [ + { + id: "u1", + role: "user", + parts: [{ type: "text", text: "attached images" }, ...existingFileParts], + metadata: { timestamp: 1 }, + }, + { + id: "a1", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: "call1", + toolName: "mcp__shots__take", + input: {}, + state: "output-available", + output: { + type: "content", + value: [0, 1, 2].map((i) => ({ + type: "media", + mediaType: "image/svg+xml", + data: svg(i), + })), + }, + }, + ], + metadata: { timestamp: 2 }, + }, + ]; + + const rewritten = await extractToolMediaAsUserMessages(input); + expect(rewritten).toHaveLength(3); + const syntheticJson = JSON.stringify(rewritten[2].parts); + // Allowance is 1 (cap minus existing parts): the two oldest extracted + // attachments are omitted, the newest survives. + expect(syntheticJson).toContain("2 extracted media attachment(s) omitted"); + expect(syntheticJson).not.toContain("marker-00"); + expect(syntheticJson).not.toContain("marker-01"); + expect(syntheticJson).toContain("marker-02"); + }); + it("keeps over-depth replacements schema-valid inside content containers", async () => { // The over-depth replacement is a bare string; inserted raw into a // content container's value array it would make the container malformed diff --git a/src/node/utils/messages/extractToolMediaAsUserMessages.ts b/src/node/utils/messages/extractToolMediaAsUserMessages.ts index cd50fa5b07..edabb6517c 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessages.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessages.ts @@ -38,7 +38,16 @@ export async function extractToolMediaAsUserMessages( NonNullable> >(); let totalAttachments = 0; + // Media parts already bound for the provider (user-attached images/PDFs and + // prior synthetic file parts) share the same per-request provider limits, + // so they consume the extraction allowance too (r31): 50 existing images + // plus a full 64-part extraction allowance would exceed a ~100-image + // provider cap the constant is sized to stay below. + let existingMediaParts = 0; for (const message of messages) { + for (const part of message.parts) { + if (part.type === "file") existingMediaParts++; + } if (message.role !== "assistant") continue; for (const part of message.parts) { if (part.type !== "dynamic-tool" || part.state !== "output-available") continue; @@ -55,7 +64,8 @@ export async function extractToolMediaAsUserMessages( // fan out tens of thousands of synthetic provider parts that every later // request re-processes (r28 security). Chronologically OLDEST overflow is // omitted (replaced with one bounded placeholder per synthetic message). - let omitRemaining = Math.max(0, totalAttachments - MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST); + const allowance = Math.max(0, MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST - existingMediaParts); + let omitRemaining = Math.max(0, totalAttachments - allowance); const result: MuxMessage[] = []; @@ -126,13 +136,15 @@ export async function extractToolMediaAsUserMessages( extractedUserParts = [...extractedUserParts, ...nextExtractedUserParts]; newParts.push({ ...part, - // Omitted attachments also leave per-item placeholders behind in the - // rewritten output; coalesce them so a flooded transcript cannot keep - // megabytes of placeholder JSON in every request (r29 security). - output: - omittedHere > 0 - ? coalesceAttachmentPlaceholders(extracted.newOutput) - : extracted.newOutput, + // Excess per-item placeholders (cap omission OR same-payload dedup — + // r31) are coalesced so a flooded transcript cannot keep megabytes of + // placeholder JSON in every request (r29 security). The helper + // returns the output unchanged when placeholders match the emitted + // attachments 1:1. + output: coalesceAttachmentPlaceholders( + extracted.newOutput, + extracted.attachments.length - omittedHere + ), }); } diff --git a/src/node/utils/messages/extractToolMediaAsUserMessagesFromModelMessages.ts b/src/node/utils/messages/extractToolMediaAsUserMessagesFromModelMessages.ts index ac76aef691..024cdadc3e 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessagesFromModelMessages.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessagesFromModelMessages.ts @@ -38,9 +38,21 @@ export async function extractToolMediaAsUserMessagesFromModelMessages( NonNullable> >(); let totalAttachments = 0; + // Media parts already bound for the provider (user-attached images/files + // and prior synthetic parts) share the same per-request provider limits, so + // they consume the extraction allowance too (r31). + let existingMediaParts = 0; for (const message of messages) { - if (message.role !== "assistant" && message.role !== "tool") continue; if (!Array.isArray(message.content)) continue; + for (const part of message.content) { + if ( + (part as { type?: unknown }).type === "image" || + (part as { type?: unknown }).type === "file" + ) { + existingMediaParts++; + } + } + if (message.role !== "assistant" && message.role !== "tool") continue; for (const part of message.content) { if (part.type !== "tool-result") continue; const extracted = extractAttachmentsFromToolOutput(part.output as unknown); @@ -55,8 +67,9 @@ export async function extractToolMediaAsUserMessagesFromModelMessages( // parts, not distinct records, so a looped bridged media tool could // otherwise fan out tens of thousands of synthetic provider parts. // Chronologically OLDEST overflow is omitted behind a bounded placeholder. + const allowance = Math.max(0, MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST - existingMediaParts); const capState = { - omitRemaining: Math.max(0, totalAttachments - MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST), + omitRemaining: Math.max(0, totalAttachments - allowance), }; const result: ModelMessage[] = []; @@ -98,9 +111,13 @@ export async function extractToolMediaAsUserMessagesFromModelMessages( return { ...part, - output: (omittedHere > 0 - ? coalesceAttachmentPlaceholders(extracted.newOutput) - : extracted.newOutput) as ToolResultOutput, + // Excess per-item placeholders (cap omission OR same-payload dedup — + // r31) are coalesced; unchanged when placeholders match emitted + // attachments 1:1. + output: coalesceAttachmentPlaceholders( + extracted.newOutput, + extracted.attachments.length - omittedHere + ) as ToolResultOutput, }; }); diff --git a/src/node/utils/messages/toolResultAttachments.ts b/src/node/utils/messages/toolResultAttachments.ts index e4a2713023..d452834971 100644 --- a/src/node/utils/messages/toolResultAttachments.ts +++ b/src/node/utils/messages/toolResultAttachments.ts @@ -630,22 +630,28 @@ const COALESCED_PLACEHOLDER_STUB = { type: "text", text: "[attachment placeholde /** * Coalesce per-item attachment placeholders inside a rewritten tool output. - * Applied only to outputs whose attachments were (partly) omitted by the - * request-wide media cap: each media item still leaves a per-item - * `[Attachment attached: …]` text part behind, so a flooded transcript could - * carry tens of thousands of placeholder parts (megabytes of provider JSON) - * even after the attachment cap (r29 security). Coalescing is GLOBAL across - * the whole output, not per-array (r30): one small image per nested record - * leaves singleton placeholders in separate `value` arrays, so run-based - * coalescing would preserve every one of them. The FIRST placeholder becomes - * a bounded summary carrying the total; later ones are dropped from arrays - * and stubbed in non-array positions. A single placeholder stays individual. - * The walk mirrors extraction's shapes and depth bound — the value was - * already rebuilt (and depth-bounded) by extraction. + * Each media item leaves a per-item `[Attachment attached: …]` text part + * behind, so a flooded transcript could carry tens of thousands of + * placeholder parts (megabytes of provider JSON) even after the request-wide + * attachment cap (r29 security). Coalescing engages when placeholders EXCEED + * the attachments actually emitted for the output (r31): cap omission and + * same-payload dedup (`pushUnique` collapses repeats into one attachment + * while every occurrence still leaves a placeholder) both produce excess; + * normal outputs whose placeholders match their attachments 1:1 stay + * individual. Coalescing is GLOBAL across the whole output, not per-array + * (r30): one small image per nested record leaves singleton placeholders in + * separate `value` arrays, so run-based coalescing would preserve every one. + * The FIRST placeholder becomes a bounded summary carrying the total; later + * ones are dropped from arrays and stubbed in non-array positions. The walk + * mirrors extraction's shapes and depth bound — the value was already + * rebuilt (and depth-bounded) by extraction. */ -export function coalesceAttachmentPlaceholders(output: unknown): unknown { +export function coalesceAttachmentPlaceholders( + output: unknown, + keptAttachmentCount: number +): unknown { const total = countAttachmentPlaceholders(output, 0); - if (total <= 1) return output; + if (total <= 1 || total <= keptAttachmentCount) return output; const state = { total, replacedSummary: false }; return coalescePlaceholderWalk(output, 0, state); } @@ -717,7 +723,7 @@ function coalescePlaceholderWalk( function buildCoalescedPlaceholderSummary(total: number): { type: "text"; text: string } { return { type: "text", - text: `[${total} attachments attached from tool output (per-item placeholders coalesced: request-wide media cap reached)]`, + text: `[${total} attachments attached from tool output (per-item placeholders coalesced)]`, }; } From 85d6fb42903bbf67be4fbf6f77c313fb96db1c51 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Aug 2026 13:02:35 +0000 Subject: [PATCH 38/40] review r32: unbounded iterative placeholder coalescing (deep generic wrappers) --- .../extractToolMediaAsUserMessages.test.ts | 51 ++++++ .../utils/messages/toolResultAttachments.ts | 173 +++++++++++++----- 2 files changed, 175 insertions(+), 49 deletions(-) diff --git a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts index b46f61dc90..af8dfbf310 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessages.test.ts @@ -1094,6 +1094,57 @@ describe("extractToolMediaAsUserMessages", () => { expect(syntheticJson.split("dedup-marker").length - 1).toBe(1); }); + it("coalesces placeholder floods below deep generic wrappers", async () => { + // Extraction rewrites media at ANY wrapper depth (its wrapper walk is + // iterative), so the coalescer must be unbounded too (r32 security): a + // depth-capped counter saw no placeholders below 64 generic wrappers and + // left a multi-hundred-KB placeholder flood intact in provider JSON. + const svg = (i: number) => + Buffer.from( + `deep-${String(i).padStart(2, "0")}` + ).toString("base64"); + const total = MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST + 2; + const leaves = Array.from({ length: total }, (_, i) => ({ + type: "media", + mediaType: "image/svg+xml", + data: svg(i), + })); + let deep: unknown = { leaves }; + for (let i = 0; i < 70; i++) deep = { wrap: deep }; + const input: MuxMessage[] = [ + { + id: "a1", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: "call1", + toolName: "mcp__shots__take", + input: {}, + state: "output-available", + output: deep, + }, + ], + metadata: { timestamp: 1 }, + }, + ]; + + const rewritten = await extractToolMediaAsUserMessages(input); + expect(rewritten).toHaveLength(2); + const toolPart = rewritten[0].parts[0]; + expect(toolPart.type).toBe("dynamic-tool"); + if (toolPart.type === "dynamic-tool" && toolPart.state === "output-available") { + const outputJson = JSON.stringify(toolPart.output); + expect(outputJson).not.toContain("[Attachment attached"); + expect(outputJson).toContain(`${total} attachments attached from tool output`); + } + // The newest attachments survive the cap; the two oldest were omitted. + const syntheticJson = JSON.stringify(rewritten[1].parts); + expect(syntheticJson).toContain("2 extracted media attachment(s) omitted"); + expect(syntheticJson).not.toContain("deep-00"); + expect(syntheticJson).toContain(`deep-${total - 1}`); + }); + it("counts existing conversation media parts against the extraction allowance", async () => { // messagePipeline runs this transform after ordinary attachments are // already in the request, and providers cap TOTAL media parts: existing diff --git a/src/node/utils/messages/toolResultAttachments.ts b/src/node/utils/messages/toolResultAttachments.ts index d452834971..7ee0e07d11 100644 --- a/src/node/utils/messages/toolResultAttachments.ts +++ b/src/node/utils/messages/toolResultAttachments.ts @@ -650,74 +650,149 @@ export function coalesceAttachmentPlaceholders( output: unknown, keptAttachmentCount: number ): unknown { - const total = countAttachmentPlaceholders(output, 0); + const total = countAttachmentPlaceholders(output); if (total <= 1 || total <= keptAttachmentCount) return output; const state = { total, replacedSummary: false }; - return coalescePlaceholderWalk(output, 0, state); + return coalescePlaceholderWalk(output, state); } -function countAttachmentPlaceholders(value: unknown, depth: number): number { - if (depth > MAX_NESTED_TOOL_EXTRACTION_DEPTH) return 0; - if (isAttachmentPlaceholderPart(value)) return 1; - if (Array.isArray(value)) { - let count = 0; - for (const item of value) count += countAttachmentPlaceholders(item, depth + 1); - return count; - } - if (typeof value === "object" && value !== null) { - let count = 0; - for (const child of Object.values(value as Record)) { - count += countAttachmentPlaceholders(child, depth + 1); +/** + * Iterative, unbounded count (r32 security): extraction deliberately creates + * placeholders at ARBITRARY generic wrapper depth (its wrapper walk is + * iterative), so a depth-capped recursion here would see none of a + * deep-wrapped placeholder flood and leave the multi-megabyte rewritten + * output unchanged. The visited set keeps shared subtrees linear and + * terminates cycle back-edges. + */ +function countAttachmentPlaceholders(root: unknown): number { + let count = 0; + const stack: unknown[] = [root]; + const visited = new Set(); + while (stack.length > 0) { + const value = stack.pop(); + if (typeof value !== "object" || value === null) continue; + if (visited.has(value)) continue; + visited.add(value); + if (isAttachmentPlaceholderPart(value)) { + count++; + continue; } - return count; + const children: unknown[] = Array.isArray(value) + ? value + : Object.values(value as Record); + for (const child of children) stack.push(child); } - return 0; + return count; } +/** + * Iterative post-order copy-on-write rebuild replacing/dropping attachment + * placeholders (see coalesceAttachmentPlaceholders). Unbounded over generic + * wrappers for the same reason as the counter above (r32). Shared subtrees + * are processed once via the memo; in-stack back-edges (impossible for + * extraction-rebuilt JSON, cheap to guard) pass through unchanged. + */ function coalescePlaceholderWalk( - value: unknown, - depth: number, + root: unknown, state: { total: number; replacedSummary: boolean } ): unknown { - if (depth > MAX_NESTED_TOOL_EXTRACTION_DEPTH) return value; - if (isAttachmentPlaceholderPart(value)) { - // Non-array position (e.g. a nested record's whole result): cannot be - // dropped structurally, so stub it after the first summary. - if (!state.replacedSummary) { - state.replacedSummary = true; - return buildCoalescedPlaceholderSummary(state.total); - } - return COALESCED_PLACEHOLDER_STUB; + if (typeof root !== "object" || root === null) return root; + if (isAttachmentPlaceholderPart(root)) { + state.replacedSummary = true; + return buildCoalescedPlaceholderSummary(state.total); } - if (Array.isArray(value)) { - const next: unknown[] = []; - let changed = false; - for (const item of value) { - if (isAttachmentPlaceholderPart(item)) { - changed = true; + + interface Frame { + source: object; + /** null for arrays; object keys otherwise. */ + keys: string[] | null; + children: unknown[]; + index: number; + results: unknown[]; + changed: boolean; + } + const makeFrame = (source: Record | unknown[]): Frame => { + if (Array.isArray(source)) { + return { source, keys: null, children: source, index: 0, results: [], changed: false }; + } + const keys = Object.keys(source); + return { + source, + keys, + children: keys.map((key) => source[key]), + index: 0, + results: [], + changed: false, + }; + }; + const memo = new Map(); + const inStack = new Set(); + const stack: Frame[] = [makeFrame(root as Record | unknown[])]; + inStack.add(root); + let rootResult: unknown = root; + + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + if (frame.index < frame.children.length) { + const child = frame.children[frame.index]; + frame.index++; + if (isAttachmentPlaceholderPart(child)) { + frame.changed = true; if (!state.replacedSummary) { state.replacedSummary = true; - next.push(buildCoalescedPlaceholderSummary(state.total)); + frame.results.push(buildCoalescedPlaceholderSummary(state.total)); + } else if (frame.keys !== null) { + // Non-array position (e.g. a nested record's whole result): cannot + // be dropped structurally, so stub it after the first summary. + frame.results.push(COALESCED_PLACEHOLDER_STUB); } - // Later placeholders are dropped from arrays entirely. + // Array positions: later placeholders are dropped entirely. + continue; + } + if (typeof child !== "object" || child === null) { + frame.results.push(child); + continue; + } + if (memo.has(child)) { + const remembered = memo.get(child); + if (remembered !== child) frame.changed = true; + frame.results.push(remembered); + continue; + } + if (inStack.has(child)) { + frame.results.push(child); continue; } - const walked = coalescePlaceholderWalk(item, depth + 1, state); - if (walked !== item) changed = true; - next.push(walked); + stack.push(makeFrame(child as Record | unknown[])); + inStack.add(child); + continue; + } + + // Frame complete: rebuild copy-on-write. + stack.pop(); + inStack.delete(frame.source); + let result: unknown; + if (!frame.changed) { + result = frame.source; + } else if (frame.keys === null) { + result = frame.results; + } else { + const rebuilt: Record = {}; + for (let i = 0; i < frame.keys.length; i++) { + rebuilt[frame.keys[i]] = frame.results[i]; + } + result = rebuilt; + } + memo.set(frame.source, result); + if (stack.length === 0) { + rootResult = result; + } else { + const parent = stack[stack.length - 1]; + if (result !== frame.source) parent.changed = true; + parent.results.push(result); } - return changed ? next : value; - } - if (typeof value === "object" && value !== null) { - let changed = false; - const entries = Object.entries(value as Record).map(([key, child]) => { - const walked = coalescePlaceholderWalk(child, depth + 1, state); - if (walked !== child) changed = true; - return [key, walked] as const; - }); - return changed ? Object.fromEntries(entries) : value; } - return value; + return rootResult; } function buildCoalescedPlaceholderSummary(total: number): { type: "text"; text: string } { From 08ece9c971f7a4ac5e94be51e65a27719a66bf4c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Aug 2026 13:10:37 +0000 Subject: [PATCH 39/40] review r33: positive success for inline read records, init-time legacy PTC mirror reconcile in renderer --- .../contexts/ExperimentsContext.test.tsx | 43 +++++++++++++++++++ src/browser/contexts/ExperimentsContext.tsx | 26 +++++++++++ .../utils/messages/extractReadFiles.test.ts | 16 ++++++- src/common/utils/messages/extractReadFiles.ts | 15 ++++--- 4 files changed, 92 insertions(+), 8 deletions(-) diff --git a/src/browser/contexts/ExperimentsContext.test.tsx b/src/browser/contexts/ExperimentsContext.test.tsx index d95018234f..5195b39c34 100644 --- a/src/browser/contexts/ExperimentsContext.test.tsx +++ b/src/browser/contexts/ExperimentsContext.test.tsx @@ -253,6 +253,49 @@ describe("ExperimentsProvider", () => { }); }); + test("initialization stamps the legacy mirror over a stale explicit false", async () => { + // An old renderer can leave ptc:true beside a stale legacy exclusive + // `false`; upgrading without touching the toggle previously never rewrote + // the mirror, and a downgraded renderer treats the stale explicit key as + // an override that wins over the backend flag — resuming the removed + // supplement posture (r33). Initialization reconciles it. + currentClientMock = { + experiments: { + setOverride: mock(() => Promise.resolve()), + getOverrides: mock(() => Promise.resolve({})), + }, + }; + + globalThis.window.localStorage.setItem( + getExperimentKey(EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING), + JSON.stringify(true) + ); + globalThis.window.localStorage.setItem( + getLegacyPtcExclusiveExperimentKey(), + JSON.stringify(false) + ); + + function Probe() { + const enabled = useExperimentValue(EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING); + return
{String(enabled)}
; + } + + const { getByTestId } = render( + + + + + + ); + + expect(getByTestId("probe").textContent).toBe("true"); + await waitFor(() => { + expect(globalThis.window.localStorage.getItem(getLegacyPtcExclusiveExperimentKey())).toBe( + "true" + ); + }); + }); + test("stale legacy exclusive true reads as PTC on, and toggling PTC rewrites the legacy key", async () => { currentClientMock = { experiments: { diff --git a/src/browser/contexts/ExperimentsContext.tsx b/src/browser/contexts/ExperimentsContext.tsx index ffb89e6df3..a7ab79154c 100644 --- a/src/browser/contexts/ExperimentsContext.tsx +++ b/src/browser/contexts/ExperimentsContext.tsx @@ -141,6 +141,28 @@ function setExperimentState(experimentId: ExperimentId, enabled: boolean): void } } +/** + * Upgrade reconciliation for the legacy exclusive mirror (r33): an old + * renderer can leave `programmatic-tool-calling: true` alongside a stale + * legacy exclusive `false` (or none), and setExperimentState rewrites the + * mirror only on toggles — a user who upgrades and never touches the setting + * would downgrade into the removed supplement posture, because a downgraded + * renderer treats the stale explicit legacy key as an override that wins over + * the backend's mirrored flag. Keep the mirror stamped whenever the EFFECTIVE + * PTC state (local override first, else the backend override) is enabled. + * Only the enabled state needs stamping: a legacy `true` already aliases + * effective PTC to true, so a disagreeing pair can only be + * (ptc: true, legacy: false/absent). + */ +function reconcileLegacyPtcExclusiveMirror( + backendOverrides: Partial> | null +): void { + const local = getExperimentOverrideSnapshot(EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING); + const effective = local ?? backendOverrides?.[EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING]; + if (effective !== true || hasLegacyPtcExclusiveOverride()) return; + updatePersistedState(getLegacyPtcExclusiveExperimentKey(), true); +} + /** * Context value type - provides setter function. * Individual experiment values are accessed via useExperimentValue hook. @@ -213,10 +235,14 @@ export function ExperimentsProvider(props: { children: React.ReactNode }) { const overrides = await api.experiments.getOverrides(); if (!cancelled) { setBackendOverrides(overrides); + reconcileLegacyPtcExclusiveMirror(overrides); } } catch { if (!cancelled) { setBackendOverrides(null); + // Still reconciles the purely-local stale pair (ptc: true, + // legacy: false/absent) even when the backend is unreachable. + reconcileLegacyPtcExclusiveMirror(null); } } }; diff --git a/src/common/utils/messages/extractReadFiles.test.ts b/src/common/utils/messages/extractReadFiles.test.ts index 498d5fcbb0..2c0e34d5e7 100644 --- a/src/common/utils/messages/extractReadFiles.test.ts +++ b/src/common/utils/messages/extractReadFiles.test.ts @@ -119,6 +119,17 @@ describe("extractReadFilePaths", () => { args: { path: "/resolved-but-failed.ts" }, result: { success: false, error: "File not found" }, }, + // Corrupted persisted rows (r33): result PRESENCE alone must not + // advertise a read — null, primitive, and success-less results + // are all rejected; only the positive successful shape counts. + { toolName: "file_read", args: { path: "/corrupt-null.ts" }, result: null }, + { toolName: "file_read", args: { path: "/corrupt-primitive.ts" }, result: 5 }, + { toolName: "file_read", args: { path: "/corrupt-successless.ts" }, result: {} }, + { + toolName: "file_read", + args: { path: "/classic-read.ts" }, + result: { success: true, content: "x" }, + }, ], }, }, @@ -129,9 +140,10 @@ describe("extractReadFilePaths", () => { codeExecutionMessage, ]; - // Newest-first at every level: within the execution, /loaded.jsonl is - // chronologically after /nested-read.ts, so it surfaces first. + // Newest-first at every level: within the execution, /classic-read.ts is + // chronologically last, so it surfaces first. expect(extractReadFilePaths(messages)).toEqual([ + "/classic-read.ts", "/loaded.jsonl", "/nested-read.ts", "/direct.ts", diff --git a/src/common/utils/messages/extractReadFiles.ts b/src/common/utils/messages/extractReadFiles.ts index bc56ae5f62..f4c2f9818a 100644 --- a/src/common/utils/messages/extractReadFiles.ts +++ b/src/common/utils/messages/extractReadFiles.ts @@ -45,12 +45,15 @@ function collectNestedReadPaths(output: unknown): string[] { // do) — a malformed row with neither result nor ok must not advertise a // never-read path. if (result === undefined && record.ok !== true) continue; - if ( - typeof result === "object" && - result !== null && - (result as { success?: unknown }).success === false - ) { - continue; + // Records WITH a result must show the POSITIVE successful file_read shape + // (r33): a corrupted persisted row can carry null, a primitive, or a + // success-less object, and result presence alone must not tell the model + // an unread file was already inspected. Kernel `load` results are the + // exception — their retained {key, bytes, lines, preview} shape has no + // success field; load failures surface through error/ok above. + if (result !== undefined && record.toolName !== "load") { + if (typeof result !== "object" || result === null) continue; + if ((result as { success?: unknown }).success !== true) continue; } const filePath = extractToolFilePath(record.args); if (filePath) paths.push(filePath); From 6e1b9fed7fe87b04b1d4698c1385c499cda67262 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Aug 2026 13:42:44 +0000 Subject: [PATCH 40/40] review r34: evict oldest synthetic tool media at cap saturation instead of omitting fresh extractions --- src/common/types/message.ts | 8 + .../extractToolMediaAsUserMessages.ts | 5 + ...diaAsUserMessagesFromModelMessages.test.ts | 147 +++++++++++++++++- ...oolMediaAsUserMessagesFromModelMessages.ts | 83 ++++++++-- .../utils/messages/toolResultAttachments.ts | 33 ++++ 5 files changed, 263 insertions(+), 13 deletions(-) diff --git a/src/common/types/message.ts b/src/common/types/message.ts index 13a59e369b..a87d853656 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -1029,6 +1029,14 @@ export interface MuxFilePart { mediaType: string; // IANA media type, e.g., "image/png", "application/pdf" url: string; // Data URL (e.g., "data:application/pdf;base64,...") or hosted URL filename?: string; // Optional filename + /** + * Part-level metadata convertToModelMessages forwards as FilePart.providerOptions. + * Used to mark request-only synthetic tool-media parts (see + * SYNTHETIC_TOOL_MEDIA_PART_METADATA in toolResultAttachments.ts) so per-step + * transforms can evict the oldest ones under the request-wide media cap + * instead of treating them like immutable user uploads (r34). + */ + providerMetadata?: Record>; } // XumMessage extends UIMessage with our metadata and custom parts diff --git a/src/node/utils/messages/extractToolMediaAsUserMessages.ts b/src/node/utils/messages/extractToolMediaAsUserMessages.ts index edabb6517c..6bc11405ef 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessages.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessages.ts @@ -8,6 +8,7 @@ import { createToolAttachmentSummaryText, extractAttachmentsFromToolOutput, prepareExtractedToolAttachmentForProvider, + SYNTHETIC_TOOL_MEDIA_PART_METADATA, } from "@/node/utils/messages/toolResultAttachments"; /** @@ -116,6 +117,10 @@ export async function extractToolMediaAsUserMessages( type: "file", mediaType: preparedAttachment.mediaType, url: createDataUrlForExtractedAttachment(preparedAttachment), + // Marks the part evictable under the request-wide media cap so a + // later step's fresh screenshot can displace it — genuine user + // uploads carry no marker and are never evicted (r34). + providerMetadata: SYNTHETIC_TOOL_MEDIA_PART_METADATA, ...(preparedAttachment.filename ? { filename: diff --git a/src/node/utils/messages/extractToolMediaAsUserMessagesFromModelMessages.test.ts b/src/node/utils/messages/extractToolMediaAsUserMessagesFromModelMessages.test.ts index 8957a3ba7b..301624cd6c 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessagesFromModelMessages.test.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessagesFromModelMessages.test.ts @@ -1,8 +1,14 @@ import { describe, expect, it } from "@jest/globals"; import type { ModelMessage, ToolResultPart } from "ai"; +import { convertToModelMessages } from "ai"; import sharp from "sharp"; -import { MAX_IMAGE_DIMENSION } from "@/common/constants/imageAttachments"; +import type { MuxMessage } from "@/common/types/message"; +import { + MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST, + MAX_IMAGE_DIMENSION, +} from "@/common/constants/imageAttachments"; import { expectContentOutputValue } from "./testToolOutputHelpers"; +import { extractToolMediaAsUserMessages } from "./extractToolMediaAsUserMessages"; import { extractToolMediaAsUserMessagesFromModelMessages } from "./extractToolMediaAsUserMessagesFromModelMessages"; describe("extractToolMediaAsUserMessagesFromModelMessages", () => { @@ -275,6 +281,145 @@ describe("extractToolMediaAsUserMessagesFromModelMessages", () => { ).toBe(false); }); + it("evicts oldest synthetic tool media instead of omitting a fresh extraction", async () => { + // r34: history-level extraction can saturate the request-wide cap with + // synthetic tool media. Those parts must be evictable (oldest-first) so a + // screenshot produced by the CURRENT tool call still reaches the next + // step — end-to-end through convertToModelMessages so the marker contract + // (providerMetadata -> providerOptions) is what's actually validated. + // PDFs pass through provider preparation unchanged (no raster decode), + // giving distinct real file parts after conversion. + const oldPayload = (i: number) => + Buffer.from(`old-${String(i).padStart(2, "0")}`).toString("base64"); + const historyInput: MuxMessage[] = [ + { + id: "a1", + role: "assistant", + parts: [ + { + type: "dynamic-tool", + toolCallId: "call-history", + toolName: "mcp__shots__take", + input: {}, + state: "output-available", + output: { + type: "content", + value: Array.from({ length: MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST }, (_, i) => ({ + type: "media", + mediaType: "application/pdf", + data: oldPayload(i), + })), + }, + }, + ], + metadata: { timestamp: 1 }, + }, + ]; + const historyMessages = await convertToModelMessages( + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + (await extractToolMediaAsUserMessages(historyInput)) as any + ); + const syntheticHistoryMedia = historyMessages + .flatMap((message): unknown[] => (Array.isArray(message.content) ? message.content : [])) + .filter( + (part) => + (part as { type?: unknown }).type === "file" || + (part as { type?: unknown }).type === "image" + ); + expect(syntheticHistoryMedia).toHaveLength(MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST); + + const stepMessages: ModelMessage[] = [ + ...historyMessages, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-current", + toolName: "attach_file", + output: { + type: "content", + value: [ + { + type: "media", + mediaType: "application/pdf", + data: Buffer.from("fresh-shot").toString("base64"), + }, + ], + } as unknown as ToolResultPart["output"], + }, + ], + }, + ]; + + const rewritten = await extractToolMediaAsUserMessagesFromModelMessages(stepMessages); + const rewrittenJson = JSON.stringify(rewritten); + // The fresh attachment survives; the OLDEST synthetic part is evicted + // behind a bounded note, keeping total media at the cap. + expect(rewrittenJson).toContain(Buffer.from("fresh-shot").toString("base64")); + expect(rewrittenJson).not.toContain(oldPayload(0)); + expect(rewrittenJson).toContain(oldPayload(1)); + expect(rewrittenJson).toContain("1 extracted media attachment(s) omitted"); + const mediaParts = rewritten + .flatMap((message): unknown[] => (Array.isArray(message.content) ? message.content : [])) + .filter( + (part) => + (part as { type?: unknown }).type === "file" || + (part as { type?: unknown }).type === "image" + ); + expect(mediaParts).toHaveLength(MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST); + }); + + it("never evicts genuine user uploads at saturation", async () => { + // Unmarked media (actual user attachments) reserves the allowance: with + // the cap fully consumed by user uploads, the new extraction is omitted + // and every upload is preserved untouched (r31 + r34). + const userUploads = Array.from( + { length: MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST }, + (_, i) => + ({ + type: "file", + mediaType: "image/png", + data: `QUJD${i}`, + }) as const + ); + const input: ModelMessage[] = [ + { role: "user", content: [{ type: "text", text: "uploads" }, ...userUploads] }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call1", + toolName: "attach_file", + output: { + type: "content", + value: [ + { + type: "media", + mediaType: "image/svg+xml", + data: Buffer.from( + `fresh-shot` + ).toString("base64"), + }, + ], + } as unknown as ToolResultPart["output"], + }, + ], + }, + ]; + + const rewritten = await extractToolMediaAsUserMessagesFromModelMessages(input); + // All user uploads intact (same parts, same message). + const userMessage = rewritten[0]; + expect(Array.isArray(userMessage.content) ? userMessage.content : []).toHaveLength( + 1 + MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST + ); + const rewrittenJson = JSON.stringify(rewritten); + expect(rewrittenJson).toContain("1 extracted media attachment(s) omitted"); + expect(rewrittenJson).not.toContain("fresh-shot"); + }); + it("is a no-op when there is no media", async () => { const input: ModelMessage[] = [ { diff --git a/src/node/utils/messages/extractToolMediaAsUserMessagesFromModelMessages.ts b/src/node/utils/messages/extractToolMediaAsUserMessagesFromModelMessages.ts index 024cdadc3e..e4f75f7b7f 100644 --- a/src/node/utils/messages/extractToolMediaAsUserMessagesFromModelMessages.ts +++ b/src/node/utils/messages/extractToolMediaAsUserMessagesFromModelMessages.ts @@ -6,7 +6,9 @@ import { createOmittedToolAttachmentText, createToolAttachmentSummaryText, extractAttachmentsFromToolOutput, + isSyntheticToolMediaPart, prepareExtractedToolAttachmentForProvider, + SYNTHETIC_TOOL_MEDIA_PART_METADATA, type ExtractedToolAttachment, } from "@/node/utils/messages/toolResultAttachments"; @@ -38,18 +40,25 @@ export async function extractToolMediaAsUserMessagesFromModelMessages( NonNullable> >(); let totalAttachments = 0; - // Media parts already bound for the provider (user-attached images/files - // and prior synthetic parts) share the same per-request provider limits, so - // they consume the extraction allowance too (r31). - let existingMediaParts = 0; + // Existing media parts share the same per-request provider limits as new + // extractions (r31), but they split into two pools: + // - reserved: genuine user uploads (unmarked) — consume the allowance and + // are never evicted. + // - synthetic tool media (marker from a prior history-level extraction) — + // evictable oldest-first so a fresh screenshot from the current tool call + // still reaches the model at saturation (r34). + let reservedMediaParts = 0; + const syntheticMediaParts: unknown[] = []; for (const message of messages) { if (!Array.isArray(message.content)) continue; for (const part of message.content) { - if ( - (part as { type?: unknown }).type === "image" || - (part as { type?: unknown }).type === "file" - ) { - existingMediaParts++; + const partType = (part as { type?: unknown }).type; + if (partType === "image" || partType === "file") { + if (isSyntheticToolMediaPart(part)) { + syntheticMediaParts.push(part); + } else { + reservedMediaParts++; + } } } if (message.role !== "assistant" && message.role !== "tool") continue; @@ -66,15 +75,27 @@ export async function extractToolMediaAsUserMessagesFromModelMessages( // Request-wide cap (r28 security): capture bounds bytes and per-container // parts, not distinct records, so a looped bridged media tool could // otherwise fan out tens of thousands of synthetic provider parts. - // Chronologically OLDEST overflow is omitted behind a bounded placeholder. - const allowance = Math.max(0, MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST - existingMediaParts); + // Chronologically OLDEST tool media is dropped first: prior synthetic parts + // (all older — they were extracted from earlier turns' tool results) are + // evicted before new extractions are omitted behind bounded placeholders. + const budget = Math.max(0, MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST - reservedMediaParts); + const overBudget = Math.max(0, syntheticMediaParts.length + totalAttachments - budget); + // syntheticMediaParts is collected in message order, so a prefix slice is + // the chronologically oldest set. + const evictedSyntheticParts = new Set( + syntheticMediaParts.slice(0, Math.min(overBudget, syntheticMediaParts.length)) + ); const capState = { - omitRemaining: Math.max(0, totalAttachments - allowance), + omitRemaining: overBudget - evictedSyntheticParts.size, }; const result: ModelMessage[] = []; for (const message of messages) { + if (message.role === "user" && Array.isArray(message.content)) { + result.push(evictSyntheticMediaParts(message, evictedSyntheticParts)); + continue; + } if (message.role !== "assistant" && message.role !== "tool") { result.push(message); continue; @@ -146,6 +167,40 @@ export async function extractToolMediaAsUserMessagesFromModelMessages( return result; } +/** + * Replaces evicted synthetic tool-media parts in a user message with one + * bounded omission note (r34). Only parts identified by the pass-1 scan are + * touched; genuine user uploads never enter the evicted set. + */ +function evictSyntheticMediaParts( + message: Extract, + evictedSyntheticParts: ReadonlySet +): ModelMessage { + if (evictedSyntheticParts.size === 0 || !Array.isArray(message.content)) return message; + let evictedHere = 0; + for (const part of message.content) { + if (evictedSyntheticParts.has(part)) evictedHere++; + } + if (evictedHere === 0) return message; + + const newContent: Array = []; + let noteInserted = false; + for (const part of message.content) { + if (!evictedSyntheticParts.has(part)) { + newContent.push(part); + continue; + } + if (!noteInserted) { + noteInserted = true; + newContent.push({ + type: "text", + text: createOmittedToolAttachmentText(evictedHere), + }); + } + } + return { ...message, content: newContent }; +} + async function createSyntheticUserMessage( keptAttachments: ExtractedToolAttachment[], omittedCount: number, @@ -174,6 +229,9 @@ async function createSyntheticUserMessage( type: "image", image: preparedAttachment.data, mediaType: preparedAttachment.mediaType, + // Marks the part evictable under the request-wide media cap (r34) — + // matters when these messages re-enter the transform (replay). + providerOptions: SYNTHETIC_TOOL_MEDIA_PART_METADATA, }); continue; } @@ -182,6 +240,7 @@ async function createSyntheticUserMessage( type: "file", data: preparedAttachment.data, mediaType: preparedAttachment.mediaType, + providerOptions: SYNTHETIC_TOOL_MEDIA_PART_METADATA, ...(preparedAttachment.filename ? { filename: sanitizeAnthropicDocumentFilename(preparedAttachment.filename), diff --git a/src/node/utils/messages/toolResultAttachments.ts b/src/node/utils/messages/toolResultAttachments.ts index 7ee0e07d11..c3cc6a64fd 100644 --- a/src/node/utils/messages/toolResultAttachments.ts +++ b/src/node/utils/messages/toolResultAttachments.ts @@ -621,6 +621,39 @@ export function createOmittedToolAttachmentText(omitted: number): string { return `[${omitted} extracted media attachment(s) omitted: request-wide cap of ${MAX_EXTRACTED_TOOL_MEDIA_PARTS_PER_REQUEST} media parts reached; newest attachments are kept]`; } +/** + * Part-level marker for synthetic tool-media parts (media extracted out of + * tool results into synthetic user messages). Stamped as UI-part + * providerMetadata, which convertToModelMessages forwards verbatim as + * ModelMessage part providerOptions; provider SDKs only read their own + * namespace, so the `mux` namespace passes through harmlessly. + * + * Why: the request-wide media cap must treat these parts as EVICTABLE + * (newest-first policy — the model usually needs its latest screenshot, not + * its oldest), unlike genuine user uploads which are reserved and never + * evicted (r34). Tool outputs cannot fabricate provider-bound image/file + * parts — only these transforms create them — so the marker is not spoofable + * through tool results. + */ +// Literal type keeps the constant assignable to both UI-part providerMetadata +// (Record>) and ModelMessage part +// providerOptions (JSONValue-constrained). +export const SYNTHETIC_TOOL_MEDIA_PART_METADATA: { mux: { syntheticToolMedia: true } } = { + mux: { syntheticToolMedia: true }, +}; + +/** Recognizes ModelMessage image/file parts carrying the synthetic tool-media marker. */ +export function isSyntheticToolMediaPart(part: unknown): boolean { + if (typeof part !== "object" || part === null) return false; + const record = part as { type?: unknown; providerOptions?: unknown }; + if (record.type !== "image" && record.type !== "file") return false; + const providerOptions = record.providerOptions; + if (typeof providerOptions !== "object" || providerOptions === null) return false; + const muxNamespace = (providerOptions as Record).mux; + if (typeof muxNamespace !== "object" || muxNamespace === null) return false; + return (muxNamespace as Record).syntheticToolMedia === true; +} + const ATTACHMENT_PLACEHOLDER_PREFIX = "[Attachment attached"; /** See coalesceAttachmentPlaceholders: replacement for every placeholder