diff --git a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts index cb67ccaf1c..85122f4931 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts @@ -1,6 +1,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import type { ComputerUseToolSet, MakaTool, MakaToolContext } from '@maka/runtime'; +import { + buildComputerUseTools, + type ComputerUseToolSet, + type CuDispatchBackend, + type MakaTool, + type MakaToolContext, +} from '@maka/runtime'; import type { ClientCapabilityProvider } from '@maka/runtime-host/client'; import { decodeClientCapabilityReplaceInput, @@ -54,6 +60,27 @@ test('publishes self-described session-affine Browser and Computer Use offers', ); }); +test('publishes the real Computer Use schema through the Client Capability protocol', () => { + const computerUseTools = buildComputerUseTools({ backend: computerBackend() }); + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [], + releaseBrowserSession() {}, + computerUseTools, + releaseComputerUseSession: (sessionId) => computerUseTools.clearSession(sessionId), + }); + + assert.doesNotThrow(() => + decodeClientCapabilityReplaceInput({ + registrationId: 'registration-1', + offers: provider.offers(), + }), + ); + const coordinateSchema = provider.offers()[0]?.tools[0]?.inputSchema.properties as + | Record + | undefined; + assert.equal(Array.isArray(coordinateSchema?.coordinate?.items), true); +}); + test('validates before admission and invokes the exact offered tool with Host context', async () => { let admitted = false; let invoked = false; @@ -399,6 +426,17 @@ function computerTools( return tools; } +function computerBackend(): CuDispatchBackend { + return { + async preflight() { + return { accessibility: true, screenRecording: true }; + }, + async run() { + return { outcome: { ok: true, tier: 'ax', verified: true } }; + }, + }; +} + function capabilityFrame(overrides: Partial = {}): ClientCapabilityCallFrame { return { kind: 'client.capability.call', diff --git a/packages/computer-use/src/__tests__/maka-cu-backend.test.ts b/packages/computer-use/src/__tests__/maka-cu-backend.test.ts index 96057824c7..a5e41ec7a4 100644 --- a/packages/computer-use/src/__tests__/maka-cu-backend.test.ts +++ b/packages/computer-use/src/__tests__/maka-cu-backend.test.ts @@ -469,6 +469,16 @@ describe('maka-cu backend', () => { assert.deepEqual(received(records, 'permissions.check')[0], { prompt: false }); }); + it('keeps routine preflight quiet and exposes an explicit Accessibility prompt request', async () => { + const { backend, logPath } = makeBackend(); + + await backend.preflight(signal()); + await backend.requestAccessibilityPermission(signal()); + + const checks = received(await readRecords(logPath), 'permissions.check'); + assert.deepEqual(checks, [{ prompt: false }, { prompt: true }]); + }); + it('fails loudly on a protocol version mismatch and does not retry', async () => { const { backend, logPath } = makeBackend({ protocol: 'maka.cu/99' }); await assert.rejects(backend.preflight(signal()), /service_mismatch/); diff --git a/packages/computer-use/src/maka-cu-backend.ts b/packages/computer-use/src/maka-cu-backend.ts index 825ad5edd9..d0ac2bf3b7 100644 --- a/packages/computer-use/src/maka-cu-backend.ts +++ b/packages/computer-use/src/maka-cu-backend.ts @@ -454,8 +454,9 @@ export interface MakaCuLaunchedApp { */ export type MakaCuBackend = Omit< CuDispatchBackend, - 'runSemantic' | 'observeApp' | 'captureObservation' + 'runSemantic' | 'observeApp' | 'captureObservation' | 'requestAccessibilityPermission' > & { + requestAccessibilityPermission(signal: AbortSignal): Promise; observeApp( input: { app?: string; @@ -2251,6 +2252,12 @@ export function createMakaCuBackend(opts: MakaCuBackendOptions): MakaCuBackend { }); }, + async requestAccessibilityPermission(signal) { + await withOperationQueue(signal, async () => { + await service.call('permissions.check', { prompt: true }, signal); + }); + }, + async listApps(signal) { return withOperationQueue(signal, async (): Promise => { try { diff --git a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts index 9b96d472ea..4cb272e78c 100644 --- a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts @@ -281,6 +281,51 @@ describe('Client Capability protocol', () => { ]), ), ); + assert.doesNotThrow(() => + decodeClientFrame( + replaceFrame([ + { + ...offer('tuple_schema', 'move'), + tools: [ + { + ...offer('tuple_schema', 'move').tools[0], + inputSchema: { + type: 'object', + properties: { + coordinate: { + type: 'array', + items: [{ type: 'integer' }, { type: 'integer' }], + }, + }, + }, + }, + ], + }, + ]), + ), + ); + for (const items of [[], [{ type: 'integer' }, 'not-a-schema']]) { + assert.throws( + () => + decodeClientFrame( + replaceFrame([ + { + ...offer('invalid_tuple_schema', 'move'), + tools: [ + { + ...offer('invalid_tuple_schema', 'move').tools[0], + inputSchema: { + type: 'object', + properties: { coordinate: { type: 'array', items } }, + }, + }, + ], + }, + ]), + ), + (error: unknown) => error instanceof RuntimeHostProtocolError, + ); + } assert.throws( () => decodeClientFrame({ diff --git a/packages/runtime-host/src/protocol/client-capability.ts b/packages/runtime-host/src/protocol/client-capability.ts index 6ee7bea14f..f13f625300 100644 --- a/packages/runtime-host/src/protocol/client-capability.ts +++ b/packages/runtime-host/src/protocol/client-capability.ts @@ -66,6 +66,7 @@ export const CLIENT_CAPABILITY_MAX_RESULT_CHUNKS = Math.ceil( const CLIENT_CAPABILITY_INLINE_RESULT_MAX_BYTES = 40 * 1024; const CLIENT_CAPABILITY_JSON_MAX_DEPTH = 32; const CLIENT_CAPABILITY_JSON_MAX_NODES = 8_192; +const CLIENT_CAPABILITY_TOOL_DESCRIPTION_MAX_CHARS = 8_192; const CLIENT_CAPABILITY_ERRORS = [ 'host_not_ready', 'host_draining', @@ -590,7 +591,11 @@ function decodeToolDescriptor(value: unknown): ClientCapabilityToolDescriptor { ...(record.description === undefined ? {} : { - description: requireString(record.description, 'description', 4_096), + description: requireString( + record.description, + 'description', + CLIENT_CAPABILITY_TOOL_DESCRIPTION_MAX_CHARS, + ), }), inputSchema, ...(record.annotations === undefined @@ -721,7 +726,16 @@ function validateToolInputSchema(root: Record): void { ) { visit(schema.additionalProperties); } - if (schema.items !== undefined) visit(schema.items); + if (schema.items !== undefined) { + if (Array.isArray(schema.items)) { + if (schema.items.length === 0) { + throw invalidProtocolFrame('Invalid Client Capability tool schema items'); + } + for (const nested of schema.items) visit(nested); + } else { + visit(schema.items); + } + } for (const key of ['allOf', 'anyOf', 'oneOf'] as const) { if (schema[key] === undefined) continue; if (!Array.isArray(schema[key]) || schema[key].length === 0) { diff --git a/packages/runtime/src/__tests__/computer-use-tools.test.ts b/packages/runtime/src/__tests__/computer-use-tools.test.ts index 575d915cb7..33e8323389 100644 --- a/packages/runtime/src/__tests__/computer-use-tools.test.ts +++ b/packages/runtime/src/__tests__/computer-use-tools.test.ts @@ -1791,6 +1791,28 @@ describe('buildComputerUseTools — the `maka_computer` MakaTool', () => { assert.match(r.text, /Accessibility/); }); + test('requests Accessibility once on first use while every action still preflights', async () => { + let preflights = 0; + let requests = 0; + const backend = fakeBackend({ accessibility: false }); + backend.preflight = async () => { + preflights += 1; + return { accessibility: false, screenRecording: true }; + }; + backend.requestAccessibilityPermission = async () => { + requests += 1; + }; + const [tool] = buildComputerUseTools({ backend }); + + const first = (await tool.impl({ action: 'wait' } as never, ctx())) as { text: string }; + const second = (await tool.impl({ action: 'wait' } as never, ctx())) as { text: string }; + + assert.match(first.text, /permission_missing/); + assert.match(second.text, /permission_missing/); + assert.equal(preflights, 2); + assert.equal(requests, 1); + }); + test('S12: a capture action fails closed when Screen Recording is not granted', async () => { const backend = fakeBackend({ screenRecording: false }); backend.observeApp = async () => observation(); diff --git a/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts index 48ea9c598c..29b3c9b28c 100644 --- a/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts @@ -4,13 +4,196 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it } from 'node:test'; -import type { LlmConnection, SessionEvent, SessionHeader } from '@maka/core'; +import { + createGenesisExecutionBoundary, + type LlmConnection, + type SessionEvent, + type SessionHeader, +} from '@maka/core'; import { createSqliteRuntimeStore } from '@maka/storage'; import { createSessionEventMapMemory, mapSessionEventToRuntimeEvent } from '../ai-sdk-flow.js'; import type { InvocationContext } from '../invocation-context.js'; -import { ToolRuntime, type MakaTool } from '../tool-runtime.js'; +import { MAX_ACTIVE_SUBAGENT_TOOLS_PER_TURN, ToolRuntime, type MakaTool } from '../tool-runtime.js'; describe('ToolRuntime with real SQLite boundary', () => { + it('persists a boundary-blocked Client Capability without an orphan response', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-tool-sqlite-client-capability-reject-')); + const store = createSqliteRuntimeStore(join(root, 'runtime.sqlite')); + try { + let implementationCalls = 0; + const runtime = createTestToolRuntime({ + sessionId: 'session-1', + header: header(), + connection: connection(), + modelId: 'model-1', + appendMessage: async () => {}, + readExecutionBoundary: async () => createGenesisExecutionBoundary('ask'), + newId: nextId(), + now: nextNow(), + getPermissionPauseTarget: () => null, + runId: 'run-1', + invocationId: 'invocation-1', + runtimeCommitSink: store, + }); + const published: SessionEvent[] = []; + const result = await runtime.settleToolCall({ + tool: { + name: 'mcp__desktop_computer_use__maka_computer', + description: 'Client-owned Computer Use', + parameters: {}, + categoryHint: 'client_capability', + impl: async () => { + implementationCalls += 1; + return { ok: true }; + }, + }, + turnId: 'turn-1', + toolCallId: 'provider-call-computer-use', + input: { action: 'list_apps' }, + abortSignal: new AbortController().signal, + eventSink: { + push: (event) => published.push(event), + pushAndWaitUntilConsumed: async (event) => { + published.push(event); + }, + }, + }); + + assert.equal(implementationCalls, 0); + assert.match(JSON.stringify(result.result), /require the Bypass execution boundary/u); + const toolEvents = published.filter( + (event) => event.type === 'tool_start' || event.type === 'tool_result', + ); + assert.equal(toolEvents.length, 2); + assert.equal( + toolEvents.some((event) => event.operationId !== undefined), + false, + ); + + const memory = createSessionEventMapMemory(); + for (const event of toolEvents) { + await store.appendRuntimeEvent( + 'session-1', + 'run-1', + mapSessionEventToRuntimeEvent(event, invocationContext(), memory), + ); + } + assert.deepEqual( + (await store.readRuntimeEvents('session-1', 'run-1')).map((event) => event.content?.kind), + ['function_call', 'function_response'], + ); + } finally { + store.close(); + await rm(root, { recursive: true, force: true }); + } + }); + + it('persists a subagent admission rejection without an orphan response', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-tool-sqlite-subagent-limit-')); + const store = createSqliteRuntimeStore(join(root, 'runtime.sqlite')); + const releases: Array<() => void> = []; + const pending: Promise[] = []; + try { + let implementationsStarted = 0; + let resolveAllStarted!: () => void; + const allStarted = new Promise((resolve) => { + resolveAllStarted = resolve; + }); + const runtime = createTestToolRuntime({ + sessionId: 'session-1', + header: header(), + connection: connection(), + modelId: 'model-1', + appendMessage: async () => {}, + newId: nextId(), + now: nextNow(), + getPermissionPauseTarget: () => null, + runId: 'run-1', + invocationId: 'invocation-1', + runtimeCommitSink: store, + }); + const tool: MakaTool = { + name: 'agent_probe', + description: 'probe', + parameters: {}, + categoryHint: 'subagent', + impl: async () => { + implementationsStarted += 1; + if (implementationsStarted === MAX_ACTIVE_SUBAGENT_TOOLS_PER_TURN) resolveAllStarted(); + await new Promise((resolve) => releases.push(resolve)); + return { ok: true }; + }, + }; + const quietSink = { + push: (_event: SessionEvent) => {}, + pushAndWaitUntilConsumed: async (_event: SessionEvent) => {}, + }; + for (let index = 0; index < MAX_ACTIVE_SUBAGENT_TOOLS_PER_TURN; index += 1) { + pending.push( + runtime.settleToolCall({ + tool, + turnId: 'turn-1', + toolCallId: `provider-call-active-${index}`, + input: {}, + abortSignal: new AbortController().signal, + eventSink: quietSink, + }), + ); + } + await withTimeout(allStarted, 'Timed out waiting for subagent slots to fill'); + + const published: SessionEvent[] = []; + const rejected = await runtime.settleToolCall({ + tool, + turnId: 'turn-1', + toolCallId: 'provider-call-rejected', + input: {}, + abortSignal: new AbortController().signal, + eventSink: { + push: (event) => published.push(event), + pushAndWaitUntilConsumed: async (event) => { + published.push(event); + }, + }, + }); + + assert.match(JSON.stringify(rejected.result), /subagents|子代理/u); + const toolEvents = published.filter( + (event) => event.type === 'tool_start' || event.type === 'tool_result', + ); + assert.equal(toolEvents.length, 2); + assert.equal( + toolEvents.some((event) => event.operationId !== undefined), + false, + ); + + const memory = createSessionEventMapMemory(); + for (const event of toolEvents) { + await store.appendRuntimeEvent( + 'session-1', + 'run-1', + mapSessionEventToRuntimeEvent(event, invocationContext(), memory), + ); + } + const rejectedEvents = (await store.readRuntimeEvents('session-1', 'run-1')).filter( + (event) => + event.content?.kind === 'function_call' + ? event.content.id === 'provider-call-rejected' + : event.content?.kind === 'function_response' && + event.content.id === 'provider-call-rejected', + ); + assert.deepEqual( + rejectedEvents.map((event) => event.content?.kind), + ['function_call', 'function_response'], + ); + } finally { + for (const release of releases) release(); + await Promise.allSettled(pending); + store.close(); + await rm(root, { recursive: true, force: true }); + } + }); + it('persists a preflight-rejected sibling beside an exclusive tool without an orphan response', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-tool-sqlite-exclusive-reject-')); const store = createSqliteRuntimeStore(join(root, 'runtime.sqlite')); @@ -321,3 +504,17 @@ function nextNow(): () => number { let value = 0; return () => ++value; } + +async function withTimeout(promise: Promise, message: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(message)), 1_000); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} diff --git a/packages/runtime/src/computer-use-tools.ts b/packages/runtime/src/computer-use-tools.ts index 9a43a3fc57..94f8de3576 100644 --- a/packages/runtime/src/computer-use-tools.ts +++ b/packages/runtime/src/computer-use-tools.ts @@ -649,6 +649,7 @@ export function buildComputerUseTools(deps: { const presentationGenerations = new Map(); const pendingInvocationTurns = new Map>(); let presentationQueue = Promise.resolve(); + let accessibilityPermissionRequested = false; interface SessionObservationRecord { turnId: string; state: CuaFrameState; @@ -1624,6 +1625,16 @@ export function buildComputerUseTools(deps: { // S12: re-check TCC at action-start; cached "granted" is insufficient. const tcc = await deps.backend.preflight(abortSignal); if (!tcc.accessibility) { + if (!accessibilityPermissionRequested && deps.backend.requestAccessibilityPermission) { + accessibilityPermissionRequested = true; + try { + await deps.backend.requestAccessibilityPermission(abortSignal); + } catch { + // Prompting is best-effort presentation. The live preflight + // result remains the authority and still fails this action + // closed with the stable permission guidance below. + } + } return { text: 'maka_computer failed: permission_missing — Accessibility not granted (System Settings → Privacy & Security → Accessibility)', }; diff --git a/packages/runtime/src/computer-use-types.ts b/packages/runtime/src/computer-use-types.ts index f7094442a1..0a782811fe 100644 --- a/packages/runtime/src/computer-use-types.ts +++ b/packages/runtime/src/computer-use-types.ts @@ -340,6 +340,15 @@ export interface CuDispatchBackend { /** Live macOS TCC status. Called at EVERY action-start — cached "granted" is * insufficient because the user can revoke at any time (S12). */ preflight(signal: AbortSignal): Promise<{ accessibility: boolean; screenRecording: boolean }>; + /** + * Ask the native executor to show the Accessibility consent prompt. + * + * This is separate from `preflight`: the latter runs before every action and + * must stay free of presentation side effects. The tool layer invokes this + * seam at most once, when an explicit Computer Use call first finds the grant + * missing. + */ + requestAccessibilityPermission?(signal: AbortSignal): Promise; listApps?(signal: AbortSignal): Promise; /** * Start an app in the background. The launched app must not take focus — diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 966743bd10..e22da26022 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -847,7 +847,6 @@ export class ToolRuntime { const now = this.input.now(); const toolIntent = describeToolIntent(tool, persistedArgs); const trace = this.input.getRunTrace?.() ?? null; - const runId = this.input.runId; const invocationId = this.input.invocationId ?? runId; if (this.input.runtimeCommitSink && !runId) { @@ -856,13 +855,56 @@ export class ToolRuntime { new Error('Durable tool execution requires a run id'), ); } - // Exclusive-step rejection is preflight: it must remain on the generic - // call/response lane instead of claiming the T1 dispatch protocol. If the - // call carried an operationId here, AgentRun would (correctly) skip its - // generic projection assuming commitToolPrepared already persisted it; - // the synthetic response would then become an orphan. + const callSignature = `${tool.name} ${loopGateArgsKey(executionArgs, toolUseId)}`; + const computerSemanticSignature = + tool.categoryHint === 'computer_use' + ? computerUseSemanticSignature(permissionArgs) + : undefined; + const repeatedAmbiguousComputerTarget = + computerSemanticSignature !== undefined && + computerSemanticSignature === this.lastAmbiguousComputerSignature; + const repeatedFailedCall = + callSignature === this.lastFailedToolCallSignature && + this.failedToolCallStreak >= LOOP_GATE_IDENTICAL_THRESHOLD - 1; + const deferredToolNotLoaded = + this.gating !== undefined && + this.gating.gatedNames.has(tool.name) && + !this.gating.activeNames().has(tool.name); + const rejectedBeforeClientBoundary = + admissionFailure !== undefined || + permissionArgsError !== undefined || + repeatedAmbiguousComputerTarget || + repeatedFailedCall || + deferredToolNotLoaded; + let clientCapabilityBoundary: ExecutionBoundary | undefined; + let clientCapabilityBoundaryReadFailed = false; + let clientCapabilityBoundaryReadError: unknown; + if (!rejectedBeforeClientBoundary && tool.categoryHint === 'client_capability') { + try { + clientCapabilityBoundary = await this.readExecutionBoundary(); + } catch (error) { + clientCapabilityBoundaryReadFailed = true; + clientCapabilityBoundaryReadError = error; + } + } + const clientCapabilityBoundaryRejected = + clientCapabilityBoundaryReadFailed || + (tool.categoryHint === 'client_capability' && clientCapabilityBoundary?.kind !== 'bypass'); + const rejectedBeforeSubagentAdmission = + rejectedBeforeClientBoundary || clientCapabilityBoundaryRejected; + // Slot admission is part of preflight too. Reserve it before assigning a + // durable operation id so a saturated subagent call stays on the generic + // call/response lane just like every other pre-dispatch rejection. + const reservedSubagentSlot = !rejectedBeforeSubagentAdmission && this.reserveSubagentSlot(tool); + const preflightRejected = rejectedBeforeSubagentAdmission || !reservedSubagentSlot; + + // Preflight rejection must remain on the generic call/response lane instead + // of claiming the T1 dispatch protocol. If the call carried an operationId + // here, AgentRun would (correctly) skip its generic projection assuming + // commitToolPrepared already persisted it; the synthetic response would + // then become an orphan. const operationId = - this.input.runtimeCommitSink && invocationId && !admissionFailure + this.input.runtimeCommitSink && invocationId && !preflightRejected ? buildToolOperationId({ invocationId, providerToolCallId: toolUseId }) : undefined; const startEv: ToolStartEvent = { @@ -899,14 +941,18 @@ export class ToolRuntime { // timeline and post-restart backfill can pair this call with its step. ...(stepId !== undefined ? { stepId } : {}), }; - await this.input.appendMessage(callMsg); - queue.push(startEv); - trace?.emit('tool', 'tool_started', 'Tool execution started', { - toolUseId, - toolName: tool.name, - ...(tool.categoryHint !== undefined ? { categoryHint: tool.categoryHint } : {}), - }); - const callSignature = `${tool.name} ${loopGateArgsKey(executionArgs, toolUseId)}`; + try { + await this.input.appendMessage(callMsg); + queue.push(startEv); + trace?.emit('tool', 'tool_started', 'Tool execution started', { + toolUseId, + toolName: tool.name, + ...(tool.categoryHint !== undefined ? { categoryHint: tool.categoryHint } : {}), + }); + } catch (error) { + if (reservedSubagentSlot) this.releaseSubagentSlot(tool); + throw error; + } if (admissionFailure) { await this.writeSyntheticToolResult(toolUseId, turnId, admissionFailure, queue); trace?.emit('tool', 'tool_failed', 'Tool rejected by exclusive-step admission', { @@ -919,10 +965,6 @@ export class ToolRuntime { this.recordLoopGateOutcome(callSignature, true); return this.errorReturn(admissionFailure); } - const computerSemanticSignature = - tool.categoryHint === 'computer_use' - ? computerUseSemanticSignature(permissionArgs) - : undefined; if (permissionArgsError !== undefined) { // Computer Use keeps its own formatter: the generic one relays whatever // the error carries, and these arguments can hold typed text. The @@ -997,10 +1039,7 @@ export class ToolRuntime { // so polling and iterate-then-retry are never gated. Recoverable: the model // is told to change its approach. The block itself records no outcome, so the // streak stays parked and every further identical repeat stays blocked. - if ( - computerSemanticSignature && - computerSemanticSignature === this.lastAmbiguousComputerSignature - ) { + if (repeatedAmbiguousComputerTarget) { const reason = formatAmbiguousComputerLoopGateText(); await this.writeSyntheticToolResult(toolUseId, turnId, reason, queue); trace?.emit('tool', 'tool_failed', 'Blocked repeated ambiguous Computer Use target', { @@ -1018,10 +1057,7 @@ export class ToolRuntime { ) { this.lastAmbiguousComputerSignature = undefined; } - if ( - callSignature === this.lastFailedToolCallSignature && - this.failedToolCallStreak >= LOOP_GATE_IDENTICAL_THRESHOLD - 1 - ) { + if (repeatedFailedCall) { const reason = formatLoopGateText(tool.name); await this.writeSyntheticToolResult(toolUseId, turnId, reason, queue); trace?.emit('tool', 'tool_failed', 'Loop-gate blocked a repeated identical failing call', { @@ -1040,11 +1076,7 @@ export class ToolRuntime { // before permission eval and before the real impl. This also closes the AI // SDK `activeTools` leak (vercel/ai#8653). The rejection is recoverable: the // model loads via `load_tools`, then retries next step. - if ( - this.gating && - this.gating.gatedNames.has(tool.name) && - !this.gating.activeNames().has(tool.name) - ) { + if (deferredToolNotLoaded) { const reason = formatDeferredNotLoadedText(tool.name); await this.writeSyntheticToolResult(toolUseId, turnId, reason, queue); trace?.emit('tool', 'tool_failed', 'Deferred tool used before load', { @@ -1057,12 +1089,9 @@ export class ToolRuntime { return this.errorReturn(reason); } - let clientCapabilityBoundary: ExecutionBoundary | undefined; if (tool.categoryHint === 'client_capability') { - try { - clientCapabilityBoundary = await this.readExecutionBoundary(); - } catch (error) { - const reason = formatSyntheticToolErrorText(error); + if (clientCapabilityBoundaryReadFailed) { + const reason = formatSyntheticToolErrorText(clientCapabilityBoundaryReadError); await this.writeSyntheticToolResult(toolUseId, turnId, reason, queue); trace?.emit('tool', 'tool_failed', 'Client Capability boundary read failed', { toolUseId, @@ -1073,7 +1102,7 @@ export class ToolRuntime { this.recordLoopGateOutcome(callSignature, true); return this.errorReturn(reason); } - if (clientCapabilityBoundary.kind !== 'bypass') { + if (clientCapabilityBoundary?.kind !== 'bypass') { await this.writeSyntheticToolResult( toolUseId, turnId, @@ -1091,7 +1120,6 @@ export class ToolRuntime { } } - const reservedSubagentSlot = this.reserveSubagentSlot(tool); if (!reservedSubagentSlot) { trace?.emit('tool', 'tool_failed', 'Tool execution rejected by runtime limit', { toolUseId,