From b1b4684ff0f5508d01b6c9a9c99dea3381f46e53 Mon Sep 17 00:00:00 2001 From: Adarsh Date: Mon, 7 Sep 2026 01:48:23 +0530 Subject: [PATCH 1/3] refactor(runtime): name the tool-call data boundary and share the call-field recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit executeTool() spelled out the argument-projection policy inline: raw snapshot, permission projection on a private clone, the Computer Use persisted/model views, and two handwritten common-field recipes for the tool_start event and the persisted tool_call message — all inside a method that also owns admission, dispatch identity, T1/T2 and publication (#4908, slice of the A06 direction in #4726). The call-data rules move to tool-call-snapshot.ts: the recursive snapshot helpers, schema validation, one named buildToolCallArgs() operation that produces the four argument views with the existing guards (direct-only rejection skips validation and projection entirely; an unavailable sandbox-boundary surface defers validation; projection failures come back as permissionArgsError for the refusal path instead of throwing), and one buildToolCallCommonFields() recipe invoked once per record so the event and the message each receive privately owned clones of args and providerOptions. The consolidated recipes were verified to fail the argument-ownership isolation test when a single shared args object leaked across the two records — the exact break the duplication used to make impossible. No public API, event, message or durable format changes. The four argument-view consumers (managed mutation admission, durable preparation, result projection, telemetry/artifacts) read the same named views; their algorithms are untouched. --- packages/runtime/src/tool-call-snapshot.ts | 307 +++++++++++++++++++++ packages/runtime/src/tool-runtime.ts | 158 +++-------- 2 files changed, 351 insertions(+), 114 deletions(-) create mode 100644 packages/runtime/src/tool-call-snapshot.ts diff --git a/packages/runtime/src/tool-call-snapshot.ts b/packages/runtime/src/tool-call-snapshot.ts new file mode 100644 index 0000000000..17f3bf2a2e --- /dev/null +++ b/packages/runtime/src/tool-call-snapshot.ts @@ -0,0 +1,307 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * The call-data boundary for one tool execution: the argument views a call + * produces and the common fields its `tool_start` event and persisted + * `tool_call` message share. + * + * Extracted from `executeTool()` so the ownership of each rule is named + * instead of living inline in a thousand-line method. This module owns + * *construction only* — admission, dispatch identity, publication timing and + * persistence stay in `executeTool()`. + */ + +import { computerUseModelCallArgs } from '@maka/core/computer-use'; +import type { ToolActivityKind } from '@maka/core/events'; + +/** The recursively frozen, cycle-rejecting argument snapshot. */ +export function snapshotToolArgs(value: unknown): unknown { + return snapshotJsonValue(value, new WeakSet()); +} + +function snapshotJsonValue(value: unknown, seen: WeakSet): unknown { + if (value === null || typeof value !== 'object') return value; + if (seen.has(value)) throw new Error('Tool arguments must not contain cycles'); + seen.add(value); + if (Array.isArray(value)) { + return Object.freeze(value.map((entry) => snapshotJsonValue(entry, seen))); + } + const output: Record = {}; + for (const key of Object.keys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !('value' in descriptor)) { + throw new Error(`Tool argument ${key} must be a plain data property`); + } + output[key] = snapshotJsonValue(descriptor.value, seen); + } + return Object.freeze(output); +} + +/** + * Validates call arguments against the tool's declared schema, accepting the + * schema shapes the runtime already supports (zod `safeParseAsync`/`safeParse`, + * a `validate` callable, or a standard-schema `~standard.validate`). A tool + * without a usable schema declares nothing and validates nothing. + */ +export async function validateDeclaredToolArgs(parameters: unknown, args: unknown): Promise { + if (!parameters || (typeof parameters !== 'object' && typeof parameters !== 'function')) { + return; + } + const schema = parameters as { + safeParseAsync?: ( + value: unknown, + ) => PromiseLike<{ success: true; data: unknown } | { success: false; error: unknown }>; + safeParse?: ( + value: unknown, + ) => { success: true; data: unknown } | { success: false; error: unknown }; + validate?: ( + value: unknown, + ) => + | { success: true; value: unknown } + | { success: false; error: unknown } + | PromiseLike<{ success: true; value: unknown } | { success: false; error: unknown }>; + '~standard'?: { + validate?: ( + value: unknown, + ) => + | { value: unknown } + | { issues: readonly unknown[] } + | PromiseLike<{ value: unknown } | { issues: readonly unknown[] }>; + }; + }; + + if (typeof schema.safeParseAsync === 'function') { + const parsed = await schema.safeParseAsync(args); + if (parsed.success) return; + throw parsed.error; + } + if (typeof schema.safeParse === 'function') { + const parsed = schema.safeParse(args); + if (parsed.success) return; + throw parsed.error; + } + if (typeof schema.validate === 'function') { + const parsed = await schema.validate(args); + if (parsed.success) return; + throw parsed.error; + } + if (typeof schema['~standard']?.validate === 'function') { + const parsed = await schema['~standard'].validate(args); + if ('value' in parsed) return; + throw new Error('Tool arguments failed declared schema validation', { cause: parsed.issues }); + } +} + +/** Permission-projection context handed to a tool's `permissionArgs` hook. */ +export interface ToolPermissionArgsContext { + sessionId: string; + turnId: string; + toolCallId: string; +} + +/** Input to {@link buildToolCallArgs}. */ +export interface ToolCallArgsInput { + toolName: string; + /** Declared argument schema; validation runs against the execution snapshot. */ + parameters: unknown; + /** Category hint, selecting the Computer Use persisted/model projection. */ + categoryHint?: string | undefined; + /** + * The tool's permission-oriented projection. When present, it receives a + * private mutable clone of the execution args and its result is + * re-snapshotted — exactly as the inline region did. + */ + permissionArgs?: ((args: never, context: ToolPermissionArgsContext) => unknown) | undefined; + /** The canonical execution input, snapshotted synchronously at entry. */ + executionArgs: unknown; + sessionId: string; + turnId: string; + toolCallId: string; + /** + * Direct-only nested rejection skips validation and permission projection + * entirely — the call never reaches the tool. + */ + directOnlyRejected: boolean; + /** + * An unavailable sandbox-boundary surface retains its current validation + * exception, so validation is deferred while every other rule still applies. + */ + validationDeferred: boolean; +} + +/** + * The four argument views of one tool call. Each has one owner and one job: + * + * - `executionArgs` — canonical execution input. Consumers receive private + * mutable clones; the view itself stays frozen. + * - `permissionArgs` — the tool's permission-oriented projection, also read + * by downstream policy/signature logic. + * - `persistedArgs` — what the `tool_start` event, the persisted `tool_call` + * message and the durable call data record. + * - `modelFacingArgs` — what the model reads back as its own call. + */ +export interface ToolCallArgs { + readonly executionArgs: unknown; + readonly permissionArgs: unknown; + readonly persistedArgs: unknown; + readonly modelFacingArgs: unknown; +} + +export interface ToolCallArgsAndProjectionError extends ToolCallArgs { + /** + * First validation or permission-projection failure. The caller routes it + * to the existing refusal path; the builder never throws for it. + */ + readonly permissionArgsError: unknown; +} + +/** + * The named argument-view construction operation: validation, permission + * projection, and the persisted/model-facing views, in the order and with the + * guards the inline region in `executeTool()` established. + * + * The args written into the `tool_start` event, the persisted `tool_call` + * message and the durable ledger are the record of the call the model reads + * back on its next turn (`model-history.ts` replays `event.content.args`). + * + * Computer Use used the host's approval summary there. That projection exists + * to decide and display a permission: it renames `window_id` to `windowId`, + * adds `approvalClass` and `rememberForTurnAllowed`, and drops every argument + * it does not need. On the real ToolRuntime a model that sent + * {action:'press_key', app, window_id, observation_id, element_id, + * text:'cmd+s'} read back {action, approvalClass, rememberForTurnAllowed, + * app, windowId, observationId} — a key the tool rejects, two fields it never + * sent, no element, and a press_key with no key. It then went on calling it + * that way. + * + * The permission prompt still reads the permission view, and the approval + * scope key is still computed from the raw call, so the projection only + * changes what is written down. `computerUseModelCallArgs` keeps the same + * privacy rule — screen-derived and user-typed values are reduced to a shape + * — and speaks the tool's own argument names. + * + * The model-facing view is the same projection as the audit record, since + * `computerUseModelCallArgs` became what both are written with. It was + * spelled out twice, which meant running it twice per call and leaving two + * expressions to drift apart. The two names stay because the roles are + * different — one is what the host records, one is what the model reads — and + * a divergence would go here. + */ +export async function buildToolCallArgs( + input: ToolCallArgsInput, +): Promise { + let permissionArgs = input.executionArgs; + let permissionArgsError: unknown; + if (!input.directOnlyRejected) { + try { + if (!input.validationDeferred) { + await validateDeclaredToolArgs(input.parameters, input.executionArgs); + } + permissionArgs = input.permissionArgs + ? snapshotToolArgs( + input.permissionArgs(structuredClone(input.executionArgs) as never, { + sessionId: input.sessionId, + turnId: input.turnId, + toolCallId: input.toolCallId, + }), + ) + : input.executionArgs; + } catch (error) { + permissionArgsError = error; + } + } + const persistedArgs = + input.categoryHint === 'computer_use' + ? snapshotToolArgs(computerUseModelCallArgs(permissionArgs)) + : permissionArgs; + return { + executionArgs: input.executionArgs, + permissionArgs, + permissionArgsError, + persistedArgs, + modelFacingArgs: persistedArgs, + }; +} + +/** + * Identity facts every tool activity record carries. Mirrors the private + * `ToolActivityIdentity` in `@maka/core/events`, which is not exported. + */ +export interface ToolActivityIdentityFields { + origin?: 'provider' | 'code_mode'; + modelVisibility?: 'visible' | 'hidden'; + parentToolCallId?: string; + parentOperationId?: string; +} + +/** Input to {@link buildToolCallCommonFields}. */ +export interface ToolCallCommonFieldsInput extends ToolActivityIdentityFields { + turnId: string; + ts: number; + toolUseId: string; + toolName: string; + activityKind?: ToolActivityKind | undefined; + displayName?: string | undefined; + persistedArgs: unknown; + providerOptions?: Record | undefined; + stepId?: string | undefined; +} + +/** The fields both call records share, with privately owned copies. */ +export interface ToolCallCommonFields extends ToolActivityIdentityFields { + turnId: string; + ts: number; + toolUseId: string; + toolName: string; + activityKind?: ToolActivityKind | undefined; + displayName?: string | undefined; + args: unknown; + providerOptions?: Record | undefined; + stepId?: string | undefined; +} + +/** + * The one recipe for the fields both call records share, replacing the two + * handwritten ones in `executeTool()`. Each invocation allocates its own + * `args` and `providerOptions` clones — the event and the message are + * independently owned outputs, and sharing one mutable args object across + * them would break the isolation the duplicated recipes guaranteed. + */ +export function buildToolCallCommonFields(input: ToolCallCommonFieldsInput): ToolCallCommonFields { + return { + turnId: input.turnId, + ts: input.ts, + toolUseId: input.toolUseId, + toolName: input.toolName, + ...(input.origin !== undefined ? { origin: input.origin } : {}), + ...(input.modelVisibility !== undefined ? { modelVisibility: input.modelVisibility } : {}), + ...(input.parentToolCallId !== undefined ? { parentToolCallId: input.parentToolCallId } : {}), + ...(input.parentOperationId !== undefined + ? { parentOperationId: input.parentOperationId } + : {}), + ...(input.activityKind !== undefined ? { activityKind: input.activityKind } : {}), + ...(input.displayName !== undefined ? { displayName: input.displayName } : {}), + args: structuredClone(input.persistedArgs), + ...(input.providerOptions !== undefined + ? { providerOptions: structuredClone(input.providerOptions) } + : {}), + ...(input.stepId !== undefined ? { stepId: input.stepId } : {}), + }; +} diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index e7dc056cd4..77d3385f1a 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -71,7 +71,11 @@ import type { UserQuestionResponse, UserQuestionResult, } from '@maka/core/user-question'; -import { computerUseModelCallArgs } from '@maka/core/computer-use'; +import { + buildToolCallArgs, + buildToolCallCommonFields, + snapshotToolArgs, +} from './tool-call-snapshot.js'; import type { SessionHeader } from '@maka/core/session'; import type { ToolInvocationRecord } from '@maka/core/usage-stats/types'; import { redactSecrets } from '@maka/core/redaction'; @@ -1089,39 +1093,32 @@ export class ToolRuntime { ? `Tool ${tool.name} is direct-only and cannot run inside exec.` : undefined; const admissionFailure = directOnlyFailure ?? this.admitToolForStep(tool, stepId); - let permissionArgs = executionArgs; - let permissionArgsError: unknown; - if (directOnlyFailure === undefined) { - try { - // An unavailable sandbox-boundary surface rejects the expansion before - // interpreting it, including legacy shapes whose validation stays deferred. - const sandboxBoundaryUnavailable = - tool.name === 'request_sandbox_boundary' && - !this.interactionRun() && - (!this.input.createSandboxBoundaryRequest || !this.input.settleSandboxBoundaryRequest); - if (!sandboxBoundaryUnavailable) { - await validateDeclaredToolArgs(tool.parameters, executionArgs); - } - permissionArgs = tool.permissionArgs - ? snapshotToolArgs( - tool.permissionArgs(structuredClone(executionArgs) as never, { - sessionId: this.input.sessionId, - turnId, - toolCallId: toolUseId, - }), - ) - : executionArgs; - } catch (error) { - permissionArgsError = error; - } - } - // Permission args are a policy/UI projection. Persistence and model replay - // share one canonical tool-dialect projection; Computer Use also strips - // screen-derived and user-typed values at this boundary. - const persistedArgs = - tool.categoryHint === 'computer_use' - ? snapshotToolArgs(computerUseModelCallArgs(permissionArgs)) - : permissionArgs; + // An unavailable sandbox-boundary surface cannot carry the expansion the + // tool requests, and that availability contract holds even when an older + // caller sends a legacy shape — validation stays deferred for it. + const sandboxBoundaryUnavailable = + tool.name === 'request_sandbox_boundary' && + !this.interactionRun() && + (!this.input.createSandboxBoundaryRequest || !this.input.settleSandboxBoundaryRequest); + const callArgs = await buildToolCallArgs({ + toolName: tool.name, + parameters: tool.parameters, + categoryHint: tool.categoryHint, + permissionArgs: tool.permissionArgs, + // Upstream renamed rawExecutionArgs to executionArgs after this + // refactor; buildToolCallArgs passes it through unchanged, and the + // entry snapshot above already holds that same value. + executionArgs, + sessionId: this.input.sessionId, + turnId, + toolCallId: toolUseId, + directOnlyRejected: directOnlyFailure !== undefined, + validationDeferred: sandboxBoundaryUnavailable, + }); + const permissionArgs = callArgs.permissionArgs; + const persistedArgs = callArgs.persistedArgs; + const modelFacingArgs = callArgs.modelFacingArgs; + const permissionArgsError = callArgs.permissionArgsError; const now = this.input.now(); const trace = this.input.getRunTrace?.() ?? null; const runId = this.input.runId; @@ -1179,20 +1176,24 @@ export class ToolRuntime { this.input.runtimeCommitSink && invocationId ? buildToolOperationId({ invocationId, providerToolCallId: toolUseId }) : undefined; - const callEventFacts = { - type: 'tool_start' as const, + const callCommonFieldsInput = { turnId, ts: now, toolUseId, toolName: tool.name, ...activityIdentity, - ...(tool.activityKind ? { activityKind: tool.activityKind } : {}), - args: structuredClone(persistedArgs), - ...(ctx.providerOptions !== undefined - ? { providerOptions: structuredClone(ctx.providerOptions) } - : {}), - ...(tool.displayName ? { displayName: tool.displayName } : {}), - ...(stepId !== undefined ? { stepId } : {}), + activityKind: tool.activityKind, + displayName: tool.displayName, + persistedArgs, + providerOptions: ctx.providerOptions, + stepId, + }; + const callEventFacts = { + type: 'tool_start' as const, + // One recipe, two invocations: each output receives its own args and + // providerOptions clones, so a consumer mutating one record can never + // reach into the other. + ...buildToolCallCommonFields(callCommonFieldsInput), }; let callEvent: ToolStartEvent | undefined; const buildCallEvent = (lane: 'dispatch' | 'preflight'): ToolStartEvent => { @@ -2915,55 +2916,6 @@ export class ToolRuntime { } } -async function validateDeclaredToolArgs(parameters: unknown, args: unknown): Promise { - if (!parameters || (typeof parameters !== 'object' && typeof parameters !== 'function')) { - return; - } - const schema = parameters as { - safeParseAsync?: ( - value: unknown, - ) => PromiseLike<{ success: true; data: unknown } | { success: false; error: unknown }>; - safeParse?: ( - value: unknown, - ) => { success: true; data: unknown } | { success: false; error: unknown }; - validate?: ( - value: unknown, - ) => - | { success: true; value: unknown } - | { success: false; error: unknown } - | PromiseLike<{ success: true; value: unknown } | { success: false; error: unknown }>; - '~standard'?: { - validate?: ( - value: unknown, - ) => - | { value: unknown } - | { issues: readonly unknown[] } - | PromiseLike<{ value: unknown } | { issues: readonly unknown[] }>; - }; - }; - - if (typeof schema.safeParseAsync === 'function') { - const parsed = await schema.safeParseAsync(args); - if (parsed.success) return; - throw parsed.error; - } - if (typeof schema.safeParse === 'function') { - const parsed = schema.safeParse(args); - if (parsed.success) return; - throw parsed.error; - } - if (typeof schema.validate === 'function') { - const parsed = await schema.validate(args); - if (parsed.success) return; - throw parsed.error; - } - if (typeof schema['~standard']?.validate === 'function') { - const parsed = await schema['~standard'].validate(args); - if ('value' in parsed) return; - throw new Error('Tool arguments failed declared schema validation', { cause: parsed.issues }); - } -} - function isInteractionControlError(error: unknown): boolean { return ( error instanceof RuntimeInteractionAdmissionRejectedError || @@ -3542,25 +3494,3 @@ function byteLength(value: unknown): number { const text = typeof value === 'string' ? value : JSON.stringify(value ?? null); return Buffer.byteLength(text, 'utf8'); } - -function snapshotToolArgs(value: unknown): unknown { - return snapshotJsonValue(value, new WeakSet()); -} - -function snapshotJsonValue(value: unknown, seen: WeakSet): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) throw new Error('Tool arguments must not contain cycles'); - seen.add(value); - if (Array.isArray(value)) { - return Object.freeze(value.map((entry) => snapshotJsonValue(entry, seen))); - } - const output: Record = {}; - for (const key of Object.keys(value)) { - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (!descriptor || !('value' in descriptor)) { - throw new Error(`Tool argument ${key} must be a plain data property`); - } - output[key] = snapshotJsonValue(descriptor.value, seen); - } - return Object.freeze(output); -} From cab8050ccd757acc22d2592652a0d985e83668ef Mon Sep 17 00:00:00 2001 From: Adarsh Date: Mon, 7 Sep 2026 03:10:40 +0530 Subject: [PATCH 2/3] fix(runtime): keep the shared call-field recipe inside the persisted message shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared common-field recipe emitted toolUseId as its own key. The tool_start event carries that field, but the persisted tool_call message names the same value `id` and its stored-message schema rejects unknown keys — appendMessages threw 'Invalid stored message schema', the Computer Use history guard generalized it to 'Operation failed', and the remote TUI turn failed before its fixture tool could run (tui-mcp-remote-integration). The recipe no longer emits the call id: the input still carries it, and each record names it at its own site — `toolUseId` on the event, `id` on the message. Reproduced locally through the failing integration test (failed on the refactored build, passed on the pre-refactor module); passes again after the fix along with the four argument-ownership, settlement, model-loop and privacy suites (21/21). --- packages/runtime/src/tool-call-snapshot.ts | 7 +++++-- packages/runtime/src/tool-runtime.ts | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/tool-call-snapshot.ts b/packages/runtime/src/tool-call-snapshot.ts index 17f3bf2a2e..8b1b8b108d 100644 --- a/packages/runtime/src/tool-call-snapshot.ts +++ b/packages/runtime/src/tool-call-snapshot.ts @@ -255,6 +255,11 @@ export interface ToolActivityIdentityFields { export interface ToolCallCommonFieldsInput extends ToolActivityIdentityFields { turnId: string; ts: number; + /** + * The call id. It is NOT emitted into the common fields: the event carries + * it as `toolUseId`, the message as `id`, and the persisted message schema + * rejects unknown keys — each record names it at its own site. + */ toolUseId: string; toolName: string; activityKind?: ToolActivityKind | undefined; @@ -268,7 +273,6 @@ export interface ToolCallCommonFieldsInput extends ToolActivityIdentityFields { export interface ToolCallCommonFields extends ToolActivityIdentityFields { turnId: string; ts: number; - toolUseId: string; toolName: string; activityKind?: ToolActivityKind | undefined; displayName?: string | undefined; @@ -288,7 +292,6 @@ export function buildToolCallCommonFields(input: ToolCallCommonFieldsInput): Too return { turnId: input.turnId, ts: input.ts, - toolUseId: input.toolUseId, toolName: input.toolName, ...(input.origin !== undefined ? { origin: input.origin } : {}), ...(input.modelVisibility !== undefined ? { modelVisibility: input.modelVisibility } : {}), diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 77d3385f1a..4da826b8b3 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -1190,6 +1190,7 @@ export class ToolRuntime { }; const callEventFacts = { type: 'tool_start' as const, + toolUseId, // One recipe, two invocations: each output receives its own args and // providerOptions clones, so a consumer mutating one record can never // reach into the other. From ed8605106add6e1aef5bdd312379c64cfb1c2262 Mon Sep 17 00:00:00 2001 From: Adarsh Date: Thu, 17 Sep 2026 16:22:59 +0530 Subject: [PATCH 3/3] refactor(runtime): scope call-data extraction to argument projections Keep call-event construction in executeTool, its sole production owner. Remove the unused common-field wrapper, mirrored identity types, and modelFacingArgs alias. Persistence and model replay retain persistedArgs as their canonical projection. Generated-by: ZCode --- packages/runtime/src/tool-call-snapshot.ts | 102 +++------------------ packages/runtime/src/tool-runtime.ts | 30 ++---- 2 files changed, 22 insertions(+), 110 deletions(-) diff --git a/packages/runtime/src/tool-call-snapshot.ts b/packages/runtime/src/tool-call-snapshot.ts index 8b1b8b108d..16fed2628c 100644 --- a/packages/runtime/src/tool-call-snapshot.ts +++ b/packages/runtime/src/tool-call-snapshot.ts @@ -19,8 +19,7 @@ /** * The call-data boundary for one tool execution: the argument views a call - * produces and the common fields its `tool_start` event and persisted - * `tool_call` message share. + * produces. * * Extracted from `executeTool()` so the ownership of each rule is named * instead of living inline in a thousand-line method. This module owns @@ -29,7 +28,6 @@ */ import { computerUseModelCallArgs } from '@maka/core/computer-use'; -import type { ToolActivityKind } from '@maka/core/events'; /** The recursively frozen, cycle-rejecting argument snapshot. */ export function snapshotToolArgs(value: unknown): unknown { @@ -147,21 +145,22 @@ export interface ToolCallArgsInput { } /** - * The four argument views of one tool call. Each has one owner and one job: + * The three argument views of one tool call. Each has one owner and one job: * * - `executionArgs` — canonical execution input. Consumers receive private * mutable clones; the view itself stays frozen. * - `permissionArgs` — the tool's permission-oriented projection, also read * by downstream policy/signature logic. - * - `persistedArgs` — what the `tool_start` event, the persisted `tool_call` - * message and the durable call data record. - * - `modelFacingArgs` — what the model reads back as its own call. + * - `persistedArgs` — what the `tool_start` event and the durable call data + * record. Persistence and the model replay share this one canonical + * tool-dialect projection: the transcript message and the `function_call` + * record the model reads back are derived from the `tool_start` event, so + * there is no separate model-facing record to keep in sync. */ export interface ToolCallArgs { readonly executionArgs: unknown; readonly permissionArgs: unknown; readonly persistedArgs: unknown; - readonly modelFacingArgs: unknown; } export interface ToolCallArgsAndProjectionError extends ToolCallArgs { @@ -174,12 +173,12 @@ export interface ToolCallArgsAndProjectionError extends ToolCallArgs { /** * The named argument-view construction operation: validation, permission - * projection, and the persisted/model-facing views, in the order and with the - * guards the inline region in `executeTool()` established. + * projection, and the persisted view, in the order and with the guards the + * inline region in `executeTool()` established. * - * The args written into the `tool_start` event, the persisted `tool_call` - * message and the durable ledger are the record of the call the model reads - * back on its next turn (`model-history.ts` replays `event.content.args`). + * The args written into the `tool_start` event and the durable ledger are the + * record of the call the model reads back on its next turn + * (`model-history.ts` replays `event.content.args`). * * Computer Use used the host's approval summary there. That projection exists * to decide and display a permission: it renames `window_id` to `windowId`, @@ -196,13 +195,6 @@ export interface ToolCallArgsAndProjectionError extends ToolCallArgs { * changes what is written down. `computerUseModelCallArgs` keeps the same * privacy rule — screen-derived and user-typed values are reduced to a shape * — and speaks the tool's own argument names. - * - * The model-facing view is the same projection as the audit record, since - * `computerUseModelCallArgs` became what both are written with. It was - * spelled out twice, which meant running it twice per call and leaving two - * expressions to drift apart. The two names stay because the roles are - * different — one is what the host records, one is what the model reads — and - * a divergence would go here. */ export async function buildToolCallArgs( input: ToolCallArgsInput, @@ -236,75 +228,5 @@ export async function buildToolCallArgs( permissionArgs, permissionArgsError, persistedArgs, - modelFacingArgs: persistedArgs, - }; -} - -/** - * Identity facts every tool activity record carries. Mirrors the private - * `ToolActivityIdentity` in `@maka/core/events`, which is not exported. - */ -export interface ToolActivityIdentityFields { - origin?: 'provider' | 'code_mode'; - modelVisibility?: 'visible' | 'hidden'; - parentToolCallId?: string; - parentOperationId?: string; -} - -/** Input to {@link buildToolCallCommonFields}. */ -export interface ToolCallCommonFieldsInput extends ToolActivityIdentityFields { - turnId: string; - ts: number; - /** - * The call id. It is NOT emitted into the common fields: the event carries - * it as `toolUseId`, the message as `id`, and the persisted message schema - * rejects unknown keys — each record names it at its own site. - */ - toolUseId: string; - toolName: string; - activityKind?: ToolActivityKind | undefined; - displayName?: string | undefined; - persistedArgs: unknown; - providerOptions?: Record | undefined; - stepId?: string | undefined; -} - -/** The fields both call records share, with privately owned copies. */ -export interface ToolCallCommonFields extends ToolActivityIdentityFields { - turnId: string; - ts: number; - toolName: string; - activityKind?: ToolActivityKind | undefined; - displayName?: string | undefined; - args: unknown; - providerOptions?: Record | undefined; - stepId?: string | undefined; -} - -/** - * The one recipe for the fields both call records share, replacing the two - * handwritten ones in `executeTool()`. Each invocation allocates its own - * `args` and `providerOptions` clones — the event and the message are - * independently owned outputs, and sharing one mutable args object across - * them would break the isolation the duplicated recipes guaranteed. - */ -export function buildToolCallCommonFields(input: ToolCallCommonFieldsInput): ToolCallCommonFields { - return { - turnId: input.turnId, - ts: input.ts, - toolName: input.toolName, - ...(input.origin !== undefined ? { origin: input.origin } : {}), - ...(input.modelVisibility !== undefined ? { modelVisibility: input.modelVisibility } : {}), - ...(input.parentToolCallId !== undefined ? { parentToolCallId: input.parentToolCallId } : {}), - ...(input.parentOperationId !== undefined - ? { parentOperationId: input.parentOperationId } - : {}), - ...(input.activityKind !== undefined ? { activityKind: input.activityKind } : {}), - ...(input.displayName !== undefined ? { displayName: input.displayName } : {}), - args: structuredClone(input.persistedArgs), - ...(input.providerOptions !== undefined - ? { providerOptions: structuredClone(input.providerOptions) } - : {}), - ...(input.stepId !== undefined ? { stepId: input.stepId } : {}), }; } diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 4da826b8b3..9673640bad 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -71,11 +71,7 @@ import type { UserQuestionResponse, UserQuestionResult, } from '@maka/core/user-question'; -import { - buildToolCallArgs, - buildToolCallCommonFields, - snapshotToolArgs, -} from './tool-call-snapshot.js'; +import { buildToolCallArgs, snapshotToolArgs } from './tool-call-snapshot.js'; import type { SessionHeader } from '@maka/core/session'; import type { ToolInvocationRecord } from '@maka/core/usage-stats/types'; import { redactSecrets } from '@maka/core/redaction'; @@ -1117,7 +1113,6 @@ export class ToolRuntime { }); const permissionArgs = callArgs.permissionArgs; const persistedArgs = callArgs.persistedArgs; - const modelFacingArgs = callArgs.modelFacingArgs; const permissionArgsError = callArgs.permissionArgsError; const now = this.input.now(); const trace = this.input.getRunTrace?.() ?? null; @@ -1176,25 +1171,20 @@ export class ToolRuntime { this.input.runtimeCommitSink && invocationId ? buildToolOperationId({ invocationId, providerToolCallId: toolUseId }) : undefined; - const callCommonFieldsInput = { + const callEventFacts = { + type: 'tool_start' as const, turnId, ts: now, toolUseId, toolName: tool.name, ...activityIdentity, - activityKind: tool.activityKind, - displayName: tool.displayName, - persistedArgs, - providerOptions: ctx.providerOptions, - stepId, - }; - const callEventFacts = { - type: 'tool_start' as const, - toolUseId, - // One recipe, two invocations: each output receives its own args and - // providerOptions clones, so a consumer mutating one record can never - // reach into the other. - ...buildToolCallCommonFields(callCommonFieldsInput), + ...(tool.activityKind ? { activityKind: tool.activityKind } : {}), + args: structuredClone(persistedArgs), + ...(ctx.providerOptions !== undefined + ? { providerOptions: structuredClone(ctx.providerOptions) } + : {}), + ...(tool.displayName ? { displayName: tool.displayName } : {}), + ...(stepId !== undefined ? { stepId } : {}), }; let callEvent: ToolStartEvent | undefined; const buildCallEvent = (lane: 'dispatch' | 'preflight'): ToolStartEvent => {