diff --git a/packages/core/src/__tests__/run-composition.test.ts b/packages/core/src/__tests__/run-composition.test.ts index d79f009789..1d0167762a 100644 --- a/packages/core/src/__tests__/run-composition.test.ts +++ b/packages/core/src/__tests__/run-composition.test.ts @@ -20,11 +20,15 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { + createRequestCompositionSnapshot, + COMPOSITION_MAX_TOOLS, + decodeRequestCompositionSnapshot, decodeRunCompositionSnapshot, + REQUEST_COMPOSITION_SCHEMA_VERSION, RUN_COMPOSITION_SCHEMA_VERSION, } from '../run-composition.js'; -test('Run Composition snapshots reject ambiguous toolsets and malformed hashes', () => { +test('Run Composition snapshots retain the persisted v1 bootstrap shape', () => { const valid = { schemaVersion: RUN_COMPOSITION_SCHEMA_VERSION, composerId: 'maka.interactive', @@ -38,23 +42,86 @@ test('Run Composition snapshots reject ambiguous toolsets and malformed hashes', contextWindow: null, }; + assert.equal(decodeRunCompositionSnapshot(valid).schemaVersion, 1); + for (const candidate of [ + { ...valid, schemaVersion: 2 }, { ...valid, baseSystemPromptHash: 'sha256:short' }, { ...valid, toolNames: ['Write', 'Read'] }, { ...valid, toolNames: ['Read', 'Read'] }, - { - ...valid, - sourceRevisions: [ - { id: 'skill-catalog', revision: 'skills-0' }, - { id: 'runtime-policy', revision: '1' }, - ], - }, - { ...valid, sourceRevisions: [{ id: 'skill-catalog', revision: '' }] }, ]) { assert.throws(() => decodeRunCompositionSnapshot(candidate)); } }); +test('Request Composition snapshots canonicalize complete model-visible tool surfaces', () => { + const snapshot = createRequestCompositionSnapshot( + { + compositionId: 'composition-1', + step: 1, + sourceRevisions: [{ id: 'skill-catalog', revision: 'skills-1' }], + systemPromptHash: hash('1'), + toolCatalogHash: hash('2'), + toolAvailabilityHash: hash('3'), + providerOptionsHash: hash('4'), + toolNames: ['Write', 'Read'], + toolSchemas: [ + { name: 'Write', description: 'write', inputSchema: { type: 'object' } }, + { name: 'Read', description: 'read', inputSchema: { type: 'object' } }, + ], + }, + 'change', + ); + assert.equal(snapshot.schemaVersion, REQUEST_COMPOSITION_SCHEMA_VERSION); + assert.deepEqual(snapshot.toolNames, ['Read', 'Write']); + assert.deepEqual( + snapshot.toolSchemas.map((schema) => schema.name), + ['Read', 'Write'], + ); + assert.throws(() => + decodeRequestCompositionSnapshot({ ...snapshot, toolNames: ['Read', 'Read'] }), + ); +}); + +test('Request Composition applies one fail-closed bound to names and schemas', () => { + const toolNames = Array.from( + { length: COMPOSITION_MAX_TOOLS }, + (_, index) => `tool-${index.toString().padStart(3, '0')}`, + ); + const toolSchemas = toolNames.map((name) => ({ + name, + description: name, + inputSchema: { type: 'object' }, + })); + const valid = { + schemaVersion: REQUEST_COMPOSITION_SCHEMA_VERSION, + compositionId: 'composition-bounded', + step: 0, + reason: 'initial', + sourceRevisions: [], + systemPromptHash: hash('1'), + toolCatalogHash: hash('2'), + toolAvailabilityHash: hash('3'), + providerOptionsHash: hash('4'), + toolNames, + toolSchemas, + } as const; + + assert.equal(decodeRequestCompositionSnapshot(valid).toolNames.length, COMPOSITION_MAX_TOOLS); + assert.throws(() => + decodeRequestCompositionSnapshot({ ...valid, toolNames: [...toolNames, 'tool-overflow'] }), + ); + assert.throws(() => + decodeRequestCompositionSnapshot({ + ...valid, + toolSchemas: [ + ...toolSchemas, + { name: 'tool-overflow', description: 'overflow', inputSchema: { type: 'object' } }, + ], + }), + ); +}); + function hash(seed: string): `sha256:${string}` { return `sha256:${seed.repeat(64)}`; } diff --git a/packages/core/src/agent-run.ts b/packages/core/src/agent-run.ts index ffc7e44d4c..8a24f6ce1b 100644 --- a/packages/core/src/agent-run.ts +++ b/packages/core/src/agent-run.ts @@ -188,7 +188,7 @@ export interface AgentRunHeader { agentSwarmAuthorization?: AgentSwarmAuthorizationSource; /** Effective tool protocol for this run. Optional on legacy runs. */ toolMode?: ToolMode; - /** Immutable composer-owned prompt and tool-surface snapshot committed before provider dispatch. */ + /** Immutable composer and provider bootstrap baseline committed before provider dispatch. */ runComposition?: RunCompositionSnapshot; createdAt: number; updatedAt: number; @@ -429,6 +429,7 @@ export const AGENT_RUN_EVENT_TYPES = [ 'sandbox_denial_detected', 'provider_request_captured', 'provider_request_attempt_recorded', + 'request_composition_resolved', 'model_call_attempt_recorded', 'history_compact_checkpoint_recorded', 'model_projection_transition_recorded', diff --git a/packages/core/src/model-call-attempt.ts b/packages/core/src/model-call-attempt.ts index efac9504c7..20f868d4ac 100644 --- a/packages/core/src/model-call-attempt.ts +++ b/packages/core/src/model-call-attempt.ts @@ -155,6 +155,8 @@ export interface ModelCallAttempt { /** Runtime tool-loop step index within the turn. */ step: number; + /** Logical request-composition snapshot used by this step and all of its retries. */ + requestCompositionId?: string; /** Retry ordinal within the logical call; 0 is the first dispatch. */ attempt: number; @@ -224,6 +226,7 @@ const MODEL_CALL_ATTEMPT_SHAPE = defineObjectShape()( [ 'connectionSlug', 'historyCompactRoute', + 'requestCompositionId', 'contextWindow', 'captureArtifactId', 'requestObservation', @@ -410,6 +413,7 @@ export function decodeModelCallAttempt(value: unknown): ModelCallAttempt { isNonEmptyString(value.runId) && isNonEmptyString(value.turnId) && isNonNegativeInteger(value.step) && + isOptionalString(value.requestCompositionId) && isNonNegativeInteger(value.attempt) && (MODEL_CALL_KINDS as readonly unknown[]).includes(value.callKind) && (value.historyCompactRoute === undefined || diff --git a/packages/core/src/run-composition.ts b/packages/core/src/run-composition.ts index 8da0b8ee69..3eada5dfaf 100644 --- a/packages/core/src/run-composition.ts +++ b/packages/core/src/run-composition.ts @@ -20,6 +20,12 @@ import { defineObjectShape, hasExactShape, isRecord } from './record-schema.js'; export const RUN_COMPOSITION_SCHEMA_VERSION = 1 as const; +export const REQUEST_COMPOSITION_SCHEMA_VERSION = 1 as const; +// Composition evidence is exact: reject an over-bound provider surface rather +// than silently truncating the resolved snapshot. +export const COMPOSITION_MAX_TOOLS = 256; +export const COMPOSITION_MAX_TOOL_NAME_LENGTH = 128; +export const REQUEST_COMPOSITION_MAX_TOOL_DESCRIPTION_LENGTH = 16_384; export interface RunCompositionSourceRevision { readonly id: string; @@ -39,6 +45,32 @@ export interface RunCompositionSnapshot { readonly contextWindow: number | null; } +export interface RequestCompositionToolSchema { + readonly name: string; + readonly description: string; + readonly inputSchema: Record; + readonly providerTool?: Record; +} + +/** + * One model-visible request surface, frozen at a logical model-step boundary. + * A later step appends a new snapshot only when one of these effective fields + * changes; physical retries of the same step keep referring to this snapshot. + */ +export interface RequestCompositionSnapshot { + readonly schemaVersion: typeof REQUEST_COMPOSITION_SCHEMA_VERSION; + readonly compositionId: string; + readonly step: number; + readonly reason: 'initial' | 'change'; + readonly sourceRevisions: readonly RunCompositionSourceRevision[]; + readonly systemPromptHash: `sha256:${string}`; + readonly toolCatalogHash: `sha256:${string}`; + readonly toolAvailabilityHash: `sha256:${string}`; + readonly providerOptionsHash: `sha256:${string}`; + readonly toolNames: readonly string[]; + readonly toolSchemas: readonly RequestCompositionToolSchema[]; +} + const RUN_COMPOSITION_SHAPE = defineObjectShape()( [ 'schemaVersion', @@ -54,6 +86,26 @@ const RUN_COMPOSITION_SHAPE = defineObjectShape()( ], [], ); +const REQUEST_COMPOSITION_SHAPE = defineObjectShape()( + [ + 'schemaVersion', + 'compositionId', + 'step', + 'reason', + 'sourceRevisions', + 'systemPromptHash', + 'toolCatalogHash', + 'toolAvailabilityHash', + 'providerOptionsHash', + 'toolNames', + 'toolSchemas', + ], + [], +); +const REQUEST_COMPOSITION_TOOL_SCHEMA_SHAPE = defineObjectShape()( + ['name', 'description', 'inputSchema'], + ['providerTool'], +); const ID_PATTERN = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/; const HASH_PATTERN = /^sha256:[a-f0-9]{64}$/; @@ -105,6 +157,63 @@ export function createRunCompositionSnapshot( }); } +export type RequestCompositionSnapshotInput = Omit< + RequestCompositionSnapshot, + 'schemaVersion' | 'reason' +>; + +export function createRequestCompositionSnapshot( + input: RequestCompositionSnapshotInput, + reason: RequestCompositionSnapshot['reason'], +): RequestCompositionSnapshot { + return decodeRequestCompositionSnapshot({ + schemaVersion: REQUEST_COMPOSITION_SCHEMA_VERSION, + ...input, + reason, + sourceRevisions: [...input.sourceRevisions].sort((left, right) => + compareExactString(left.id, right.id), + ), + toolNames: [...input.toolNames].sort(compareExactString), + toolSchemas: [...input.toolSchemas].sort((left, right) => + compareExactString(left.name, right.name), + ), + }); +} + +export function decodeRequestCompositionSnapshot(value: unknown): RequestCompositionSnapshot { + if (!isRecord(value) || !hasExactShape(value, REQUEST_COMPOSITION_SHAPE)) { + throw new Error('Invalid Request Composition snapshot schema'); + } + const valid = + value.schemaVersion === REQUEST_COMPOSITION_SCHEMA_VERSION && + boundedString(value.compositionId, 128) && + Number.isSafeInteger(value.step) && + (value.step as number) >= 0 && + (value.reason === 'initial' || value.reason === 'change') && + canonicalSourceRevisions(value.sourceRevisions) && + hash(value.systemPromptHash) && + hash(value.toolCatalogHash) && + hash(value.toolAvailabilityHash) && + hash(value.providerOptionsHash) && + canonicalToolNames(value.toolNames) && + canonicalToolSchemas(value.toolSchemas); + if (!valid) throw new Error('Invalid Request Composition snapshot schema'); + return Object.freeze({ + ...(value as unknown as RequestCompositionSnapshot), + sourceRevisions: Object.freeze( + (value.sourceRevisions as RunCompositionSourceRevision[]).map((source) => + Object.freeze({ ...source }), + ), + ), + toolNames: Object.freeze([...(value.toolNames as string[])]), + toolSchemas: Object.freeze( + (value.toolSchemas as RequestCompositionToolSchema[]).map((schema) => + Object.freeze(structuredClone(schema)), + ), + ), + }); +} + function compareExactString(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; } @@ -128,15 +237,40 @@ function canonicalSourceRevisions(value: unknown): value is RunCompositionSource } function canonicalToolNames(value: unknown): value is string[] { - if (!Array.isArray(value) || value.length > 256) return false; + if (!Array.isArray(value) || value.length > COMPOSITION_MAX_TOOLS) return false; let previous: string | undefined; for (const name of value) { - if (!boundedString(name, 128) || (previous !== undefined && previous >= name)) return false; + if ( + !boundedString(name, COMPOSITION_MAX_TOOL_NAME_LENGTH) || + (previous !== undefined && previous >= name) + ) { + return false; + } previous = name; } return true; } +function canonicalToolSchemas(value: unknown): value is RequestCompositionToolSchema[] { + if (!Array.isArray(value) || value.length > COMPOSITION_MAX_TOOLS) return false; + let previous: string | undefined; + for (const schema of value) { + if ( + !isRecord(schema) || + !hasExactShape(schema, REQUEST_COMPOSITION_TOOL_SCHEMA_SHAPE) || + !boundedString(schema.name, COMPOSITION_MAX_TOOL_NAME_LENGTH) || + !boundedString(schema.description, REQUEST_COMPOSITION_MAX_TOOL_DESCRIPTION_LENGTH) || + !isRecord(schema.inputSchema) || + (schema.providerTool !== undefined && !isRecord(schema.providerTool)) || + (previous !== undefined && previous >= schema.name) + ) { + return false; + } + previous = schema.name; + } + return true; +} + function hash(value: unknown): value is `sha256:${string}` { return typeof value === 'string' && HASH_PATTERN.test(value); } diff --git a/packages/runtime-host/src/__tests__/client-capability-uds.test.ts b/packages/runtime-host/src/__tests__/client-capability-uds.test.ts index 19366443e7..b99987ebe1 100644 --- a/packages/runtime-host/src/__tests__/client-capability-uds.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-uds.test.ts @@ -205,7 +205,7 @@ test('unknown Client Capability loads, invokes, and rebinds after UDS reconnect' abortSignal: new AbortController().signal, emitOutput: () => undefined, }; - const activeTools = new Map(); + const activeTools = new Map(); const availability = new ToolAvailabilityRuntime( snapshot.tools, { groups: snapshot.groups }, diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 782695d39b..6ce702fb2a 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -39,7 +39,10 @@ import { } from '@maka/core/sandbox-boundary'; import { PROVIDER_REGISTRY } from '@maka/core/llm-connections'; import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; -import { decodeRunCompositionSnapshot } from '@maka/core/run-composition'; +import { + decodeRequestCompositionSnapshot, + decodeRunCompositionSnapshot, +} from '@maka/core/run-composition'; import type { AgentRunHeader } from '@maka/core/agent-run'; import type { BackendCompactHistoryInput } from '@maka/core/backend-types'; import { decodeCanonicalToolResultContent } from '@maka/core/tool-result-record-schema'; @@ -808,6 +811,153 @@ test('provider dispatch fails closed when the Run Composition commit fails', asy } }); +test('a failed Run Composition commit can recover on a later dispatch', async () => { + const provider = await startProvider(); + let commits = 0; + let backend: Awaited> | undefined; + try { + backend = await createHostAiSdkBackend( + backendCreationFixture({ + abortSignal: new AbortController().signal, + resolveExecutionConnection: async () => readyExecutionConnection(provider.baseUrl), + readPricing: async () => ({ revision: 0, overrides: [] }), + executionBoundary: createBypassExecutionBoundary(0), + recordRunComposition: async (_runId, snapshot) => { + commits += 1; + decodeRunCompositionSnapshot(snapshot); + if (commits === 1) throw new Error('transient Run Composition failure'); + }, + }), + ); + for await (const _event of backend.send({ + invocationId: 'composition-retry-invocation-1', + runId: 'composition-retry-run', + turnId: 'composition-retry-turn-1', + text: 'The first request must fail closed.', + context: [], + })) { + // Drain the failed attempt. + } + assert.equal(provider.requests.length, 0); + + for await (const _event of backend.send({ + invocationId: 'composition-retry-invocation-2', + runId: 'composition-retry-run', + turnId: 'composition-retry-turn-2', + text: 'Retry after the authority recovers.', + context: [], + })) { + // Drain the successful retry. + } + + assert.equal(commits, 2); + assert.equal(provider.requests.length, 1); + } finally { + await backend?.dispose(); + await provider.close(); + } +}); + +test('Run Composition keeps the immutable composer Tool baseline', async () => { + const provider = await startProvider(); + const makeTool = (name: string): MakaTool => ({ + name, + description: name, + parameters: z.object({}), + impl: async () => name, + }); + const initial = makeTool('initial_tool'); + const dynamic = makeTool('dynamic_tool'); + let currentTools: readonly MakaTool[] = [initial]; + let committedToolNames: readonly string[] = []; + let backend: Awaited> | undefined; + try { + backend = await createHostAiSdkBackend( + backendCreationFixture({ + abortSignal: new AbortController().signal, + resolveExecutionConnection: async () => readyExecutionConnection(provider.baseUrl), + readPricing: async () => ({ revision: 0, overrides: [] }), + executionBoundary: createBypassExecutionBoundary(0), + createRunComposer: async () => ({ + composerId: 'test.dynamic-tools', + composerRevision: '1', + tools: [initial], + resolveTools: () => currentTools, + resolveSystemPrompt: async () => ({ text: 'test prompt', sourceRevisions: [] }), + }), + recordRunComposition: async (_runId, snapshot) => { + committedToolNames = decodeRunCompositionSnapshot(snapshot).toolNames; + }, + }), + ); + currentTools = [dynamic]; + + for await (const _event of backend.send({ + invocationId: 'composition-baseline-invocation', + runId: 'composition-baseline-run', + turnId: 'composition-baseline-turn', + text: 'Use the current Tool surface.', + context: [], + })) { + // Drain the request. + } + + assert.deepEqual(committedToolNames, ['initial_tool']); + const requestTools = provider.requests[0]?.body.tools as Array<{ + function?: { name?: string }; + }>; + assert.equal( + requestTools.some((entry) => entry.function?.name === 'dynamic_tool'), + true, + ); + assert.equal( + requestTools.some((entry) => entry.function?.name === 'initial_tool'), + false, + ); + } finally { + await backend?.dispose(); + await provider.close(); + } +}); + +test('provider dispatch fails closed when the Request Composition epoch commit fails', async () => { + const provider = await startProvider(); + let commits = 0; + let backend: Awaited> | undefined; + try { + backend = await createHostAiSdkBackend( + backendCreationFixture({ + abortSignal: new AbortController().signal, + resolveExecutionConnection: async () => readyExecutionConnection(provider.baseUrl), + readPricing: async () => ({ revision: 0, overrides: [] }), + executionBoundary: createBypassExecutionBoundary(0), + recordRunComposition: async () => undefined, + recordRequestComposition: async () => { + commits += 1; + throw new Error('Request Composition store unavailable'); + }, + }), + ); + const events = []; + for await (const event of backend.send({ + invocationId: 'request-composition-invocation', + runId: 'request-composition-run', + turnId: 'request-composition-turn', + text: 'This request must not reach the provider.', + context: [], + })) { + events.push(event); + } + + assert.equal(commits, 1); + assert.equal(provider.requests.length, 0); + assert.ok(events.some((event) => event.type === 'error')); + } finally { + await backend?.dispose(); + await provider.close(); + } +}); + test('Codex OAuth history compaction falls back to a text checkpoint after native rejection', async () => { const modelId = 'gpt-5.6-sol'; const requests: Array<{ url: string; body: Record }> = []; @@ -2225,8 +2375,32 @@ test('production Host executes and durably supervises an Agent Graph over a real const rootRun = runs.find((run) => run.runId === initialTerminal.runId); assert.equal(rootRun?.runComposition?.composerId, 'maka.interactive'); assert.equal(rootRun?.runComposition?.contextWindow, 32_768); - assert.match(rootRun?.runComposition?.baseSystemPromptHash ?? '', /^sha256:[a-f0-9]{64}$/u); - assert.ok(rootRun?.runComposition?.toolNames.includes('view_agent_graph')); + const rootRunEvents = await execution.agentRunStore.readEvents( + session.id, + initialTerminal.runId, + ); + const requestCompositions = rootRunEvents + .filter((event) => event.type === 'request_composition_resolved') + .map((event) => decodeRequestCompositionSnapshot(event.data?.snapshot)); + assert.ok(requestCompositions.length > 0); + assert.match(requestCompositions[0]?.systemPromptHash ?? '', /^sha256:[a-f0-9]{64}$/u); + assert.ok( + requestCompositions.some((snapshot) => snapshot.toolNames.includes('view_agent_graph')), + ); + const requestCompositionIds = new Set( + requestCompositions.map((snapshot) => snapshot.compositionId), + ); + const modelAttempts = rootRunEvents.filter( + (event) => event.type === 'model_call_attempt_recorded', + ); + assert.ok(modelAttempts.length > 0); + assert.ok( + modelAttempts.every( + (event) => + typeof event.data?.requestCompositionId === 'string' && + requestCompositionIds.has(event.data.requestCompositionId), + ), + ); const wakeRuns = runs.filter((run) => run.agentGraphWakeAttemptId !== undefined); assert.ok(wakeRuns.length > 0); assert.ok(wakeRuns.every((run) => run.status === 'completed')); @@ -3947,6 +4121,7 @@ function backendCreationFixture(input: { recordRunTrace?: (event: RunTraceEvent) => unknown; runtimeCommitSink?: HostAiSdkBackendInput['runtimeCommitSink']; recordRunComposition?: BackendFactoryContext['recordRunComposition']; + recordRequestComposition?: BackendFactoryContext['recordRequestComposition']; recordHistoryCompactCheckpoint?: BackendFactoryContext['recordHistoryCompactCheckpoint']; recordModelCallAttempt?: BackendFactoryContext['recordModelCallAttempt']; createFetchTransport?: HostAiSdkBackendInput['createFetchTransport']; @@ -4010,6 +4185,9 @@ function backendCreationFixture(input: { : {}), ...(input.recordRunTrace ? { recordRunTrace: input.recordRunTrace } : {}), ...(input.recordRunComposition ? { recordRunComposition: input.recordRunComposition } : {}), + ...(input.recordRequestComposition + ? { recordRequestComposition: input.recordRequestComposition } + : {}), ...(input.recordHistoryCompactCheckpoint ? { recordHistoryCompactCheckpoint: input.recordHistoryCompactCheckpoint } : {}), diff --git a/packages/runtime-host/src/__tests__/interactive-run-composer.test.ts b/packages/runtime-host/src/__tests__/interactive-run-composer.test.ts index c2bac3e5bc..2befc4173f 100644 --- a/packages/runtime-host/src/__tests__/interactive-run-composer.test.ts +++ b/packages/runtime-host/src/__tests__/interactive-run-composer.test.ts @@ -56,6 +56,86 @@ test('Deep Research keeps standard inspection tools and its durable workspace to } }); +test('the composer resolves scoped Tool additions without rebuilding the backend', () => { + let additions: readonly MakaTool[] = []; + const dynamic = tool('dynamic_tool'); + const composer = createFixtureComposer({ resolveAdditionalTools: () => additions }); + + assert.equal( + composer.tools.some(({ name }) => name === dynamic.name), + false, + ); + additions = [dynamic]; + assert.equal( + composer.resolveTools?.().some(({ name }) => name === dynamic.name), + true, + ); +}); + +test('the composer keeps Host bindings stable while resampling scoped Tool additions', () => { + let additions: readonly MakaTool[] = []; + const composer = createFixtureComposer({ resolveAdditionalTools: () => additions }); + const initialRead = composer.tools.find(({ name }) => name === 'Read'); + assert.ok(initialRead); + + additions = [tool('dynamic_tool')]; + const next = composer.resolveTools?.() ?? []; + assert.equal( + next.find(({ name }) => name === 'Read'), + initialRead, + ); + assert.equal( + next.some(({ name }) => name === 'dynamic_tool'), + true, + ); +}); + +test('scoped Tool resolution receives the complete stable Host binding', () => { + let observedHostTools: readonly MakaTool[] = []; + const composer = createFixtureComposer({ + hostTools: [tool('host_extension')], + resolveAdditionalTools: (hostTools) => { + observedHostTools = hostTools; + return [tool('plugin_extension')]; + }, + }); + + assert.equal( + observedHostTools.some(({ name }) => name === 'Read'), + true, + ); + assert.equal( + observedHostTools.some(({ name }) => name === 'host_extension'), + true, + ); + assert.equal( + composer.tools.some(({ name }) => name === 'plugin_extension'), + true, + ); +}); + +test('an explicit tool profile remains an exact ceiling over scoped Tool additions', () => { + const composer = createFixtureComposer({ + toolProfile: 'headless-coding-v1', + resolveAdditionalTools: () => [tool('Read'), tool('plugin_only')], + }); + + assert.equal( + composer.resolveTools?.().some(({ name }) => name === 'plugin_only'), + false, + ); + assert.equal(composer.resolveTools?.().filter(({ name }) => name === 'Read').length, 1); +}); + +function tool(name: string): MakaTool { + return { + name, + description: name, + parameters: {}, + impl: async () => name, + }; +} + function createFixtureComposer( overrides: Partial[0]> = {}, ) { diff --git a/packages/runtime-host/src/__tests__/plugin-platform.test.ts b/packages/runtime-host/src/__tests__/plugin-platform.test.ts index 99e4646f7b..c07b137cf6 100644 --- a/packages/runtime-host/src/__tests__/plugin-platform.test.ts +++ b/packages/runtime-host/src/__tests__/plugin-platform.test.ts @@ -24,6 +24,8 @@ import { join } from 'node:path'; import { test } from 'node:test'; import { waitFor } from '@maka/core/test-only/async-primitives'; import { MakaCompositionLoader } from '@maka/runtime/plugin-composition-loader'; +import { Context } from '@maka/runtime/plugin-kernel'; +import { PluginToolService } from '@maka/runtime/plugin-tool-service'; import { decodePluginCompositionApplyInput, decodeRequestFrame, @@ -63,6 +65,7 @@ function createPlatform( packages, packageLoader, store, + ...(options.tools ? { tools: options.tools } : {}), }); testPlatformInternals.set(platform, { composition, packages, store }); return platform; @@ -128,6 +131,46 @@ test('Plugin Platform installs, activates, persists, and recovers a generic pack } }); +test('a real package publishes an executable Tool and removes it on uninstall', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-tool-lifecycle-')); + try { + const pluginRoot = new Context(); + const tools = new PluginToolService(pluginRoot); + const composition = new MakaCompositionLoader({ root: pluginRoot }); + const source = await writeFixturePackage(root, 'inventory-package', 'inventory', { + tool: { name: 'lookup_inventory', result: { sku: 'SKU-42', available: 7 } }, + composition: [ + { + type: 'insert', + rootId: 'profile', + entry: { id: 'inventory-entry', packageId: 'inventory-package' }, + }, + ], + }); + const platform = createPlatform(join(root, 'control'), { composition, tools }); + await platform.recover(); + + const installed = await platform.installPackage(source); + assert.equal(installed.convergence, 'converged'); + const published = tools.resolve('shopping-session', []).tools; + assert.deepEqual( + published.map(({ name }) => name), + ['lookup_inventory'], + ); + assert.deepEqual(await published[0]!.impl({}, {} as never), { + sku: 'SKU-42', + available: 7, + }); + + const uninstalled = await platform.uninstallPackage('inventory-package'); + assert.equal(uninstalled.convergence, 'converged'); + assert.deepEqual(tools.resolve('shopping-session', []).tools, []); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('Plugin Platform coordinator keeps package and composition operations generic', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-plugin-protocol-')); try { @@ -188,6 +231,59 @@ test('Plugin Platform coordinator keeps package and composition operations gener } }); +test('Plugin Platform query exposes bounded Tool contribution inspection', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-tool-inspection-')); + try { + const platform = createPlatform(join(root, 'control'), { + tools: { + inspect: (rootId) => + rootId === undefined || rootId === 'session:alpha' + ? [ + { + entryId: 'tool-entry', + scopeId: 'session:alpha', + extensionId: 'tool-package', + generation: 3, + toolName: 'fixture_tool', + activeCalls: 1, + retired: false, + }, + ] + : [], + }, + }); + const coordinator = new HostPluginPlatformCoordinator(platform); + await platform.recover(); + + const queried = await coordinator.handlers['plugin.platform.query']( + { view: 'tools', rootId: 'session:alpha' }, + null as never, + ); + + assert.deepEqual(queried, { + ok: true, + result: { + view: 'tools', + items: [ + { + entryId: 'tool-entry', + scopeId: 'session:alpha', + extensionId: 'tool-package', + generation: 3, + toolName: 'fixture_tool', + activeCalls: 1, + retired: false, + }, + ], + nextCursor: null, + }, + }); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('Plugin Platform query pages share the protocol byte budget across multiple items', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-plugin-query-budget-')); try { @@ -704,6 +800,36 @@ test('Plugin Platform protocol rejects open and malformed generic composition sh }).operation, 'plugin.composition.apply', ); + assert.equal( + decodeRequestFrame({ + requestId: 'plugin-tools', + operation: 'plugin.platform.query', + input: { view: 'tools', rootId: 'session:one' }, + }).operation, + 'plugin.platform.query', + ); + assert.doesNotThrow(() => + decodeResponseFrame({ + requestId: 'plugin-tools', + operation: 'plugin.platform.query', + ok: true, + result: { + view: 'tools', + items: [ + { + entryId: 'tool-entry', + scopeId: 'session:one', + extensionId: 'tool-package', + generation: 1, + toolName: 'fixture_tool', + activeCalls: 0, + retired: false, + }, + ], + nextCursor: null, + }, + }), + ); assert.throws(() => decodeRequestFrame({ requestId: 'plugin-request', @@ -1468,6 +1594,7 @@ async function writeFixturePackage( readonly structuralDependencies?: readonly string[]; readonly manifest?: Readonly>; readonly composition?: readonly unknown[]; + readonly tool?: { readonly name: string; readonly result: unknown }; } = {}, ): Promise { const source = join( @@ -1503,6 +1630,7 @@ async function writeFixturePackage( host: Object.freeze({ apply(ctx) { ${options.throwOnApply ? "throw new Error('fixture activation failed');" : ''} ${options.provideService ? `ctx.provide(${JSON.stringify(options.provideService)}, { source: ${JSON.stringify(contributionId)} });` : ''} + ${options.tool ? `ctx.tools.register(Object.freeze({ name: ${JSON.stringify(options.tool.name)}, description: 'fixture tool', parameters: {}, impl: async () => (${JSON.stringify(options.tool.result)}) }));` : ''} ctx.effect(() => () => undefined, 'fixture'); } }), });\n`, diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index cf441eb4e2..1c0c5776cf 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -100,7 +100,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 99 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 100 as const; +// 100: logical model steps can bind durable Request Composition identities while +// the persisted Run Composition remains the backward-compatible v1 baseline. // 99: ScheduledTask Agent execution templates carry immutable Connection // identity. Older peers cannot preserve the ID/slug/model binding and could // silently route a deleted Connection to a same-slug replacement. diff --git a/packages/runtime-host/src/protocol/plugin-platform.ts b/packages/runtime-host/src/protocol/plugin-platform.ts index 2a0620e1e7..ee23deef9f 100644 --- a/packages/runtime-host/src/protocol/plugin-platform.ts +++ b/packages/runtime-host/src/protocol/plugin-platform.ts @@ -26,6 +26,7 @@ import { type MakaCompositionOperation, type MakaPluginRootId, } from '@maka/runtime/plugin-runtime'; +import type { PluginToolInspection } from '@maka/runtime/plugin-tool-service'; import { requireCount, requireEncodedByteLimit, @@ -86,7 +87,7 @@ export interface PluginPackageProjection { } export interface PluginPlatformQueryInput { - readonly view: 'status' | 'packages' | 'entries' | 'failures'; + readonly view: 'status' | 'packages' | 'entries' | 'tools' | 'failures'; readonly rootId?: MakaPluginRootId; readonly cursor?: string; readonly limit?: number; @@ -115,6 +116,11 @@ export type PluginPlatformQueryResult = readonly items: readonly MakaCompositionEntryInspection[]; readonly nextCursor: string | null; } + | { + readonly view: 'tools'; + readonly items: readonly PluginToolInspection[]; + readonly nextCursor: string | null; + } | { readonly view: 'failures'; readonly items: readonly PluginPlatformFailureProjection[]; @@ -255,7 +261,7 @@ function decodePluginPlatformQueryInput(value: unknown): PluginPlatformQueryInpu ['view'], ['rootId', 'cursor', 'limit'], ); - if (!['status', 'packages', 'entries', 'failures'].includes(input.view as string)) { + if (!['status', 'packages', 'entries', 'tools', 'failures'].includes(input.view as string)) { throw invalidProtocolFrame('Invalid Plugin Platform query view'); } const view = input.view as PluginPlatformQueryInput['view']; @@ -276,8 +282,8 @@ function decodePluginPlatformQueryInput(value: unknown): PluginPlatformQueryInpu ) { throw invalidProtocolFrame('Plugin Platform status query does not accept paging'); } - if (input.rootId !== undefined && view !== 'entries') { - throw invalidProtocolFrame('Plugin root identity is only valid for Entry queries'); + if (input.rootId !== undefined && view !== 'entries' && view !== 'tools') { + throw invalidProtocolFrame('Plugin root identity is only valid for Entry and Tool queries'); } let rootId: MakaPluginRootId | undefined; if (input.rootId !== undefined) { @@ -349,7 +355,7 @@ function decodePluginPlatformQueryResult(value: unknown): PluginPlatformQueryRes if ( !Array.isArray(output.items) || output.items.length > 64 || - !['packages', 'entries', 'failures'].includes(view as string) + !['packages', 'entries', 'tools', 'failures'].includes(view as string) ) { throw invalidProtocolFrame('Invalid Plugin Platform page'); } @@ -362,7 +368,9 @@ function decodePluginPlatformQueryResult(value: unknown): PluginPlatformQueryRes ? { view, items: output.items.map(decodePackageProjection), nextCursor } : view === 'entries' ? { view, items: decodeInspections(output.items), nextCursor } - : { view: 'failures', items: output.items.map(decodePlatformFailure), nextCursor }; + : view === 'tools' + ? { view, items: output.items.map(decodeToolInspection), nextCursor } + : { view: 'failures', items: output.items.map(decodePlatformFailure), nextCursor }; } requireEncodedByteLimit( decoded, @@ -670,6 +678,27 @@ function decodeInspections(value: unknown): readonly MakaCompositionEntryInspect }); } +function decodeToolInspection(value: unknown): PluginToolInspection { + const inspection = requireExactRecord(value, 'Plugin Tool inspection', [ + 'entryId', + 'scopeId', + 'extensionId', + 'generation', + 'toolName', + 'activeCalls', + 'retired', + ]); + return { + entryId: requireId(inspection.entryId, 'Plugin Tool Entry identity'), + scopeId: decodeRootId(inspection.scopeId), + extensionId: requireId(inspection.extensionId, 'Plugin Tool package identity'), + generation: requireCount(inspection.generation, 'Plugin Tool generation'), + toolName: requireString(inspection.toolName, 'Plugin Tool name', 128), + activeCalls: requireCount(inspection.activeCalls, 'Plugin Tool active call count'), + retired: requireBoolean(inspection.retired), + }; +} + function decodeEntries(value: unknown): readonly MakaCompositionEntry[] { if (!Array.isArray(value)) throw invalidProtocolFrame('Invalid Plugin Entry list'); return value.map(decodeCompositionEntry); diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 2c390fed4f..c9314d6437 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -72,6 +72,9 @@ import { validateShellPreference, } from '@maka/runtime/shell-detect'; import { type MakaTool } from '@maka/runtime/tool-runtime'; +import { Context } from '@maka/runtime/plugin-kernel'; +import { MakaCompositionLoader } from '@maka/runtime/plugin-composition-loader'; +import { PluginToolService } from '@maka/runtime/plugin-tool-service'; import { type RuntimeHostedRootAuthority } from '@maka/runtime/message-authority'; import { createAgentGraphControlStore } from '@maka/storage/agent-graph-control-store'; import { createArtifactAttachmentResourceReader } from '@maka/storage/artifact-stores'; @@ -267,9 +270,15 @@ export async function createExecutionRuntimeHostComposition( let workspaceExecution: RuntimeHostWorkspaceExecutionComposition | undefined; let goalExecutions: HostGoalExecutionCoordinator | undefined; let pluginPlatform: HostPluginPlatform | undefined; + let manager: SessionManager | undefined; let modelMetadataRefresh: ReturnType | undefined; try { - pluginPlatform = new HostPluginPlatform(context.owner.controlDirectory); + const pluginRoot = new Context(); + const pluginTools = new PluginToolService(pluginRoot); + pluginPlatform = new HostPluginPlatform(context.owner.controlDirectory, { + composition: new MakaCompositionLoader({ root: pluginRoot }), + tools: pluginTools, + }); const pluginPlatformCoordinator = new HostPluginPlatformCoordinator(pluginPlatform); const openedProjectCatalog = storage.projectCatalog; const runtimePolicyStores = storage.runtimePolicy; @@ -342,7 +351,6 @@ export async function createExecutionRuntimeHostComposition( const memoryExtractionLane = new MemoryExtractionSessionLane(); let runtimeResources: HostRuntimeResourceCoordinator | undefined; let continuity: SessionContinuityCoordinator | undefined; - let manager: SessionManager | undefined; let graphCoordinator: AgentGraphCoordinator | undefined; let graphSupervisorWake: AgentGraphSupervisorWakeCoordinator | undefined; const graphWakeActivities = new SessionActivityRegistry(); @@ -734,6 +742,8 @@ export async function createExecutionRuntimeHostComposition( hostTools, resolveRootTools: (sessionId) => requireGraphCoordinator(graphCoordinator).toolsForSession(sessionId), + resolvePluginTools: (sessionId, coreTools) => + pluginTools.resolveContributions(sessionId, coreTools), parentAgentTools: childAgentTools.parentTools, childTools: childAgentTools.childTools, worktreePatchWriteBackAvailable: true, @@ -1109,7 +1119,7 @@ export async function createExecutionRuntimeHostComposition( }); }; const registerBackendInvalidation = (): void => { - observeBackendInvalidation(manager.refreshIdleBackends()); + observeBackendInvalidation(requireSessionManager(manager).refreshIdleBackends()); }; const registerConfigurationMutation = (): void => { hostChanges.publishConfiguration(); @@ -1130,7 +1140,7 @@ export async function createExecutionRuntimeHostComposition( acquireResidency: () => context.acquireResidency('oauth'), invalidateBackends: () => { hostChanges.publishConfiguration(); - return manager.refreshIdleBackends(); + return requireSessionManager(manager).refreshIdleBackends(); }, onFatal: (error) => { if (poisonFailure) return; @@ -1740,7 +1750,10 @@ export async function createExecutionRuntimeHostComposition( close: [ () => modelMetadataRefresh?.close(), () => connectionEffects.close(), - () => (backendInvalidationPoisoned ? undefined : manager.refreshIdleBackends()), + () => + backendInvalidationPoisoned + ? undefined + : requireSessionManager(manager).refreshIdleBackends(), () => skills.close(), () => oauth?.close(), () => { @@ -1808,8 +1821,8 @@ export async function createExecutionRuntimeHostComposition( executions: async () => { await coordinator.prepareRecovery(); await interactions.recoverPendingAfterHostRestart(); - await manager.recoverInterruptedSessionsStrict(stores); - await manager.recoverChildWorkspacePatches( + await requireSessionManager(manager).recoverInterruptedSessionsStrict(stores); + await requireSessionManager(manager).recoverChildWorkspacePatches( recoverySessions.flatMap((session) => session.subagentWorkspace ? [session.id] : [], ), diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index e5dd3bd2e6..4a78e0b1d7 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -43,6 +43,7 @@ import { } from '@maka/runtime/network/scoped-fetch-transport'; import { stableHash, toolCatalogHash } from '@maka/runtime/request-shape'; import { toolAvailabilityHash } from '@maka/runtime/tool-availability'; +import type { MakaTool } from '@maka/runtime/tool-runtime'; import { type BackendFactoryContext, type BackendPreparationContext, @@ -326,23 +327,45 @@ async function buildHostAiSdkBackend( }); }; const recordRunComposition = input.context.recordRunComposition; + const recordRequestComposition = input.context.recordRequestComposition; + const resolveModelTools = (): readonly MakaTool[] => + modelComposition.resolveTools?.() ?? modelComposition.tools; + // RunComposition remains the immutable C0 baseline. Dynamic Tool changes + // belong exclusively to RequestComposition epochs, so never re-sample them + // while committing the baseline immediately before provider dispatch. + const initialModelTools = Object.freeze([...modelComposition.tools]); + const runCompositionCommits = new Map>(); const commitRunComposition = recordRunComposition ? async (context: { readonly turnId: string; readonly runId: string }): Promise => { - const resolved = await resolveRunPrompt(context); - await recordRunComposition( - context.runId, - createRunCompositionSnapshot({ - composerId: modelComposition.composerId, - composerRevision: modelComposition.composerRevision, - sourceRevisions: resolved.sourceRevisions, - baseSystemPromptHash: stableHash(resolved.text ?? ''), - toolCatalogHash: toolCatalogHash(modelComposition.tools), - toolAvailabilityHash: toolAvailabilityHash(modelComposition.toolAvailability), - baseProviderOptionsHash: stableHash(providerOptions), - toolNames: modelComposition.tools.map(({ name }) => name), - contextWindow: contextWindow ?? null, - }), - ); + let commit = runCompositionCommits.get(context.runId); + if (!commit) { + commit = (async (): Promise => { + const resolved = await resolveRunPrompt(context); + await recordRunComposition( + context.runId, + createRunCompositionSnapshot({ + composerId: modelComposition.composerId, + composerRevision: modelComposition.composerRevision, + sourceRevisions: resolved.sourceRevisions, + baseSystemPromptHash: stableHash(resolved.text ?? ''), + toolCatalogHash: toolCatalogHash(initialModelTools), + toolAvailabilityHash: toolAvailabilityHash(modelComposition.toolAvailability), + baseProviderOptionsHash: stableHash(providerOptions), + toolNames: initialModelTools.map(({ name }) => name), + contextWindow: contextWindow ?? null, + }), + ); + })(); + runCompositionCommits.set(context.runId, commit); + } + try { + await commit; + } catch (error) { + if (runCompositionCommits.get(context.runId) === commit) { + runCompositionCommits.delete(context.runId); + } + throw error; + } } : undefined; const planProjectionImage = createReadImageSnapshotPlanner( @@ -385,7 +408,8 @@ async function buildHostAiSdkBackend( apiKey, modelId: target.model, modelFactory, - tools: [...modelComposition.tools], + tools: [...resolveModelTools()], + resolveTools: resolveModelTools, toolAvailability: modelComposition.toolAvailability, ...(modelComposition.planTraceContext ? { planTraceContext: modelComposition.planTraceContext } @@ -455,6 +479,12 @@ async function buildHostAiSdkBackend( beforeRunProviderDispatch: commitRunComposition, } : {}), + ...(recordRequestComposition + ? { + recordRequestComposition: (runId, snapshot) => + recordRequestComposition(runId, snapshot), + } + : {}), systemPrompt: async (context) => { const resolved = await resolveRunPrompt({ turnId: context.turnId, @@ -462,7 +492,7 @@ async function buildHostAiSdkBackend( ? { emitSkillCatalogTrace: context.emitSkillCatalogTrace } : {}), }); - return resolved.text; + return { text: resolved.text, sourceRevisions: resolved.sourceRevisions }; }, lookupPricing: pricing, recordModelCallAttempt, diff --git a/packages/runtime-host/src/server/host-run-composer.ts b/packages/runtime-host/src/server/host-run-composer.ts index 968b601dd1..e69425fc97 100644 --- a/packages/runtime-host/src/server/host-run-composer.ts +++ b/packages/runtime-host/src/server/host-run-composer.ts @@ -39,6 +39,8 @@ export interface HostRunComposer { readonly composerId: string; readonly composerRevision: string; readonly tools: readonly MakaTool[]; + /** Reads the current scoped Tool surface before each logical model step. */ + readonly resolveTools?: () => readonly MakaTool[]; readonly toolAvailability?: ToolAvailabilityConfig; readonly resolveSystemPrompt: (context: HostModelPromptContext) => Promise; readonly planTraceContext?: AiSdkBackendInput['planTraceContext']; diff --git a/packages/runtime-host/src/server/interactive-run-composer.ts b/packages/runtime-host/src/server/interactive-run-composer.ts index dc35d96779..fad8b91a18 100644 --- a/packages/runtime-host/src/server/interactive-run-composer.ts +++ b/packages/runtime-host/src/server/interactive-run-composer.ts @@ -109,6 +109,7 @@ export interface InteractiveRunComposerInput { readonly clientCapabilities?: Pick; readonly builtinTools?: BuildBuiltinToolsOptions; readonly hostTools?: readonly MakaTool[]; + readonly resolveAdditionalTools?: (hostTools: readonly MakaTool[]) => readonly MakaTool[]; readonly scheduledTaskTool?: MakaTool; readonly goalTools?: readonly MakaTool[]; readonly parentAgentTools?: readonly MakaTool[]; @@ -132,41 +133,51 @@ export function createInteractiveRunComposer(input: InteractiveRunComposerInput) const inventorySnapshotFor = createTurnSkillInventorySnapshotResolver(input.skills); const inventoryFor: SkillInventoryResolver = async (context) => (await inventorySnapshotFor(context)).inventory; + const hasToolCeiling = input.boundTools !== undefined || input.toolProfile !== undefined; + const activeExecution = input.plan ? activePlanExecution(input.plan.state) : undefined; + // The base Host binding is immutable for this backend. Only scoped plugin + // contributions are sampled at logical step boundaries. const defaultTools = input.boundTools ? input.boundTools : buildDefaultHostTools( input.sessionTodo, inventoryFor, builtinTools, - input.hostTools, + input.hostTools ?? [], input.scheduledTaskTool, input.goalTools, input.parentAgentTools, input.plan, input.deepResearch?.tools, ); - const hasToolCeiling = input.boundTools !== undefined || input.toolProfile !== undefined; const clientCapabilityTools = hasToolCeiling ? [] : (input.clientCapabilities?.tools ?? []); - const unscopedCandidateTools = [...defaultTools, ...clientCapabilityTools]; - const routedCandidateTools = input.deepResearch - ? unscopedCandidateTools.filter(isDeepResearchToolAllowed) - : unscopedCandidateTools; - const candidateTools = projectHostedExecutionTools(routedCandidateTools, input.toolProfile); - const activeExecution = input.plan ? activePlanExecution(input.plan.state) : undefined; - const selectedTools = input.plan - ? selectCollaborationTools({ - mode: input.plan.mode, - tools: candidateTools, - hasActiveExecution: activeExecution !== undefined, - fullAccess: input.plan.permissionMode === 'bypass', - }) - : candidateTools; - // A bound tool list is an exact child/local activation ceiling. Dynamic - // capabilities must be included by the authority that constructs that - // list. The ceiling is also an exact wire contract: no deferred search - // groups inside it, so the bound tools stay fully visible. - const tools = [...selectedTools]; - assertUniqueToolNames(tools); + const resolveTools = (): readonly MakaTool[] => { + const stableHostTools = [...defaultTools, ...clientCapabilityTools]; + const additionalTools = hasToolCeiling + ? [] + : (input.resolveAdditionalTools?.(stableHostTools) ?? []); + const unscopedCandidateTools = [...stableHostTools, ...additionalTools]; + const routedCandidateTools = input.deepResearch + ? unscopedCandidateTools.filter(isDeepResearchToolAllowed) + : unscopedCandidateTools; + const candidateTools = projectHostedExecutionTools(routedCandidateTools, input.toolProfile); + const selectedTools = input.plan + ? selectCollaborationTools({ + mode: input.plan.mode, + tools: candidateTools, + hasActiveExecution: activeExecution !== undefined, + fullAccess: input.plan.permissionMode === 'bypass', + }) + : candidateTools; + // A bound tool list is an exact child/local activation ceiling. Dynamic + // capabilities must be included by the authority that constructs that + // list. The ceiling is also an exact wire contract: no deferred search + // groups inside it, so the bound tools stay fully visible. + const resolved = [...selectedTools]; + assertUniqueToolNames(resolved); + return Object.freeze(resolved); + }; + const tools = resolveTools(); const hostCapabilities = buildHostCapabilitiesFromBinding(tools.map(({ name }) => name)); const toolAvailability = hasToolCeiling ? undefined @@ -257,6 +268,7 @@ export function createInteractiveRunComposer(input: InteractiveRunComposerInput) composerId: INTERACTIVE_RUN_COMPOSER_ID, composerRevision: INTERACTIVE_RUN_COMPOSER_REVISION, tools, + resolveTools, toolAvailability, resolveSystemPrompt, }); @@ -270,6 +282,12 @@ export interface InteractiveRunComposerFactoryInput readonly clientCapabilities: HostClientCapabilityCoordinator; readonly resolveTavilyWebSearchReadiness: () => Promise; readonly resolveRootTools?: (sessionId: string) => Promise; + readonly resolvePluginTools?: ( + sessionId: string, + hostTools: readonly MakaTool[], + ) => { + readonly tools: readonly MakaTool[]; + }; readonly childTools?: readonly MakaTool[]; readonly worktreePatchWriteBackAvailable?: boolean; readonly planStore?: PlanStore; @@ -394,6 +412,20 @@ export function createInteractiveRunComposerFactory( ...(clientCapabilities ? { clientCapabilities } : {}), ...(input.builtinTools ? { builtinTools: input.builtinTools } : {}), ...(hostTools.length > 0 ? { hostTools } : {}), + ...(input.resolvePluginTools && !backendContext.tools + ? { + resolveAdditionalTools: (hostTools) => { + return routeInteractiveRunToolSurface({ + runtimePolicy, + connection, + modelId, + hostTools: input.resolvePluginTools!(backendContext.sessionId, hostTools).tools, + worktreePatchWriteBackAvailable: input.worktreePatchWriteBackAvailable, + tavilyReady, + }).hostTools; + }, + } + : {}), ...(input.scheduledTaskTool ? { scheduledTaskTool: input.scheduledTaskTool } : {}), ...(input.goalTools ? { goalTools: input.goalTools } : {}), ...(parentAgentTools ? { parentAgentTools } : {}), diff --git a/packages/runtime-host/src/server/plugin-platform-coordinator.ts b/packages/runtime-host/src/server/plugin-platform-coordinator.ts index ec1cc78071..f117b526c2 100644 --- a/packages/runtime-host/src/server/plugin-platform-coordinator.ts +++ b/packages/runtime-host/src/server/plugin-platform-coordinator.ts @@ -23,6 +23,7 @@ import { type MakaCompositionApplyInput, type MakaCompositionEntryInspection, } from '@maka/runtime/plugin-runtime'; +import type { PluginToolInspection } from '@maka/runtime/plugin-tool-service'; import type { OperationOutcome, PluginPackageExportInput, @@ -74,6 +75,12 @@ export class HostPluginPlatformCoordinator { const inspections = flattenInspections(this.platform.inspect(input.rootId)); return { ok: true, result: boundedPage('entries', inspections, input) }; } + if (input.view === 'tools') { + return { + ok: true, + result: boundedPage('tools', this.platform.inspectTools(input.rootId), input), + }; + } if (input.view === 'failures') { return { ok: true, result: boundedPage('failures', failures, input) }; } @@ -172,13 +179,18 @@ function boundedPage( values: readonly MakaCompositionEntryInspection[], input: PluginPlatformQueryInput, ): Extract; +function boundedPage( + view: 'tools', + values: readonly PluginToolInspection[], + input: PluginPlatformQueryInput, +): Extract; function boundedPage( view: 'failures', values: readonly PluginPlatformFailureProjection[], input: PluginPlatformQueryInput, ): Extract; function boundedPage( - view: 'packages' | 'entries' | 'failures', + view: 'packages' | 'entries' | 'tools' | 'failures', values: readonly T[], input: PluginPlatformQueryInput, ): PluginPlatformQueryResult { @@ -218,7 +230,7 @@ function boundedPage( interface PageCursor { readonly version: 1; - readonly view: 'packages' | 'entries' | 'failures'; + readonly view: 'packages' | 'entries' | 'tools' | 'failures'; readonly rootId?: string; readonly digest: string; readonly offset: number; diff --git a/packages/runtime-host/src/server/plugin-platform.ts b/packages/runtime-host/src/server/plugin-platform.ts index 7ffec1121a..b2d9e20271 100644 --- a/packages/runtime-host/src/server/plugin-platform.ts +++ b/packages/runtime-host/src/server/plugin-platform.ts @@ -33,6 +33,7 @@ import { type MakaPluginRootId, } from '@maka/runtime/plugin-runtime'; import type { ExtensionPackageManifest } from './extension-package-manifest.js'; +import type { PluginToolInspection } from '@maka/runtime/plugin-tool-service'; import { validateExtensionConfiguration } from './extension-package-manifest.js'; import { recoverExtensionBundleImports } from './extension-bundle.js'; import { loadPluginCompositionPatch } from './plugin-composition-patch.js'; @@ -74,6 +75,7 @@ export interface HostPluginPlatformOptions { readonly packages?: PluginPackageStore; readonly packageLoader?: TrustedPluginPackageLoader; readonly store?: HostPluginCompositionStore; + readonly tools?: { inspect(rootId?: MakaPluginRootId): readonly PluginToolInspection[] }; } export interface HostPluginPlatformFailure { @@ -109,6 +111,7 @@ export class HostPluginPlatform { readonly #packages: PluginPackageStore; readonly #packageLoader: TrustedPluginPackageLoader; readonly #store: HostPluginCompositionStore; + readonly #tools?: HostPluginPlatformOptions['tools']; #authority: PersistedPluginComposition = emptyCompositionAuthority(); #desired: MakaCompositionState = emptyCompositionState(); @@ -133,6 +136,7 @@ export class HostPluginPlatform { this.#packageLoader = options.packageLoader ?? new TrustedPluginPackageLoader(controlDirectory, this.#packages); this.#store = options.store ?? new HostPluginCompositionStore(controlDirectory); + this.#tools = options.tools; } async recover(): Promise { @@ -470,6 +474,11 @@ export class HostPluginPlatform { return this.#composition.inspectTree(rootId); } + inspectTools(rootId?: MakaPluginRootId): readonly PluginToolInspection[] { + this.#assertReadable(); + return this.#tools?.inspect(rootId) ?? Object.freeze([]); + } + async status(): Promise<{ readonly phase: PluginPlatformPhase; readonly authorityEpoch: number; diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 854124140e..3215d652ae 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -80,6 +80,7 @@ "./plugin-composition-loader": "./dist/plugin-composition-loader.js", "./plugin-kernel": "./dist/plugin-kernel.js", "./plugin-runtime": "./dist/plugin-runtime.js", + "./plugin-tool-service": "./dist/plugin-tool-service.js", "./process-tree-terminator": "./dist/process-tree-terminator.js", "./provider-request-telemetry": "./dist/provider-request-telemetry.js", "./request-customization-fetch": "./dist/request-customization-fetch.js", diff --git a/packages/runtime/src/__tests__/active-tool-result-prune.test.ts b/packages/runtime/src/__tests__/active-tool-result-prune.test.ts index 2acf4c70b5..34cb61c7dc 100644 --- a/packages/runtime/src/__tests__/active-tool-result-prune.test.ts +++ b/packages/runtime/src/__tests__/active-tool-result-prune.test.ts @@ -33,6 +33,7 @@ import type { ModelProjectionTransition } from '@maka/core/model-projection-tran import { planActiveToolResultSupersession } from '../active-tool-result-working-set.js'; import { composeRequestProjection } from '../request-projection.js'; import { ToolAvailabilityRuntime, TOOL_SEARCH_NAME } from '../tool-availability.js'; +import { toolActivationKey } from '../tool-activation-identity.js'; import type { MakaTool } from '../tool-runtime.js'; describe('active current-turn tool-result pruning', () => { @@ -289,11 +290,11 @@ describe('active current-turn tool-result pruning', () => { { groups: [{ id: 'rive', toolNames: ['RiveWorkflow'] }] }, makaTool('invalid'), ); - const active = new Map(); + const active = new Map(); const plan = runtime.prepare(active); active.set( 'RiveWorkflow', - plan.providerTools.find((candidate) => candidate.name === 'RiveWorkflow')!, + toolActivationKey(plan.providerTools.find((candidate) => candidate.name === 'RiveWorkflow')!), ); const activePrune = async (options: { messages: ModelMessage[]; stepNumber: number }) => { const rewritten = await rewriteActiveToolResultsInMessages({ diff --git a/packages/runtime/src/__tests__/agent-run-steering-recovery.test.ts b/packages/runtime/src/__tests__/agent-run-steering-recovery.test.ts index 610c66a335..e79ee64456 100644 --- a/packages/runtime/src/__tests__/agent-run-steering-recovery.test.ts +++ b/packages/runtime/src/__tests__/agent-run-steering-recovery.test.ts @@ -26,6 +26,7 @@ import { test } from 'node:test'; import type { AgentRunHeader } from '@maka/core/agent-run'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { SessionEvent } from '@maka/core/events'; +import { decodeRequestCompositionSnapshot } from '@maka/core/run-composition'; import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; import { createWorkspaceRuntimeStore } from '@maka/storage/runtime-event-persistence'; import { createSessionStore } from '@maka/storage/session-store'; @@ -76,6 +77,145 @@ test('rejects an invalid tool mode before a durable AgentRun can be created', as } }); +test('records changed request surfaces append-only and reuses unchanged epochs', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-agent-run-request-composition-')); + try { + const store = createSessionStore(root); + const session = await store.create({ + cwd: '/tmp/cwd', + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + }); + const runStore = createSqliteAgentRunStore(root); + const runtimeEventStore = createWorkspaceRuntimeStore(root); + const runId = 'run-request-composition'; + const turnId = 'turn-request-composition'; + await runStore.createRun(makeRunHeader(session.id, runId, turnId)); + let id = 0; + const run = new AgentRun({ + sessionId: session.id, + header: session, + userInput: { turnId, text: 'exercise request composition' }, + runId, + runStore, + runtimeEventStore, + store, + newId: () => `generated-${++id}`, + now: () => 10 + id, + hooks: { + reserveRun: async () => { + throw new Error('reserveRun should not be called'); + }, + unregisterRun: () => {}, + updateHeader: (sessionId, patch) => store.updateHeader(sessionId, patch), + updateStatus: async () => {}, + appendTurnState: async () => {}, + }, + }); + const base = { + sourceRevisions: [{ id: 'plugin-tools', revision: '1' }], + systemPromptHash: digest('1'), + toolCatalogHash: digest('2'), + toolAvailabilityHash: digest('3'), + providerOptionsHash: digest('4'), + toolNames: ['enable_inventory'], + toolSchemas: [ + { + name: 'enable_inventory', + description: 'enable inventory', + inputSchema: { type: 'object' }, + }, + ], + } as const; + + const firstId = await run.recordRequestComposition({ + ...base, + compositionId: 'composition-1', + step: 0, + }); + const reusedId = await run.recordRequestComposition({ + ...base, + compositionId: 'composition-2', + step: 1, + }); + const changedId = await run.recordRequestComposition({ + ...base, + compositionId: 'composition-3', + step: 2, + toolCatalogHash: digest('5'), + toolNames: ['enable_inventory', 'inventory_quote'], + toolSchemas: [ + ...base.toolSchemas, + { + name: 'inventory_quote', + description: 'quote inventory', + inputSchema: { type: 'object' }, + }, + ], + }); + const returnedId = await run.recordRequestComposition({ + ...base, + compositionId: 'composition-4', + step: 3, + }); + + assert.equal(firstId, 'composition-1'); + assert.equal(reusedId, 'composition-1'); + assert.equal(changedId, 'composition-3'); + assert.equal(returnedId, 'composition-1'); + + const resumed = new AgentRun({ + sessionId: session.id, + header: session, + userInput: { turnId, text: 'resume request composition' }, + runId, + runStore, + runtimeEventStore, + store, + newId: () => `resumed-${++id}`, + now: () => 20 + id, + hooks: { + reserveRun: async () => { + throw new Error('reserveRun should not be called'); + }, + unregisterRun: () => {}, + updateHeader: (sessionId, patch) => store.updateHeader(sessionId, patch), + updateStatus: async () => {}, + appendTurnState: async () => {}, + }, + }); + const resumedId = await resumed.recordRequestComposition({ + ...base, + compositionId: 'composition-5', + step: 4, + toolCatalogHash: digest('5'), + toolNames: ['enable_inventory', 'inventory_quote'], + toolSchemas: [ + ...base.toolSchemas, + { + name: 'inventory_quote', + description: 'quote inventory', + inputSchema: { type: 'object' }, + }, + ], + }); + assert.equal(resumedId, 'composition-3'); + const snapshots = (await runStore.readEvents(session.id, runId)) + .filter((event) => event.type === 'request_composition_resolved') + .map((event) => decodeRequestCompositionSnapshot(event.data?.snapshot)); + assert.deepEqual( + snapshots.map((snapshot) => ({ reason: snapshot.reason, step: snapshot.step })), + [ + { reason: 'initial', step: 0 }, + { reason: 'change', step: 2 }, + ], + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('does not re-append atomically committed tool facts through the generic event lane', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-agent-run-atomic-tool-boundary-')); try { @@ -745,6 +885,11 @@ function makeRunHeader(sessionId: string, runId: string, turnId: string): AgentR updatedAt: 1, }; } + +function digest(seed: string): `sha256:${string}` { + return `sha256:${seed.repeat(64)}`; +} + async function waitFor(predicate: () => Promise): Promise { await pollFor(predicate, { attempts: 100, diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index a021546196..3a72a6704c 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -37,6 +37,7 @@ import type { StorageRef } from '@maka/core/events'; import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; import type { SessionEvent } from '@maka/core/events'; import type { RuntimeEvent } from '@maka/core/runtime-event'; +import type { RequestCompositionSnapshotInput } from '@maka/core/run-composition'; import { createSessionEventMapMemory, mapSessionEventToRuntimeEvent, @@ -94,6 +95,9 @@ import type { OpenAiResponsesSemanticBaseline } from '../openai-responses-contin import type { OpenAiResponsesTransportState } from '../openai-responses-websocket.js'; import { getAIModel } from '../model-factory.js'; import { waitFor as pollFor } from '@maka/core/test-only/async-primitives'; +import { Context } from '../plugin-kernel.js'; +import { MakaCompositionLoader } from '../plugin-composition-loader.js'; +import { PluginToolService } from '../plugin-tool-service.js'; describe('AiSdkBackend ApplyPatch routing', () => { test('advertises apply_patch only to supported native OpenAI models', async () => { @@ -3952,6 +3956,300 @@ describe('AiSdkBackend model history', () => { assert.equal(artifactReads, 1); }); + test('a live Plugin can enable, execute, and disable a Tool within one Turn', async () => { + const durable = durableTurnHarness('turn-dynamic-tools', 'check inventory then disable access'); + const root = new Context(); + const pluginTools = new PluginToolService(root); + const loader = new MakaCompositionLoader({ root }); + let disposeInventory: (() => Promise) | undefined; + const inventoryTool: MakaTool = { + name: 'lookup_inventory', + description: 'look up current inventory', + parameters: z.object({ sku: z.string() }), + impl: async ({ sku }) => ({ sku, available: 7 }), + }; + await loader.install({ + packageId: 'inventory-plugin', + host: (ctx) => { + ctx.tools.register({ + name: 'enable_inventory', + description: 'enable inventory access', + parameters: z.object({}), + impl: async () => { + disposeInventory ??= ctx.tools.register(inventoryTool); + return { enabled: inventoryTool.name }; + }, + }); + ctx.tools.register({ + name: 'disable_inventory', + description: 'disable inventory access', + parameters: z.object({}), + impl: async () => { + await disposeInventory?.(); + disposeInventory = undefined; + return { disabled: inventoryTool.name }; + }, + }); + }, + }); + await loader.create('profile', { + id: 'inventory-entry', + packageId: 'inventory-plugin', + }); + let calls = 0; + const requestCompositions: RequestCompositionSnapshotInput[] = []; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + const toolCall = + calls === 1 + ? { + id: 'search-enable-call', + name: TOOL_SEARCH_NAME, + input: JSON.stringify({ query: 'enable inventory', limit: 1 }), + } + : calls === 2 + ? { id: 'enable-call', name: 'enable_inventory', input: '{}' } + : calls === 3 + ? { + id: 'search-inventory-call', + name: TOOL_SEARCH_NAME, + input: JSON.stringify({ query: 'look up current inventory', limit: 1 }), + } + : calls === 4 + ? { + id: 'inventory-call', + name: inventoryTool.name, + input: JSON.stringify({ sku: 'SKU-42' }), + } + : calls === 5 + ? { + id: 'search-disable-call', + name: TOOL_SEARCH_NAME, + input: JSON.stringify({ query: 'disable inventory', limit: 1 }), + } + : calls === 6 + ? { id: 'disable-call', name: 'disable_inventory', input: '{}' } + : undefined; + return { + stream: simulateReadableStream({ + chunks: (toolCall + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: toolCall.id, + toolName: toolCall.name, + input: toolCall.input, + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ]) as LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [...pluginTools.resolve('session-1', []).tools], + resolveTools: () => pluginTools.resolve('session-1', []).tools, + toolAvailability: { + groups: [ + { + id: 'plugins', + toolNames: ['enable_inventory', 'lookup_inventory', 'disable_inventory'], + }, + ], + }, + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + recordRequestComposition: async (_runId, snapshot) => { + requestCompositions.push(snapshot); + return snapshot.compositionId; + }, + newId: idGenerator(), + now: monotonicClock(), + }); + + await drainDurably( + backend.send(durable.input({ runId: 'run-1', invocationId: 'invocation-1' })), + durable, + ); + + const namesForRequest = (index: number): string[] => { + const tools = model.doStreamCalls[index]?.tools ?? []; + return Array.isArray(tools) + ? tools.flatMap((tool) => + tool && typeof tool === 'object' && 'name' in tool ? [String(tool.name)] : [], + ) + : Object.keys(tools); + }; + assert.equal(namesForRequest(0).includes('enable_inventory'), false); + assert.equal(namesForRequest(1).includes('enable_inventory'), true); + assert.equal(namesForRequest(2).includes(inventoryTool.name), false); + assert.equal(namesForRequest(3).includes(inventoryTool.name), true); + assert.equal(namesForRequest(4).includes('disable_inventory'), false); + assert.equal(namesForRequest(5).includes('disable_inventory'), true); + assert.equal(namesForRequest(6).includes(inventoryTool.name), false); + assert.equal(requestCompositions.length, 7); + assert.equal(requestCompositions[2]?.toolNames.includes(inventoryTool.name), false); + assert.equal(requestCompositions[3]?.toolNames.includes(inventoryTool.name), true); + assert.equal(requestCompositions[6]?.toolNames.includes(inventoryTool.name), false); + const finalPrompt = model.doStreamCalls[6]?.prompt as Array<{ + role: string; + content: Array<{ output?: { value?: unknown } }>; + }>; + assert.equal( + JSON.stringify(finalPrompt).includes('SKU-42') && + JSON.stringify(finalPrompt).includes('available'), + true, + ); + await loader.close(); + }); + + test('installs, invokes, and removes a live weather plugin within one model turn', async () => { + const root = new Context(); + const pluginTools = new PluginToolService(root); + const loader = new MakaCompositionLoader({ root }); + const invocations: Array<{ city: string }> = []; + await loader.install({ + packageId: 'weather-package', + host: (ctx) => { + ctx.tools.register({ + name: 'weather_forecast', + description: 'Get the current weather forecast for a city', + parameters: z.object({ city: z.string() }), + impl: async (input) => { + const { city } = input as { city: string }; + invocations.push({ city }); + return { city, condition: 'sunny', temperatureCelsius: 28 }; + }, + }); + }, + }); + + const installPlugin: MakaTool = { + name: 'install_weather_plugin', + description: 'Install the weather plugin for the current profile', + parameters: z.object({}), + impl: async () => { + await loader.create('profile', { + id: 'weather-entry', + packageId: 'weather-package', + }); + return { installed: true }; + }, + }; + const removePlugin: MakaTool = { + name: 'remove_weather_plugin', + description: 'Remove the installed weather plugin', + parameters: z.object({}), + impl: async () => { + await loader.remove('weather-entry'); + return { removed: true }; + }, + }; + const resolveTools = (): readonly MakaTool[] => + pluginTools.resolve('session-1', [installPlugin, removePlugin]).tools; + const durable = durableTurnHarness( + 'turn-live-weather-plugin', + 'Install a weather plugin, check Shanghai, then remove the plugin.', + ); + const scriptedCalls = [ + { toolCallId: 'install-call', toolName: installPlugin.name, input: '{}' }, + { + toolCallId: 'forecast-call', + toolName: 'weather_forecast', + input: JSON.stringify({ city: 'Shanghai' }), + }, + { toolCallId: 'remove-call', toolName: removePlugin.name, input: '{}' }, + ] as const; + let step = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + const call = scriptedCalls[step++]; + return { + stream: simulateReadableStream({ + chunks: (call + ? [ + { type: 'stream-start', warnings: [] }, + { type: 'tool-call', ...call }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ]) as LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [...resolveTools()], + resolveTools, + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + newId: idGenerator(), + now: monotonicClock(), + }); + + try { + const events = await drainDurably(backend.send(durable.input()), durable); + const namesForStep = (index: number): string[] => { + const tools = model.doStreamCalls[index]?.tools ?? []; + return Array.isArray(tools) + ? tools.flatMap((tool) => + tool && typeof tool === 'object' && 'name' in tool ? [String(tool.name)] : [], + ) + : Object.keys(tools); + }; + + assert.equal(namesForStep(0).includes('weather_forecast'), false); + assert.equal(namesForStep(1).includes('weather_forecast'), true); + assert.equal(namesForStep(2).includes('weather_forecast'), true); + assert.equal(namesForStep(3).includes('weather_forecast'), false); + assert.deepEqual(invocations, [{ city: 'Shanghai' }]); + assert.equal(events.filter((event) => event.type === 'tool_result').length, 3); + assert.deepEqual(pluginTools.inspect(), []); + } finally { + await loader.close(); + } + }); + test('reloads durable multi-tool settlement before terminal continuation', async () => { const anchor = runtimeTextEvent({ id: 'runtime-user', @@ -9698,11 +9996,16 @@ describe('AiSdkBackend RunTrace', () => { test('disables hidden AI SDK retries and traces the one explicit Runtime retry', async () => { const captures: PreparedRequestArtifactInput[] = []; const attempts: ModelCallAttempt[] = []; + const stableTool = testTool('stable_tool', z.object({})); + const retryOnlyTool = testTool('retry_only_tool', z.object({})); + let surface: readonly MakaTool[] = [stableTool]; let calls = 0; + const requestCompositions: RequestCompositionSnapshotInput[] = []; const model = new MockLanguageModelV4({ doStream: async () => { calls += 1; if (calls === 1) { + surface = [stableTool, retryOnlyTool]; throw new APICallError({ message: 'retry me', url: 'https://provider.invalid/v1/messages', @@ -9738,7 +10041,8 @@ describe('AiSdkBackend RunTrace', () => { apiKey: 'sk-test', modelId: 'mock-model-id', modelFactory: () => model, - tools: [], + tools: [...surface], + resolveTools: () => surface, newId: idGenerator(), now: monotonicClock(), persistPreparedRequestArtifact: async (capture) => { @@ -9748,13 +10052,33 @@ describe('AiSdkBackend RunTrace', () => { recordModelCallAttempt: ({ attempt }) => { attempts.push(attempt); }, + recordRequestComposition: async (_runId, snapshot) => { + requestCompositions.push(snapshot); + return snapshot.compositionId; + }, providerRetrySleep: async () => {}, }); await drain(backend.send({ turnId: 'turn-1', runId: 'run-1', text: 'hi', context: [] })); assert.equal(calls, 2); + assert.equal( + model.doStreamCalls.every((call) => + Array.isArray(call.tools) + ? call.tools.every( + (tool) => !('name' in tool) || String(tool.name) !== retryOnlyTool.name, + ) + : !(retryOnlyTool.name in (call.tools ?? {})), + ), + true, + ); assert.equal(captures.length, 1); + assert.equal(requestCompositions.length, 1); + assert.ok( + attempts.every( + (attempt) => attempt.requestCompositionId === requestCompositions[0]?.compositionId, + ), + ); assert.deepEqual( attempts.map(({ attempt, status }) => ({ attempt, status })), [ diff --git a/packages/runtime/src/__tests__/deferred-tools-backend.test.ts b/packages/runtime/src/__tests__/deferred-tools-backend.test.ts index 086f717a27..6c0db29189 100644 --- a/packages/runtime/src/__tests__/deferred-tools-backend.test.ts +++ b/packages/runtime/src/__tests__/deferred-tools-backend.test.ts @@ -80,6 +80,7 @@ function backend(input: { traces?: RunTraceEvent[]; toolAvailability?: ToolAvailabilityConfig; fullSurface?: boolean; + resolveTools?: () => readonly MakaTool[]; }): AiSdkBackend { let id = 0; return createTestAiSdkBackend({ @@ -91,6 +92,7 @@ function backend(input: { modelId: 'mock-model-id', modelFactory: () => input.model, tools: boundTools(input.calls), + ...(input.resolveTools ? { resolveTools: input.resolveTools } : {}), ...(input.fullSurface ? {} : { toolAvailability: input.toolAvailability ?? availability }), ...(input.durable ? { loadTurnRuntimeEvents: input.durable.loadTurnRuntimeEvents } : {}), ...(input.traces ? { recordRunTrace: (event) => input.traces!.push(event) } : {}), @@ -138,6 +140,30 @@ describe('AiSdkBackend tool_search activation', () => { assert.deepEqual(searched?.data?.activated, ['browser_click']); }); + test('equivalent Tool wrappers rebuilt between steps retain search activation', async () => { + const durable = createDurableTurnHarness({ turnId: 'turn-1', text: 'click it' }); + const captured: string[][] = []; + const calls: string[] = []; + let resolutions = 0; + await drainWithDurableTurn( + backend({ + model: searchThenUseModel(captured), + calls, + durable, + resolveTools: () => { + resolutions += 1; + return boundTools(calls); + }, + }).send(durable.sendInput()), + durable, + ); + + assert.ok(resolutions >= 2); + assert.ok(!captured[0]?.includes('browser_click')); + assert.ok(captured[1]?.includes('browser_click')); + assert.deepEqual(calls, ['browser_click']); + }); + test('parallel search and hidden-tool use still rejects the same-step call', async () => { const durable = createDurableTurnHarness({ turnId: 'turn-1', text: 'search and click' }); const captured: string[][] = []; diff --git a/packages/runtime/src/__tests__/mcp-tools.test.ts b/packages/runtime/src/__tests__/mcp-tools.test.ts index b3ffdeb2b3..8326f808ed 100644 --- a/packages/runtime/src/__tests__/mcp-tools.test.ts +++ b/packages/runtime/src/__tests__/mcp-tools.test.ts @@ -27,6 +27,7 @@ import type { McpToolBinding, McpToolDescriptor, } from '@maka/core/mcp'; +import { REQUEST_COMPOSITION_MAX_TOOL_DESCRIPTION_LENGTH } from '@maka/core/run-composition'; import { buildMcpTools, mcpProxyToolName, type McpToolProvider } from '../mcp-tools.js'; import { selectCollaborationTools } from '../plan-mode.js'; @@ -271,6 +272,21 @@ test('mcpProxyToolName is stable, provider-safe, and bounded to 64 chars', () => ); }); +test('MCP descriptions are normalized to the Request Composition bound', () => { + const oversized = 'x'.repeat(REQUEST_COMPOSITION_MAX_TOOL_DESCRIPTION_LENGTH + 1); + const [tool] = buildMcpTools( + fakeProvider( + [boundTool({ ...descriptor('server', 'large'), description: oversized }, binding('large'))], + async () => ({ content: [{ type: 'text', text: 'unused' }] }), + ), + ); + + assert.equal( + tool?.description, + oversized.slice(0, REQUEST_COMPOSITION_MAX_TOOL_DESCRIPTION_LENGTH), + ); +}); + function descriptor(serverId: string, name: string, readOnlyHint?: boolean): McpToolDescriptor { return { serverId, diff --git a/packages/runtime/src/__tests__/plugin-tool-service.test.ts b/packages/runtime/src/__tests__/plugin-tool-service.test.ts new file mode 100644 index 0000000000..8eb7a54ddf --- /dev/null +++ b/packages/runtime/src/__tests__/plugin-tool-service.test.ts @@ -0,0 +1,241 @@ +/* + * 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 { test } from 'node:test'; +import { REQUEST_COMPOSITION_MAX_TOOL_DESCRIPTION_LENGTH } from '@maka/core/run-composition'; +import { Context } from '../plugin-kernel.js'; +import { MakaCompositionLoader } from '../plugin-composition-loader.js'; +import { PluginToolService } from '../plugin-tool-service.js'; +import type { MakaPluginPackage } from '../plugin-runtime.js'; +import { toolActivationKey } from '../tool-activation-identity.js'; +import type { MakaTool } from '../tool-runtime.js'; + +test('Profile tools are inherited and exact Session tools shadow them', async () => { + const changed: string[] = []; + let eventCount = 0; + const root = new Context(); + root.on('tools/change', () => { + eventCount += 1; + }); + const tools = new PluginToolService(root, { onChanged: (rootId) => changed.push(rootId) }); + const loader = new MakaCompositionLoader({ root }); + await loader.install(toolPackage('profile-package', tool('answer', 'profile'))); + await loader.install(toolPackage('session-package', tool('answer', 'session'))); + await loader.create('profile', { id: 'profile-entry', packageId: 'profile-package' }); + await loader.create('session:alpha', { id: 'session-entry', packageId: 'session-package' }); + + const alpha = tools.resolve('alpha', []); + const beta = tools.resolve('beta', []); + assert.equal(await invoke(alpha.tools[0]!), 'session'); + assert.equal(await invoke(beta.tools[0]!), 'profile'); + assert.deepEqual(changed, ['profile', 'session:alpha']); + + await loader.remove('session-entry'); + assert.equal(await invoke(tools.resolve('alpha', []).tools[0]!), 'profile'); + assert.equal(eventCount, 3); + await loader.close(); +}); + +test('Tool publication is atomic with Entry activation', async () => { + const root = new Context(); + const tools = new PluginToolService(root); + const loader = new MakaCompositionLoader({ root }); + await loader.install({ + packageId: 'broken-package', + host: (ctx) => { + ctx.tools.register(tool('partial', 'never-visible')); + throw new Error('activation failed'); + }, + }); + + await assert.rejects( + () => loader.create('profile', { id: 'broken-entry', packageId: 'broken-package' }), + /activation failed/u, + ); + assert.deepEqual(tools.inspect(), []); + assert.deepEqual(tools.resolve('alpha', []).tools, []); + await loader.close(); +}); + +test('a failing tools/change listener rolls registration back atomically', async () => { + const root = new Context(); + root.on('tools/change', () => { + throw new Error('change rejected'); + }); + const tools = new PluginToolService(root); + const loader = new MakaCompositionLoader({ root }); + await loader.install(toolPackage('rejected-package', tool('rejected', 'never-visible'))); + + await assert.rejects( + () => loader.create('profile', { id: 'rejected-entry', packageId: 'rejected-package' }), + /change rejected/u, + ); + assert.deepEqual(tools.inspect(), []); + await loader.close(); +}); + +test('ctx.tools.register returns a live disposer', async () => { + let dispose!: () => Promise; + const root = new Context(); + const tools = new PluginToolService(root); + const loader = new MakaCompositionLoader({ root }); + await loader.install({ + packageId: 'dynamic-package', + host: (ctx) => { + dispose = ctx.tools.register(tool('dynamic', 'visible')); + }, + }); + await loader.create('profile', { id: 'dynamic-entry', packageId: 'dynamic-package' }); + + assert.deepEqual( + tools.resolve('alpha', []).tools.map(({ name }) => name), + ['dynamic'], + ); + await dispose(); + assert.deepEqual(tools.resolve('alpha', []).tools, []); + await loader.close(); +}); + +test('Plugin Tool activation identity is stable within and fenced across generations', async () => { + const root = new Context(); + const tools = new PluginToolService(root); + const loader = new MakaCompositionLoader({ root }); + await loader.install(toolPackage('dynamic-package', tool('dynamic', 'visible'))); + await loader.create('profile', { id: 'dynamic-entry', packageId: 'dynamic-package' }); + + const first = tools.resolve('alpha', []).tools[0]!; + assert.equal(toolActivationKey(tools.resolve('alpha', []).tools[0]!), toolActivationKey(first)); + + await loader.remove('dynamic-entry'); + await loader.create('profile', { id: 'dynamic-entry', packageId: 'dynamic-package' }); + const replacement = tools.resolve('alpha', []).tools[0]!; + assert.notEqual(toolActivationKey(replacement), toolActivationKey(first)); + await loader.close(); +}); + +test('retirement rejects stale starts and waits for an active call to drain', async () => { + let finish!: () => void; + const gate = new Promise((resolve) => { + finish = resolve; + }); + const root = new Context(); + const tools = new PluginToolService(root); + const loader = new MakaCompositionLoader({ root }); + await loader.install( + toolPackage('slow-package', { + ...tool('slow', 'done'), + impl: async () => { + await gate; + return 'done'; + }, + }), + ); + await loader.create('profile', { id: 'slow-entry', packageId: 'slow-package' }); + const exposed = tools.resolve('alpha', []).tools[0]!; + const call = invoke(exposed); + let retired = false; + const removal = loader.remove('slow-entry').then(() => { + retired = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(retired, false); + await assert.rejects(() => invoke(exposed), /no longer active/u); + finish(); + assert.equal(await call, 'done'); + await removal; + assert.deepEqual(tools.inspect(), []); + await loader.close(); +}); + +test('desktop-ui and Host-owned Tool conflicts fail closed', async () => { + const root = new Context(); + const tools = new PluginToolService(root); + const loader = new MakaCompositionLoader({ root }); + await loader.install(toolPackage('plugin-package', tool('Read', 'plugin'))); + await assert.rejects( + () => loader.create('desktop-ui', { id: 'ui-entry', packageId: 'plugin-package' }), + /desktop-ui plugins cannot register Host tools/u, + ); + await loader.create('profile', { id: 'host-conflict-entry', packageId: 'plugin-package' }); + assert.throws(() => tools.resolve('alpha', [tool('Read', 'host')]), /Host-owned Tool/u); + await loader.close(); +}); + +test('Runtime-owned deferred search names are rejected atomically', async () => { + const root = new Context(); + const tools = new PluginToolService(root); + const loader = new MakaCompositionLoader({ root }); + await loader.install(toolPackage('reserved-package', tool('tool_search', 'plugin'))); + + await assert.rejects( + () => loader.create('profile', { id: 'reserved-entry', packageId: 'reserved-package' }), + /reserved by Runtime/u, + ); + assert.deepEqual(tools.inspect(), []); + await loader.close(); +}); + +test('Plugin Tool descriptions satisfy the Request Composition bound', async () => { + const root = new Context(); + const tools = new PluginToolService(root); + const loader = new MakaCompositionLoader({ root }); + await loader.install({ + packageId: 'oversized-description-package', + host: (ctx) => { + ctx.tools.register({ + ...tool('oversized', 'unused'), + description: 'x'.repeat(REQUEST_COMPOSITION_MAX_TOOL_DESCRIPTION_LENGTH + 1), + }); + }, + }); + + await assert.rejects( + () => + loader.create('profile', { + id: 'oversized-description-entry', + packageId: 'oversized-description-package', + }), + /description of at most 16384 characters/u, + ); + assert.deepEqual(tools.inspect(), []); + await loader.close(); +}); + +function tool(name: string, result: string): MakaTool { + return { + name, + description: name, + parameters: {}, + impl: async () => result, + }; +} + +function toolPackage(packageId: string, definition: MakaTool): MakaPluginPackage { + return { + packageId, + host: (ctx) => { + ctx.tools.register(definition); + }, + }; +} + +async function invoke(definition: MakaTool): Promise { + return await definition.impl({}, {} as never); +} diff --git a/packages/runtime/src/__tests__/tool-availability.test.ts b/packages/runtime/src/__tests__/tool-availability.test.ts index 2a7bada34b..d79c719d02 100644 --- a/packages/runtime/src/__tests__/tool-availability.test.ts +++ b/packages/runtime/src/__tests__/tool-availability.test.ts @@ -28,6 +28,7 @@ import { toolAvailabilityHash, type ToolSearchResult, } from '../tool-availability.js'; +import { bindToolActivationIdentity, toolActivationKey } from '../tool-activation-identity.js'; import type { MakaTool, MakaToolContext } from '../tool-runtime.js'; function tool(name: string, description = name): MakaTool { @@ -170,7 +171,7 @@ describe('ToolAvailabilityRuntime — search activation', () => { }); test('a successful search activates bounded matches for the next projection', async () => { - const active = new Map(); + const active = new Map(); const traces: Record[] = []; const plan = runtime().prepare(active); const connector = searchTool(plan); @@ -197,6 +198,64 @@ describe('ToolAvailabilityRuntime — search activation', () => { assert.deepEqual(traces[0]?.activated, ['docs_edit']); }); + test('a same-name replacement does not inherit the retired contribution activation', async () => { + const first = bindToolActivationIdentity(tool('plugin_weather', 'weather'), { + kind: 'plugin', + scopeId: 'profile', + entryId: 'weather-entry', + extensionId: 'weather-plugin', + generation: 1, + toolName: 'plugin_weather', + }); + const active = new Map(); + const initial = new ToolAvailabilityRuntime( + [first], + { groups: [{ id: 'plugins', toolNames: ['plugin_weather'] }] }, + invalid, + ).prepare(active); + await searchTool(initial).impl({ query: 'weather' }, ctx); + assert.equal(active.get('plugin_weather'), toolActivationKey(first)); + + const replacement = bindToolActivationIdentity(tool('plugin_weather', 'weather'), { + kind: 'plugin', + scopeId: 'profile', + entryId: 'weather-entry', + extensionId: 'weather-plugin', + generation: 2, + toolName: 'plugin_weather', + }); + const next = new ToolAvailabilityRuntime( + [replacement], + { groups: [{ id: 'plugins', toolNames: ['plugin_weather'] }] }, + invalid, + ).prepare(active); + + assert.equal(active.has('plugin_weather'), false); + assert.equal(next.activeTools.includes('plugin_weather'), false); + }); + + test('an equivalent rebuilt Host wrapper keeps its activation', async () => { + const active = new Map(); + const first = tool('todo_read', 'read the session todo'); + const initial = new ToolAvailabilityRuntime( + [first], + { groups: [{ id: 'todo', toolNames: ['todo_read'] }] }, + invalid, + ).prepare(active); + await searchTool(initial).impl({ query: 'read todo' }, ctx); + + const rebuilt = tool('todo_read', 'read the session todo'); + assert.notEqual(rebuilt, first); + const next = new ToolAvailabilityRuntime( + [rebuilt], + { groups: [{ id: 'todo', toolNames: ['todo_read'] }] }, + invalid, + ).prepare(active); + + assert.equal(active.get('todo_read'), toolActivationKey(rebuilt)); + assert.equal(next.activeTools.includes('todo_read'), true); + }); + test('ordinary result is thin and contains no complete schemas', async () => { const connector = searchTool(runtime().prepare(new Map())); const output = await connector.impl({ query: 'browser click' }, ctx); @@ -208,7 +267,7 @@ describe('ToolAvailabilityRuntime — search activation', () => { }); test('repeated and parallel searches union and deduplicate turn activation', async () => { - const active = new Map(); + const active = new Map(); const plan = runtime().prepare(active); const connector = searchTool(plan); await Promise.all([ @@ -220,7 +279,7 @@ describe('ToolAvailabilityRuntime — search activation', () => { }); test('already-active matches do not consume a later search limit or schema budget', async () => { - const active = new Map(); + const active = new Map(); const largeDescription = `Perform a calendar action ${'x'.repeat(40 * 1024)}`; const plan = new ToolAvailabilityRuntime( [tool('calendar_primary', largeDescription), tool('calendar_secondary', largeDescription)], @@ -252,7 +311,7 @@ describe('ToolAvailabilityRuntime — search activation', () => { }); test('reports and skips an oversized tool without hiding a smaller later match', async () => { - const active = new Map(); + const active = new Map(); const plan = new ToolAvailabilityRuntime( [ tool('oversized_target', `Oversized target ${'x'.repeat(TOOL_SEARCH_MAX_SCHEMA_CHARS)}`), @@ -288,7 +347,7 @@ describe('ToolAvailabilityRuntime — search activation', () => { test('stops at the schema ceiling instead of silently changing relevance order', async () => { const largeDescription = `Budget branch ${'x'.repeat(40 * 1024)}`; - const active = new Map(); + const active = new Map(); const plan = new ToolAvailabilityRuntime( [ tool('budget_branch_primary', largeDescription), @@ -318,7 +377,7 @@ describe('ToolAvailabilityRuntime — search activation', () => { }); test('required orchestration tools are visible without changing activation state', () => { - const active = new Map(); + const active = new Map(); const plan = runtime().prepare(active, new Set(['docs_read'])); assert.ok(plan.activeTools.includes('docs_read')); assert.equal(active.size, 0); @@ -326,7 +385,7 @@ describe('ToolAvailabilityRuntime — search activation', () => { }); test('activation maps isolate overlapping and subsequent turns', async () => { - const first = new Map(); + const first = new Map(); const firstPlan = runtime().prepare(first); await searchTool(firstPlan).impl({ query: 'browser click' }, ctx); assert.ok(firstPlan.projectActiveTools!().activeTools.includes('browser_click')); diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index cff8aaed16..af6b9587e0 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -25,8 +25,16 @@ import type { } from '@maka/core/agent-run'; import type { RuntimeEvent, ToolBoundaryProtocol } from '@maka/core/runtime-event'; import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; -import type { RunCompositionSnapshot } from '@maka/core/run-composition'; -import { decodeRunCompositionSnapshot } from '@maka/core/run-composition'; +import type { + RequestCompositionSnapshot, + RequestCompositionSnapshotInput, + RunCompositionSnapshot, +} from '@maka/core/run-composition'; +import { + createRequestCompositionSnapshot, + decodeRequestCompositionSnapshot, + decodeRunCompositionSnapshot, +} from '@maka/core/run-composition'; import { DurableStoreWriteError, RunSealedError } from '@maka/core/runtime-event-store'; import { isSessionInlineRun } from '@maka/core/agent-run'; import { @@ -68,6 +76,7 @@ import type { RunTraceEvent } from './run-trace.js'; import type { StopSessionInput } from './session-manager.js'; import type { HistoryCompactCheckpoint } from './history-compact-checkpoint.js'; import { projectRuntimeEventsToStoredMessages } from './runtime-event-read-model.js'; +import { stableHash } from './request-shape.js'; import { buildPriorRuntimeContext as buildPriorRuntimeContextProjection, type PriorRuntimeContext, @@ -242,6 +251,9 @@ export class AgentRun { private traceWriteError: string | undefined; private runComposition: RunCompositionSnapshot | undefined; private runCompositionWrite: Promise | undefined; + private requestComposition: RequestCompositionSnapshot | undefined; + private requestCompositionIndex: Map | undefined; + private requestCompositionIndexRead: Promise | undefined; private failureClass: string | undefined; private failureMessage: string | undefined; private lastTs = 0; @@ -456,6 +468,77 @@ export class AgentRun { }); } + /** + * Durably binds one logical model step to its effective request surface. + * Unchanged steps reuse the latest snapshot; a changed surface appends a full + * replacement before provider dispatch, matching DSH request/header epochs. + */ + async recordRequestComposition(input: RequestCompositionSnapshotInput): Promise { + if (!this.input.runStore) { + throw new Error('AgentRun store is not configured'); + } + await this.loadRequestCompositionIndex(); + const snapshot = createRequestCompositionSnapshot( + input, + this.requestCompositionIndex?.size ? 'change' : 'initial', + ); + const surfaceHash = requestCompositionSurfaceHash(snapshot); + const existing = this.requestCompositionIndex?.get(surfaceHash); + if (existing) { + if (!sameRequestCompositionSurface(existing, snapshot)) { + throw new Error(`Request Composition surface hash collision: ${surfaceHash}`); + } + this.requestComposition = existing; + return existing.compositionId; + } + await this.enqueueRequiredRunStoreWrite('append request composition', async () => { + await this.input.runStore?.appendEvent( + this.sessionId, + this.runId, + { + type: 'request_composition_resolved', + id: snapshot.compositionId, + runId: this.runId, + sessionId: this.sessionId, + turnId: this.turnId, + ts: this.input.now(), + data: { snapshot }, + }, + { durable: true }, + ); + }); + this.requestComposition = snapshot; + this.requestCompositionIndex?.set(surfaceHash, snapshot); + return snapshot.compositionId; + } + + private async loadRequestCompositionIndex(): Promise { + if (this.requestCompositionIndex) return; + if (this.requestCompositionIndexRead) return await this.requestCompositionIndexRead; + const read = (async (): Promise => { + const index = new Map(); + const events = await this.input.runStore?.readEvents(this.sessionId, this.runId); + for (const event of events ?? []) { + if (event.type !== 'request_composition_resolved') continue; + const snapshot = decodeRequestCompositionSnapshot(event.data?.snapshot); + const surfaceHash = requestCompositionSurfaceHash(snapshot); + const existing = index.get(surfaceHash); + if (existing && !sameRequestCompositionSurface(existing, snapshot)) { + throw new Error(`Request Composition surface hash collision: ${surfaceHash}`); + } + index.set(surfaceHash, existing ?? snapshot); + this.requestComposition = snapshot; + } + this.requestCompositionIndex = index; + })(); + this.requestCompositionIndexRead = read; + try { + await read; + } finally { + if (this.requestCompositionIndexRead === read) this.requestCompositionIndexRead = undefined; + } + } + /** * Canonical accounting record for one physical provider request (#1679). * @@ -1877,6 +1960,38 @@ function errorMessage(error: unknown): string { return redactTraceString(error instanceof Error ? error.message : String(error)); } +function sameRequestCompositionSurface( + current: RequestCompositionSnapshot, + candidate: RequestCompositionSnapshot, +): boolean { + const { + schemaVersion: _schemaVersion, + compositionId: _compositionId, + step: _step, + reason: _reason, + ...currentSurface + } = current; + const { + schemaVersion: _candidateSchemaVersion, + compositionId: _candidateCompositionId, + step: _candidateStep, + reason: _candidateReason, + ...candidateSurface + } = candidate; + return isDeepStrictEqual(currentSurface, candidateSurface); +} + +function requestCompositionSurfaceHash(snapshot: RequestCompositionSnapshot): string { + const { + schemaVersion: _schemaVersion, + compositionId: _compositionId, + step: _step, + reason: _reason, + ...surface + } = snapshot; + return stableHash(surface); +} + async function appendUserMessageOnce( store: AgentRunSessionStore, sessionId: string, diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index fe67a5fffb..4503c43587 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -122,6 +122,10 @@ import type { UserContent, } from './model-protocol.js'; import type { ModelCallCommit } from '@maka/core/agent-run'; +import type { + RequestCompositionSnapshotInput, + RunCompositionSourceRevision, +} from '@maka/core/run-composition'; import Ajv, { type AnySchema, type ErrorObject, type ValidateFunction } from 'ajv'; import Ajv2019 from 'ajv/dist/2019.js'; import Ajv2020 from 'ajv/dist/2020.js'; @@ -224,7 +228,12 @@ import { type RuntimeEventModelReplayPlan, type RuntimeEventReplayFallbackGate, } from './model-history.js'; -import { toolSchemaCharsForDiagnostics } from './request-shape.js'; +import { + requestCompositionToolSchemas, + stableHash, + toolCatalogHash, + toolSchemaCharsForDiagnostics, +} from './request-shape.js'; import type { ModelCallAttempt, ModelCallKind } from '@maka/core/model-call-attempt'; import { ProviderRequestTracker, @@ -237,6 +246,7 @@ import { ToolAvailabilityRuntime, type ToolAvailabilityConfig, type ToolAvailabilityPlan, + toolAvailabilityHash, } from './tool-availability.js'; import { renderSwarmModePrompt } from './swarm-mode.js'; import { renderGraphModePrompt } from './graph-mode.js'; @@ -710,6 +720,8 @@ export interface AiSdkBackendInput extends AiSdkCompactionCapabilities { // ── Process-singleton deps ───────────────────────────────────────────── /** Canonical-named tools available this session. */ tools: MakaTool[]; + /** Trusted scoped catalog reader, sampled before every logical model step. */ + resolveTools?: () => readonly MakaTool[]; /** Diagnostic-only Plan Mode/execution identity snapshot. */ planTraceContext?: { mode: 'agent' | 'plan'; @@ -739,7 +751,13 @@ export interface AiSdkBackendInput extends AiSdkCompactionCapabilities { /** Optional system prompt (skills + workspace AGENTS.md merged upstream). */ systemPrompt?: | string - | ((context: SystemPromptContext) => string | undefined | Promise); + | (( + context: SystemPromptContext, + ) => + | string + | undefined + | ResolvedSystemPrompt + | Promise); /** Provider-native options passed through to ai-sdk. */ providerOptions?: Record; /** Test seam for the adapter-owned incremental Responses transport. */ @@ -782,6 +800,11 @@ export interface AiSdkBackendInput extends AiSdkCompactionCapabilities { turnId: string; runId: string; }) => void | Promise; + /** Durable DSH-style request/header epoch written once per changed logical step surface. */ + recordRequestComposition?: ( + runId: string, + snapshot: RequestCompositionSnapshotInput, + ) => Promise; /** * Optional artifact recorder. Runtime derives only deterministic candidates * from structured tool results / explicit redirects; desktop main owns @@ -814,6 +837,11 @@ export interface SystemPromptContext { emitSkillCatalogTrace?: (message: string, data?: Record) => void; } +export interface ResolvedSystemPrompt { + text?: string; + sourceRevisions: readonly RunCompositionSourceRevision[]; +} + function isImageToolResult( value: unknown, ): value is { kind: 'image'; mimeType: string; ref: StorageRef } { @@ -990,7 +1018,7 @@ function turnAbortError(): Error { class TurnScope { readonly abortController = new AbortController(); /** Monotonic provider-visible activations owned by this one send(). */ - readonly activeTools = new Map(); + readonly activeTools = new Map(); aborted = false; loopStopRequested = false; loopStopReason: CompleteEvent['stopReason'] | undefined; @@ -1053,7 +1081,7 @@ export class AiSdkBackend implements AgentBackend { private readonly providerRetrySleep: (delayMs: number, signal: AbortSignal) => Promise; private readonly modelAdapter: ModelAdapter; private readonly resolvedProviderOptions: Record; - private readonly toolAvailabilityRuntime: ToolAvailabilityRuntime; + private readonly memoryTools: readonly MakaTool[]; private readonly applyPatchProfile: ApplyPatchProfile | null; /** Bounds outstanding Code Mode cells on this backend. */ @@ -1138,7 +1166,7 @@ export class AiSdkBackend implements AgentBackend { ) { throw new Error('Long-term Memory trigger tool names are reserved by Runtime'); } - const memoryTools = input.memoryExtraction + this.memoryTools = input.memoryExtraction ? buildMemoryExtractionTriggerTools({ capabilities: input.memoryExtraction, snapshot: (trigger, context) => this.memorySourceSnapshot(trigger, context), @@ -1156,14 +1184,32 @@ export class AiSdkBackend implements AgentBackend { : []; const runtime = resolveModelRuntime(input.connection, input.modelId); this.applyPatchProfile = runtime.applyPatchProfile; - const modelTools = routeApplyPatchTools(input.tools, this.applyPatchProfile); - this.toolAvailabilityRuntime = new ToolAvailabilityRuntime( - // The archive decoder is a runtime protocol tool, not a host binding: - // this session's placeholders name it, so this session advertises it. - bindToolResultArchiveDecoder([...modelTools, ...memoryTools], input.toolResultArchive), - input.toolAvailability, - buildInvalidMakaTool(), - ); + } + + private snapshotToolAvailability(): { + readonly hostTools: readonly MakaTool[]; + readonly runtime: ToolAvailabilityRuntime; + } { + const hostTools = Object.freeze([...(this.input.resolveTools?.() ?? this.input.tools)]); + if ( + hostTools.some( + (tool) => tool.name === MEMORY_REMEMBER_TOOL_NAME || tool.name === MEMORY_EXTRACT_TOOL_NAME, + ) + ) { + throw new Error('Long-term Memory trigger tool names are reserved by Runtime'); + } + const modelTools = routeApplyPatchTools(hostTools, this.applyPatchProfile); + return { + hostTools, + runtime: new ToolAvailabilityRuntime( + bindToolResultArchiveDecoder( + [...modelTools, ...this.memoryTools], + this.input.toolResultArchive, + ), + this.input.toolAvailability, + buildInvalidMakaTool(), + ), + }; } private memorySourceSnapshot( @@ -1645,15 +1691,34 @@ export class AiSdkBackend implements AgentBackend { throw new Error(`Invalid tool mode: ${String(requestedToolMode)}`); } const toolMode = requestedToolMode; - if (toolMode === 'code_mode' && this.input.tools.some((tool) => tool.name === 'exec')) { - throw new Error('Tool name "exec" is reserved for Code Mode.'); - } - const plan = projectToolModePlan( - this.toolAvailabilityRuntime.prepare(scope.activeTools, requiredOrchestrationTools), - toolMode, - codeModeExecTool, - ); - const providerTools = plan.providerTools; + const snapshotStepTools = () => { + const snapshot = this.snapshotToolAvailability(); + if (toolMode === 'code_mode' && snapshot.hostTools.some((tool) => tool.name === 'exec')) { + throw new Error('Tool name "exec" is reserved for Code Mode.'); + } + const stepPlan = projectToolModePlan( + snapshot.runtime.prepare(scope.activeTools, requiredOrchestrationTools), + toolMode, + codeModeExecTool, + ); + const stepModelTools: ModelToolSet = {}; + for (const tool of stepPlan.providerTools) { + stepModelTools[tool.name] = tool.providerTool + ? { kind: 'provider', providerTool: tool.providerTool } + : { + kind: 'function', + description: tool.description, + inputSchema: tool.parameters, + }; + } + toolRuntime.setGating(stepPlan.gating); + return { + plan: stepPlan, + providerTools: stepPlan.providerTools, + modelTools: stepModelTools, + }; + }; + let { plan, providerTools, modelTools } = snapshotStepTools(); let activeToolResultPruneDiagnosticPatch: ActiveToolResultPruneDiagnosticPatch = {}; let midTurnCompactDiagnosticPatch: Partial | undefined; // Tool names the repair path matches a mis-cased call against — follows the @@ -1666,45 +1731,10 @@ export class AiSdkBackend implements AgentBackend { : [...names]; }; const currentRepairToolNames = () => boundaryAwareToolNames(plan.currentRepairToolNames()); - if (plan.gating) { - toolRuntime.setGating(plan.gating); - } - - const modelTools: ModelToolSet = {}; - for (const t of providerTools) { - modelTools[t.name] = t.providerTool - ? { kind: 'provider', providerTool: t.providerTool } - : { - kind: 'function', - description: t.description, - inputSchema: t.parameters, - }; - } - - // Resolve the stable Provider envelope before automatic Compaction freezes - // its source. The same value is reused by the primary request; Memory does - // not resolve or mutate Agent configuration after the checkpoint commits. + // Resolved at every logical step below. A physical retry reuses the frozen + // value, while a Tool result may change Context before the next step. + let resolvedSystemPrompt: ResolvedSystemPrompt = { sourceRevisions: [] }; let systemPrompt: string | undefined; - try { - systemPrompt = joinPromptFragments([ - await this.resolveSystemPrompt(scope), - scope.orchestration?.mode === 'swarm' ? renderSwarmModePrompt() : undefined, - scope.orchestration?.mode === 'graph' ? renderGraphModePrompt() : undefined, - ]); - } catch (err) { - trace.modelStreamFailed(this.modelAdapter.classifyError(err), err); - queue.push(this.makeErrorEvent(turnId, err)); - queue.push({ - type: 'complete', - id: this.newId(), - turnId, - ts: this.now(), - stopReason: 'error', - } satisfies CompleteEvent); - queue.close(); - yield* this.drain(queue); - return; - } // --- Build messages from RuntimeEvent history and its compatibility projection. --- const priorReplayResult = await this.buildPriorMessages( @@ -1936,11 +1966,15 @@ export class AiSdkBackend implements AgentBackend { patch, ); }; + // The compaction stages retain cross-step state, so keep one array + // identity and refresh its contents from the current dynamic Tool + // projection before each request. + const capacityProviderTools = [...providerTools]; const midTurnCapacityHook = this.compaction.buildMidTurnCapacityCompactProjection( turnId, midTurnState, queue, - providerTools, + capacityProviderTools, onMidTurnDiagnosticPatch, scope, this.automaticMemoryCompactionSupported() @@ -1964,8 +1998,10 @@ export class AiSdkBackend implements AgentBackend { ); }, ); + const projectCurrentToolAvailability: RequestProjectionStage = (options) => + plan.projectActiveTools?.(options); const shapedProjection = composeRequestProjection( - plan.projectActiveTools, + projectCurrentToolAvailability, midTurnCapacityHook, activeToolResultPruneHook, ); @@ -1981,7 +2017,7 @@ export class AiSdkBackend implements AgentBackend { activeToolResultPruneHook, )!, state: midTurnState, - providerTools, + providerTools: capacityProviderTools, charsPerToken: this.input.contextBudget?.charsPerToken ?? 4, }) : shapedProjection; @@ -1994,6 +2030,14 @@ export class AiSdkBackend implements AgentBackend { let finishReason: ModelFinishReason = 'stop'; let terminalProviderError: unknown; agentLoop: for (;;) { + ({ plan, providerTools, modelTools } = snapshotStepTools()); + resolvedSystemPrompt = await this.resolveSystemPrompt(scope); + systemPrompt = joinPromptFragments([ + resolvedSystemPrompt.text, + scope.orchestration?.mode === 'swarm' ? renderSwarmModePrompt() : undefined, + scope.orchestration?.mode === 'graph' ? renderGraphModePrompt() : undefined, + ]); + capacityProviderTools.splice(0, capacityProviderTools.length, ...providerTools); await this.drainSteeringInto(scope, input, queue); if (this.input.loadTurnRuntimeEvents) { requestMessages = await loadDurableTurnProjection(); @@ -2049,7 +2093,21 @@ export class AiSdkBackend implements AgentBackend { : undefined; const projectedMessages = shaped?.messages ?? requestMessages; const activeToolsForRequest = resolveDispatch(shaped?.activeTools).activeTools; - providerRequestTracker?.setStep(runtimeSteps); + const requestCompositionId = + scope.runId && this.input.recordRequestComposition + ? await this.input.recordRequestComposition(scope.runId, { + compositionId: this.newId(), + step: runtimeSteps, + sourceRevisions: resolvedSystemPrompt.sourceRevisions, + systemPromptHash: stableHash(requestSystemPrompt ?? ''), + toolCatalogHash: toolCatalogHash(providerTools), + toolAvailabilityHash: toolAvailabilityHash(this.input.toolAvailability), + providerOptionsHash: stableHash(this.input.providerOptions ?? {}), + toolNames: activeToolsForRequest, + toolSchemas: requestCompositionToolSchemas(providerTools, activeToolsForRequest), + }) + : undefined; + providerRequestTracker?.setStep(runtimeSteps, requestCompositionId); let attemptMessages = projectedMessages; let providerAttempt = 1; let idleWatchdogRetryCount = 0; @@ -4442,18 +4500,24 @@ export class AiSdkBackend implements AgentBackend { ); } - private async resolveSystemPrompt(scope: TurnScope): Promise { + private async resolveSystemPrompt(scope: TurnScope): Promise { const turnId = scope.turnId; if (typeof this.input.systemPrompt === 'function') { - return await this.input.systemPrompt({ + const resolved = await this.input.systemPrompt({ sessionId: this.sessionId, turnId, cwd: this.input.header.cwd, emitSkillCatalogTrace: (message, data) => scope.runTrace?.emit('skill', 'skill_catalog_built', message, data), }); + return typeof resolved === 'string' || resolved === undefined + ? { ...(resolved === undefined ? {} : { text: resolved }), sourceRevisions: [] } + : resolved; } - return this.input.systemPrompt; + return { + ...(this.input.systemPrompt === undefined ? {} : { text: this.input.systemPrompt }), + sourceRevisions: [], + }; } private async *drain(queue: AsyncEventQueue): AsyncIterable { diff --git a/packages/runtime/src/mcp-tools.ts b/packages/runtime/src/mcp-tools.ts index a7c48941db..c85f9615af 100644 --- a/packages/runtime/src/mcp-tools.ts +++ b/packages/runtime/src/mcp-tools.ts @@ -27,6 +27,7 @@ import type { McpToolSnapshot, } from '@maka/core/mcp'; import type { PermissionMode, ToolCategory } from '@maka/core/permission'; +import { REQUEST_COMPOSITION_MAX_TOOL_DESCRIPTION_LENGTH } from '@maka/core/run-composition'; import type { ExecutionBoundary } from '@maka/core/sandbox-boundary'; import type { ToolRecoveryMode } from '@maka/core/runtime-event'; import type { ToolResultContentPart, ToolResultOutput } from './model-protocol.js'; @@ -103,9 +104,7 @@ export function buildMcpTools( names.set(name, identity); return { name, - description: - descriptor.description?.trim() || - `MCP tool ${descriptor.name} provided by ${descriptor.serverId}`, + description: mcpToolDescription(descriptor), displayName: descriptor.annotations?.title?.trim() || descriptor.name, activityKind: options.activityKindForDescriptor?.(descriptor) ?? 'tool', // MCP annotations are advisory provider claims, not a security boundary. @@ -178,6 +177,13 @@ export function buildMcpTools( }); } +function mcpToolDescription(descriptor: McpToolDescriptor): string { + const description = + descriptor.description?.trim() || + `MCP tool ${descriptor.name} provided by ${descriptor.serverId}`; + return description.slice(0, REQUEST_COMPOSITION_MAX_TOOL_DESCRIPTION_LENGTH); +} + export function mcpProxyToolName(serverId: string, toolName: string): string { const raw = `mcp__${sanitizeNamePart(serverId)}__${sanitizeNamePart(toolName)}`; if (raw.length <= MAX_PROVIDER_TOOL_NAME) return raw; diff --git a/packages/runtime/src/plugin-runtime.ts b/packages/runtime/src/plugin-runtime.ts index 8b8e106e6c..5565a5fa27 100644 --- a/packages/runtime/src/plugin-runtime.ts +++ b/packages/runtime/src/plugin-runtime.ts @@ -351,7 +351,11 @@ export interface MakaContributionContext extends MakaContributionIdentity { } export interface MakaPluginTransaction { - stage(label: string, register: () => () => void | Promise, owner?: Context): void; + stage( + label: string, + register: () => () => void | Promise, + owner?: Context, + ): () => Promise; commit(): void | Promise; rollback(): void | Promise; } @@ -515,12 +519,11 @@ export function registerPluginContribution( ctx: Context, label: string, register: () => () => void | Promise, -): void { +): () => Promise { if (ctx.makaTransaction) { - ctx.makaTransaction.stage(label, register, ctx); - return; + return ctx.makaTransaction.stage(label, register, ctx); } - registerPluginEffect(ctx, label, register); + return registerPluginEffect(ctx, label, register); } export class MakaPluginTransactionBuffer implements MakaPluginTransaction { @@ -528,15 +531,20 @@ export class MakaPluginTransactionBuffer implements MakaPluginTransaction { readonly label: string; readonly register: () => () => void | Promise; readonly owner: Context; + cancelled: boolean; + release?: () => Promise; }> = []; #state: 'staging' | 'committed' | 'rolled_back' = 'staging'; constructor(private readonly context: Context) {} - stage(label: string, register: () => () => void | Promise, owner = this.context): void { + stage( + label: string, + register: () => () => void | Promise, + owner = this.context, + ): () => Promise { if (this.#state === 'committed') { - registerPluginEffect(owner, label, register); - return; + return registerPluginEffect(owner, label, register); } if (this.#state === 'rolled_back') { throw new MakaPluginRuntimeError( @@ -544,7 +552,23 @@ export class MakaPluginTransactionBuffer implements MakaPluginTransaction { `Cannot stage contribution after transaction is ${this.#state}`, ); } - this.#registrations.push({ label, register, owner }); + const item: { + readonly label: string; + readonly register: () => () => void | Promise; + readonly owner: Context; + cancelled: boolean; + release?: () => Promise; + } = { + label, + register, + owner, + cancelled: false, + }; + this.#registrations.push(item); + return async () => { + item.cancelled = true; + await item.release?.(); + }; } async commit(): Promise { @@ -558,7 +582,9 @@ export class MakaPluginTransactionBuffer implements MakaPluginTransaction { const registered: Array<() => Promise> = []; try { for (const item of this.#registrations) { - registered.push(registerPluginEffect(item.owner, item.label, item.register)); + if (item.cancelled) continue; + item.release = registerPluginEffect(item.owner, item.label, item.register); + registered.push(item.release); } this.#state = 'committed'; this.#registrations.length = 0; diff --git a/packages/runtime/src/plugin-tool-service.ts b/packages/runtime/src/plugin-tool-service.ts new file mode 100644 index 0000000000..a290078d6e --- /dev/null +++ b/packages/runtime/src/plugin-tool-service.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. + */ + +import { REQUEST_COMPOSITION_MAX_TOOL_DESCRIPTION_LENGTH } from '@maka/core/run-composition'; +import { Service, type Context } from './plugin-kernel.js'; +import { + MakaPluginRuntimeError, + pluginIdentity, + registerPluginContribution, + type MakaContributionIdentity, + type MakaPluginRootId, +} from './plugin-runtime.js'; +import type { MakaTool } from './tool-runtime.js'; +import { bindToolActivationIdentity } from './tool-activation-identity.js'; +import { TOOL_SEARCH_NAME, TOOL_SEARCH_PROVIDER_NAME } from './tool-availability.js'; + +declare module './plugin-kernel.js' { + interface Context { + readonly tools: PluginToolService; + } +} + +interface RegisteredPluginTool extends MakaContributionIdentity { + readonly definition: MakaTool; + readonly exposed: MakaTool; + readonly token: symbol; + activeCalls: number; + retired: boolean; + readonly drainWaiters: Set<() => void>; +} + +export interface PluginToolInspection extends MakaContributionIdentity { + readonly toolName: string; + readonly activeCalls: number; + readonly retired: boolean; +} + +export interface ResolvedPluginTools { + readonly tools: readonly MakaTool[]; +} + +export interface PluginToolServiceOptions { + readonly onChanged?: (rootId: MakaPluginRootId) => void; +} + +/** + * Context-scoped Tool contribution registry for trusted Host plugins. + * + * Registration is staged by the Plugin Platform transaction and owned by the + * registering Fiber. Profile registrations are inherited by Session roots; + * an exact Session registration shadows the Profile registration. Core tools + * remain Host-owned and cannot be shadowed. + */ +export class PluginToolService extends Service { + private readonly layers = new Map>(); + private readonly onChanged?: (rootId: MakaPluginRootId) => void; + + constructor(ctx: Context, options: PluginToolServiceOptions = {}) { + super(ctx, 'tools'); + this.onChanged = options.onChanged; + } + + register(definition: MakaTool): () => Promise { + const identity = pluginIdentity(this.ctx); + if (identity.scopeId === 'desktop-ui') { + throw new MakaPluginRuntimeError( + 'activation_failed', + 'desktop-ui plugins cannot register Host tools', + ); + } + validateTool(definition); + return registerPluginContribution( + this.ctx, + `tools.register(${JSON.stringify(definition.name)})`, + () => this.publish(identity, definition), + ); + } + + resolve(sessionId: string, coreTools: readonly MakaTool[]): ResolvedPluginTools { + const contributions = this.resolveContributions(sessionId, coreTools); + return Object.freeze({ + tools: Object.freeze([...coreTools, ...contributions.tools]), + }); + } + + /** Resolve only Plugin-owned additions after validating them against the Host binding. */ + resolveContributions(sessionId: string, coreTools: readonly MakaTool[]): ResolvedPluginTools { + if (!sessionId || /[\r\n\0]/u.test(sessionId)) throw new Error('Invalid Tool Session scope'); + const visible = new Map(); + for (const entry of this.layers.get('profile')?.values() ?? []) { + visible.set(entry.definition.name, entry); + } + const sessionRoot = `session:${sessionId}` as const; + for (const entry of this.layers.get(sessionRoot)?.values() ?? []) { + visible.set(entry.definition.name, entry); + } + + const coreNames = new Set(coreTools.map(({ name }) => name)); + for (const name of visible.keys()) { + if (coreNames.has(name)) { + throw new MakaPluginRuntimeError( + 'activation_failed', + `Plugin Tool ${JSON.stringify(name)} conflicts with a Host-owned Tool`, + ); + } + } + const entries = [...visible.values()].sort(compareRegistration); + return Object.freeze({ + tools: Object.freeze(entries.map(({ exposed }) => exposed)), + }); + } + + inspect(rootId?: MakaPluginRootId): readonly PluginToolInspection[] { + const layers = rootId + ? [[rootId, this.layers.get(rootId)] as const] + : [...this.layers.entries()]; + return Object.freeze( + layers + .flatMap(([, layer]) => [...(layer?.values() ?? [])]) + .sort(compareRegistration) + .map((entry) => + Object.freeze({ + entryId: entry.entryId, + scopeId: entry.scopeId, + extensionId: entry.extensionId, + generation: entry.generation, + toolName: entry.definition.name, + activeCalls: entry.activeCalls, + retired: entry.retired, + }), + ), + ); + } + + private publish(identity: MakaContributionIdentity, definition: MakaTool): () => Promise { + const rootId = identity.scopeId as MakaPluginRootId; + let layer = this.layers.get(rootId); + if (!layer) { + layer = new Map(); + this.layers.set(rootId, layer); + } + const existing = layer.get(definition.name); + if (existing && existing.entryId !== identity.entryId) { + throw new MakaPluginRuntimeError( + 'activation_failed', + `Plugin Tool ${JSON.stringify(definition.name)} is already registered by ${existing.entryId}`, + ); + } + let entry!: RegisteredPluginTool; + const exposed: MakaTool = bindToolActivationIdentity( + { + ...definition, + impl: async (args, context) => { + if (entry.retired) { + throw new Error(`Plugin Tool ${JSON.stringify(definition.name)} is no longer active`); + } + entry.activeCalls += 1; + try { + return await definition.impl(args, context); + } finally { + entry.activeCalls -= 1; + if (entry.activeCalls === 0) { + for (const resolve of entry.drainWaiters) resolve(); + entry.drainWaiters.clear(); + } + } + }, + }, + { + kind: 'plugin', + scopeId: identity.scopeId, + entryId: identity.entryId, + extensionId: identity.extensionId, + generation: identity.generation, + toolName: definition.name, + }, + ); + entry = { + ...identity, + definition, + exposed, + token: Symbol(definition.name), + activeCalls: 0, + retired: false, + drainWaiters: new Set(), + }; + layer.set(definition.name, entry); + try { + this.notifyChanged(rootId); + } catch (error) { + if (existing) layer.set(definition.name, existing); + else layer.delete(definition.name); + if (layer.size === 0) this.layers.delete(rootId); + throw error; + } + + return async () => { + entry.retired = true; + const currentLayer = this.layers.get(rootId); + if (currentLayer?.get(definition.name)?.token === entry.token) { + if (existing && !existing.retired) currentLayer.set(definition.name, existing); + else currentLayer.delete(definition.name); + if (currentLayer.size === 0) this.layers.delete(rootId); + this.notifyChanged(rootId); + } + if (entry.activeCalls > 0) { + await new Promise((resolve) => entry.drainWaiters.add(resolve)); + } + }; + } + + private notifyChanged(rootId: MakaPluginRootId): void { + this.ctx.emit('tools/change'); + this.onChanged?.(rootId); + } +} + +function validateTool(tool: MakaTool): void { + if (!tool || typeof tool !== 'object') throw new TypeError('Tool definition is required'); + if ( + typeof tool.name !== 'string' || + tool.name.length === 0 || + tool.name.length > 128 || + /[\r\n\0]/u.test(tool.name) + ) { + throw new TypeError('Tool requires a valid name'); + } + if ( + typeof tool.description !== 'string' || + tool.description.length === 0 || + tool.description.length > REQUEST_COMPOSITION_MAX_TOOL_DESCRIPTION_LENGTH || + typeof tool.impl !== 'function' + ) { + throw new TypeError( + `Tool ${JSON.stringify(tool.name)} requires a description of at most ${REQUEST_COMPOSITION_MAX_TOOL_DESCRIPTION_LENGTH} characters and an implementation`, + ); + } + if (tool.parameters === undefined) { + throw new TypeError(`Tool ${JSON.stringify(tool.name)} requires an input schema`); + } + if (tool.providerTool) { + throw new TypeError( + `Plugin Tool ${JSON.stringify(tool.name)} cannot claim a provider protocol`, + ); + } + if (tool.name === TOOL_SEARCH_NAME || tool.name === TOOL_SEARCH_PROVIDER_NAME) { + throw new TypeError(`Plugin Tool name ${JSON.stringify(tool.name)} is reserved by Runtime`); + } +} + +function compareRegistration(left: RegisteredPluginTool, right: RegisteredPluginTool): number { + return ( + left.definition.name.localeCompare(right.definition.name) || + left.scopeId.localeCompare(right.scopeId) || + left.entryId.localeCompare(right.entryId) + ); +} diff --git a/packages/runtime/src/provider-request-telemetry.ts b/packages/runtime/src/provider-request-telemetry.ts index 49f9809caf..95823578cd 100644 --- a/packages/runtime/src/provider-request-telemetry.ts +++ b/packages/runtime/src/provider-request-telemetry.ts @@ -83,6 +83,7 @@ interface SettledProviderAttempt extends ProviderRequestUsage { attemptId: string; turnId: string; step: number; + requestCompositionId?: string; attempt: number; captureArtifactId?: string; providerId: string; @@ -334,6 +335,7 @@ export class ProviderRequestTracker { * that call, not new calls, so they share this id. */ private readonly logicalCallIdByStep = new Map(); + private readonly requestCompositionIdByStep = new Map(); constructor(private readonly input: ProviderRequestTrackerInput) {} @@ -341,8 +343,11 @@ export class ProviderRequestTracker { return this.input.traceId; } - setStep(step: number): void { + setStep(step: number, requestCompositionId?: string): void { this.step = step; + if (requestCompositionId !== undefined) { + this.requestCompositionIdByStep.set(step, requestCompositionId); + } } async trackStream(input: TrackProviderStreamInput): Promise { @@ -492,6 +497,9 @@ export class ProviderRequestTracker { attemptId, turnId: this.input.turnId, step, + ...(this.requestCompositionIdByStep.get(step) + ? { requestCompositionId: this.requestCompositionIdByStep.get(step) } + : {}), attempt, ...(capture.artifactId ? { captureArtifactId: capture.artifactId } : {}), providerId: input.providerId, @@ -584,6 +592,9 @@ export class ProviderRequestTracker { // The physical ordinals are one-based on the diagnostic record; the // canonical record counts retries from zero. step: Math.max(0, record.step), + ...(record.requestCompositionId !== undefined + ? { requestCompositionId: record.requestCompositionId } + : {}), attempt: Math.max(0, record.attempt - 1), callKind: accounting.callKind, ...(context.historyCompactRoute !== undefined diff --git a/packages/runtime/src/request-shape.ts b/packages/runtime/src/request-shape.ts index 341ba344e4..026557543c 100644 --- a/packages/runtime/src/request-shape.ts +++ b/packages/runtime/src/request-shape.ts @@ -30,6 +30,7 @@ import { import { toJSONSchema } from 'zod'; import type { MakaTool } from './tool-runtime.js'; +import type { RequestCompositionToolSchema } from '@maka/core/run-composition'; export interface CanonicalToolSet { providerTools: MakaTool[]; @@ -264,6 +265,38 @@ export function toolCatalogHash(tools: readonly MakaTool[]): `sha256:${string}` ); } +/** Canonical, JSON-safe schemas for the exact provider-visible tool subset. */ +export function requestCompositionToolSchemas( + tools: readonly MakaTool[], + activeToolNames: readonly string[], +): readonly RequestCompositionToolSchema[] { + const active = new Set(activeToolNames); + return tools + .filter((tool) => active.has(tool.name)) + .map((tool) => { + const inputSchema = JSON.parse( + stableStringify(schemaShapeForHash(tool.parameters)), + ) as unknown; + if (!isPlainObject(inputSchema)) { + throw new Error(`Tool ${JSON.stringify(tool.name)} produced a non-object input schema`); + } + return { + name: tool.name, + description: tool.description, + inputSchema, + ...(tool.providerTool + ? { + providerTool: JSON.parse(stableStringify(tool.providerTool)) as Record< + string, + unknown + >, + } + : {}), + }; + }) + .sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0)); +} + export function stableStringify(value: unknown): string { return JSON.stringify(canonicalize(value)); } diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 1da029c273..309c272444 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -2273,6 +2273,7 @@ export class RuntimeKernel implements RuntimeKernelLike { | 'recordRunTrace' | 'recordModelCallAttempt' | 'recordRunComposition' + | 'recordRequestComposition' | 'loadHistoryCompactCheckpoint' | 'recordHistoryCompactCheckpoint' | 'loadModelProjectionTransitions' @@ -2304,6 +2305,13 @@ export class RuntimeKernel implements RuntimeKernelLike { } return run.recordRunComposition(snapshot); }, + recordRequestComposition: (runId, snapshot) => { + const run = this.active.get(sessionId)?.activeRuns.get(runId); + if (!run) { + return Promise.reject(new Error('No active AgentRun for Request Composition')); + } + return run.recordRequestComposition(snapshot); + }, loadHistoryCompactCheckpoint: () => this.historyCompactCoordinator.load(sessionId), recordHistoryCompactCheckpoint: ( checkpoint: HistoryCompactCheckpoint, diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index fe98e096f7..36cfa76572 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -125,7 +125,10 @@ import type { RuntimeContinuationAuthorityStore, } from '@maka/core/runtime-event-store'; import type { RuntimeEvent, ToolBoundaryProtocol } from '@maka/core/runtime-event'; -import type { RunCompositionSnapshot } from '@maka/core/run-composition'; +import type { + RequestCompositionSnapshotInput, + RunCompositionSnapshot, +} from '@maka/core/run-composition'; import type { SubagentWorkspaceBinding, SubagentWorktreeExecutor, @@ -677,6 +680,11 @@ export interface BackendFactoryContext { recordModelCallAttempt?: (commit: ModelCallCommit) => Promise; /** Immutable Run policy snapshot; provider dispatch waits for this durable commit. */ recordRunComposition?: (runId: string, snapshot: RunCompositionSnapshot) => Promise; + /** Append-only logical request surface; provider dispatch waits for this durable epoch. */ + recordRequestComposition?: ( + runId: string, + snapshot: RequestCompositionSnapshotInput, + ) => Promise; loadHistoryCompactCheckpoint?: () => Promise; recordHistoryCompactCheckpoint?: ( checkpoint: HistoryCompactCheckpoint, diff --git a/packages/runtime/src/tool-activation-identity.ts b/packages/runtime/src/tool-activation-identity.ts new file mode 100644 index 0000000000..9064f92b10 --- /dev/null +++ b/packages/runtime/src/tool-activation-identity.ts @@ -0,0 +1,62 @@ +/* + * 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 { stableHash, toolCatalogHash } from './request-shape.js'; +import type { MakaTool } from './tool-runtime.js'; + +const TOOL_ACTIVATION_IDENTITY = Symbol('maka.toolActivationIdentity'); + +type ToolWithActivationIdentity = MakaTool & { + readonly [TOOL_ACTIVATION_IDENTITY]?: `sha256:${string}`; +}; + +export interface PluginToolActivationIdentity { + readonly kind: 'plugin'; + readonly scopeId: string; + readonly entryId: string; + readonly extensionId: string; + readonly generation: number; + readonly toolName: string; +} + +/** Bind a Runtime-owned contribution identity to a Tool without exposing it to providers. */ +export function bindToolActivationIdentity( + tool: MakaTool, + identity: PluginToolActivationIdentity, +): MakaTool { + return Object.freeze({ + ...tool, + [TOOL_ACTIVATION_IDENTITY]: stableHash({ + identity, + schemaHash: toolCatalogHash([tool]), + }), + }); +} + +/** + * Stable logical identity used by deferred-tool activation. + * + * Host wrappers without explicit contribution metadata fall back to their + * canonical provider-visible shape, so rebuilding an equivalent wrapper does + * not revoke activation. Dynamic contributors bind generation-aware metadata. + */ +export function toolActivationKey(tool: MakaTool): `sha256:${string}` { + const explicit = (tool as ToolWithActivationIdentity)[TOOL_ACTIVATION_IDENTITY]; + return explicit ?? stableHash({ kind: 'host', schemaHash: toolCatalogHash([tool]) }); +} diff --git a/packages/runtime/src/tool-availability.ts b/packages/runtime/src/tool-availability.ts index ae69c554c2..3cbd6bfdfd 100644 --- a/packages/runtime/src/tool-availability.ts +++ b/packages/runtime/src/tool-availability.ts @@ -24,6 +24,7 @@ import { z } from 'zod'; import { estimateTokens } from './context-budget-helpers.js'; import { canonicalizeToolSet, stableHash, toolSchemaCharsForDiagnostics } from './request-shape.js'; +import { toolActivationKey } from './tool-activation-identity.js'; import type { MakaTool, ToolGating } from './tool-runtime.js'; /** Canonical name of Maka's provider-independent deferred-tool search connector. */ @@ -170,6 +171,7 @@ interface SearchDocument { export class ToolAvailabilityRuntime { private readonly tools: readonly MakaTool[]; private readonly toolsByName: ReadonlyMap; + private readonly activationKeysByName: ReadonlyMap; private readonly groups: readonly SearchGroup[]; private readonly searchableNames: ReadonlySet; private readonly directNames: ReadonlySet; @@ -188,6 +190,7 @@ export class ToolAvailabilityRuntime { } this.tools = [...tools]; this.toolsByName = new Map(tools.map((tool) => [tool.name, tool])); + this.activationKeysByName = new Map(tools.map((tool) => [tool.name, toolActivationKey(tool)])); const known = new Set(this.toolsByName.keys()); const searchable = @@ -303,7 +306,7 @@ export class ToolAvailabilityRuntime { } prepare( - activeTools: Map, + activeTools: Map, requiredToolNames: ReadonlySet = new Set(), ): ToolAvailabilityPlan { if (!this.searchIndex) { @@ -320,6 +323,12 @@ export class ToolAvailabilityRuntime { const allTools = [...this.tools, connector]; const canonical = canonicalizeToolSet(allTools, this.invalidTool); const knownNames = new Set(canonical.providerTools.map((tool) => tool.name)); + // Activation belongs to a stable logical contribution, not a temporary + // wrapper object or merely its name. Equivalent Host wrappers survive + // per-step rebuilding; a replaced Plugin generation does not. + for (const [name, activatedKey] of activeTools) { + if (this.activationKeysByName.get(name) !== activatedKey) activeTools.delete(name); + } const requiredNames = [...requiredToolNames].filter((name) => knownNames.has(name)); const step = { active: new Set() }; const computeActive = (): string[] => { @@ -345,7 +354,7 @@ export class ToolAvailabilityRuntime { } private buildSearchConnector( - activeTools: Map, + activeTools: Map, ): MakaTool<{ query: string; limit?: number }, ToolSearchResult> { return { name: TOOL_SEARCH_NAME, @@ -387,7 +396,7 @@ export class ToolAvailabilityRuntime { activated.push(name); schemaChars += chars; } - for (const name of activated) activeTools.set(name, this.toolsByName.get(name)!); + for (const name of activated) activeTools.set(name, this.activationKeysByName.get(name)!); const result: ToolSearchResult = { activated, ...(blocked ? { blocked } : {}),