From 39214a7cb0a9153b08d24c880ff23a220dd63695 Mon Sep 17 00:00:00 2001 From: canblmz1 <116688414+canblmz1@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:20:42 +0300 Subject: [PATCH 1/4] fix(runtime): gate tool execution on raw stream completion --- .../src/__tests__/ai-sdk-backend.test.ts | 2 + ...length-cutoff-tool-execution-repro.test.ts | 609 ++++++++++++++++++ .../tool-call-execution-guard.test.ts | 259 ++++++++ packages/runtime/src/ai-sdk-backend.ts | 92 ++- packages/runtime/src/model-adapter.ts | 26 +- packages/runtime/src/model-protocol.ts | 44 ++ .../runtime/src/tool-call-execution-guard.ts | 273 ++++++++ 7 files changed, 1301 insertions(+), 4 deletions(-) create mode 100644 packages/runtime/src/__tests__/length-cutoff-tool-execution-repro.test.ts create mode 100644 packages/runtime/src/__tests__/tool-call-execution-guard.test.ts create mode 100644 packages/runtime/src/tool-call-execution-guard.ts diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index cbf8a5e2c9..517ca2b7d1 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -10843,6 +10843,7 @@ describe('AiSdkBackend RunTrace', () => { request: { messages: [] }, continuation: 'none', }), + toolCallSafety: Promise.resolve({ hadRawArgumentEvidence: false, decisions: new Map() }), }; }; @@ -13546,6 +13547,7 @@ describe('AiSdkBackend thinking persistence', () => { request: { messages: [] }, continuation: 'none', }), + toolCallSafety: Promise.resolve({ hadRawArgumentEvidence: false, decisions: new Map() }), }); for await (const event of backend.send({ turnId: 'turn-1', text: 'hi', context: [] })) { diff --git a/packages/runtime/src/__tests__/length-cutoff-tool-execution-repro.test.ts b/packages/runtime/src/__tests__/length-cutoff-tool-execution-repro.test.ts new file mode 100644 index 0000000000..001927f775 --- /dev/null +++ b/packages/runtime/src/__tests__/length-cutoff-tool-execution-repro.test.ts @@ -0,0 +1,609 @@ +/* + * 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. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import type { LanguageModelV4StreamPart } from '@ai-sdk/provider'; +import type { BackendSendInput } from '@maka/core/backend-types'; +import type { SessionEvent } from '@maka/core/events'; +import type { LlmConnection } from '@maka/core/llm-connections'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; +import type { SessionHeader } from '@maka/core/session'; +import { MockLanguageModelV4, simulateReadableStream } from 'ai/test'; +import { z } from 'zod'; + +import { createSessionEventMapMemory, mapSessionEventToRuntimeEvent } from '../ai-sdk-flow.js'; +import type { InvocationContext } from '../invocation-context.js'; +import type { MakaTool } from '../tool-runtime.js'; +import { createTestAiSdkBackend } from './execution-boundary-test-helpers.js'; + +/** + * Production-path regression coverage for the execution boundary introduced by + * tool-call-execution-guard.ts. Every assertion reaches the real + * AiSdkBackend -> ModelAdapter -> ToolRuntime settlement path and checks the + * irreversible result (zero executions or exactly one), rather than restating + * the guard implementation. + */ + +function header(): SessionHeader { + return { + id: 'session-1', + workspaceRoot: '/tmp/maka-repro', + cwd: '/tmp/maka-repro', + createdAt: 1, + lastUsedAt: 1, + name: 'Repro', + titleIsManual: true, + isFlagged: false, + labels: [], + isArchived: false, + status: 'active', + statusUpdatedAt: 1, + hasUnread: false, + backend: 'ai-sdk', + llmConnectionSlug: 'anthropic-main', + connectionLocked: true, + model: 'claude-sonnet-4-5-20250929', + permissionMode: 'ask', + schemaVersion: 1, + }; +} + +function connection(): LlmConnection { + return { + slug: 'anthropic-main', + name: 'Anthropic', + providerType: 'anthropic', + defaultModel: 'claude-sonnet-4-5-20250929', + enabled: true, + createdAt: 1, + updatedAt: 1, + }; +} + +function idGenerator(): () => string { + let index = 0; + return () => `id-${++index}`; +} + +function monotonicClock(): () => number { + let value = 1_000; + return () => ++value; +} + +function durableTurnHarness(turnId: string, text: string) { + const runId = 'run-1'; + const invocationId = 'invocation-1'; + const anchor: RuntimeEvent = { + id: `runtime-user-${turnId}`, + invocationId, + runId, + sessionId: 'session-1', + turnId, + ts: 1, + partial: false, + role: 'user', + author: 'user', + content: { kind: 'text', text }, + }; + const ledger: RuntimeEvent[] = [anchor]; + const memory = createSessionEventMapMemory(); + const ctx: InvocationContext = { + sessionId: 'session-1', + invocationId, + runId, + turnId, + source: 'desktop', + startedAt: 1, + request: { + sessionId: 'session-1', + turnId, + text, + source: 'desktop', + initialRuntimeEvent: anchor, + }, + newId: idGenerator(), + now: monotonicClock(), + }; + return { + anchor, + ledger, + loadTurnRuntimeEvents: async (requestedTurnId: string) => + ledger.filter((event) => event.turnId === requestedTurnId), + input: (overrides: Partial = {}): BackendSendInput => ({ + turnId, + text, + context: [], + headAnchorRuntimeEvent: anchor, + ...overrides, + }), + record: (event: SessionEvent): void => { + const mapped = mapSessionEventToRuntimeEvent(event, ctx, memory); + if (mapped.partial !== true && mapped.content?.kind !== 'error') ledger.push(mapped); + }, + }; +} + +async function drainDurably( + iterable: AsyncIterable, + durable: ReturnType, +): Promise { + const events: SessionEvent[] = []; + for await (const event of iterable) { + durable.record(event); + events.push(event); + } + return events; +} + +const ZERO_USAGE = { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, +}; + +function makeGate(): { promise: Promise; release: () => void } { + let release!: () => void; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + +function hangingProviderStream( + chunks: readonly LanguageModelV4StreamPart[], + signal: AbortSignal | undefined, +): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + const abort = () => controller.error(signal?.reason ?? new Error('aborted')); + if (signal?.aborted) abort(); + else signal?.addEventListener('abort', abort, { once: true }); + }, + }); +} + +type UnifiedFinishReason = + | 'length' + | 'stop' + | 'tool-calls' + | 'content-filter' + | 'error' + | 'other'; + +type FinishReason = { + unified: UnifiedFinishReason; + raw: string | undefined; +}; + +function doneChunks(): LanguageModelV4StreamPart[] { + return [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-final' }, + { type: 'text-delta', id: 'text-final', delta: 'done' }, + { type: 'text-end', id: 'text-final' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: ZERO_USAGE, + }, + ]; +} + +function twoStepModel(firstChunks: LanguageModelV4StreamPart[]): MockLanguageModelV4 { + let calls = 0; + return new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + return { + stream: simulateReadableStream({ + chunks: calls === 1 ? firstChunks : doneChunks(), + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); +} + +function toolCallChunks( + delivery: 'incremental' | 'atomic', + finishReason: FinishReason, + options: { + rawId?: string; + resolvedId?: string; + rawToolName?: string; + resolvedToolName?: string; + rawInput?: unknown; + projectedInput?: unknown; + } = {}, +): LanguageModelV4StreamPart[] { + const rawId = options.rawId ?? 'call-1'; + const resolvedId = options.resolvedId ?? rawId; + const rawToolName = options.rawToolName ?? 'Write'; + const resolvedToolName = options.resolvedToolName ?? rawToolName; + const rawInput = options.rawInput ?? { path: 'notes.md', content: 'hello from the model' }; + const projectedInput = options.projectedInput ?? rawInput; + const chunks: LanguageModelV4StreamPart[] = [ + { type: 'stream-start', warnings: [] }, + { type: 'tool-input-start', id: rawId, toolName: rawToolName }, + ]; + if (delivery === 'incremental') { + chunks.push({ type: 'tool-input-delta', id: rawId, delta: JSON.stringify(rawInput) }); + } + chunks.push( + { type: 'tool-input-end', id: rawId }, + { + type: 'tool-call', + toolCallId: resolvedId, + toolName: resolvedToolName, + input: JSON.stringify(projectedInput), + }, + { type: 'finish', finishReason, usage: ZERO_USAGE }, + ); + return chunks; +} + +function writeTool(onExecute: (input: unknown) => void): MakaTool { + return { + name: 'Write', + description: 'Write file contents', + parameters: z.object({ path: z.string(), content: z.string() }), + impl: async (input) => { + onExecute(input); + return { ok: true }; + }, + }; +} + +function notifyTool(onExecute: (input: unknown) => void): MakaTool { + return { + name: 'Notify', + description: 'Send a notification', + parameters: z.object({ message: z.string() }), + impl: async (input) => { + onExecute(input); + return { ok: true }; + }, + }; +} + +function shellTool(onExecute: (input: unknown) => void): MakaTool { + return { + name: 'Shell', + description: 'Run a shell command', + parameters: z.object({ command: z.string() }), + impl: async (input) => { + onExecute(input); + return { ok: true }; + }, + }; +} + +async function runModel( + model: MockLanguageModelV4, + tools: MakaTool[], + turnId = 'turn-1', +): Promise { + const durable = durableTurnHarness(turnId, 'write it'); + const backend = createTestAiSdkBackend({ + sessionId: `session-${turnId}`, + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools, + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + newId: idGenerator(), + now: monotonicClock(), + }); + return drainDurably(backend.send(durable.input()), durable); +} + +async function executionCountFor( + delivery: 'incremental' | 'atomic', + finishReason: FinishReason, +): Promise { + let executions = 0; + const model = twoStepModel(toolCallChunks(delivery, finishReason)); + await runModel( + model, + [ + writeTool(() => { + executions += 1; + }), + ], + ).catch(() => []); + return executions; +} + +describe('tool execution safety (real production path)', () => { + for (const delivery of ['incremental', 'atomic'] as const) { + for (const scenario of [ + { unified: 'stop', raw: 'stop', executions: 1 }, + { unified: 'tool-calls', raw: 'tool_calls', executions: 1 }, + { unified: 'length', raw: 'length', executions: 0 }, + { unified: 'content-filter', raw: 'content_filter', executions: 0 }, + { unified: 'error', raw: 'error', executions: 0 }, + { unified: 'other', raw: 'other', executions: 0 }, + ] as const) { + test(`${delivery} + ${scenario.unified}: executes ${scenario.executions} time(s)`, async () => { + const executions = await executionCountFor(delivery, { + unified: scenario.unified, + raw: scenario.raw, + }); + assert.equal(executions, scenario.executions); + }); + } + } + + test('missing terminal event executes zero times', async () => { + let executions = 0; + const chunks = toolCallChunks('incremental', { unified: 'stop', raw: 'stop' }).slice(0, -1); + await runModel( + twoStepModel(chunks), + [ + writeTool(() => { + executions += 1; + }), + ], + ).catch(() => []); + assert.equal(executions, 0); + }); + + test('truncated raw arguments without tool-input-end execute zero times', async () => { + let executions = 0; + const model = twoStepModel([ + { type: 'stream-start', warnings: [] }, + { type: 'tool-input-start', id: 'call-1', toolName: 'Write' }, + { type: 'tool-input-delta', id: 'call-1', delta: '{"path":"notes.md","content":"unf' }, + { + type: 'finish', + finishReason: { unified: 'length', raw: 'length' }, + usage: ZERO_USAGE, + }, + ]); + await runModel( + model, + [ + writeTool(() => { + executions += 1; + }), + ], + ).catch(() => []); + assert.equal(executions, 0); + }); + + test('abort after a complete incremental call but before terminal finish executes zero times', async () => { + let executions = 0; + const durable = durableTurnHarness('turn-abort', 'write it'); + const chunksEnqueued = makeGate(); + const model = new MockLanguageModelV4({ + doStream: async (options) => { + const chunks = toolCallChunks('incremental', { unified: 'stop', raw: 'stop' }).slice(0, -1); + const stream = hangingProviderStream(chunks, options.abortSignal); + chunksEnqueued.release(); + return { stream }; + }, + }); + const backend = createTestAiSdkBackend({ + sessionId: 'session-abort', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [ + writeTool(() => { + executions += 1; + }), + ], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + newId: idGenerator(), + now: monotonicClock(), + }); + const drainPromise = drainDurably(backend.send(durable.input()), durable).catch(() => []); + await chunksEnqueued.promise; + await backend.stop('user_stop'); + await drainPromise; + assert.equal(executions, 0); + }); + + test('concurrent incremental requests sharing call_1 stay isolated', async () => { + let safeExecutions = 0; + let unsafeExecutions = 0; + const safe = runModel( + twoStepModel(toolCallChunks('incremental', { unified: 'stop', raw: 'stop' })), + [ + writeTool(() => { + safeExecutions += 1; + }), + ], + 'turn-safe', + ); + const unsafe = runModel( + twoStepModel(toolCallChunks('incremental', { unified: 'length', raw: 'length' })), + [ + writeTool(() => { + unsafeExecutions += 1; + }), + ], + 'turn-unsafe', + ).catch(() => []); + await unsafe; + await safe; + assert.equal(safeExecutions, 1); + assert.equal(unsafeExecutions, 0); + }); + + test('concurrent atomic requests sharing call_1 stay isolated', async () => { + let safeExecutions = 0; + let unsafeExecutions = 0; + const safe = runModel( + twoStepModel(toolCallChunks('atomic', { unified: 'stop', raw: 'stop' })), + [ + writeTool(() => { + safeExecutions += 1; + }), + ], + 'turn-safe-atomic', + ); + const unsafe = runModel( + twoStepModel(toolCallChunks('atomic', { unified: 'length', raw: 'length' })), + [ + writeTool(() => { + unsafeExecutions += 1; + }), + ], + 'turn-unsafe-atomic', + ).catch(() => []); + await unsafe; + await safe; + assert.equal(safeExecutions, 1); + assert.equal(unsafeExecutions, 0); + }); + + test('raw evidence for call_1 cannot authorize a resolved call_2', async () => { + let executions = 0; + const model = twoStepModel( + toolCallChunks('incremental', { unified: 'stop', raw: 'stop' }, { + rawId: 'call_1', + resolvedId: 'call_2', + }), + ); + await runModel( + model, + [ + writeTool(() => { + executions += 1; + }), + ], + ).catch(() => []); + assert.equal(executions, 0); + }); + + test('an atomic sibling is blocked once the same request contains any raw argument evidence', async () => { + let writeExecutions = 0; + let notifyExecutions = 0; + const writeInput = { path: 'notes.md', content: 'hello' }; + const notifyInput = { message: 'done' }; + const model = twoStepModel([ + { type: 'stream-start', warnings: [] }, + { type: 'tool-input-start', id: 'call-incremental', toolName: 'Write' }, + { + type: 'tool-input-delta', + id: 'call-incremental', + delta: JSON.stringify(writeInput), + }, + { type: 'tool-input-end', id: 'call-incremental' }, + { + type: 'tool-call', + toolCallId: 'call-incremental', + toolName: 'Write', + input: JSON.stringify(writeInput), + }, + { type: 'tool-input-start', id: 'call-atomic', toolName: 'Notify' }, + { type: 'tool-input-end', id: 'call-atomic' }, + { + type: 'tool-call', + toolCallId: 'call-atomic', + toolName: 'Notify', + input: JSON.stringify(notifyInput), + }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: ZERO_USAGE, + }, + ]); + await runModel(model, [ + writeTool(() => { + writeExecutions += 1; + }), + notifyTool(() => { + notifyExecutions += 1; + }), + ]).catch(() => []); + assert.equal(writeExecutions, 1); + assert.equal(notifyExecutions, 0); + }); + + test('proved Write identity cannot be substituted with Shell under the same id', async () => { + let writeExecutions = 0; + let shellExecutions = 0; + const model = twoStepModel( + toolCallChunks('incremental', { unified: 'stop', raw: 'stop' }, { + rawToolName: 'Write', + resolvedToolName: 'Shell', + rawInput: { path: 'notes.md', content: 'hello' }, + projectedInput: { command: 'echo hi' }, + }), + ); + await runModel(model, [ + writeTool(() => { + writeExecutions += 1; + }), + shellTool(() => { + shellExecutions += 1; + }), + ]).catch(() => []); + assert.equal(writeExecutions, 0); + assert.equal(shellExecutions, 0); + }); + + test('ToolRuntime receives the raw-proved object, never divergent projected input', async () => { + let receivedInput: unknown; + let executions = 0; + const model = twoStepModel( + toolCallChunks('incremental', { unified: 'stop', raw: 'stop' }, { + rawInput: { path: 'safe.md', content: 'hello' }, + projectedInput: { path: 'evil.md', content: 'hello' }, + }), + ); + await runModel(model, [ + writeTool((input) => { + executions += 1; + receivedInput = input; + }), + ]); + assert.equal(executions, 1); + assert.deepEqual(receivedInput, { path: 'safe.md', content: 'hello' }); + }); + + test('matching id/name/value executes exactly once with the proved object', async () => { + let receivedInput: unknown; + let executions = 0; + const model = twoStepModel( + toolCallChunks('incremental', { unified: 'stop', raw: 'stop' }, { + rawInput: { path: 'notes.md', content: 'hello' }, + }), + ); + await runModel(model, [ + writeTool((input) => { + executions += 1; + receivedInput = input; + }), + ]); + assert.equal(executions, 1); + assert.deepEqual(receivedInput, { path: 'notes.md', content: 'hello' }); + }); +}); diff --git a/packages/runtime/src/__tests__/tool-call-execution-guard.test.ts b/packages/runtime/src/__tests__/tool-call-execution-guard.test.ts new file mode 100644 index 0000000000..dc719fc431 --- /dev/null +++ b/packages/runtime/src/__tests__/tool-call-execution-guard.test.ts @@ -0,0 +1,259 @@ +/* + * 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. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { + createToolCallSafetyTracker, + isSafeToolExecutionStepOutcome, + observeRawChunk, + resolveToolCallSafety, +} from '../tool-call-execution-guard.js'; +import type { ModelFailure, ModelStepOutcome } from '../model-protocol.js'; + +function completeToolCallParts(id: string, input: unknown, toolName = 'Write') { + return [ + { type: 'tool-input-start', id, toolName }, + { type: 'tool-input-delta', id, delta: JSON.stringify(input) }, + { type: 'tool-input-end', id }, + { type: 'tool-call', toolCallId: id, toolName, input: JSON.stringify(input) }, + ]; +} + +function pushCompleteCall( + tracker: ReturnType, + id = 'call-1', + input: unknown = { path: 'a.md' }, +): void { + for (const part of completeToolCallParts(id, input)) observeRawChunk(tracker, part); +} + +function actionFor( + tracker: ReturnType, + id = 'call-1', +): string | undefined { + return resolveToolCallSafety(tracker).decisions.get(id)?.action; +} + +describe('tool-call-execution-guard', () => { + test('stop positively proves the raw-stream name and value', () => { + const tracker = createToolCallSafetyTracker(); + pushCompleteCall(tracker, 'call-1', { path: 'a.md', content: 'hello' }); + observeRawChunk(tracker, { type: 'finish', finishReason: 'stop' }); + + const safety = resolveToolCallSafety(tracker); + assert.equal(safety.hadRawArgumentEvidence, true); + assert.deepEqual(safety.decisions.get('call-1'), { + action: 'execute', + name: 'Write', + value: { path: 'a.md', content: 'hello' }, + }); + }); + + test('tool-calls is also an explicitly safe terminal reason', () => { + const tracker = createToolCallSafetyTracker(); + pushCompleteCall(tracker); + observeRawChunk(tracker, { type: 'finish', finishReason: 'tool-calls' }); + assert.equal(actionFor(tracker), 'execute'); + }); + + for (const finishReason of ['length', 'content-filter', 'error', 'other', 'unknown']) { + test(`${finishReason} never authorizes a fully streamed call`, () => { + const tracker = createToolCallSafetyTracker(); + pushCompleteCall(tracker); + observeRawChunk(tracker, { type: 'finish', finishReason }); + assert.notEqual(actionFor(tracker), 'execute'); + }); + } + + test('provider error part fails closed', () => { + const tracker = createToolCallSafetyTracker(); + pushCompleteCall(tracker); + observeRawChunk(tracker, { type: 'error', error: new Error('upstream 500') }); + assert.notEqual(actionFor(tracker), 'execute'); + }); + + test('abort part fails closed', () => { + const tracker = createToolCallSafetyTracker(); + pushCompleteCall(tracker); + observeRawChunk(tracker, { type: 'abort' }); + assert.notEqual(actionFor(tracker), 'execute'); + }); + + test('missing terminal event fails closed even when fallback metadata says stop', () => { + const tracker = createToolCallSafetyTracker(); + pushCompleteCall(tracker); + const safety = resolveToolCallSafety(tracker, { providerReason: 'stop' }); + assert.notEqual(safety.decisions.get('call-1')?.action, 'execute'); + }); + + test('missing tool-input-end fails closed', () => { + const tracker = createToolCallSafetyTracker(); + observeRawChunk(tracker, { type: 'tool-input-start', id: 'call-1', toolName: 'Write' }); + observeRawChunk(tracker, { + type: 'tool-input-delta', + id: 'call-1', + delta: '{"path":"a.md"}', + }); + observeRawChunk(tracker, { type: 'finish', finishReason: 'stop' }); + assert.notEqual(actionFor(tracker), 'execute'); + }); + + test('malformed JSON fails closed despite start/end and a safe finish', () => { + const tracker = createToolCallSafetyTracker(); + observeRawChunk(tracker, { type: 'tool-input-start', id: 'call-1', toolName: 'Write' }); + observeRawChunk(tracker, { type: 'tool-input-delta', id: 'call-1', delta: '{"path":' }); + observeRawChunk(tracker, { type: 'tool-input-end', id: 'call-1' }); + observeRawChunk(tracker, { type: 'finish', finishReason: 'stop' }); + assert.notEqual(actionFor(tracker), 'execute'); + }); + + test('delta before start is contradictory evidence and fails closed', () => { + const tracker = createToolCallSafetyTracker(); + observeRawChunk(tracker, { type: 'tool-input-delta', id: 'call-1', delta: '{"path":"a.md"}' }); + observeRawChunk(tracker, { type: 'tool-input-start', id: 'call-1', toolName: 'Write' }); + observeRawChunk(tracker, { type: 'tool-input-end', id: 'call-1' }); + observeRawChunk(tracker, { type: 'finish', finishReason: 'stop' }); + assert.notEqual(actionFor(tracker), 'execute'); + }); + + test('duplicate start fails closed', () => { + const tracker = createToolCallSafetyTracker(); + observeRawChunk(tracker, { type: 'tool-input-start', id: 'call-1', toolName: 'Write' }); + observeRawChunk(tracker, { type: 'tool-input-start', id: 'call-1', toolName: 'Write' }); + observeRawChunk(tracker, { type: 'tool-input-delta', id: 'call-1', delta: '{"path":"a.md"}' }); + observeRawChunk(tracker, { type: 'tool-input-end', id: 'call-1' }); + observeRawChunk(tracker, { type: 'finish', finishReason: 'stop' }); + assert.notEqual(actionFor(tracker), 'execute'); + }); + + test('tool evidence after a terminal event poisons the request', () => { + const tracker = createToolCallSafetyTracker(); + pushCompleteCall(tracker); + observeRawChunk(tracker, { type: 'finish', finishReason: 'stop' }); + observeRawChunk(tracker, { type: 'tool-input-delta', id: 'call-1', delta: ' ' }); + assert.notEqual(actionFor(tracker), 'execute'); + }); + + test('pure atomic delivery has no raw decision and keeps request-level evidence false', () => { + const tracker = createToolCallSafetyTracker(); + observeRawChunk(tracker, { type: 'tool-input-start', id: 'call-1', toolName: 'Write' }); + observeRawChunk(tracker, { type: 'tool-input-end', id: 'call-1' }); + observeRawChunk(tracker, { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'Write', + input: JSON.stringify({ path: 'a.md' }), + }); + observeRawChunk(tracker, { type: 'finish', finishReason: 'stop' }); + + const safety = resolveToolCallSafety(tracker); + assert.equal(safety.hadRawArgumentEvidence, false); + assert.equal(safety.decisions.has('call-1'), false); + }); + + test('a mixed request records raw evidence globally while leaving the atomic sibling undecided', () => { + const tracker = createToolCallSafetyTracker(); + pushCompleteCall(tracker, 'call-incremental', { path: 'a.md' }); + observeRawChunk(tracker, { type: 'tool-input-start', id: 'call-atomic', toolName: 'Write' }); + observeRawChunk(tracker, { type: 'tool-input-end', id: 'call-atomic' }); + observeRawChunk(tracker, { + type: 'tool-call', + toolCallId: 'call-atomic', + toolName: 'Write', + input: JSON.stringify({ path: 'b.md' }), + }); + observeRawChunk(tracker, { type: 'finish', finishReason: 'stop' }); + + const safety = resolveToolCallSafety(tracker); + assert.equal(safety.hadRawArgumentEvidence, true); + assert.equal(safety.decisions.get('call-incremental')?.action, 'execute'); + assert.equal(safety.decisions.has('call-atomic'), false); + }); + + test('concurrent request trackers can reuse call_1 without cross-resolution', () => { + const safe = createToolCallSafetyTracker(); + const unsafe = createToolCallSafetyTracker(); + pushCompleteCall(safe, 'call_1', { path: 'safe.md' }); + pushCompleteCall(unsafe, 'call_1', { path: 'unsafe.md' }); + observeRawChunk(unsafe, { type: 'finish', finishReason: 'length' }); + observeRawChunk(safe, { type: 'finish', finishReason: 'stop' }); + + assert.equal(actionFor(safe, 'call_1'), 'execute'); + assert.notEqual(actionFor(unsafe, 'call_1'), 'execute'); + assert.deepEqual(resolveToolCallSafety(safe).decisions.get('call_1')?.value, { + path: 'safe.md', + }); + }); + + test('resolution is stable when read more than once', () => { + const tracker = createToolCallSafetyTracker(); + pushCompleteCall(tracker); + observeRawChunk(tracker, { type: 'finish', finishReason: 'stop' }); + const first = resolveToolCallSafety(tracker); + const second = resolveToolCallSafety(tracker); + assert.equal(second, first); + }); +}); + +describe('isSafeToolExecutionStepOutcome', () => { + const request = {}; + const failure: ModelFailure = { + type: 'model_failure', + kind: 'provider_unavailable', + message: 'boom', + retryable: false, + }; + + for (const finishReason of ['stop', 'tool-calls'] as const) { + test(`allows ${finishReason}`, () => { + const outcome: ModelStepOutcome = { + kind: 'completed', + finishReason, + request, + continuation: 'none', + }; + assert.equal(isSafeToolExecutionStepOutcome(outcome), true); + }); + } + + for (const finishReason of ['length', 'content-filter', 'other', 'unknown'] as const) { + test(`rejects completed/${finishReason}`, () => { + const outcome: ModelStepOutcome = { + kind: 'completed', + finishReason, + request, + continuation: 'none', + }; + assert.equal(isSafeToolExecutionStepOutcome(outcome), false); + }); + } + + for (const outcome of [ + { kind: 'truncated', failure, request, continuation: 'none' }, + { kind: 'terminal-failure', failure, request, continuation: 'none' }, + { kind: 'retryable-failure', failure: { ...failure, retryable: true }, request, continuation: 'none' }, + { kind: 'aborted', failure: { ...failure, kind: 'abort' as const }, request, continuation: 'none' }, + ] satisfies ModelStepOutcome[]) { + test(`rejects ${outcome.kind}`, () => { + assert.equal(isSafeToolExecutionStepOutcome(outcome), false); + }); + } +}); diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 96e09fdf06..498882c6ff 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -119,6 +119,7 @@ import type { NormalizedUsage, ModelFailureKind, ToolCallPart, + ToolCallExecutionSafety, ToolResultOutput, UserContent, } from './model-protocol.js'; @@ -288,6 +289,7 @@ import { type HistoryCompactCheckpoint, } from './history-compact-checkpoint.js'; import { resolveSelectedModelContextWindow } from './context-budget-policy.js'; +import { isSafeToolExecutionStepOutcome } from './tool-call-execution-guard.js'; export { DEFAULT_PERMISSION_TIMEOUT_MS, MAX_ACTIVE_CHILD_AGENT_RUNS_PER_TURN, @@ -2204,6 +2206,16 @@ export class AiSdkBackend implements AgentBackend { let overflowRetryUsed = false; let result: ModelStreamResult; let providerOutcome: ModelStepOutcome; + // Per-tool-call positive-completion proof for the physical provider + // request that produced `returnedToolCalls` below — see + // tool-call-execution-guard.ts. Reset every loop iteration alongside + // `providerOutcome`/`result`: it describes only the most recent + // request, matching `returnedToolCalls`'s own per-request lifetime + // (settled once per iteration, never carried across steps). + let toolCallSafety: ToolCallExecutionSafety = { + hadRawArgumentEvidence: false, + decisions: new Map(), + }; let finishReason: ModelFinishReason = 'stop'; let terminalProviderError: unknown; agentLoop: for (;;) { @@ -2514,6 +2526,7 @@ export class AiSdkBackend implements AgentBackend { // must not be reported as the already-handled watchdog timeout. const settledWatchdogTimeout = consumeWatchdogTimeout(); providerOutcome = await result.outcome; + toolCallSafety = await result.toolCallSafety; const incompleteStreamTerminal = providerOutcome.kind === 'truncated'; const incompleteStreamHasNoObservableOutput = incompleteStreamTerminal && @@ -2697,7 +2710,76 @@ export class AiSdkBackend implements AgentBackend { `Provider-executed tool call "${toolCall.toolName}" is outside the main-agent tool loop`, ); } - const requestedTool = toolsByName.get(toolCall.toolName); + // A structurally complete tool call is not proof the raw + // stream that produced it was ever confirmed safe — see + // tool-call-execution-guard.ts. `settleModelStepOutcome` + // treats `finishReason: "length"` as `{ kind: 'completed' }` + // (the same branch `"stop"`/`"tool-calls"` take, for that + // function's own continuation/retry bookkeeping purpose), so + // without this check a call whose own JSON streamed + // incrementally (`tool-input-start`/`-delta`/`-end`) to + // genuine completion right before a token-limit cutoff would + // still reach ToolRuntime and its real side effect. + // + // The guard has no raw bytes — and so no opinion — for a + // call that arrives as a single atomic `tool-call` chunk + // with no incremental precursor: some providers (and this + // suite's own simpler mock streams) emit tool calls that + // way, and an atomic chunk was never truncated mid-stream in + // the first place, so there is nothing for raw-byte tracking + // to have caught. For those, "no raw-byte evidence" must not + // become "safe to execute" on its own — fall back to + // isSafeToolExecutionStepOutcome, which only allows a step + // that positively finished with "stop" or "tool-calls". + // `providerOutcome.kind === 'completed'` alone is NOT + // sufficient here, because it is also true for "length". + // + // That atomic fallback is sound only when NO call anywhere + // in this physical request ever streamed real raw bytes + // (`!toolCallSafety.hadRawArgumentEvidence`): the moment one + // call did, a different call missing its own decision here + // is no longer distinguishable from an id mismatch between + // the guard's raw-chunk view and this resolved `tool-call` — + // that must fail closed instead of borrowing the step's + // own outcome. A decision the guard did record is only + // trusted when its own proved tool name agrees with this + // call's — case-insensitively, since `repairMakaToolCall` + // legitimately corrects a mis-cased name (streamed as + // "WRITE", dispatched as "Write") between what the guard + // observed at `tool-input-start` and the final resolved + // `tool-call`. A positively-proved id whose name disagrees + // even case-insensitively is the identity-substitution this + // gate exists to catch (e.g. "Write" proved, "Shell" + // dispatched), not something to execute under either name. + // The one exception is `INVALID_TOOL_NAME`: repair routes an + // unrepairable call there deliberately, and that handler + // never does more than format an error — so it is exempt + // from the identity check but, per the value rule below, + // never eligible for the guard's proved value either, since + // that value describes the ORIGINAL (now-irrelevant) tool + // call, not the repair-synthesized `{tool, error}` payload + // `buildInvalidMakaTool` expects. + const toolCallDecision = toolCallSafety.decisions.get(toolCall.toolCallId); + const provedNameMatches = + toolCallDecision?.action === 'execute' && + toolCallDecision.name?.toLowerCase() === toolCall.toolName.toLowerCase(); + const confirmedSafe = toolCallDecision + ? toolCallDecision.action === 'execute' && + (toolCall.toolName === INVALID_TOOL_NAME || provedNameMatches) + : !toolCallSafety.hadRawArgumentEvidence && + isSafeToolExecutionStepOutcome(providerOutcome); + // The guard's own proved value — decoded from this call's + // raw bytes, never the SDK-projected `toolCall.input` a + // later repair/coercion could have substituted — is the + // sole payload authority whenever the proved identity + // genuinely matches the tool about to run. The atomic + // fallback (no decision at all) and the invalid-tool bypass + // above both have no such value and fall back to + // `toolCall.input`. + const provedValue = provedNameMatches ? toolCallDecision.value : undefined; + const requestedTool = confirmedSafe + ? toolsByName.get(toolCall.toolName) + : undefined; const tool = requestedTool ?? toolsByName.get(INVALID_TOOL_NAME); if (!tool) throw new Error('Runtime invalid-tool fallback is unavailable'); return await toolRuntime.settleToolCall({ @@ -2718,10 +2800,14 @@ export class AiSdkBackend implements AgentBackend { : {}), input: requestedTool !== undefined - ? toolCall.input + ? provedValue !== undefined + ? provedValue + : toolCall.input : { tool: toolCall.toolName, - error: 'returned tool is unavailable', + error: confirmedSafe + ? 'returned tool is unavailable' + : 'the stream that produced this call was not confirmed to complete safely', }, abortSignal: turnAbortController.signal, eventSink: queue, diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index 33fc8afbbe..f160ba94fb 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -28,6 +28,11 @@ import { lookupModelMetadata } from '@maka/core/model-metadata'; import { generalizedErrorMessage } from '@maka/core/redaction'; import type { CacheMissInputSource } from '@maka/core/usage-stats/types'; import { rawFinishReasonString } from './model-protocol.js'; +import { + createToolCallSafetyTracker, + observeRawChunk, + resolveToolCallSafety, +} from './tool-call-execution-guard.js'; import type { ModelMessage, NormalizedUsage, @@ -41,6 +46,7 @@ import type { ModelRequestMetadata, ModelToolSet, ToolCallPart, + ToolCallExecutionSafety, } from './model-protocol.js'; export type { NormalizedUsage, @@ -355,6 +361,16 @@ export class ModelAdapter { const outcome = new Promise((resolve) => { settleOutcome = resolve; }); + let settleToolCallSafety!: (safety: ToolCallExecutionSafety) => void; + const toolCallSafety = new Promise((resolve) => { + settleToolCallSafety = resolve; + }); + // One tracker per physical provider request (this method's own + // "one physical provider request" contract — see ModelStreamResult) so + // two concurrent requests, even with colliding provider-issued + // toolCallIds, can never share or cross-resolve state. See + // tool-call-execution-guard.ts. + const toolCallGuard = createToolCallSafetyTracker(); const request = { messages: continuation.requestMessages }; const events: AsyncIterable = { async *[Symbol.asyncIterator]() { @@ -364,6 +380,11 @@ export class ModelAdapter { try { for await (const chunk of sdk.stream as AsyncIterable) { onStreamActivity(); + // Raw, unmodified chunk — before translateChunk's semantic + // narrowing — so the guard proves each tool call's argument + // completeness from its own raw byte stream, not from + // translateChunk's already-trusted `tool-call.input`. + observeRawChunk(toolCallGuard, chunk); for (const event of translateChunk(chunk, openAiChatReasoningTransportState)) { if (event.kind === 'error') failure = event.failure; if (event.kind === 'finish') sawFinish = true; @@ -414,12 +435,15 @@ export class ModelAdapter { } } } finally { + settleToolCallSafety( + resolveToolCallSafety(toolCallGuard, { providerReason: finishReason }), + ); settleOutcome(settled); } } }, }; - return { events, outcome }; + return { events, outcome, toolCallSafety }; } endContinuation(lane: string): void { diff --git a/packages/runtime/src/model-protocol.ts b/packages/runtime/src/model-protocol.ts index 7042731658..47685c2729 100644 --- a/packages/runtime/src/model-protocol.ts +++ b/packages/runtime/src/model-protocol.ts @@ -426,8 +426,52 @@ export type ModelStepOutcome = continuation: 'none'; }; +/** + * One tool call's outcome from `tool-call-execution-guard.ts`'s raw-byte + * verification, keyed by `toolCallId` in `ToolCallExecutionSafety.decisions`. + * `name`/`value` are the guard's own proof — the tool name and parsed + * argument value it verified from the raw stream, present only for + * `action: 'execute'` — and are authoritative over whatever the AI SDK's + * post-hoc `tool-call` chunk claims for that same id: a caller that trusts + * `toolCall.toolName`/`.input` instead accepts an unverified value a later + * repair/coercion could have substituted for what actually streamed. + */ +export interface ToolCallSafetyDecision { + readonly action: 'execute' | 'retry' | 'reject'; + readonly name?: string; + readonly value?: unknown; +} + +/** + * The full per-physical-request result of `tool-call-execution-guard.ts`'s + * raw-byte verification. `decisions` covers only calls the guard actually + * observed real (non-empty) `tool-input-delta` bytes for; a call missing + * from it got no raw-byte evidence either way — genuinely atomic delivery + * (no delta chunks exist for it to have been cut short from) if + * `hadRawArgumentEvidence` is `false` for the whole request, but otherwise + * indistinguishable from an id mismatch between the guard's raw-chunk view + * and the SDK's resolved `tool-call`, and must be treated as unsafe either + * way. `hadRawArgumentEvidence` is therefore the ONLY condition under which + * a missing decision may fall back to a step-level safety check: once any + * call in this physical request streamed real bytes, every other call's own + * identity must be independently proved too, or it fails closed. + */ +export interface ToolCallExecutionSafety { + readonly hadRawArgumentEvidence: boolean; + readonly decisions: ReadonlyMap; +} + /** One physical provider request: live output plus one authoritative settlement. */ export interface ModelStreamResult { events: AsyncIterable; outcome: Promise; + /** + * Per-tool-call positive-completion proof for this same physical provider + * request — see `tool-call-execution-guard.ts` and `ToolCallExecutionSafety` + * above. A call is safe to execute only when `decisions.get(toolCallId)` + * is present with `action: 'execute'` AND its proved `name` matches the + * call's own; a missing decision falls back to a step-level check only + * when `hadRawArgumentEvidence` is `false` for the whole request. + */ + toolCallSafety: Promise; } diff --git a/packages/runtime/src/tool-call-execution-guard.ts b/packages/runtime/src/tool-call-execution-guard.ts new file mode 100644 index 0000000000..c6578e201b --- /dev/null +++ b/packages/runtime/src/tool-call-execution-guard.ts @@ -0,0 +1,273 @@ +/* + * 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. + */ + +/** + * tool-call-execution-guard.ts — positive-completion proof for a provider + * step's tool calls before `ai-sdk-backend.ts` settles them through + * `ToolRuntime` (real side effects: filesystem writes, shell commands, + * apply_patch, SQL execution, dependency installs, and so on). + * + * Why this exists: `settleModelStepOutcome` (model-adapter.ts) classifies a + * step as `{ kind: 'completed' }` for `finishReason: "length"` — the same + * branch `"stop"` and `"tool-calls"` take. `ai-sdk-backend.ts`'s historical + * gate before settling `returnedToolCalls` only asked for that broader + * completed outcome, so a step that streamed a structurally complete tool + * call and was then cut off by a token limit while producing more content + * could still execute that call. The `length -> completed` classification is + * correct for that function's continuation/retry bookkeeping purpose and is + * deliberately not reused as an irreversible tool-execution gate here. + * + * `translateChunk` (model-adapter.ts) also does not establish execution + * authority for a final `'tool-call'` chunk's `input`: that value is the AI + * SDK's parsed/post-processed projection and may have passed through + * `repairToolCall` or another coercion path. Where the provider exposes raw + * `tool-input-delta` chunks, those bytes are stronger evidence of what the + * provider actually streamed. This tracker observes the raw SDK chunks + * verbatim before translation, keeps the argument bytes per physical request, + * requires matching start/end evidence plus a positively safe terminal event, + * and parses only the captured raw JSON. The resulting raw-stream tool name + * and parsed value are the execution authority for that call. + * + * Not every real provider integration streams a call's arguments via + * `tool-input-delta` at all: this project's Anthropic-compatible wire protocol + * (verified against the real HTTP round-trip in + * `computer-use-provider-protocol.test.ts`) can emit `tool-input-start` + * immediately followed by `tool-input-end` with zero delta chunks between + * them, carrying the actual arguments only in the trailing `tool-call` chunk's + * already-parsed `input`. That is legitimate atomic delivery, not a truncation + * — there is no partial byte stream for it to have been cut short from. + * `resolveToolCallSafety` therefore omits an id that received no real + * non-empty delta from `decisions` entirely. + * + * A caller (see `ai-sdk-backend.ts`) may only interpret that absence as + * "genuinely atomic; use the step-level fallback" when + * `hadRawArgumentEvidence` is false for the WHOLE physical request. The + * moment any sibling call streamed real bytes, another call's missing + * decision is indistinguishable from an id mismatch between this tracker's + * raw-chunk view and the SDK's resolved `tool-call`; that case must fail + * closed. A call that did stream real delta bytes gets the strict raw-byte + * verdict, including the raw-stream name/value as execution authority — never + * the SDK's post-hoc projection of the same call. + * + * Concurrency note: nothing here is shared across requests or stored beyond + * one `ModelAdapter.startStream` result. `createToolCallSafetyTracker()` owns + * fresh maps and terminal state every time, so two concurrent provider + * requests that both use a provider-issued id like `"call_1"` can never + * observe or resolve each other's evidence. + * + * `isSafeToolExecutionStepOutcome` below is the fallback used for a call with + * no raw-byte evidence either way. It is deliberately narrower than + * `settleModelStepOutcome`'s own `kind === 'completed'` — that classification + * also covers `"length"`, which is correct for that function's bookkeeping + * purpose but is not an execution-safe outcome. This helper exists only for + * the tool-execution gate; it does not change `settleModelStepOutcome` itself + * or anything else that reads `ModelStepOutcome`. + */ +import type { + ModelStepOutcome, + ToolCallExecutionSafety, + ToolCallSafetyDecision, +} from './model-protocol.js'; + +interface RawToolCallState { + name?: string; + raw: string; + started: boolean; + ended: boolean; + invalid: boolean; +} + +type TerminalState = 'pending' | 'safe' | 'unsafe'; + +export interface ToolCallSafetyTracker { + /** Per-id raw argument state for this physical provider request only. */ + readonly calls: Map; + /** ids that received at least one non-empty tool-input-delta chunk. */ + readonly idsWithRawDelta: Set; + terminal: TerminalState; + resolved?: ToolCallExecutionSafety; +} + +function toolPartId(part: { id?: unknown; toolCallId?: unknown }): string | undefined { + if (typeof part.id === 'string') return part.id; + if (typeof part.toolCallId === 'string') return part.toolCallId; + return undefined; +} + +function callState(tracker: ToolCallSafetyTracker, id: string): RawToolCallState { + let state = tracker.calls.get(id); + if (state === undefined) { + state = { raw: '', started: false, ended: false, invalid: false }; + tracker.calls.set(id, state); + } + return state; +} + +function normalizedFinishReason(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if ( + value !== null && + typeof value === 'object' && + typeof (value as { unified?: unknown }).unified === 'string' + ) { + return (value as { unified: string }).unified; + } + return undefined; +} + +function markTerminal(tracker: ToolCallSafetyTracker, safe: boolean): void { + if (!safe || tracker.terminal === 'unsafe') { + tracker.terminal = 'unsafe'; + return; + } + tracker.terminal = 'safe'; +} + +/** Starts tracking one physical provider request's raw stream. */ +export function createToolCallSafetyTracker(): ToolCallSafetyTracker { + return { calls: new Map(), idsWithRawDelta: new Set(), terminal: 'pending' }; +} + +/** Feed one raw AI SDK stream chunk through, unchanged, as observed. */ +export function observeRawChunk(tracker: ToolCallSafetyTracker, chunk: unknown): void { + if (chunk === null || typeof chunk !== 'object') return; + const part = chunk as { + type?: unknown; + id?: unknown; + toolCallId?: unknown; + toolName?: unknown; + delta?: unknown; + finishReason?: unknown; + }; + + const isToolEvidence = + part.type === 'tool-input-start' || + part.type === 'tool-input-delta' || + part.type === 'tool-input-end' || + part.type === 'tool-error'; + if (tracker.terminal !== 'pending' && isToolEvidence) tracker.terminal = 'unsafe'; + + switch (part.type) { + case 'tool-input-start': { + const id = toolPartId(part); + if (id === undefined) return; + const state = callState(tracker, id); + if (state.started || state.ended || state.raw.length > 0) state.invalid = true; + state.started = true; + if (typeof part.toolName === 'string' && part.toolName.length > 0) { + if (state.name !== undefined && state.name !== part.toolName) state.invalid = true; + state.name = part.toolName; + } + return; + } + case 'tool-input-delta': { + const id = toolPartId(part); + if (id === undefined || typeof part.delta !== 'string' || part.delta.length === 0) return; + const state = callState(tracker, id); + if (!state.started || state.ended) state.invalid = true; + state.raw += part.delta; + tracker.idsWithRawDelta.add(id); + return; + } + case 'tool-input-end': { + const id = toolPartId(part); + if (id === undefined) return; + const state = callState(tracker, id); + if (!state.started || state.ended) state.invalid = true; + state.ended = true; + return; + } + case 'tool-error': { + const id = toolPartId(part); + if (id !== undefined) callState(tracker, id).invalid = true; + return; + } + case 'finish': { + const reason = normalizedFinishReason(part.finishReason); + markTerminal(tracker, reason === 'stop' || reason === 'tool-calls'); + return; + } + case 'error': + case 'abort': + markTerminal(tracker, false); + return; + default: + return; + } +} + +function rejectedDecision(): ToolCallSafetyDecision { + return { action: 'reject' }; +} + +/** + * Resolves every call that actually streamed raw bytes. Positive execution + * requires start + non-empty raw bytes + end + a raw-stream tool name + valid + * JSON decoded from exactly those bytes + an observed `stop`/`tool-calls` + * terminal event. Missing, contradictory, or out-of-order evidence fails + * closed. `meta` is accepted for the ModelAdapter call shape but cannot + * promote a stream with no terminal event to safe. + */ +export function resolveToolCallSafety( + tracker: ToolCallSafetyTracker, + _meta?: { providerReason?: string }, +): ToolCallExecutionSafety { + if (tracker.resolved !== undefined) return tracker.resolved; + + const decisions = new Map(); + for (const id of tracker.idsWithRawDelta) { + const state = tracker.calls.get(id); + if ( + tracker.terminal !== 'safe' || + state === undefined || + !state.started || + !state.ended || + state.invalid || + state.name === undefined + ) { + decisions.set(id, rejectedDecision()); + continue; + } + + try { + const value: unknown = JSON.parse(state.raw); + decisions.set(id, { action: 'execute', name: state.name, value }); + } catch { + decisions.set(id, rejectedDecision()); + } + } + + tracker.resolved = { + hadRawArgumentEvidence: tracker.idsWithRawDelta.size > 0, + decisions, + }; + return tracker.resolved; +} + +/** + * Whether an all-atomic provider step's own termination is execution-safe. + * Only an unambiguous normal completion qualifies. `length` is intentionally + * excluded even though `settleModelStepOutcome` classifies it as completed. + */ +export function isSafeToolExecutionStepOutcome(outcome: ModelStepOutcome): boolean { + return ( + outcome.kind === 'completed' && + (outcome.finishReason === 'stop' || outcome.finishReason === 'tool-calls') + ); +} From e106669d5b7e36ccaf6515a1b8d821de42f80889 Mon Sep 17 00:00:00 2001 From: canblmz1 <116688414+canblmz1@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:48:43 +0300 Subject: [PATCH 2/4] docs(runtime): cite atomic delivery coverage correctly --- .../runtime/src/tool-call-execution-guard.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/runtime/src/tool-call-execution-guard.ts b/packages/runtime/src/tool-call-execution-guard.ts index c6578e201b..789d1d7388 100644 --- a/packages/runtime/src/tool-call-execution-guard.ts +++ b/packages/runtime/src/tool-call-execution-guard.ts @@ -44,14 +44,14 @@ * and parses only the captured raw JSON. The resulting raw-stream tool name * and parsed value are the execution authority for that call. * - * Not every real provider integration streams a call's arguments via - * `tool-input-delta` at all: this project's Anthropic-compatible wire protocol - * (verified against the real HTTP round-trip in - * `computer-use-provider-protocol.test.ts`) can emit `tool-input-start` - * immediately followed by `tool-input-end` with zero delta chunks between - * them, carrying the actual arguments only in the trailing `tool-call` chunk's - * already-parsed `input`. That is legitimate atomic delivery, not a truncation - * — there is no partial byte stream for it to have been cut short from. + * Some provider-facing chunk sequences may contain `tool-input-start` + * immediately followed by `tool-input-end` with zero `tool-input-delta` + * chunks, with the actual arguments present only in the trailing `tool-call` + * projection. The focused guard and production-path regression suites exercise + * that zero-delta/atomic shape directly (`tool-call-execution-guard.test.ts`, + * `length-cutoff-tool-execution-repro.test.ts`, and `ai-sdk-backend.test.ts`). + * For this tracker, zero raw deltas therefore means there is no raw-byte + * completeness proof for that id, not that a partial byte stream was observed. * `resolveToolCallSafety` therefore omits an id that received no real * non-empty delta from `decisions` entirely. * From 9c92f47e47a31512b0d91b425ab194fd7ac68320 Mon Sep 17 00:00:00 2001 From: canblmz1 <116688414+canblmz1@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:06:00 +0300 Subject: [PATCH 3/4] fix(runtime): format tool execution safety tests --- ...length-cutoff-tool-execution-repro.test.ts | 110 +++++++++--------- .../tool-call-execution-guard.test.ts | 14 ++- 2 files changed, 66 insertions(+), 58 deletions(-) diff --git a/packages/runtime/src/__tests__/length-cutoff-tool-execution-repro.test.ts b/packages/runtime/src/__tests__/length-cutoff-tool-execution-repro.test.ts index 001927f775..ee65138b01 100644 --- a/packages/runtime/src/__tests__/length-cutoff-tool-execution-repro.test.ts +++ b/packages/runtime/src/__tests__/length-cutoff-tool-execution-repro.test.ts @@ -179,13 +179,7 @@ function hangingProviderStream( }); } -type UnifiedFinishReason = - | 'length' - | 'stop' - | 'tool-calls' - | 'content-filter' - | 'error' - | 'other'; +type UnifiedFinishReason = 'length' | 'stop' | 'tool-calls' | 'content-filter' | 'error' | 'other'; type FinishReason = { unified: UnifiedFinishReason; @@ -324,14 +318,11 @@ async function executionCountFor( ): Promise { let executions = 0; const model = twoStepModel(toolCallChunks(delivery, finishReason)); - await runModel( - model, - [ - writeTool(() => { - executions += 1; - }), - ], - ).catch(() => []); + await runModel(model, [ + writeTool(() => { + executions += 1; + }), + ]).catch(() => []); return executions; } @@ -358,14 +349,11 @@ describe('tool execution safety (real production path)', () => { test('missing terminal event executes zero times', async () => { let executions = 0; const chunks = toolCallChunks('incremental', { unified: 'stop', raw: 'stop' }).slice(0, -1); - await runModel( - twoStepModel(chunks), - [ - writeTool(() => { - executions += 1; - }), - ], - ).catch(() => []); + await runModel(twoStepModel(chunks), [ + writeTool(() => { + executions += 1; + }), + ]).catch(() => []); assert.equal(executions, 0); }); @@ -381,14 +369,11 @@ describe('tool execution safety (real production path)', () => { usage: ZERO_USAGE, }, ]); - await runModel( - model, - [ - writeTool(() => { - executions += 1; - }), - ], - ).catch(() => []); + await runModel(model, [ + writeTool(() => { + executions += 1; + }), + ]).catch(() => []); assert.equal(executions, 0); }); @@ -485,19 +470,20 @@ describe('tool execution safety (real production path)', () => { test('raw evidence for call_1 cannot authorize a resolved call_2', async () => { let executions = 0; const model = twoStepModel( - toolCallChunks('incremental', { unified: 'stop', raw: 'stop' }, { - rawId: 'call_1', - resolvedId: 'call_2', - }), + toolCallChunks( + 'incremental', + { unified: 'stop', raw: 'stop' }, + { + rawId: 'call_1', + resolvedId: 'call_2', + }, + ), ); - await runModel( - model, - [ - writeTool(() => { - executions += 1; - }), - ], - ).catch(() => []); + await runModel(model, [ + writeTool(() => { + executions += 1; + }), + ]).catch(() => []); assert.equal(executions, 0); }); @@ -551,12 +537,16 @@ describe('tool execution safety (real production path)', () => { let writeExecutions = 0; let shellExecutions = 0; const model = twoStepModel( - toolCallChunks('incremental', { unified: 'stop', raw: 'stop' }, { - rawToolName: 'Write', - resolvedToolName: 'Shell', - rawInput: { path: 'notes.md', content: 'hello' }, - projectedInput: { command: 'echo hi' }, - }), + toolCallChunks( + 'incremental', + { unified: 'stop', raw: 'stop' }, + { + rawToolName: 'Write', + resolvedToolName: 'Shell', + rawInput: { path: 'notes.md', content: 'hello' }, + projectedInput: { command: 'echo hi' }, + }, + ), ); await runModel(model, [ writeTool(() => { @@ -574,10 +564,14 @@ describe('tool execution safety (real production path)', () => { let receivedInput: unknown; let executions = 0; const model = twoStepModel( - toolCallChunks('incremental', { unified: 'stop', raw: 'stop' }, { - rawInput: { path: 'safe.md', content: 'hello' }, - projectedInput: { path: 'evil.md', content: 'hello' }, - }), + toolCallChunks( + 'incremental', + { unified: 'stop', raw: 'stop' }, + { + rawInput: { path: 'safe.md', content: 'hello' }, + projectedInput: { path: 'evil.md', content: 'hello' }, + }, + ), ); await runModel(model, [ writeTool((input) => { @@ -593,9 +587,13 @@ describe('tool execution safety (real production path)', () => { let receivedInput: unknown; let executions = 0; const model = twoStepModel( - toolCallChunks('incremental', { unified: 'stop', raw: 'stop' }, { - rawInput: { path: 'notes.md', content: 'hello' }, - }), + toolCallChunks( + 'incremental', + { unified: 'stop', raw: 'stop' }, + { + rawInput: { path: 'notes.md', content: 'hello' }, + }, + ), ); await runModel(model, [ writeTool((input) => { diff --git a/packages/runtime/src/__tests__/tool-call-execution-guard.test.ts b/packages/runtime/src/__tests__/tool-call-execution-guard.test.ts index dc719fc431..67a5908c06 100644 --- a/packages/runtime/src/__tests__/tool-call-execution-guard.test.ts +++ b/packages/runtime/src/__tests__/tool-call-execution-guard.test.ts @@ -249,8 +249,18 @@ describe('isSafeToolExecutionStepOutcome', () => { for (const outcome of [ { kind: 'truncated', failure, request, continuation: 'none' }, { kind: 'terminal-failure', failure, request, continuation: 'none' }, - { kind: 'retryable-failure', failure: { ...failure, retryable: true }, request, continuation: 'none' }, - { kind: 'aborted', failure: { ...failure, kind: 'abort' as const }, request, continuation: 'none' }, + { + kind: 'retryable-failure', + failure: { ...failure, retryable: true }, + request, + continuation: 'none', + }, + { + kind: 'aborted', + failure: { ...failure, kind: 'abort' as const }, + request, + continuation: 'none', + }, ] satisfies ModelStepOutcome[]) { test(`rejects ${outcome.kind}`, () => { assert.equal(isSafeToolExecutionStepOutcome(outcome), false); From 80dacb4bcdd922a08b15dc1a656400db2bca91a2 Mon Sep 17 00:00:00 2001 From: canblmz1 <116688414+canblmz1@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:29:07 +0300 Subject: [PATCH 4/4] fix(runtime): align tool guard finish reason handling --- ...length-cutoff-tool-execution-repro.test.ts | 13 +++ .../tool-call-execution-guard.test.ts | 74 ++++++++++++++++ .../runtime/src/tool-call-execution-guard.ts | 84 +++++++++++++++---- 3 files changed, 153 insertions(+), 18 deletions(-) diff --git a/packages/runtime/src/__tests__/length-cutoff-tool-execution-repro.test.ts b/packages/runtime/src/__tests__/length-cutoff-tool-execution-repro.test.ts index ee65138b01..35c397212f 100644 --- a/packages/runtime/src/__tests__/length-cutoff-tool-execution-repro.test.ts +++ b/packages/runtime/src/__tests__/length-cutoff-tool-execution-repro.test.ts @@ -346,6 +346,19 @@ describe('tool execution safety (real production path)', () => { } } + // Finish-reason authority: chunkFinishReason (model-adapter.ts) already + // resolves "other" through the provider's own raw spelling for step + // settlement. This proves the guard's own terminal classification for an + // incrementally-streamed call now agrees with that resolution end to end, + // through the real ModelAdapter -> resolveToolCallSafety wiring, rather + // than only in the unit-level tracker tests. ("unknown" is exercised at + // the unit level only — it is Maka's own settlement-layer fallback, never + // a value a real raw SDK finish chunk's own `unified` field carries.) + test('incremental + finish reason unified "other" but raw "stop": executes once', async () => { + const executions = await executionCountFor('incremental', { unified: 'other', raw: 'stop' }); + assert.equal(executions, 1); + }); + test('missing terminal event executes zero times', async () => { let executions = 0; const chunks = toolCallChunks('incremental', { unified: 'stop', raw: 'stop' }).slice(0, -1); diff --git a/packages/runtime/src/__tests__/tool-call-execution-guard.test.ts b/packages/runtime/src/__tests__/tool-call-execution-guard.test.ts index 67a5908c06..275898e9ab 100644 --- a/packages/runtime/src/__tests__/tool-call-execution-guard.test.ts +++ b/packages/runtime/src/__tests__/tool-call-execution-guard.test.ts @@ -104,6 +104,80 @@ describe('tool-call-execution-guard', () => { assert.notEqual(safety.decisions.get('call-1')?.action, 'execute'); }); + // Finish-reason authority: model-adapter.ts already resolves a stronger + // finish reason (chunkFinishReason) that falls back to the provider's own + // spelling when the SDK's unified reason is "other"/"unknown", and passes + // it as providerReason. Before this, the tracker's own local (unified-only) + // read of the same finish chunk could disagree with that stronger answer + // for the exact same physical request. + test('a real finish event with an ambiguous unified reason is rescued by a real providerReason', () => { + const tracker = createToolCallSafetyTracker(); + pushCompleteCall(tracker); + observeRawChunk(tracker, { type: 'finish', finishReason: { unified: 'other', raw: 'stop' } }); + const safety = resolveToolCallSafety(tracker, { providerReason: 'stop' }); + assert.equal(safety.decisions.get('call-1')?.action, 'execute'); + }); + + test('unknown is also rescuable when providerReason resolves to tool-calls', () => { + const tracker = createToolCallSafetyTracker(); + pushCompleteCall(tracker); + observeRawChunk(tracker, { + type: 'finish', + finishReason: { unified: 'unknown', raw: 'tool-calls' }, + }); + const safety = resolveToolCallSafety(tracker, { providerReason: 'tool-calls' }); + assert.equal(safety.decisions.get('call-1')?.action, 'execute'); + }); + + test('an ambiguous reason with no rescuing providerReason still fails closed', () => { + const tracker = createToolCallSafetyTracker(); + pushCompleteCall(tracker); + observeRawChunk(tracker, { type: 'finish', finishReason: { unified: 'other', raw: 'other' } }); + const safety = resolveToolCallSafety(tracker, { providerReason: 'other' }); + assert.notEqual(safety.decisions.get('call-1')?.action, 'execute'); + }); + + test('an abort terminal event is never rescued by providerReason', () => { + const tracker = createToolCallSafetyTracker(); + pushCompleteCall(tracker); + observeRawChunk(tracker, { type: 'abort' }); + const safety = resolveToolCallSafety(tracker, { providerReason: 'stop' }); + assert.notEqual(safety.decisions.get('call-1')?.action, 'execute'); + }); + + test('a provider error terminal event is never rescued by providerReason', () => { + const tracker = createToolCallSafetyTracker(); + pushCompleteCall(tracker); + observeRawChunk(tracker, { type: 'error', error: new Error('upstream 500') }); + const safety = resolveToolCallSafety(tracker, { providerReason: 'stop' }); + assert.notEqual(safety.decisions.get('call-1')?.action, 'execute'); + }); + + test('a directly observed length finish reason is never rescued by providerReason', () => { + const tracker = createToolCallSafetyTracker(); + pushCompleteCall(tracker); + observeRawChunk(tracker, { type: 'finish', finishReason: 'length' }); + const safety = resolveToolCallSafety(tracker, { providerReason: 'stop' }); + assert.notEqual(safety.decisions.get('call-1')?.action, 'execute'); + }); + + test('a directly observed content-filter finish reason is never rescued by providerReason', () => { + const tracker = createToolCallSafetyTracker(); + pushCompleteCall(tracker); + observeRawChunk(tracker, { type: 'finish', finishReason: 'content-filter' }); + const safety = resolveToolCallSafety(tracker, { providerReason: 'stop' }); + assert.notEqual(safety.decisions.get('call-1')?.action, 'execute'); + }); + + test('a second finish-shaped event is treated as poisoning, not a softer verdict', () => { + const tracker = createToolCallSafetyTracker(); + pushCompleteCall(tracker); + observeRawChunk(tracker, { type: 'finish', finishReason: { unified: 'other', raw: 'stop' } }); + observeRawChunk(tracker, { type: 'finish', finishReason: 'stop' }); + const safety = resolveToolCallSafety(tracker, { providerReason: 'stop' }); + assert.notEqual(safety.decisions.get('call-1')?.action, 'execute'); + }); + test('missing tool-input-end fails closed', () => { const tracker = createToolCallSafetyTracker(); observeRawChunk(tracker, { type: 'tool-input-start', id: 'call-1', toolName: 'Write' }); diff --git a/packages/runtime/src/tool-call-execution-guard.ts b/packages/runtime/src/tool-call-execution-guard.ts index 789d1d7388..11e1949324 100644 --- a/packages/runtime/src/tool-call-execution-guard.ts +++ b/packages/runtime/src/tool-call-execution-guard.ts @@ -93,7 +93,21 @@ interface RawToolCallState { invalid: boolean; } -type TerminalState = 'pending' | 'safe' | 'unsafe'; +/** + * `pending`: no terminal stream event observed yet. + * `finish`: an actual `finish` chunk was observed; `reason` is this + * tracker's own local read of it (`.unified` only — see + * `normalizedFinishReason`), which may be ambiguous (`"other"`/`"unknown"`/ + * `undefined`) even when the provider's own spelling, available elsewhere, + * is not. See `isTerminalSafe`. + * `blocked`: an explicit `error`/`abort` part, or tool-call evidence + * arriving after any terminal event already happened (a poisoned request). + * Sticky: nothing can move a tracker out of `blocked`. + */ +type TerminalState = + | { readonly kind: 'pending' } + | { readonly kind: 'finish'; readonly reason: string | undefined } + | { readonly kind: 'blocked' }; export interface ToolCallSafetyTracker { /** Per-id raw argument state for this physical provider request only. */ @@ -131,17 +145,13 @@ function normalizedFinishReason(value: unknown): string | undefined { return undefined; } -function markTerminal(tracker: ToolCallSafetyTracker, safe: boolean): void { - if (!safe || tracker.terminal === 'unsafe') { - tracker.terminal = 'unsafe'; - return; - } - tracker.terminal = 'safe'; +function blockTerminal(tracker: ToolCallSafetyTracker): void { + tracker.terminal = { kind: 'blocked' }; } /** Starts tracking one physical provider request's raw stream. */ export function createToolCallSafetyTracker(): ToolCallSafetyTracker { - return { calls: new Map(), idsWithRawDelta: new Set(), terminal: 'pending' }; + return { calls: new Map(), idsWithRawDelta: new Set(), terminal: { kind: 'pending' } }; } /** Feed one raw AI SDK stream chunk through, unchanged, as observed. */ @@ -161,7 +171,7 @@ export function observeRawChunk(tracker: ToolCallSafetyTracker, chunk: unknown): part.type === 'tool-input-delta' || part.type === 'tool-input-end' || part.type === 'tool-error'; - if (tracker.terminal !== 'pending' && isToolEvidence) tracker.terminal = 'unsafe'; + if (tracker.terminal.kind !== 'pending' && isToolEvidence) blockTerminal(tracker); switch (part.type) { case 'tool-input-start': { @@ -199,13 +209,19 @@ export function observeRawChunk(tracker: ToolCallSafetyTracker, chunk: unknown): return; } case 'finish': { - const reason = normalizedFinishReason(part.finishReason); - markTerminal(tracker, reason === 'stop' || reason === 'tool-calls'); + // A second finish-shaped event is exactly as suspicious as tool + // evidence after a terminal event — treat it the same way (blocked), + // rather than letting a later finish silently replace an earlier one. + if (tracker.terminal.kind !== 'pending') { + blockTerminal(tracker); + return; + } + tracker.terminal = { kind: 'finish', reason: normalizedFinishReason(part.finishReason) }; return; } case 'error': case 'abort': - markTerminal(tracker, false); + blockTerminal(tracker); return; default: return; @@ -216,25 +232,57 @@ function rejectedDecision(): ToolCallSafetyDecision { return { action: 'reject' }; } +/** + * Whether this request's terminal state is execution-safe, given the + * stronger provider reason `resolveToolCallSafety`'s caller already resolved + * (see below). Mirrors `chunkFinishReason`'s (model-adapter.ts) own rule — + * fall back to the provider's own spelling only when the SDK's unified + * reason is ambiguous (`"other"`/`"unknown"`) — without importing it + * directly: `tool-call-execution-guard.ts` is imported by `model-adapter.ts`, + * so the reverse import would cycle. + * + * `providerReason` may only resolve an AMBIGUOUS classification belonging to + * a real terminal event this tracker itself witnessed. It can never promote + * `pending` (no terminal event observed at all — see the module doc comment + * for why `sdk.finishReason`'s own fallback means a caller can have a + * non-empty `providerReason` even then) or `blocked` (explicit error/abort, + * or tool evidence poisoning the request) to safe, and it can never override + * a terminal event this tracker directly classified as unsafe on its own + * (`length`, `content-filter`, `stop`-with-no-safe-match, etc.) — only an + * ambiguous one. + */ +function isTerminalSafe(terminal: TerminalState, providerReason: string | undefined): boolean { + if (terminal.kind !== 'finish') return false; + if (terminal.reason === 'stop' || terminal.reason === 'tool-calls') return true; + const ambiguous = + terminal.reason === undefined || terminal.reason === 'other' || terminal.reason === 'unknown'; + if (!ambiguous) return false; + return providerReason === 'stop' || providerReason === 'tool-calls'; +} + /** * Resolves every call that actually streamed raw bytes. Positive execution * requires start + non-empty raw bytes + end + a raw-stream tool name + valid - * JSON decoded from exactly those bytes + an observed `stop`/`tool-calls` - * terminal event. Missing, contradictory, or out-of-order evidence fails - * closed. `meta` is accepted for the ModelAdapter call shape but cannot - * promote a stream with no terminal event to safe. + * JSON decoded from exactly those bytes + an execution-safe terminal + * classification (see `isTerminalSafe`). Missing, contradictory, or + * out-of-order evidence fails closed. `meta.providerReason` is the same + * finish reason `ModelAdapter.startStream` resolves for step settlement + * (`chunkFinishReason`, model-adapter.ts) — passing it lets an ambiguous + * local classification agree with the stronger, already-computed answer + * instead of the two diverging for the same physical request. */ export function resolveToolCallSafety( tracker: ToolCallSafetyTracker, - _meta?: { providerReason?: string }, + meta?: { providerReason?: string }, ): ToolCallExecutionSafety { if (tracker.resolved !== undefined) return tracker.resolved; + const terminalSafe = isTerminalSafe(tracker.terminal, meta?.providerReason); const decisions = new Map(); for (const id of tracker.idsWithRawDelta) { const state = tracker.calls.get(id); if ( - tracker.terminal !== 'safe' || + !terminalSafe || state === undefined || !state.started || !state.ended ||