diff --git a/packages/runtime/src/tool-call-snapshot.ts b/packages/runtime/src/tool-call-snapshot.ts new file mode 100644 index 0000000000..8b1b8b108d --- /dev/null +++ b/packages/runtime/src/tool-call-snapshot.ts @@ -0,0 +1,310 @@ +/* + * 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; + /** + * 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 a12fdce73e..41fadf5ee0 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'; @@ -1128,70 +1132,30 @@ export class ToolRuntime { ? `Tool ${tool.name} is direct-only and cannot run inside exec.` : undefined; const admissionFailure = directOnlyFailure ?? this.admitToolForStep(tool, stepId); - const executionArgs = rawExecutionArgs; - let permissionArgs = executionArgs; - let permissionArgsError: unknown; - if (directOnlyFailure === undefined) { - try { - // A surface that cannot carry a sandbox-boundary request rejects the - // operation before it interprets the requested expansion. Preserve that - // availability contract even when an older caller sends a legacy shape. - const sandboxBoundaryUnavailable = - tool.name === 'request_sandbox_boundary' && - !this.interactionRun() && - (!this.input.createSandboxBoundaryRequest || !this.input.settleSandboxBoundaryRequest); - if (!sandboxBoundaryUnavailable) { - await validateDeclaredToolArgs(tool.parameters, rawExecutionArgs); - } - permissionArgs = tool.permissionArgs - ? snapshotToolArgs( - tool.permissionArgs(structuredClone(executionArgs) as never, { - sessionId: this.input.sessionId, - turnId, - toolCallId: toolUseId, - }), - ) - : executionArgs; - } catch (error) { - permissionArgsError = error; - } - } - // The args written into the `tool_start` event, the persisted `tool_call` - // message and the durable ledger — that is, 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 here. 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 `permissionArgs`, and the approval - // scope key is still computed from the raw call, so this 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. - const persistedArgs = - tool.categoryHint === 'computer_use' - ? snapshotToolArgs(computerUseModelCallArgs(permissionArgs)) - : permissionArgs; - // What the model will read back as its own call. The approval summary is - // the host's projection for deciding a permission, and using it here taught - // the model to call the tool with `approvalClass`, `rememberForTurnAllowed` - // and `windowId` — two fields it does not take and one key in a dialect it - // rejects. Same privacy boundary, names the tool accepts. - // - // 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. - const modelFacingArgs = persistedArgs; + // 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, + executionArgs: rawExecutionArgs, + sessionId: this.input.sessionId, + turnId, + toolCallId: toolUseId, + directOnlyRejected: directOnlyFailure !== undefined, + validationDeferred: sandboxBoundaryUnavailable, + }); + const executionArgs = callArgs.executionArgs; + 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; @@ -1249,20 +1213,25 @@ 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, + 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), }; let callEvent: ToolStartEvent | undefined; const buildCallEvent = (lane: 'dispatch' | 'preflight'): ToolStartEvent => { @@ -1284,19 +1253,7 @@ export class ToolRuntime { const callMsg: ToolCallMessage = { type: 'tool_call', id: toolUseId, - turnId, - ts: now, - toolName: tool.name, - ...activityIdentity, - ...(tool.activityKind ? { activityKind: tool.activityKind } : {}), - ...(tool.displayName ? { displayName: tool.displayName } : {}), - args: structuredClone(persistedArgs), - ...(ctx.providerOptions !== undefined - ? { providerOptions: structuredClone(ctx.providerOptions) } - : {}), - // Persist the same step id the tool_start event carries so the UI - // timeline and post-restart backfill can pair this call with its step. - ...(stepId !== undefined ? { stepId } : {}), + ...buildToolCallCommonFields(callCommonFieldsInput), }; let callMessageAppended = false; const appendCallMessage = async (): Promise => { @@ -3266,55 +3223,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 || @@ -4168,25 +4076,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); -}