From 81d45c779f399836c81064a3a322308e9f3683fb Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:46:19 +0800 Subject: [PATCH 01/13] feat(plugins): add scoped system prompt service --- .../interactive-run-composer.test.ts | 35 +- .../src/__tests__/plugin-platform.test.ts | 53 ++ .../src/server/execution-composition.ts | 17 + .../src/server/interactive-run-composer.ts | 52 +- .../src/server/plugin-platform.ts | 11 + packages/runtime/package.json | 1 + .../plugin-system-prompt-service.test.ts | 218 ++++++++ .../src/plugin-system-prompt-service.ts | 521 ++++++++++++++++++ 8 files changed, 899 insertions(+), 9 deletions(-) create mode 100644 packages/runtime/src/__tests__/plugin-system-prompt-service.test.ts create mode 100644 packages/runtime/src/plugin-system-prompt-service.ts 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 b188296fbb..8a7fc6383a 100644 --- a/packages/runtime-host/src/__tests__/interactive-run-composer.test.ts +++ b/packages/runtime-host/src/__tests__/interactive-run-composer.test.ts @@ -127,6 +127,33 @@ test('an explicit tool profile remains an exact ceiling over scoped Tool additio assert.equal(composer.resolveTools?.().filter(({ name }) => name === 'Read').length, 1); }); +test('the composer caches the Host base but reassembles scoped Plugin prompts each step', async () => { + let pluginText = 'FIRST_PLUGIN_PROMPT'; + let assemblies = 0; + const composer = createFixtureComposer({ + resolveAdditionalSystemPrompt: async (_context, baseText) => { + assemblies += 1; + return { + text: `${baseText}\n\n${pluginText}`, + sourceRevisions: [{ id: 'plugin.system-prompt', revision: `revision-${assemblies}` }], + }; + }, + }); + const context = { sessionId: 'session', turnId: 'turn', cwd: '/workspace' }; + + const first = await composer.resolveSystemPrompt(context); + pluginText = 'SECOND_PLUGIN_PROMPT'; + const second = await composer.resolveSystemPrompt(context); + + assert.match(first.text ?? '', /FIRST_PLUGIN_PROMPT/u); + assert.match(second.text ?? '', /SECOND_PLUGIN_PROMPT/u); + assert.equal(assemblies, 2); + assert.deepEqual( + second.sourceRevisions.find(({ id }) => id === 'plugin.system-prompt'), + { id: 'plugin.system-prompt', revision: 'revision-2' }, + ); +}); + function tool(name: string): MakaTool { return { name, @@ -184,7 +211,13 @@ function createFixtureComposer( skills: { readCanonicalModelInventory: async () => ({ inventory: [] }), } as unknown as HostSkillCatalogCoordinator, - memory: {} as HostMemoryCoordinator, + memory: { + readPromptProjection: async () => ({ + bundleRevision: null, + memoryRevision: null, + body: undefined, + }), + } as unknown as HostMemoryCoordinator, sessionTodo: {} as SessionTodoToolStore, builtinTools: {}, ...overrides, diff --git a/packages/runtime-host/src/__tests__/plugin-platform.test.ts b/packages/runtime-host/src/__tests__/plugin-platform.test.ts index c07b137cf6..df5aead6fc 100644 --- a/packages/runtime-host/src/__tests__/plugin-platform.test.ts +++ b/packages/runtime-host/src/__tests__/plugin-platform.test.ts @@ -25,6 +25,7 @@ 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 { PluginSystemPromptService } from '@maka/runtime/plugin-system-prompt-service'; import { PluginToolService } from '@maka/runtime/plugin-tool-service'; import { decodePluginCompositionApplyInput, @@ -66,6 +67,7 @@ function createPlatform( packageLoader, store, ...(options.tools ? { tools: options.tools } : {}), + ...(options.systemPrompt ? { systemPrompt: options.systemPrompt } : {}), }); testPlatformInternals.set(platform, { composition, packages, store }); return platform; @@ -482,6 +484,51 @@ test('Package replacement releases a single-provider Service before activating i } }); +test('Package lifecycle publishes and retires scoped System Prompt contributions', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-system-prompt-')); + try { + const pluginRoot = new Context(); + const systemPrompt = new PluginSystemPromptService(pluginRoot); + const composition = new MakaCompositionLoader({ root: pluginRoot }); + const platform = createPlatform(join(root, 'control'), { composition, systemPrompt }); + await platform.recover(); + await platform.installPackage( + await writeFixturePackage(root, 'prompt-package', 'prompt', { + systemPrompt: { name: 'plugin:fixture', order: 10, text: 'fixture prompt' }, + composition: [ + { type: 'insert', entry: { id: 'prompt-entry', packageId: 'prompt-package' } }, + ], + }), + ); + + assert.equal( + ( + await systemPrompt.assemble( + { sessionId: 'alpha', turnId: 'turn-1', cwd: '/workspace' }, + 'base', + ) + ).text, + 'base\n\nfixture prompt', + ); + assert.equal(platform.inspectSystemPrompt('profile')[0]?.name, 'plugin:fixture'); + + await platform.uninstallPackage('prompt-package'); + assert.equal( + ( + await systemPrompt.assemble( + { sessionId: 'alpha', turnId: 'turn-2', cwd: '/workspace' }, + 'base', + ) + ).text, + 'base', + ); + assert.deepEqual(platform.inspectSystemPrompt('profile'), []); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('package Composition layers override in install order and unwind on uninstall', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-plugin-layers-')); try { @@ -1595,6 +1642,11 @@ async function writeFixturePackage( readonly manifest?: Readonly>; readonly composition?: readonly unknown[]; readonly tool?: { readonly name: string; readonly result: unknown }; + readonly systemPrompt?: { + readonly name: string; + readonly order: number; + readonly text: string; + }; } = {}, ): Promise { const source = join( @@ -1631,6 +1683,7 @@ async function writeFixturePackage( ${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)}) }));` : ''} + ${options.systemPrompt ? `ctx.systemPrompt.section(${JSON.stringify(options.systemPrompt)});` : ''} ctx.effect(() => () => undefined, 'fixture'); } }), });\n`, diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index b32eef9018..69b54d1852 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -82,6 +82,7 @@ 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 { PluginSystemPromptService } from '@maka/runtime/plugin-system-prompt-service'; import { type RuntimeHostedRootAuthority } from '@maka/runtime/message-authority'; import { isHostedExecutionTerminal } from './hosted-execution-authority.js'; import { createAgentGraphControlStore } from '@maka/storage/agent-graph-control-store'; @@ -296,9 +297,11 @@ export async function createExecutionRuntimeHostComposition( try { const pluginRoot = new Context(); const pluginTools = new PluginToolService(pluginRoot); + const pluginSystemPrompt = new PluginSystemPromptService(pluginRoot); pluginPlatform = new HostPluginPlatform(context.owner.controlDirectory, { composition: new MakaCompositionLoader({ root: pluginRoot }), tools: pluginTools, + systemPrompt: pluginSystemPrompt, }); const pluginPlatformCoordinator = new HostPluginPlatformCoordinator(pluginPlatform); const openedProjectCatalog = storage.projectCatalog; @@ -781,6 +784,20 @@ export async function createExecutionRuntimeHostComposition( requireGraphCoordinator(graphCoordinator).toolsForSession(sessionId), resolvePluginTools: (sessionId, coreTools) => pluginTools.resolveContributions(sessionId, coreTools), + resolvePluginSystemPrompt: async (sessionId, promptContext, baseText) => { + const assembly = await pluginSystemPrompt.assemble( + { + sessionId, + turnId: promptContext.turnId, + cwd: promptContext.cwd, + }, + baseText, + ); + return { + text: assembly.text, + sourceRevisions: assembly.sourceRevision ? [assembly.sourceRevision] : [], + }; + }, parentAgentTools: childAgentTools.parentTools, childTools: childAgentTools.childTools, worktreePatchWriteBackAvailable: true, diff --git a/packages/runtime-host/src/server/interactive-run-composer.ts b/packages/runtime-host/src/server/interactive-run-composer.ts index f88c4acae8..840499de5f 100644 --- a/packages/runtime-host/src/server/interactive-run-composer.ts +++ b/packages/runtime-host/src/server/interactive-run-composer.ts @@ -110,6 +110,11 @@ export interface InteractiveRunComposerInput { readonly builtinTools?: BuildBuiltinToolsOptions; readonly hostTools?: readonly MakaTool[]; readonly resolveAdditionalTools?: (hostTools: readonly MakaTool[]) => readonly MakaTool[]; + /** Reassembles the scoped Plugin prompt surface before each logical model step. */ + readonly resolveAdditionalSystemPrompt?: ( + context: HostModelPromptContext, + baseText: string | undefined, + ) => Promise; readonly scheduledTaskTool?: MakaTool; readonly goalTools?: readonly MakaTool[]; readonly parentAgentTools?: readonly MakaTool[]; @@ -193,8 +198,8 @@ export function createInteractiveRunComposer(input: InteractiveRunComposerInput) }; const childInstruction = input.childInstruction?.trim(); const runProfile = hostedExecutionRunProfile(input.toolProfile); - const resolvedSystemPrompts = new Map>(); - const resolveSystemPrompt = (context: HostModelPromptContext): Promise => { + const resolvedBaseSystemPrompts = new Map>(); + const resolveBaseSystemPrompt = (context: HostModelPromptContext): Promise => { if (runProfile) { return Promise.resolve( Object.freeze({ @@ -204,7 +209,7 @@ export function createInteractiveRunComposer(input: InteractiveRunComposerInput) ); } const key = `${context.sessionId}\u0000${context.turnId}`; - const cached = resolvedSystemPrompts.get(key); + const cached = resolvedBaseSystemPrompts.get(key); if (cached) return cached; const pending = Promise.all([ readPromptState(input, context.sessionId, Boolean(childInstruction)), @@ -257,16 +262,27 @@ export function createInteractiveRunComposer(input: InteractiveRunComposerInput) }); }) .catch((error: unknown) => { - if (resolvedSystemPrompts.get(key) === pending) resolvedSystemPrompts.delete(key); + if (resolvedBaseSystemPrompts.get(key) === pending) resolvedBaseSystemPrompts.delete(key); throw error; }); - resolvedSystemPrompts.set(key, pending); - if (resolvedSystemPrompts.size > 100) { - const oldest = resolvedSystemPrompts.keys().next().value; - if (typeof oldest === 'string' && oldest !== key) resolvedSystemPrompts.delete(oldest); + resolvedBaseSystemPrompts.set(key, pending); + if (resolvedBaseSystemPrompts.size > 100) { + const oldest = resolvedBaseSystemPrompts.keys().next().value; + if (typeof oldest === 'string' && oldest !== key) resolvedBaseSystemPrompts.delete(oldest); } return pending; }; + const resolveSystemPrompt = async ( + context: HostModelPromptContext, + ): Promise => { + const base = await resolveBaseSystemPrompt(context); + if (!input.resolveAdditionalSystemPrompt || runProfile) return base; + const plugin = await input.resolveAdditionalSystemPrompt(context, base.text); + return Object.freeze({ + text: plugin.text, + sourceRevisions: mergeSourceRevisions(base.sourceRevisions, plugin.sourceRevisions), + }); + }; return Object.freeze({ composerId: INTERACTIVE_RUN_COMPOSER_ID, @@ -292,6 +308,11 @@ export interface InteractiveRunComposerFactoryInput ) => { readonly tools: readonly MakaTool[]; }; + readonly resolvePluginSystemPrompt?: ( + sessionId: string, + context: HostModelPromptContext, + baseText: string | undefined, + ) => Promise; readonly childTools?: readonly MakaTool[]; readonly worktreePatchWriteBackAvailable?: boolean; readonly planStore?: PlanStore; @@ -430,6 +451,12 @@ export function createInteractiveRunComposerFactory( }, } : {}), + ...(input.resolvePluginSystemPrompt && !backendContext.tools + ? { + resolveAdditionalSystemPrompt: (context, baseText) => + input.resolvePluginSystemPrompt!(backendContext.sessionId, context, baseText), + } + : {}), ...(input.scheduledTaskTool ? { scheduledTaskTool: input.scheduledTaskTool } : {}), ...(input.goalTools ? { goalTools: input.goalTools } : {}), ...(parentAgentTools ? { parentAgentTools } : {}), @@ -621,6 +648,15 @@ function interactiveSourceRevisions(input: { ]); } +function mergeSourceRevisions( + base: readonly RunCompositionSourceRevision[], + additions: readonly RunCompositionSourceRevision[], +): readonly RunCompositionSourceRevision[] { + const merged = new Map(base.map((revision) => [revision.id, revision])); + for (const revision of additions) merged.set(revision.id, revision); + return Object.freeze([...merged.values()].sort((left, right) => left.id.localeCompare(right.id))); +} + async function readPromptState( input: Pick, sessionId: string, diff --git a/packages/runtime-host/src/server/plugin-platform.ts b/packages/runtime-host/src/server/plugin-platform.ts index b2d9e20271..5136cfdfe2 100644 --- a/packages/runtime-host/src/server/plugin-platform.ts +++ b/packages/runtime-host/src/server/plugin-platform.ts @@ -34,6 +34,7 @@ import { } from '@maka/runtime/plugin-runtime'; import type { ExtensionPackageManifest } from './extension-package-manifest.js'; import type { PluginToolInspection } from '@maka/runtime/plugin-tool-service'; +import type { PluginSystemPromptInspection } from '@maka/runtime/plugin-system-prompt-service'; import { validateExtensionConfiguration } from './extension-package-manifest.js'; import { recoverExtensionBundleImports } from './extension-bundle.js'; import { loadPluginCompositionPatch } from './plugin-composition-patch.js'; @@ -76,6 +77,9 @@ export interface HostPluginPlatformOptions { readonly packageLoader?: TrustedPluginPackageLoader; readonly store?: HostPluginCompositionStore; readonly tools?: { inspect(rootId?: MakaPluginRootId): readonly PluginToolInspection[] }; + readonly systemPrompt?: { + inspect(rootId?: MakaPluginRootId): readonly PluginSystemPromptInspection[]; + }; } export interface HostPluginPlatformFailure { @@ -112,6 +116,7 @@ export class HostPluginPlatform { readonly #packageLoader: TrustedPluginPackageLoader; readonly #store: HostPluginCompositionStore; readonly #tools?: HostPluginPlatformOptions['tools']; + readonly #systemPrompt?: HostPluginPlatformOptions['systemPrompt']; #authority: PersistedPluginComposition = emptyCompositionAuthority(); #desired: MakaCompositionState = emptyCompositionState(); @@ -137,6 +142,7 @@ export class HostPluginPlatform { options.packageLoader ?? new TrustedPluginPackageLoader(controlDirectory, this.#packages); this.#store = options.store ?? new HostPluginCompositionStore(controlDirectory); this.#tools = options.tools; + this.#systemPrompt = options.systemPrompt; } async recover(): Promise { @@ -479,6 +485,11 @@ export class HostPluginPlatform { return this.#tools?.inspect(rootId) ?? Object.freeze([]); } + inspectSystemPrompt(rootId?: MakaPluginRootId): readonly PluginSystemPromptInspection[] { + this.#assertReadable(); + return this.#systemPrompt?.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 9e7405eafa..eb0da23799 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -82,6 +82,7 @@ "./plugin-composition-loader": "./dist/plugin-composition-loader.js", "./plugin-kernel": "./dist/plugin-kernel.js", "./plugin-runtime": "./dist/plugin-runtime.js", + "./plugin-system-prompt-service": "./dist/plugin-system-prompt-service.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", diff --git a/packages/runtime/src/__tests__/plugin-system-prompt-service.test.ts b/packages/runtime/src/__tests__/plugin-system-prompt-service.test.ts new file mode 100644 index 0000000000..7d792d7e67 --- /dev/null +++ b/packages/runtime/src/__tests__/plugin-system-prompt-service.test.ts @@ -0,0 +1,218 @@ +/* + * 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 { Context } from '../plugin-kernel.js'; +import { MakaCompositionLoader } from '../plugin-composition-loader.js'; +import { + PLUGIN_SYSTEM_PROMPT_SOURCE_ID, + PluginSystemPromptService, + type PluginSystemPromptContext, +} from '../plugin-system-prompt-service.js'; + +const assemblyContext: PluginSystemPromptContext = { + sessionId: 'alpha', + turnId: 'turn-1', + cwd: '/workspace', +}; + +test('Profile sections are inherited and exact Session sections shadow them', async () => { + const { loader, prompts } = setup(); + await loader.install({ + packageId: 'profile-package', + host: (ctx) => ctx.systemPrompt.section({ name: 'plugin:policy', order: 10, text: 'profile' }), + }); + await loader.install({ + packageId: 'session-package', + host: (ctx) => ctx.systemPrompt.section({ name: 'plugin:policy', order: 10, text: 'session' }), + }); + await loader.create('profile', { id: 'profile-entry', packageId: 'profile-package' }); + await loader.create('session:alpha', { id: 'session-entry', packageId: 'session-package' }); + + assert.equal((await prompts.assemble(assemblyContext, 'base')).text, 'base\n\nsession'); + assert.equal( + (await prompts.assemble({ ...assemblyContext, sessionId: 'beta' }, 'base')).text, + 'base\n\nprofile', + ); + + await loader.remove('session-entry'); + assert.equal((await prompts.assemble(assemblyContext, 'base')).text, 'base\n\nprofile'); + await loader.close(); +}); + +test('sections and variables resolve for every assembly from a stable membership snapshot', async () => { + const { loader, prompts } = setup(); + let value = 'first'; + let late = false; + await loader.install({ + packageId: 'dynamic-package', + host: (ctx) => { + ctx.systemPrompt.variable('mode', () => value); + ctx.systemPrompt.section({ + name: 'plugin:dynamic', + order: 20, + text: () => { + if (!late) { + late = true; + ctx.systemPrompt.section({ name: 'plugin:late', order: 30, text: 'late' }); + } + return 'mode={{mode}}'; + }, + }); + }, + }); + await loader.create('profile', { id: 'dynamic-entry', packageId: 'dynamic-package' }); + + const first = await prompts.assemble(assemblyContext, 'base'); + value = 'second'; + const second = await prompts.assemble(assemblyContext, 'base'); + assert.equal(first.text, 'base\n\nmode=first'); + assert.equal(second.text, 'base\n\nmode=second\n\nlate'); + assert.equal(first.sourceRevision?.id, PLUGIN_SYSTEM_PROMPT_SOURCE_ID); + assert.notEqual(first.sourceRevision?.revision, second.sourceRevision?.revision); + await loader.close(); +}); + +test('complete sections replace the Host base and multiple complete sections fail closed', async () => { + const { loader, prompts } = setup(); + await loader.install({ + packageId: 'complete-package', + host: (ctx) => { + ctx.systemPrompt.section({ name: 'plugin:extra', order: 1, text: 'extra' }); + ctx.systemPrompt.section({ + name: 'plugin:complete', + order: 2, + text: 'replacement', + complete: true, + }); + }, + }); + await loader.create('profile', { id: 'complete-entry', packageId: 'complete-package' }); + assert.equal((await prompts.assemble(assemblyContext, 'base')).text, 'replacement'); + + await loader.install({ + packageId: 'second-complete-package', + host: (ctx) => + ctx.systemPrompt.section({ + name: 'plugin:second-complete', + order: 3, + text: 'second', + complete: true, + }), + }); + await loader.create('profile', { + id: 'second-complete-entry', + packageId: 'second-complete-package', + }); + await assert.rejects(() => prompts.assemble(assemblyContext, 'base'), /Multiple complete/u); + await loader.close(); +}); + +test('failed activation publishes no partial System Prompt contribution', async () => { + const { loader, prompts } = setup(); + await loader.install({ + packageId: 'broken-package', + host: (ctx) => { + ctx.systemPrompt.section({ name: 'plugin:partial', order: 1, text: 'partial' }); + throw new Error('activation failed'); + }, + }); + + await assert.rejects( + () => loader.create('profile', { id: 'broken-entry', packageId: 'broken-package' }), + /activation failed/u, + ); + assert.deepEqual(prompts.inspect(), []); + assert.equal((await prompts.assemble(assemblyContext, 'base')).text, 'base'); + await loader.close(); +}); + +test('replacement restores the prior generation when the candidate rolls back', async () => { + const { loader, prompts } = setup(); + await loader.install({ + packageId: 'replaceable-package', + host: (ctx) => + ctx.systemPrompt.section({ name: 'plugin:replaceable', order: 1, text: 'current' }), + }); + await loader.create('profile', { id: 'replaceable-entry', packageId: 'replaceable-package' }); + + await assert.rejects( + () => + loader.reload({ + packageId: 'replaceable-package', + host: (ctx) => { + ctx.systemPrompt.section({ name: 'plugin:replaceable', order: 1, text: 'candidate' }); + throw new Error('candidate failed'); + }, + }), + /candidate failed/u, + ); + assert.equal((await prompts.assemble(assemblyContext, 'base')).text, 'base\n\ncurrent'); + await loader.close(); +}); + +test('successful replacement does not resurrect its retired predecessor', async () => { + const { loader, prompts } = setup(); + await loader.install({ + packageId: 'replaceable-package', + host: (ctx) => ctx.systemPrompt.section({ name: 'plugin:replaceable', order: 1, text: 'old' }), + }); + await loader.create('profile', { id: 'replaceable-entry', packageId: 'replaceable-package' }); + await loader.reload({ + packageId: 'replaceable-package', + host: (ctx) => ctx.systemPrompt.section({ name: 'plugin:replaceable', order: 1, text: 'new' }), + }); + assert.equal((await prompts.assemble(assemblyContext, 'base')).text, 'base\n\nnew'); + + await loader.remove('replaceable-entry'); + assert.equal((await prompts.assemble(assemblyContext, 'base')).text, 'base'); + await loader.close(); +}); + +test('desktop-ui registration and unresolved variables fail closed', async () => { + const { loader, prompts } = setup(); + await loader.install({ + packageId: 'ui-package', + host: (ctx) => ctx.systemPrompt.section({ name: 'plugin:ui', order: 1, text: 'ui' }), + }); + await assert.rejects( + () => loader.create('desktop-ui', { id: 'ui-entry', packageId: 'ui-package' }), + /desktop-ui plugins cannot contribute Host System Prompt/u, + ); + + await loader.install({ + packageId: 'variable-package', + host: (ctx) => + ctx.systemPrompt.section({ name: 'plugin:missing', order: 1, text: '{{missing}}' }), + }); + await loader.create('profile', { id: 'variable-entry', packageId: 'variable-package' }); + await assert.rejects( + () => prompts.assemble(assemblyContext, 'base'), + /Unknown System Prompt variable/u, + ); + await loader.close(); +}); + +function setup(): { loader: MakaCompositionLoader; prompts: PluginSystemPromptService } { + const root = new Context(); + const prompts = new PluginSystemPromptService(root); + const loader = new MakaCompositionLoader({ root }); + return { loader, prompts }; +} diff --git a/packages/runtime/src/plugin-system-prompt-service.ts b/packages/runtime/src/plugin-system-prompt-service.ts new file mode 100644 index 0000000000..2260a41852 --- /dev/null +++ b/packages/runtime/src/plugin-system-prompt-service.ts @@ -0,0 +1,521 @@ +/* + * 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 { createHash } from 'node:crypto'; +import { Service, type Awaitable, type Context, type Disposable } from './plugin-kernel.js'; +import { + MakaPluginRuntimeError, + pluginIdentity, + registerPluginContribution, + type MakaContributionIdentity, + type MakaPluginRootId, +} from './plugin-runtime.js'; + +declare module './plugin-kernel.js' { + interface Context { + readonly systemPrompt: PluginSystemPromptService; + } +} + +const PROMPT_NAME_PATTERN = /^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$/u; +const VARIABLE_NAME_PATTERN = /^[a-z][a-z0-9_]*$/u; +const VARIABLE_REFERENCE_PATTERN = /\{\{([^{}]*)\}\}/gu; +const HOST_BASE_SECTION = 'maka:base'; + +export const PLUGIN_SYSTEM_PROMPT_SOURCE_ID = 'plugin.system-prompt'; + +export interface PluginSystemPromptContext { + readonly sessionId: string; + readonly turnId: string; + readonly cwd: string; +} + +export type PluginSystemPromptText = + | string + | ((context: PluginSystemPromptContext) => Awaitable); + +export interface PluginSystemPromptSection { + readonly name: string; + readonly order: number; + readonly text: PluginSystemPromptText; + /** Replaces the Host base and every non-complete section for this scope. */ + readonly complete?: boolean; +} + +export type PluginSystemPromptVariableProvider = ( + context: PluginSystemPromptContext, +) => Awaitable; + +export interface PluginSystemPromptAssembly { + readonly text: string | undefined; + readonly sourceRevision?: { readonly id: string; readonly revision: string }; +} + +export interface PluginSystemPromptInspection extends MakaContributionIdentity { + readonly kind: 'section' | 'variable'; + readonly name: string; + readonly order?: number; + readonly complete?: boolean; +} + +interface RegisteredSection extends MakaContributionIdentity { + readonly definition: PluginSystemPromptSection; + readonly token: symbol; + retired: boolean; +} + +interface RegisteredVariable extends MakaContributionIdentity { + readonly name: string; + readonly provider: PluginSystemPromptVariableProvider; + readonly token: symbol; + retired: boolean; +} + +interface PromptLayer { + readonly sections: Map; + readonly variables: Map; +} + +interface ResolvedSection extends MakaContributionIdentity { + readonly name: string; + readonly order: number; + readonly text: string; + readonly complete: boolean; +} + +/** + * Context-scoped, Fiber-owned System Prompt registry for trusted Host plugins. + * + * Profile contributions are inherited by Session roots. A Session contribution + * with the same name shadows its Profile counterpart before either provider is + * evaluated. Every assembly snapshots membership and resolves providers anew, + * so changes committed by one tool call appear at the next logical model step. + */ +export class PluginSystemPromptService extends Service { + private readonly layers = new Map(); + + constructor(ctx: Context) { + super(ctx, 'systemPrompt'); + } + + section(definition: PluginSystemPromptSection): Disposable> { + const identity = pluginIdentity(this.ctx); + assertHostPromptScope(identity.scopeId); + validateSection(definition); + return registerPluginContribution( + this.ctx, + `systemPrompt.section(${JSON.stringify(definition.name)})`, + () => this.publishSection(identity, definition), + ); + } + + variable(name: string, provider: PluginSystemPromptVariableProvider): Disposable> { + const identity = pluginIdentity(this.ctx); + assertHostPromptScope(identity.scopeId); + validateVariable(name, provider); + return registerPluginContribution( + this.ctx, + `systemPrompt.variable(${JSON.stringify(name)})`, + () => this.publishVariable(identity, name, provider), + ); + } + + async assemble( + context: PluginSystemPromptContext, + baseText: string | undefined, + ): Promise { + validateAssemblyContext(context); + const visibleSections = this.visible(context.sessionId, (layer) => layer.sections); + const visibleVariables = this.visible(context.sessionId, (layer) => layer.variables); + const sectionSnapshot = [...visibleSections.values()]; + const variableSnapshot = [...visibleVariables.values()]; + + if (sectionSnapshot.length === 0 && variableSnapshot.length === 0) { + return Object.freeze({ text: baseText }); + } + + const variables: Record = {}; + for (const variable of variableSnapshot.sort(compareRegistration)) { + const value = await variable.provider(context); + if (value !== undefined && typeof value !== 'string') { + throw new MakaPluginRuntimeError( + 'activation_failed', + `System Prompt variable ${JSON.stringify(variable.name)} returned a non-string value`, + ); + } + variables[variable.name] = value; + } + const resolved: ResolvedSection[] = []; + for (const section of sectionSnapshot) { + const value = + typeof section.definition.text === 'string' + ? section.definition.text + : await section.definition.text(context); + if (value !== undefined && typeof value !== 'string') { + throw new MakaPluginRuntimeError( + 'activation_failed', + `System Prompt section ${JSON.stringify(section.definition.name)} returned a non-string value`, + ); + } + resolved.push({ + ...section, + name: section.definition.name, + order: section.definition.order, + text: value ?? '', + complete: section.definition.complete === true, + }); + } + const complete = resolved.filter((section) => section.complete); + if (complete.length > 1) { + throw new MakaPluginRuntimeError( + 'activation_failed', + `Multiple complete System Prompt sections are active: ${complete + .map(({ name }) => JSON.stringify(name)) + .sort() + .join(', ')}`, + ); + } + const effective = complete.length + ? complete + : [ + ...(baseText === undefined + ? [] + : [ + { + entryId: HOST_BASE_SECTION, + scopeId: 'profile', + extensionId: 'maka', + generation: 0, + name: HOST_BASE_SECTION, + order: 0, + text: baseText, + complete: false, + } satisfies ResolvedSection, + ]), + ...resolved, + ]; + const rendered = effective + .sort(compareSections) + .map((section) => interpolate(section.name, section.text, variables)) + .filter(Boolean) + .join('\n\n'); + const revision = promptRevision(resolved, variableSnapshot, variables); + return Object.freeze({ + text: rendered || undefined, + sourceRevision: Object.freeze({ + id: PLUGIN_SYSTEM_PROMPT_SOURCE_ID, + revision, + }), + }); + } + + inspect(rootId?: MakaPluginRootId): readonly PluginSystemPromptInspection[] { + const layers = rootId ? [[rootId, this.layers.get(rootId)] as const] : [...this.layers]; + return Object.freeze( + layers + .flatMap(([, layer]) => [ + ...[...(layer?.sections.values() ?? [])].map((entry) => ({ + entryId: entry.entryId, + scopeId: entry.scopeId, + extensionId: entry.extensionId, + generation: entry.generation, + kind: 'section' as const, + name: entry.definition.name, + order: entry.definition.order, + complete: entry.definition.complete === true, + })), + ...[...(layer?.variables.values() ?? [])].map((entry) => ({ + entryId: entry.entryId, + scopeId: entry.scopeId, + extensionId: entry.extensionId, + generation: entry.generation, + kind: 'variable' as const, + name: entry.name, + })), + ]) + .sort(compareInspection), + ); + } + + private visible( + sessionId: string, + select: (layer: PromptLayer) => Map, + ): Map { + const visible = new Map(); + for (const [name, entry] of select(this.layers.get('profile') ?? emptyLayer())) { + visible.set(name, entry); + } + const session = this.layers.get(`session:${sessionId}`); + if (session) { + for (const [name, entry] of select(session)) visible.set(name, entry); + } + return visible; + } + + private publishSection( + identity: MakaContributionIdentity, + definition: PluginSystemPromptSection, + ): Disposable> { + const layer = this.layer(identity.scopeId as MakaPluginRootId); + const existing = layer.sections.get(definition.name); + assertOwner(existing, identity, 'section', definition.name); + const entry: RegisteredSection = { + ...identity, + definition: Object.freeze({ ...definition }), + token: Symbol(definition.name), + retired: false, + }; + layer.sections.set(definition.name, entry); + return this.retire( + identity.scopeId as MakaPluginRootId, + layer.sections, + definition.name, + entry, + existing, + ); + } + + private publishVariable( + identity: MakaContributionIdentity, + name: string, + provider: PluginSystemPromptVariableProvider, + ): Disposable> { + const layer = this.layer(identity.scopeId as MakaPluginRootId); + const existing = layer.variables.get(name); + assertOwner(existing, identity, 'variable', name); + const entry: RegisteredVariable = { + ...identity, + name, + provider, + token: Symbol(name), + retired: false, + }; + layer.variables.set(name, entry); + return this.retire( + identity.scopeId as MakaPluginRootId, + layer.variables, + name, + entry, + existing, + ); + } + + private retire( + rootId: MakaPluginRootId, + registry: Map, + name: string, + entry: T, + previous: T | undefined, + ): Disposable> { + let retired = false; + return async () => { + if (retired) return; + retired = true; + entry.retired = true; + if (registry.get(name)?.token !== entry.token) return; + if (previous && !previous.retired) registry.set(name, previous); + else registry.delete(name); + this.prune(rootId); + }; + } + + private layer(rootId: MakaPluginRootId): PromptLayer { + let layer = this.layers.get(rootId); + if (!layer) { + layer = emptyLayer(); + this.layers.set(rootId, layer); + } + return layer; + } + + private prune(rootId: MakaPluginRootId): void { + const layer = this.layers.get(rootId); + if (layer && layer.sections.size === 0 && layer.variables.size === 0) { + this.layers.delete(rootId); + } + } +} + +function emptyLayer(): PromptLayer { + return { sections: new Map(), variables: new Map() }; +} + +function assertHostPromptScope(scopeId: string): void { + if (scopeId === 'desktop-ui') { + throw new MakaPluginRuntimeError( + 'activation_failed', + 'desktop-ui plugins cannot contribute Host System Prompt sections', + ); + } +} + +function validateSection(section: PluginSystemPromptSection): void { + validateName(section.name, 'System Prompt section'); + if (section.name === HOST_BASE_SECTION) { + throw new MakaPluginRuntimeError( + 'activation_failed', + 'The Host base System Prompt is reserved', + ); + } + if (!Number.isFinite(section.order)) { + throw new MakaPluginRuntimeError( + 'activation_failed', + 'System Prompt section order must be finite', + ); + } + if (typeof section.text !== 'string' && typeof section.text !== 'function') { + throw new MakaPluginRuntimeError('activation_failed', 'System Prompt section text is invalid'); + } +} + +function validateVariable(name: string, provider: PluginSystemPromptVariableProvider): void { + if (!VARIABLE_NAME_PATTERN.test(name) || Buffer.byteLength(name, 'utf8') > 128) { + throw new MakaPluginRuntimeError( + 'activation_failed', + `Invalid System Prompt variable: ${name}`, + ); + } + if (typeof provider !== 'function') { + throw new MakaPluginRuntimeError( + 'activation_failed', + 'System Prompt variable provider is invalid', + ); + } +} + +function validateName(name: string, label: string): void { + if (!PROMPT_NAME_PATTERN.test(name) || Buffer.byteLength(name, 'utf8') > 128) { + throw new MakaPluginRuntimeError('activation_failed', `Invalid ${label} name: ${name}`); + } +} + +function validateAssemblyContext(context: PluginSystemPromptContext): void { + if ( + !context.sessionId || + !context.turnId || + !context.cwd || + /[\0\r\n]/u.test(context.sessionId) || + /[\0\r\n]/u.test(context.turnId) + ) { + throw new Error('Invalid System Prompt assembly context'); + } +} + +function assertOwner( + current: MakaContributionIdentity | undefined, + identity: MakaContributionIdentity, + kind: string, + name: string, +): void { + if (current && current.entryId !== identity.entryId) { + throw new MakaPluginRuntimeError( + 'activation_failed', + `System Prompt ${kind} ${JSON.stringify(name)} is already registered by ${current.entryId}`, + ); + } +} + +function interpolate( + sectionName: string, + text: string, + variables: Readonly>, +): string { + VARIABLE_REFERENCE_PATTERN.lastIndex = 0; + return text.replace(VARIABLE_REFERENCE_PATTERN, (reference, rawName: string) => { + if (!VARIABLE_NAME_PATTERN.test(rawName)) { + throw new MakaPluginRuntimeError( + 'activation_failed', + `Malformed System Prompt variable ${JSON.stringify(reference)} in section ${JSON.stringify(sectionName)}`, + ); + } + if (!Object.hasOwn(variables, rawName)) { + throw new MakaPluginRuntimeError( + 'activation_failed', + `Unknown System Prompt variable ${JSON.stringify(rawName)} in section ${JSON.stringify(sectionName)}`, + ); + } + const value = variables[rawName]; + if (value === undefined) { + throw new MakaPluginRuntimeError( + 'activation_failed', + `System Prompt variable ${JSON.stringify(rawName)} has no value in section ${JSON.stringify(sectionName)}`, + ); + } + return value; + }); +} + +function promptRevision( + sections: readonly ResolvedSection[], + variables: readonly RegisteredVariable[], + values: Readonly>, +): string { + const canonical = { + sections: [...sections].sort(compareSections).map((section) => ({ + scopeId: section.scopeId, + entryId: section.entryId, + extensionId: section.extensionId, + generation: section.generation, + name: section.name, + order: section.order, + complete: section.complete, + text: section.text, + })), + variables: [...variables].sort(compareRegistration).map((variable) => ({ + scopeId: variable.scopeId, + entryId: variable.entryId, + extensionId: variable.extensionId, + generation: variable.generation, + name: variable.name, + value: values[variable.name] ?? null, + })), + }; + return `sha256:${createHash('sha256').update(JSON.stringify(canonical)).digest('hex')}`; +} + +function compareSections(left: ResolvedSection, right: ResolvedSection): number { + return left.order - right.order || compareCodeUnits(left.name, right.name); +} + +function compareRegistration( + left: MakaContributionIdentity, + right: MakaContributionIdentity, +): number { + return ( + compareCodeUnits(left.scopeId, right.scopeId) || + compareCodeUnits(left.entryId, right.entryId) || + left.generation - right.generation + ); +} + +function compareInspection( + left: PluginSystemPromptInspection, + right: PluginSystemPromptInspection, +): number { + return ( + compareCodeUnits(left.scopeId, right.scopeId) || + compareCodeUnits(left.kind, right.kind) || + compareCodeUnits(left.name, right.name) || + compareCodeUnits(left.entryId, right.entryId) + ); +} + +function compareCodeUnits(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} From 685b90bfd5cd93b93ee11a075ae29a65c498f9c3 Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:19:20 +0800 Subject: [PATCH 02/13] refactor(runtime): share plugin scope registry --- packages/runtime/package.json | 1 + .../__tests__/plugin-scope-registry.test.ts | 88 ++++++++++++ packages/runtime/src/plugin-scope-registry.ts | 113 +++++++++++++++ .../src/plugin-system-prompt-service.ts | 136 ++++-------------- packages/runtime/src/plugin-tool-service.ts | 78 +++------- 5 files changed, 256 insertions(+), 160 deletions(-) create mode 100644 packages/runtime/src/__tests__/plugin-scope-registry.test.ts create mode 100644 packages/runtime/src/plugin-scope-registry.ts diff --git a/packages/runtime/package.json b/packages/runtime/package.json index eb0da23799..7cf67930e0 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -82,6 +82,7 @@ "./plugin-composition-loader": "./dist/plugin-composition-loader.js", "./plugin-kernel": "./dist/plugin-kernel.js", "./plugin-runtime": "./dist/plugin-runtime.js", + "./plugin-scope-registry": "./dist/plugin-scope-registry.js", "./plugin-system-prompt-service": "./dist/plugin-system-prompt-service.js", "./plugin-tool-service": "./dist/plugin-tool-service.js", "./process-tree-terminator": "./dist/process-tree-terminator.js", diff --git a/packages/runtime/src/__tests__/plugin-scope-registry.test.ts b/packages/runtime/src/__tests__/plugin-scope-registry.test.ts new file mode 100644 index 0000000000..7bed8cfcb1 --- /dev/null +++ b/packages/runtime/src/__tests__/plugin-scope-registry.test.ts @@ -0,0 +1,88 @@ +/* + * 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 { PluginScopeRegistry, type PluginScopeRegistryEntry } from '../plugin-scope-registry.js'; + +interface Entry extends PluginScopeRegistryEntry { + readonly value: string; +} + +function entry(value: string): Entry { + return { value, token: Symbol(value), retired: false }; +} + +test('Session membership overlays Profile membership and retirement reveals the parent', async () => { + const registry = new PluginScopeRegistry(); + const disposeProfile = registry.publish('profile', 'policy', entry('profile')); + const disposeSession = registry.publish('session:alpha', 'policy', entry('session')); + + assert.equal(registry.visible('alpha').get('policy')?.value, 'session'); + assert.equal(registry.visible('beta').get('policy')?.value, 'profile'); + + await disposeSession(); + assert.equal(registry.visible('alpha').get('policy')?.value, 'profile'); + await disposeProfile(); + assert.deepEqual([...registry.visible('alpha')], []); +}); + +test('a committed replacement cannot resurrect its retired predecessor', async () => { + const registry = new PluginScopeRegistry(); + const previous = entry('previous'); + const replacement = entry('replacement'); + const disposePrevious = registry.publish('profile', 'policy', previous); + const disposeReplacement = registry.publish('profile', 'policy', replacement); + + await disposePrevious(); + assert.equal(registry.visible('alpha').get('policy'), replacement); + await disposeReplacement(); + assert.equal(registry.visible('alpha').has('policy'), false); +}); + +test('failed change notification rolls publication back atomically', () => { + const registry = new PluginScopeRegistry(); + const previous = entry('previous'); + registry.publish('profile', 'policy', previous); + + assert.throws( + () => + registry.publish('profile', 'policy', entry('candidate'), { + onChanged: () => { + throw new Error('rejected'); + }, + }), + /rejected/u, + ); + assert.equal(registry.visible('alpha').get('policy'), previous); +}); + +test('retirement is idempotent and capability cleanup runs once', async () => { + const registry = new PluginScopeRegistry(); + let retired = 0; + const dispose = registry.publish('profile', 'policy', entry('value'), { + onRetired: () => { + retired += 1; + }, + }); + + await dispose(); + await dispose(); + assert.equal(retired, 1); +}); diff --git a/packages/runtime/src/plugin-scope-registry.ts b/packages/runtime/src/plugin-scope-registry.ts new file mode 100644 index 0000000000..d3a08e69cf --- /dev/null +++ b/packages/runtime/src/plugin-scope-registry.ts @@ -0,0 +1,113 @@ +/* + * 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 type { Awaitable, Disposable } from './plugin-kernel.js'; +import type { MakaPluginRootId } from './plugin-runtime.js'; + +export interface PluginScopeRegistryEntry { + readonly token: symbol; + retired: boolean; +} + +export interface PluginScopePublicationHooks { + /** Runs after visible membership changes. Throwing rolls publication back. */ + readonly onChanged?: (rootId: MakaPluginRootId) => void; + /** Runs after retirement is no longer visible, for capability-specific draining. */ + readonly onRetired?: (entry: T) => Awaitable; +} + +/** + * Shared Profile/Session membership and replacement semantics for typed Plugin + * capabilities. Capability services retain validation and value-specific + * behavior; this registry only answers which keyed contribution is visible. + */ +export class PluginScopeRegistry { + readonly #layers = new Map>(); + + get(rootId: MakaPluginRootId, key: string): T | undefined { + return this.#layers.get(rootId)?.get(key); + } + + /** Snapshot Profile membership overlaid by an exact Session layer. */ + visible(sessionId: string): ReadonlyMap { + if (!sessionId || /[\r\n\0]/u.test(sessionId)) { + throw new Error('Invalid Plugin Session scope'); + } + const visible = new Map(this.#layers.get('profile')); + for (const [key, entry] of this.#layers.get(`session:${sessionId}`) ?? []) { + visible.set(key, entry); + } + return visible; + } + + entries(rootId?: MakaPluginRootId): readonly T[] { + return Object.freeze( + rootId + ? [...(this.#layers.get(rootId)?.values() ?? [])] + : [...this.#layers.values()].flatMap((layer) => [...layer.values()]), + ); + } + + /** + * Atomically publishes one keyed contribution. A same-key predecessor is + * restored only when still live, which supports candidate rollback without + * resurrecting a successfully retired generation. + */ + publish( + rootId: MakaPluginRootId, + key: string, + entry: T, + hooks: PluginScopePublicationHooks = {}, + ): Disposable> { + let layer = this.#layers.get(rootId); + if (!layer) { + layer = new Map(); + this.#layers.set(rootId, layer); + } + const previous = layer.get(key); + layer.set(key, entry); + try { + hooks.onChanged?.(rootId); + } catch (error) { + if (previous) layer.set(key, previous); + else layer.delete(key); + this.#prune(rootId); + throw error; + } + + let disposed = false; + return async () => { + if (disposed) return; + disposed = true; + entry.retired = true; + const currentLayer = this.#layers.get(rootId); + if (currentLayer?.get(key)?.token === entry.token) { + if (previous && !previous.retired) currentLayer.set(key, previous); + else currentLayer.delete(key); + this.#prune(rootId); + hooks.onChanged?.(rootId); + } + await hooks.onRetired?.(entry); + }; + } + + #prune(rootId: MakaPluginRootId): void { + if (this.#layers.get(rootId)?.size === 0) this.#layers.delete(rootId); + } +} diff --git a/packages/runtime/src/plugin-system-prompt-service.ts b/packages/runtime/src/plugin-system-prompt-service.ts index 2260a41852..82c9b30412 100644 --- a/packages/runtime/src/plugin-system-prompt-service.ts +++ b/packages/runtime/src/plugin-system-prompt-service.ts @@ -26,6 +26,7 @@ import { type MakaContributionIdentity, type MakaPluginRootId, } from './plugin-runtime.js'; +import { PluginScopeRegistry } from './plugin-scope-registry.js'; declare module './plugin-kernel.js' { interface Context { @@ -87,11 +88,6 @@ interface RegisteredVariable extends MakaContributionIdentity { retired: boolean; } -interface PromptLayer { - readonly sections: Map; - readonly variables: Map; -} - interface ResolvedSection extends MakaContributionIdentity { readonly name: string; readonly order: number; @@ -108,7 +104,8 @@ interface ResolvedSection extends MakaContributionIdentity { * so changes committed by one tool call appear at the next logical model step. */ export class PluginSystemPromptService extends Service { - private readonly layers = new Map(); + private readonly sections = new PluginScopeRegistry(); + private readonly variables = new PluginScopeRegistry(); constructor(ctx: Context) { super(ctx, 'systemPrompt'); @@ -141,8 +138,8 @@ export class PluginSystemPromptService extends Service { baseText: string | undefined, ): Promise { validateAssemblyContext(context); - const visibleSections = this.visible(context.sessionId, (layer) => layer.sections); - const visibleVariables = this.visible(context.sessionId, (layer) => layer.variables); + const visibleSections = this.sections.visible(context.sessionId); + const visibleVariables = this.variables.visible(context.sessionId); const sectionSnapshot = [...visibleSections.values()]; const variableSnapshot = [...visibleVariables.values()]; @@ -226,54 +223,36 @@ export class PluginSystemPromptService extends Service { } inspect(rootId?: MakaPluginRootId): readonly PluginSystemPromptInspection[] { - const layers = rootId ? [[rootId, this.layers.get(rootId)] as const] : [...this.layers]; return Object.freeze( - layers - .flatMap(([, layer]) => [ - ...[...(layer?.sections.values() ?? [])].map((entry) => ({ - entryId: entry.entryId, - scopeId: entry.scopeId, - extensionId: entry.extensionId, - generation: entry.generation, - kind: 'section' as const, - name: entry.definition.name, - order: entry.definition.order, - complete: entry.definition.complete === true, - })), - ...[...(layer?.variables.values() ?? [])].map((entry) => ({ - entryId: entry.entryId, - scopeId: entry.scopeId, - extensionId: entry.extensionId, - generation: entry.generation, - kind: 'variable' as const, - name: entry.name, - })), - ]) - .sort(compareInspection), + [ + ...this.sections.entries(rootId).map((entry) => ({ + entryId: entry.entryId, + scopeId: entry.scopeId, + extensionId: entry.extensionId, + generation: entry.generation, + kind: 'section' as const, + name: entry.definition.name, + order: entry.definition.order, + complete: entry.definition.complete === true, + })), + ...this.variables.entries(rootId).map((entry) => ({ + entryId: entry.entryId, + scopeId: entry.scopeId, + extensionId: entry.extensionId, + generation: entry.generation, + kind: 'variable' as const, + name: entry.name, + })), + ].sort(compareInspection), ); } - private visible( - sessionId: string, - select: (layer: PromptLayer) => Map, - ): Map { - const visible = new Map(); - for (const [name, entry] of select(this.layers.get('profile') ?? emptyLayer())) { - visible.set(name, entry); - } - const session = this.layers.get(`session:${sessionId}`); - if (session) { - for (const [name, entry] of select(session)) visible.set(name, entry); - } - return visible; - } - private publishSection( identity: MakaContributionIdentity, definition: PluginSystemPromptSection, ): Disposable> { - const layer = this.layer(identity.scopeId as MakaPluginRootId); - const existing = layer.sections.get(definition.name); + const rootId = identity.scopeId as MakaPluginRootId; + const existing = this.sections.get(rootId, definition.name); assertOwner(existing, identity, 'section', definition.name); const entry: RegisteredSection = { ...identity, @@ -281,14 +260,7 @@ export class PluginSystemPromptService extends Service { token: Symbol(definition.name), retired: false, }; - layer.sections.set(definition.name, entry); - return this.retire( - identity.scopeId as MakaPluginRootId, - layer.sections, - definition.name, - entry, - existing, - ); + return this.sections.publish(rootId, definition.name, entry); } private publishVariable( @@ -296,8 +268,8 @@ export class PluginSystemPromptService extends Service { name: string, provider: PluginSystemPromptVariableProvider, ): Disposable> { - const layer = this.layer(identity.scopeId as MakaPluginRootId); - const existing = layer.variables.get(name); + const rootId = identity.scopeId as MakaPluginRootId; + const existing = this.variables.get(rootId, name); assertOwner(existing, identity, 'variable', name); const entry: RegisteredVariable = { ...identity, @@ -306,54 +278,8 @@ export class PluginSystemPromptService extends Service { token: Symbol(name), retired: false, }; - layer.variables.set(name, entry); - return this.retire( - identity.scopeId as MakaPluginRootId, - layer.variables, - name, - entry, - existing, - ); - } - - private retire( - rootId: MakaPluginRootId, - registry: Map, - name: string, - entry: T, - previous: T | undefined, - ): Disposable> { - let retired = false; - return async () => { - if (retired) return; - retired = true; - entry.retired = true; - if (registry.get(name)?.token !== entry.token) return; - if (previous && !previous.retired) registry.set(name, previous); - else registry.delete(name); - this.prune(rootId); - }; - } - - private layer(rootId: MakaPluginRootId): PromptLayer { - let layer = this.layers.get(rootId); - if (!layer) { - layer = emptyLayer(); - this.layers.set(rootId, layer); - } - return layer; + return this.variables.publish(rootId, name, entry); } - - private prune(rootId: MakaPluginRootId): void { - const layer = this.layers.get(rootId); - if (layer && layer.sections.size === 0 && layer.variables.size === 0) { - this.layers.delete(rootId); - } - } -} - -function emptyLayer(): PromptLayer { - return { sections: new Map(), variables: new Map() }; } function assertHostPromptScope(scopeId: string): void { diff --git a/packages/runtime/src/plugin-tool-service.ts b/packages/runtime/src/plugin-tool-service.ts index a290078d6e..2749eb108e 100644 --- a/packages/runtime/src/plugin-tool-service.ts +++ b/packages/runtime/src/plugin-tool-service.ts @@ -26,6 +26,7 @@ import { type MakaContributionIdentity, type MakaPluginRootId, } from './plugin-runtime.js'; +import { PluginScopeRegistry } from './plugin-scope-registry.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'; @@ -68,7 +69,7 @@ export interface PluginToolServiceOptions { * remain Host-owned and cannot be shadowed. */ export class PluginToolService extends Service { - private readonly layers = new Map>(); + private readonly registry = new PluginScopeRegistry(); private readonly onChanged?: (rootId: MakaPluginRootId) => void; constructor(ctx: Context, options: PluginToolServiceOptions = {}) { @@ -102,14 +103,7 @@ export class PluginToolService extends Service { /** 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 visible = this.registry.visible(sessionId); const coreNames = new Set(coreTools.map(({ name }) => name)); for (const name of visible.keys()) { @@ -127,35 +121,24 @@ export class PluginToolService extends Service { } 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, - }), - ), + [...this.registry.entries(rootId)].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); + const existing = this.registry.get(rootId, definition.name); if (existing && existing.entryId !== identity.entryId) { throw new MakaPluginRuntimeError( 'activation_failed', @@ -200,29 +183,14 @@ export class PluginToolService extends Service { 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)); - } - }; + return this.registry.publish(rootId, definition.name, entry, { + onChanged: (changedRootId) => this.notifyChanged(changedRootId), + onRetired: async (retired) => { + if (retired.activeCalls > 0) { + await new Promise((resolve) => retired.drainWaiters.add(resolve)); + } + }, + }); } private notifyChanged(rootId: MakaPluginRootId): void { From 4ed802f7e2860838f573545f03d7a0a0b3dbf93c Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:37:39 +0800 Subject: [PATCH 03/13] feat(plugins): add dynamic model context contributions --- .../src/server/execution-composition.ts | 1 + .../src/server/host-run-composer.ts | 1 + .../plugin-system-prompt-service.test.ts | 36 +++++ packages/runtime/src/ai-sdk-backend.ts | 2 + packages/runtime/src/ai-sdk-turn.ts | 11 +- .../src/plugin-system-prompt-service.ts | 128 +++++++++++++++++- 6 files changed, 173 insertions(+), 6 deletions(-) diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 69b54d1852..fe8a7d2021 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -795,6 +795,7 @@ export async function createExecutionRuntimeHostComposition( ); return { text: assembly.text, + contexts: assembly.contexts, sourceRevisions: assembly.sourceRevision ? [assembly.sourceRevision] : [], }; }, diff --git a/packages/runtime-host/src/server/host-run-composer.ts b/packages/runtime-host/src/server/host-run-composer.ts index e69425fc97..d6c6d2c78d 100644 --- a/packages/runtime-host/src/server/host-run-composer.ts +++ b/packages/runtime-host/src/server/host-run-composer.ts @@ -32,6 +32,7 @@ export type HostModelPromptContext = SystemPromptContext; export interface ResolvedRunPrompt { readonly text: string | undefined; + readonly contexts?: readonly { readonly name: string; readonly text: string }[]; readonly sourceRevisions: readonly RunCompositionSourceRevision[]; } diff --git a/packages/runtime/src/__tests__/plugin-system-prompt-service.test.ts b/packages/runtime/src/__tests__/plugin-system-prompt-service.test.ts index 7d792d7e67..66c1a3f9d4 100644 --- a/packages/runtime/src/__tests__/plugin-system-prompt-service.test.ts +++ b/packages/runtime/src/__tests__/plugin-system-prompt-service.test.ts @@ -90,6 +90,42 @@ test('sections and variables resolve for every assembly from a stable membership await loader.close(); }); +test('dynamic contexts are scoped, ordered, interpolated, and resolved for every assembly', async () => { + const { loader, prompts } = setup(); + let selected = 'first'; + await loader.install({ + packageId: 'context-package', + host: (ctx) => { + ctx.systemPrompt.variable('selected', () => selected); + ctx.systemPrompt.context({ name: 'plugin:late', order: 20, text: 'late' }); + ctx.systemPrompt.context({ + name: 'plugin:selection', + order: 10, + text: () => 'selected={{selected}}', + }); + }, + }); + await loader.create('session:alpha', { id: 'context-entry', packageId: 'context-package' }); + + const first = await prompts.assemble(assemblyContext, 'base'); + selected = 'second'; + const second = await prompts.assemble(assemblyContext, 'base'); + assert.deepEqual(first.contexts, [ + { name: 'plugin:selection', text: 'selected=first' }, + { name: 'plugin:late', text: 'late' }, + ]); + assert.deepEqual(second.contexts, [ + { name: 'plugin:selection', text: 'selected=second' }, + { name: 'plugin:late', text: 'late' }, + ]); + assert.deepEqual( + (await prompts.assemble({ ...assemblyContext, sessionId: 'beta' }, 'base')).contexts, + [], + ); + assert.notEqual(first.sourceRevision?.revision, second.sourceRevision?.revision); + await loader.close(); +}); + test('complete sections replace the Host base and multiple complete sections fail closed', async () => { const { loader, prompts } = setup(); await loader.install({ diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 235e1fdf0a..8a7c9d3aef 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -242,6 +242,8 @@ export interface AiSdkBackendInput extends AiSdkCompactionCapabilities { export interface ResolvedSystemPrompt { text?: string; + /** Per-step ephemeral user-role context, resolved once per logical request. */ + contexts?: readonly { readonly name: string; readonly text: string }[]; sourceRevisions: readonly RunCompositionSourceRevision[]; } diff --git a/packages/runtime/src/ai-sdk-turn.ts b/packages/runtime/src/ai-sdk-turn.ts index a23e3608f8..5d225b78d1 100644 --- a/packages/runtime/src/ai-sdk-turn.ts +++ b/packages/runtime/src/ai-sdk-turn.ts @@ -1514,16 +1514,23 @@ export class AiSdkTurn { ? [] : boundaryAwareToolNames(active ?? plan.currentRepairToolNames()), }); + const dynamicContextMessages: ModelMessage[] = (resolvedSystemPrompt.contexts ?? []).map( + ({ text }) => ({ role: 'user', content: text }), + ); + const contextualRequestMessages = + dynamicContextMessages.length === 0 + ? requestMessages + : [...requestMessages, ...dynamicContextMessages]; const shaped = requestProjection ? await requestProjection({ completedSteps: completedProviderSteps, stepNumber: runtimeSteps, model, - messages: requestMessages, + messages: contextualRequestMessages, resolveDispatch, }) : undefined; - const projectedMessages = shaped?.messages ?? requestMessages; + const projectedMessages = shaped?.messages ?? contextualRequestMessages; const activeToolsForRequest = resolveDispatch(shaped?.activeTools).activeTools; const requestCompositionId = this.runId && this.deps.backend.recordRequestComposition diff --git a/packages/runtime/src/plugin-system-prompt-service.ts b/packages/runtime/src/plugin-system-prompt-service.ts index 82c9b30412..725e19cdaa 100644 --- a/packages/runtime/src/plugin-system-prompt-service.ts +++ b/packages/runtime/src/plugin-system-prompt-service.ts @@ -45,6 +45,7 @@ export interface PluginSystemPromptContext { readonly sessionId: string; readonly turnId: string; readonly cwd: string; + readonly signal?: AbortSignal; } export type PluginSystemPromptText = @@ -59,17 +60,30 @@ export interface PluginSystemPromptSection { readonly complete?: boolean; } +/** Dynamic model context materialized as an ephemeral user-role request snapshot. */ +export interface PluginSystemPromptContextContribution { + readonly name: string; + readonly order: number; + readonly text: PluginSystemPromptText; +} + +export interface ResolvedPluginSystemPromptContext { + readonly name: string; + readonly text: string; +} + export type PluginSystemPromptVariableProvider = ( context: PluginSystemPromptContext, ) => Awaitable; export interface PluginSystemPromptAssembly { readonly text: string | undefined; + readonly contexts: readonly ResolvedPluginSystemPromptContext[]; readonly sourceRevision?: { readonly id: string; readonly revision: string }; } export interface PluginSystemPromptInspection extends MakaContributionIdentity { - readonly kind: 'section' | 'variable'; + readonly kind: 'section' | 'context' | 'variable'; readonly name: string; readonly order?: number; readonly complete?: boolean; @@ -88,6 +102,12 @@ interface RegisteredVariable extends MakaContributionIdentity { retired: boolean; } +interface RegisteredContext extends MakaContributionIdentity { + readonly definition: PluginSystemPromptContextContribution; + readonly token: symbol; + retired: boolean; +} + interface ResolvedSection extends MakaContributionIdentity { readonly name: string; readonly order: number; @@ -95,6 +115,12 @@ interface ResolvedSection extends MakaContributionIdentity { readonly complete: boolean; } +interface ResolvedContext extends MakaContributionIdentity { + readonly name: string; + readonly order: number; + readonly text: string; +} + /** * Context-scoped, Fiber-owned System Prompt registry for trusted Host plugins. * @@ -105,6 +131,7 @@ interface ResolvedSection extends MakaContributionIdentity { */ export class PluginSystemPromptService extends Service { private readonly sections = new PluginScopeRegistry(); + private readonly contexts = new PluginScopeRegistry(); private readonly variables = new PluginScopeRegistry(); constructor(ctx: Context) { @@ -122,6 +149,17 @@ export class PluginSystemPromptService extends Service { ); } + context(definition: PluginSystemPromptContextContribution): Disposable> { + const identity = pluginIdentity(this.ctx); + assertHostPromptScope(identity.scopeId); + validateContext(definition); + return registerPluginContribution( + this.ctx, + `systemPrompt.context(${JSON.stringify(definition.name)})`, + () => this.publishContext(identity, definition), + ); + } + variable(name: string, provider: PluginSystemPromptVariableProvider): Disposable> { const identity = pluginIdentity(this.ctx); assertHostPromptScope(identity.scopeId); @@ -139,12 +177,18 @@ export class PluginSystemPromptService extends Service { ): Promise { validateAssemblyContext(context); const visibleSections = this.sections.visible(context.sessionId); + const visibleContexts = this.contexts.visible(context.sessionId); const visibleVariables = this.variables.visible(context.sessionId); const sectionSnapshot = [...visibleSections.values()]; + const contextSnapshot = [...visibleContexts.values()]; const variableSnapshot = [...visibleVariables.values()]; - if (sectionSnapshot.length === 0 && variableSnapshot.length === 0) { - return Object.freeze({ text: baseText }); + if ( + sectionSnapshot.length === 0 && + contextSnapshot.length === 0 && + variableSnapshot.length === 0 + ) { + return Object.freeze({ text: baseText, contexts: Object.freeze([]) }); } const variables: Record = {}; @@ -178,6 +222,26 @@ export class PluginSystemPromptService extends Service { complete: section.definition.complete === true, }); } + const resolvedContexts: ResolvedContext[] = []; + for (const entry of contextSnapshot) { + const value = + typeof entry.definition.text === 'string' + ? entry.definition.text + : await entry.definition.text(context); + if (value !== undefined && typeof value !== 'string') { + throw new MakaPluginRuntimeError( + 'activation_failed', + `System Prompt context ${JSON.stringify(entry.definition.name)} returned a non-string value`, + ); + } + if (!value) continue; + resolvedContexts.push({ + ...entry, + name: entry.definition.name, + order: entry.definition.order, + text: interpolate(entry.definition.name, value, variables), + }); + } const complete = resolved.filter((section) => section.complete); if (complete.length > 1) { throw new MakaPluginRuntimeError( @@ -212,9 +276,13 @@ export class PluginSystemPromptService extends Service { .map((section) => interpolate(section.name, section.text, variables)) .filter(Boolean) .join('\n\n'); - const revision = promptRevision(resolved, variableSnapshot, variables); + const contexts = Object.freeze( + resolvedContexts.sort(compareContexts).map(({ name, text }) => Object.freeze({ name, text })), + ); + const revision = promptRevision(resolved, resolvedContexts, variableSnapshot, variables); return Object.freeze({ text: rendered || undefined, + contexts, sourceRevision: Object.freeze({ id: PLUGIN_SYSTEM_PROMPT_SOURCE_ID, revision, @@ -235,6 +303,15 @@ export class PluginSystemPromptService extends Service { order: entry.definition.order, complete: entry.definition.complete === true, })), + ...this.contexts.entries(rootId).map((entry) => ({ + entryId: entry.entryId, + scopeId: entry.scopeId, + extensionId: entry.extensionId, + generation: entry.generation, + kind: 'context' as const, + name: entry.definition.name, + order: entry.definition.order, + })), ...this.variables.entries(rootId).map((entry) => ({ entryId: entry.entryId, scopeId: entry.scopeId, @@ -280,6 +357,22 @@ export class PluginSystemPromptService extends Service { }; return this.variables.publish(rootId, name, entry); } + + private publishContext( + identity: MakaContributionIdentity, + definition: PluginSystemPromptContextContribution, + ): Disposable> { + const rootId = identity.scopeId as MakaPluginRootId; + const existing = this.contexts.get(rootId, definition.name); + assertOwner(existing, identity, 'context', definition.name); + const entry: RegisteredContext = { + ...identity, + definition: Object.freeze({ ...definition }), + token: Symbol(definition.name), + retired: false, + }; + return this.contexts.publish(rootId, definition.name, entry); + } } function assertHostPromptScope(scopeId: string): void { @@ -310,6 +403,19 @@ function validateSection(section: PluginSystemPromptSection): void { } } +function validateContext(context: PluginSystemPromptContextContribution): void { + validateName(context.name, 'System Prompt context'); + if (!Number.isFinite(context.order)) { + throw new MakaPluginRuntimeError( + 'activation_failed', + 'System Prompt context order must be finite', + ); + } + if (typeof context.text !== 'string' && typeof context.text !== 'function') { + throw new MakaPluginRuntimeError('activation_failed', 'System Prompt context text is invalid'); + } +} + function validateVariable(name: string, provider: PluginSystemPromptVariableProvider): void { if (!VARIABLE_NAME_PATTERN.test(name) || Buffer.byteLength(name, 'utf8') > 128) { throw new MakaPluginRuntimeError( @@ -389,6 +495,7 @@ function interpolate( function promptRevision( sections: readonly ResolvedSection[], + contexts: readonly ResolvedContext[], variables: readonly RegisteredVariable[], values: Readonly>, ): string { @@ -403,6 +510,15 @@ function promptRevision( complete: section.complete, text: section.text, })), + contexts: [...contexts].sort(compareContexts).map((context) => ({ + scopeId: context.scopeId, + entryId: context.entryId, + extensionId: context.extensionId, + generation: context.generation, + name: context.name, + order: context.order, + text: context.text, + })), variables: [...variables].sort(compareRegistration).map((variable) => ({ scopeId: variable.scopeId, entryId: variable.entryId, @@ -419,6 +535,10 @@ function compareSections(left: ResolvedSection, right: ResolvedSection): number return left.order - right.order || compareCodeUnits(left.name, right.name); } +function compareContexts(left: ResolvedContext, right: ResolvedContext): number { + return left.order - right.order || compareCodeUnits(left.name, right.name); +} + function compareRegistration( left: MakaContributionIdentity, right: MakaContributionIdentity, From e2cac13d8de967dd07e03fd890d4438aad0c4056 Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:42:28 +0800 Subject: [PATCH 04/13] feat(plugins): add agent invocation service --- .../src/server/execution-composition.ts | 4 +- packages/runtime/package.json | 1 + .../__tests__/plugin-agent-service.test.ts | 106 ++++++++ packages/runtime/src/plugin-agent-service.ts | 244 ++++++++++++++++++ packages/runtime/src/plugin-tool-service.ts | 8 +- 5 files changed, 361 insertions(+), 2 deletions(-) create mode 100644 packages/runtime/src/__tests__/plugin-agent-service.test.ts create mode 100644 packages/runtime/src/plugin-agent-service.ts diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index fe8a7d2021..41f828652e 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -80,6 +80,7 @@ import { } from '@maka/runtime/shell-detect'; import { type MakaTool } from '@maka/runtime/tool-runtime'; import { Context } from '@maka/runtime/plugin-kernel'; +import { PluginAgentService } from '@maka/runtime/plugin-agent-service'; import { MakaCompositionLoader } from '@maka/runtime/plugin-composition-loader'; import { PluginToolService } from '@maka/runtime/plugin-tool-service'; import { PluginSystemPromptService } from '@maka/runtime/plugin-system-prompt-service'; @@ -296,7 +297,8 @@ export async function createExecutionRuntimeHostComposition( let archiveEvidence: Awaited> | undefined; try { const pluginRoot = new Context(); - const pluginTools = new PluginToolService(pluginRoot); + const pluginAgents = new PluginAgentService(pluginRoot); + const pluginTools = new PluginToolService(pluginRoot, { agents: pluginAgents }); const pluginSystemPrompt = new PluginSystemPromptService(pluginRoot); pluginPlatform = new HostPluginPlatform(context.owner.controlDirectory, { composition: new MakaCompositionLoader({ root: pluginRoot }), diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 7cf67930e0..7dccd75f1d 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -80,6 +80,7 @@ "./plan-mode": "./dist/plan-mode.js", "./plan-tools": "./dist/plan-tools.js", "./plugin-composition-loader": "./dist/plugin-composition-loader.js", + "./plugin-agent-service": "./dist/plugin-agent-service.js", "./plugin-kernel": "./dist/plugin-kernel.js", "./plugin-runtime": "./dist/plugin-runtime.js", "./plugin-scope-registry": "./dist/plugin-scope-registry.js", diff --git a/packages/runtime/src/__tests__/plugin-agent-service.test.ts b/packages/runtime/src/__tests__/plugin-agent-service.test.ts new file mode 100644 index 0000000000..7738826554 --- /dev/null +++ b/packages/runtime/src/__tests__/plugin-agent-service.test.ts @@ -0,0 +1,106 @@ +/* + * 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 { PluginAgentService, type PluginAgentRuntime } from '../plugin-agent-service.js'; +import { Context } from '../plugin-kernel.js'; +import type { MakaToolContext } from '../tool-runtime.js'; + +test('ctx.agent follows the exact asynchronous Tool invocation', async () => { + const root = new Context(); + const agents = new PluginAgentService(root); + assert.equal(root.agent, undefined); + + const invocation = toolContext('session-a'); + await agents.withInvocation(invocation, async () => { + await Promise.resolve(); + assert.equal(root.agent?.id, 'session-a'); + assert.equal(agents.requireInvocation().turnId, 'turn-a'); + }); + assert.equal(root.agent, undefined); + await root.fiber.dispose(); +}); + +test('Agent handles expose the complete control and query surface', async () => { + const root = new Context(); + const agents = new PluginAgentService(root); + const calls: string[] = []; + const descriptor = { id: 'child', sessionId: 'child', root: false }; + const runtime: PluginAgentRuntime = { + create: async () => descriptor, + resume: async () => descriptor, + get: async () => descriptor, + list: async () => [descriptor], + roots: async () => [], + followup: async () => calls.push('followup'), + steer: async () => calls.push('steer'), + inject: async () => calls.push('inject'), + cancel: async () => calls.push('cancel'), + whenIdle: async () => { + calls.push('whenIdle'); + }, + snapshot: async () => calls.push('snapshot'), + inbox: async () => calls.push('inbox'), + result: async () => calls.push('result'), + artifacts: async () => calls.push('artifacts'), + transcript: async () => calls.push('transcript'), + dispose: async () => { + calls.push('dispose'); + }, + }; + agents.bindRuntime(runtime); + const agent = await agents.create(); + await agent.followup('next'); + await agent.steer('now'); + await agent.inject('context'); + await agent.cancel(); + await agent.whenIdle(); + await agent.snapshot(); + await agent.inbox(); + await agent.result(); + await agent.artifacts(); + await agent.transcript(); + await agent.dispose(); + assert.deepEqual(calls, [ + 'followup', + 'steer', + 'inject', + 'cancel', + 'whenIdle', + 'snapshot', + 'inbox', + 'result', + 'artifacts', + 'transcript', + 'dispose', + ]); + await root.fiber.dispose(); +}); + +function toolContext(sessionId: string): MakaToolContext { + return { + sessionId, + turnId: 'turn-a', + cwd: '/workspace', + toolCallId: 'call-a', + abortSignal: new AbortController().signal, + emitOutput: () => undefined, + }; +} diff --git a/packages/runtime/src/plugin-agent-service.ts b/packages/runtime/src/plugin-agent-service.ts new file mode 100644 index 0000000000..ab660ed237 --- /dev/null +++ b/packages/runtime/src/plugin-agent-service.ts @@ -0,0 +1,244 @@ +/* + * 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 { AsyncLocalStorage } from 'node:async_hooks'; +import type { PermissionMode } from '@maka/core/permission'; +import { Service, type Context, type Disposable } from './plugin-kernel.js'; +import type { MakaToolContext } from './tool-runtime.js'; + +declare module './plugin-kernel.js' { + interface Context { + readonly agents: PluginAgentService; + readonly agent?: PluginAgent; + } +} + +export interface PluginAgentInvocation { + readonly sessionId: string; + readonly runId?: string; + readonly turnId: string; + readonly cwd: string; + readonly permissionMode?: PermissionMode; + readonly toolCallId?: string; + readonly abortSignal: AbortSignal; + readonly toolContext?: MakaToolContext; +} + +export interface PluginAgentDescriptor { + readonly id: string; + readonly sessionId: string; + readonly root: boolean; + readonly status?: string; + readonly ownerId?: string; +} + +export interface PluginAgentCreateOptions { + readonly sessionId?: string; + readonly cwd?: string; + readonly prompt?: string; + readonly model?: string; + readonly permissionMode?: PermissionMode; + readonly signal?: AbortSignal; +} + +export interface PluginAgentResumeOptions { + readonly sessionId: string; + readonly prompt?: string; + readonly signal?: AbortSignal; +} + +export interface PluginAgentRuntime { + create( + options: PluginAgentCreateOptions, + initiator: PluginAgentInvocation | undefined, + ): Promise; + resume( + options: PluginAgentResumeOptions, + initiator: PluginAgentInvocation | undefined, + ): Promise; + get( + id: string, + initiator: PluginAgentInvocation | undefined, + ): Promise; + list(initiator: PluginAgentInvocation | undefined): Promise; + roots(initiator: PluginAgentInvocation | undefined): Promise; + followup( + id: string, + message: unknown, + initiator: PluginAgentInvocation | undefined, + ): Promise; + steer( + id: string, + message: unknown, + initiator: PluginAgentInvocation | undefined, + ): Promise; + inject( + id: string, + message: unknown, + initiator: PluginAgentInvocation | undefined, + ): Promise; + cancel(id: string, initiator: PluginAgentInvocation | undefined): Promise; + whenIdle(id: string, signal: AbortSignal | undefined): Promise; + snapshot(id: string, initiator: PluginAgentInvocation | undefined): Promise; + inbox(id: string, initiator: PluginAgentInvocation | undefined): Promise; + result(id: string, initiator: PluginAgentInvocation | undefined): Promise; + artifacts(id: string, initiator: PluginAgentInvocation | undefined): Promise; + transcript(id: string, initiator: PluginAgentInvocation | undefined): Promise; + dispose(id: string, initiator: PluginAgentInvocation | undefined): Promise; +} + +export interface PluginAgent { + readonly id: string; + readonly sessionId: string; + readonly root: boolean; + readonly status?: string; + readonly ownerId?: string; + followup(message: unknown): Promise; + steer(message: unknown): Promise; + inject(message: unknown): Promise; + cancel(): Promise; + whenIdle(signal?: AbortSignal): Promise; + snapshot(): Promise; + inbox(): Promise; + result(): Promise; + artifacts(): Promise; + transcript(): Promise; + dispose(): Promise; +} + +/** Agent registry and invocation carrier exposed to trusted Host plugins. */ +export class PluginAgentService extends Service { + readonly #invocations = new AsyncLocalStorage(); + #runtime: PluginAgentRuntime | undefined; + + constructor(ctx: Context) { + super(ctx, 'agents'); + ctx.accessor('agent', { + get: () => this.current(), + }); + } + + bindRuntime(runtime: PluginAgentRuntime): Disposable> { + if (this.ctx.maka) throw new Error('Only the Host may bind the Agent Runtime'); + if (this.#runtime) throw new Error('Plugin Agent Runtime is already bound'); + this.#runtime = runtime; + return this.ctx.effect( + () => () => { + if (this.#runtime === runtime) this.#runtime = undefined; + }, + 'agents.bindRuntime()', + ); + } + + currentInvocation(): PluginAgentInvocation | undefined { + return this.#invocations.getStore(); + } + + requireInvocation(): PluginAgentInvocation { + const invocation = this.currentInvocation(); + if (!invocation) throw new Error('This capability requires an active Agent invocation'); + return invocation; + } + + withInvocation(toolContext: MakaToolContext, operation: () => T): T { + const invocation: PluginAgentInvocation = Object.freeze({ + sessionId: toolContext.sessionId, + ...(toolContext.runId ? { runId: toolContext.runId } : {}), + turnId: toolContext.turnId, + cwd: toolContext.cwd, + ...(toolContext.permissionMode ? { permissionMode: toolContext.permissionMode } : {}), + toolCallId: toolContext.toolCallId, + abortSignal: toolContext.abortSignal, + toolContext, + }); + return this.#invocations.run(invocation, operation); + } + + current(): PluginAgent | undefined { + const invocation = this.currentInvocation(); + if (!invocation) return undefined; + return this.handle({ + id: invocation.sessionId, + sessionId: invocation.sessionId, + root: true, + status: 'running', + }); + } + + async create(options: PluginAgentCreateOptions = {}): Promise { + return this.handle(await this.runtime().create(options, this.currentInvocation())); + } + + async resume(options: PluginAgentResumeOptions): Promise { + return this.handle(await this.runtime().resume(options, this.currentInvocation())); + } + + async get(id: string): Promise { + const descriptor = await this.runtime().get(assertId(id), this.currentInvocation()); + return descriptor ? this.handle(descriptor) : undefined; + } + + async list(): Promise { + return Object.freeze( + (await this.runtime().list(this.currentInvocation())).map((descriptor) => + this.handle(descriptor), + ), + ); + } + + async roots(): Promise { + return Object.freeze( + (await this.runtime().roots(this.currentInvocation())).map((descriptor) => + this.handle(descriptor), + ), + ); + } + + private runtime(): PluginAgentRuntime { + if (!this.#runtime) throw new Error('Plugin Agent Runtime is unavailable'); + return this.#runtime; + } + + private handle(descriptor: PluginAgentDescriptor): PluginAgent { + const service = this; + const id = assertId(descriptor.id); + const invoke = () => service.currentInvocation(); + return Object.freeze({ + ...descriptor, + id, + followup: (message: unknown) => service.runtime().followup(id, message, invoke()), + steer: (message: unknown) => service.runtime().steer(id, message, invoke()), + inject: (message: unknown) => service.runtime().inject(id, message, invoke()), + cancel: () => service.runtime().cancel(id, invoke()), + whenIdle: (signal?: AbortSignal) => + service.runtime().whenIdle(id, signal ?? invoke()?.abortSignal), + snapshot: () => service.runtime().snapshot(id, invoke()), + inbox: () => service.runtime().inbox(id, invoke()), + result: () => service.runtime().result(id, invoke()), + artifacts: () => service.runtime().artifacts(id, invoke()), + transcript: () => service.runtime().transcript(id, invoke()), + dispose: () => service.runtime().dispose(id, invoke()), + }); + } +} + +function assertId(id: string): string { + if (!id || /[\0\r\n]/u.test(id)) throw new TypeError('Agent id is invalid'); + return id; +} diff --git a/packages/runtime/src/plugin-tool-service.ts b/packages/runtime/src/plugin-tool-service.ts index 2749eb108e..1d0e1d1927 100644 --- a/packages/runtime/src/plugin-tool-service.ts +++ b/packages/runtime/src/plugin-tool-service.ts @@ -30,6 +30,7 @@ import { PluginScopeRegistry } from './plugin-scope-registry.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'; +import type { PluginAgentService } from './plugin-agent-service.js'; declare module './plugin-kernel.js' { interface Context { @@ -58,6 +59,7 @@ export interface ResolvedPluginTools { export interface PluginToolServiceOptions { readonly onChanged?: (rootId: MakaPluginRootId) => void; + readonly agents?: PluginAgentService; } /** @@ -71,10 +73,12 @@ export interface PluginToolServiceOptions { export class PluginToolService extends Service { private readonly registry = new PluginScopeRegistry(); private readonly onChanged?: (rootId: MakaPluginRootId) => void; + private readonly agents?: PluginAgentService; constructor(ctx: Context, options: PluginToolServiceOptions = {}) { super(ctx, 'tools'); this.onChanged = options.onChanged; + this.agents = options.agents; } register(definition: MakaTool): () => Promise { @@ -155,7 +159,9 @@ export class PluginToolService extends Service { } entry.activeCalls += 1; try { - return await definition.impl(args, context); + return await (this.agents + ? this.agents.withInvocation(context, () => definition.impl(args, context)) + : definition.impl(args, context)); } finally { entry.activeCalls -= 1; if (entry.activeCalls === 0) { From 2e512246962bf0ee0c25510e6d3c52bcbb756580 Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:46:59 +0800 Subject: [PATCH 05/13] feat(plugins): add interaction services --- .../src/server/execution-composition.ts | 4 + packages/runtime/package.json | 2 + .../plugin-interaction-services.test.ts | 104 ++++++++++++++++++ .../runtime/src/plugin-approval-service.ts | 52 +++++++++ .../src/plugin-user-question-service.ts | 55 +++++++++ 5 files changed, 217 insertions(+) create mode 100644 packages/runtime/src/__tests__/plugin-interaction-services.test.ts create mode 100644 packages/runtime/src/plugin-approval-service.ts create mode 100644 packages/runtime/src/plugin-user-question-service.ts diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 41f828652e..b6a6dd967b 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -81,6 +81,8 @@ import { import { type MakaTool } from '@maka/runtime/tool-runtime'; import { Context } from '@maka/runtime/plugin-kernel'; import { PluginAgentService } from '@maka/runtime/plugin-agent-service'; +import { PluginApprovalService } from '@maka/runtime/plugin-approval-service'; +import { PluginUserQuestionService } from '@maka/runtime/plugin-user-question-service'; import { MakaCompositionLoader } from '@maka/runtime/plugin-composition-loader'; import { PluginToolService } from '@maka/runtime/plugin-tool-service'; import { PluginSystemPromptService } from '@maka/runtime/plugin-system-prompt-service'; @@ -298,6 +300,8 @@ export async function createExecutionRuntimeHostComposition( try { const pluginRoot = new Context(); const pluginAgents = new PluginAgentService(pluginRoot); + new PluginApprovalService(pluginRoot, pluginAgents); + new PluginUserQuestionService(pluginRoot, pluginAgents); const pluginTools = new PluginToolService(pluginRoot, { agents: pluginAgents }); const pluginSystemPrompt = new PluginSystemPromptService(pluginRoot); pluginPlatform = new HostPluginPlatform(context.owner.controlDirectory, { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 7dccd75f1d..8a9efd4a47 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -81,10 +81,12 @@ "./plan-tools": "./dist/plan-tools.js", "./plugin-composition-loader": "./dist/plugin-composition-loader.js", "./plugin-agent-service": "./dist/plugin-agent-service.js", + "./plugin-approval-service": "./dist/plugin-approval-service.js", "./plugin-kernel": "./dist/plugin-kernel.js", "./plugin-runtime": "./dist/plugin-runtime.js", "./plugin-scope-registry": "./dist/plugin-scope-registry.js", "./plugin-system-prompt-service": "./dist/plugin-system-prompt-service.js", + "./plugin-user-question-service": "./dist/plugin-user-question-service.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", diff --git a/packages/runtime/src/__tests__/plugin-interaction-services.test.ts b/packages/runtime/src/__tests__/plugin-interaction-services.test.ts new file mode 100644 index 0000000000..54694edcfe --- /dev/null +++ b/packages/runtime/src/__tests__/plugin-interaction-services.test.ts @@ -0,0 +1,104 @@ +/* + * 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 type { SandboxBoundaryExpansion } from '@maka/core/sandbox-boundary'; +import { PluginAgentService } from '../plugin-agent-service.js'; +import { PluginApprovalService } from '../plugin-approval-service.js'; +import { Context } from '../plugin-kernel.js'; +import { PluginUserQuestionService } from '../plugin-user-question-service.js'; +import type { MakaToolContext } from '../tool-runtime.js'; + +test('questions and approvals use the exact current Tool interaction authority', async () => { + const root = new Context(); + const agents = new PluginAgentService(root); + const approval = new PluginApprovalService(root, agents); + const questions = new PluginUserQuestionService(root, agents); + const calls: string[] = []; + const context: MakaToolContext = { + sessionId: 'session-a', + turnId: 'turn-a', + cwd: '/workspace', + toolCallId: 'call-a', + abortSignal: new AbortController().signal, + emitOutput: () => undefined, + askUserQuestion: async (items) => { + calls.push(`question:${items[0]?.question}`); + return { answers: [{ question: items[0]?.question ?? '', answer: 'yes' }] }; + }, + requestUserForm: async (form) => { + calls.push(`form:${form.message}`); + return { action: 'accept', values: { choice: 'yes' } }; + }, + requestSandboxBoundary: async (expansion, justification) => { + calls.push(`approval:${justification}`); + return { + request: { + sessionId: 'session-a', + requestId: 'request-a', + status: 'approved', + baseRevision: 0, + expansion, + justification, + createdAt: 1, + settledAt: 2, + }, + boundary: { kind: 'bypass', revision: 1 }, + changed: true, + }; + }, + }; + + await agents.withInvocation(context, async () => { + await questions.ask([{ question: 'Continue?', options: [{ label: 'yes' }, { label: 'no' }] }]); + await questions.requestForm({ + message: 'Choose', + requester: { name: 'fixture' }, + fields: [ + { + kind: 'single_select', + name: 'choice', + label: 'Choice', + required: true, + options: [{ value: 'yes', label: 'Yes' }], + }, + ], + }); + await approval.request({ + expansion: { kind: 'workspace_write', paths: ['/workspace'] } as SandboxBoundaryExpansion, + justification: 'write output', + }); + }); + assert.deepEqual(calls, ['question:Continue?', 'form:Choose', 'approval:write output']); + await root.fiber.dispose(); +}); + +test('interaction services reject calls outside an Agent invocation', async () => { + const root = new Context(); + const agents = new PluginAgentService(root); + const approval = new PluginApprovalService(root, agents); + const questions = new PluginUserQuestionService(root, agents); + assert.throws(() => questions.ask([]), /active Agent invocation/u); + assert.throws( + () => approval.request({ expansion: {} as SandboxBoundaryExpansion, justification: 'x' }), + /active Agent invocation/u, + ); + await root.fiber.dispose(); +}); diff --git a/packages/runtime/src/plugin-approval-service.ts b/packages/runtime/src/plugin-approval-service.ts new file mode 100644 index 0000000000..60b7d1de1b --- /dev/null +++ b/packages/runtime/src/plugin-approval-service.ts @@ -0,0 +1,52 @@ +/* + * 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 type { + SandboxBoundaryExpansion, + SandboxBoundarySettlement, +} from '@maka/core/sandbox-boundary'; +import { Service, type Context } from './plugin-kernel.js'; +import type { PluginAgentService } from './plugin-agent-service.js'; + +declare module './plugin-kernel.js' { + interface Context { + readonly approval: PluginApprovalService; + } +} + +export interface PluginApprovalRequest { + readonly expansion: SandboxBoundaryExpansion; + readonly justification: string; +} + +/** Permission request surface backed by Maka's durable Sandbox Boundary authority. */ +export class PluginApprovalService extends Service { + constructor( + ctx: Context, + private readonly agents: PluginAgentService, + ) { + super(ctx, 'approval'); + } + + request(request: PluginApprovalRequest): Promise { + const ask = this.agents.requireInvocation().toolContext?.requestSandboxBoundary; + if (!ask) throw new Error('Approval is unavailable on this Agent surface'); + return ask(request.expansion, request.justification); + } +} diff --git a/packages/runtime/src/plugin-user-question-service.ts b/packages/runtime/src/plugin-user-question-service.ts new file mode 100644 index 0000000000..4bfe408845 --- /dev/null +++ b/packages/runtime/src/plugin-user-question-service.ts @@ -0,0 +1,55 @@ +/* + * 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 type { InteractionFormInput, InteractionFormResult } from '@maka/core/interaction'; +import type { UserQuestion, UserQuestionResult } from '@maka/core/user-question'; +import { Service, type Context } from './plugin-kernel.js'; +import type { PluginAgentService } from './plugin-agent-service.js'; + +declare module './plugin-kernel.js' { + interface Context { + readonly userQuestions: PluginUserQuestionService; + } +} + +/** Structured human-input surface bound to the current Agent invocation. */ +export class PluginUserQuestionService extends Service { + constructor( + ctx: Context, + private readonly agents: PluginAgentService, + ) { + super(ctx, 'userQuestions'); + } + + ask(questions: readonly UserQuestion[]): Promise { + const ask = this.agents.requireInvocation().toolContext?.askUserQuestion; + if (!ask) throw new Error('User questions are unavailable on this Agent surface'); + return ask([...questions]); + } + + requestForm( + form: InteractionFormInput, + options: { readonly signal?: AbortSignal } = {}, + ): Promise { + const invocation = this.agents.requireInvocation(); + const request = invocation.toolContext?.requestUserForm; + if (!request) throw new Error('Structured user forms are unavailable on this Agent surface'); + return request(form, { cancellationSignal: options.signal ?? invocation.abortSignal }); + } +} From d66cd8fbd761d829db3e185320a6f7ef1f9bbc15 Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:58:15 +0800 Subject: [PATCH 06/13] feat(plugins): expose scoped resource services --- .../src/server/execution-composition.ts | 144 +++++++++++++++++- packages/runtime/package.json | 4 + .../plugin-resource-services.test.ts | 99 ++++++++++++ packages/runtime/src/plugin-agent-service.ts | 5 + .../runtime/src/plugin-attachment-service.ts | 85 +++++++++++ packages/runtime/src/plugin-fs-service.ts | 121 +++++++++++++++ packages/runtime/src/plugin-shell-service.ts | 95 ++++++++++++ packages/runtime/src/plugin-web-service.ts | 100 ++++++++++++ 8 files changed, 652 insertions(+), 1 deletion(-) create mode 100644 packages/runtime/src/__tests__/plugin-resource-services.test.ts create mode 100644 packages/runtime/src/plugin-attachment-service.ts create mode 100644 packages/runtime/src/plugin-fs-service.ts create mode 100644 packages/runtime/src/plugin-shell-service.ts create mode 100644 packages/runtime/src/plugin-web-service.ts diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index b6a6dd967b..95a025524d 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -19,9 +19,11 @@ import { copyWorkHubAttachmentsToTarget } from './workhub-message-attachments.js'; import { createHash, randomUUID } from 'node:crypto'; -import { MAX_READ_IMAGE_BYTES } from '@maka/core/attachments'; +import { attachmentKindFromMimeType, MAX_READ_IMAGE_BYTES } from '@maka/core/attachments'; import type { ContextOffloadLimits } from '@maka/core/context-offload'; import { messageContentDigest, normalizeMessageContent } from '@maka/core/events'; +import type { AttachmentRef } from '@maka/core/events'; +import type { ArtifactKind, ArtifactRecord } from '@maka/core/artifacts'; import { NO_REAL_CONNECTION_CODE } from '@maka/core/connection-error-copy'; import type { RuntimeExecutionConnection } from '@maka/core/llm-connections'; import { generalizedErrorMessage } from '@maka/core/redaction'; @@ -49,6 +51,7 @@ import { } from '@maka/runtime/session-manager'; import { buildToolsForAgentDefinition } from '@maka/runtime/agent-catalog'; import { buildHistoryTools } from '@maka/runtime/history-tools'; +import { buildBuiltinTools } from '@maka/runtime/builtin-tools'; import { createLocalContinuationSafetyInspector } from '@maka/runtime/continuation-safety'; import { createConfiguredSubagentCatalog } from '@maka/runtime/configured-subagent-catalog'; import { buildHostCapabilitiesFromBinding } from '@maka/runtime/skills'; @@ -81,8 +84,12 @@ import { import { type MakaTool } from '@maka/runtime/tool-runtime'; import { Context } from '@maka/runtime/plugin-kernel'; import { PluginAgentService } from '@maka/runtime/plugin-agent-service'; +import { PluginAttachmentService } from '@maka/runtime/plugin-attachment-service'; import { PluginApprovalService } from '@maka/runtime/plugin-approval-service'; +import { PluginFilesystemService } from '@maka/runtime/plugin-fs-service'; +import { PluginShellService } from '@maka/runtime/plugin-shell-service'; import { PluginUserQuestionService } from '@maka/runtime/plugin-user-question-service'; +import { PluginWebService } from '@maka/runtime/plugin-web-service'; import { MakaCompositionLoader } from '@maka/runtime/plugin-composition-loader'; import { PluginToolService } from '@maka/runtime/plugin-tool-service'; import { PluginSystemPromptService } from '@maka/runtime/plugin-system-prompt-service'; @@ -300,8 +307,12 @@ export async function createExecutionRuntimeHostComposition( try { const pluginRoot = new Context(); const pluginAgents = new PluginAgentService(pluginRoot); + const pluginAttachments = new PluginAttachmentService(pluginRoot, pluginAgents); new PluginApprovalService(pluginRoot, pluginAgents); new PluginUserQuestionService(pluginRoot, pluginAgents); + const pluginFilesystem = new PluginFilesystemService(pluginRoot, pluginAgents); + const pluginShell = new PluginShellService(pluginRoot, pluginAgents); + const pluginWeb = new PluginWebService(pluginRoot, pluginAgents); const pluginTools = new PluginToolService(pluginRoot, { agents: pluginAgents }); const pluginSystemPrompt = new PluginSystemPromptService(pluginRoot); pluginPlatform = new HostPluginPlatform(context.owner.controlDirectory, { @@ -478,12 +489,123 @@ export async function createExecutionRuntimeHostComposition( ...(sandboxManager ? { sandboxManager } : {}), ...(filesystemWorker ? { filesystemWorker } : {}), }; + const invokeBuiltin = async ( + name: string, + args: unknown, + invocation: import('@maka/runtime/plugin-agent-service').PluginAgentInvocation, + ) => { + if (!invocation.toolContext) throw new Error(`${name} requires an active Tool invocation`); + const policy = await runtimePolicyStores.runtimePolicy.getSnapshot(); + const tool = buildBuiltinTools({ + ...builtinTools, + shell: resolveTurnShellPlan(policy.policy.shell), + }).find((candidate) => candidate.name === name); + if (!tool) throw new Error(`Builtin capability is unavailable: ${name}`); + return tool.impl(args, invocation.toolContext); + }; + pluginFilesystem.bindRuntime({ + execute: (operation, invocation) => { + switch (operation.kind) { + case 'read': + return invokeBuiltin('Read', operation, invocation); + case 'write': + return invokeBuiltin('Write', operation, invocation); + case 'edit': + return invokeBuiltin( + 'Edit', + { + path: operation.path, + old_string: operation.oldString, + new_string: operation.newString, + }, + invocation, + ); + case 'glob': + return invokeBuiltin( + 'Glob', + { pattern: operation.pattern, cwd: operation.path }, + invocation, + ); + case 'grep': + return invokeBuiltin( + 'Grep', + { pattern: operation.pattern, path: operation.path, glob: operation.glob }, + invocation, + ); + case 'apply_patch': + return invokeBuiltin('apply_patch', operation.patch, invocation); + } + }, + }); + pluginShell.bindRuntime({ + run: (options, invocation) => + invokeBuiltin( + 'Bash', + { + command: options.command, + timeout_ms: options.timeoutMs, + run_in_background: options.background, + pty: options.pty, + }, + invocation, + ), + read: (ref, invocation) => + runtimeResources.readRuntimeResource(invocation.sessionId, ref, invocation.abortSignal), + write: (ref, input, invocation) => + runtimeResources.writeStdin({ + sessionId: invocation.sessionId, + ref, + input, + abortSignal: invocation.abortSignal, + caller: 'model', + }), + stop: (ref, invocation) => + runtimeResources.stopBackgroundTask(invocation.sessionId, ref, invocation.abortSignal), + }); + pluginAttachments.bindRuntime({ + create: async (input, invocation) => { + const record = await openedArtifactStore.create({ + sessionId: invocation.sessionId, + turnId: invocation.turnId, + name: input.name, + kind: pluginAttachmentArtifactKind(input.mimeType, input.name), + content: input.content, + mimeType: input.mimeType, + source: 'tool_result', + ...(input.summary ? { summary: input.summary } : {}), + }); + return pluginAttachmentRef(record); + }, + read: async (attachment, invocation) => { + if ( + attachment.ref.kind !== 'session_file' || + attachment.ref.sessionId !== invocation.sessionId + ) { + throw new Error('Attachment is outside the current Session'); + } + const result = await openedArtifactStore.readBinaryInSession( + invocation.sessionId, + attachment.ref.relativePath, + ); + if (!result.ok) throw new Error(`Attachment read failed: ${result.reason}`); + return Uint8Array.from(Buffer.from(result.base64, 'base64')); + }, + list: async (invocation) => + (await openedArtifactStore.listTurnArtifacts(invocation.sessionId, invocation.turnId)).map( + pluginAttachmentRef, + ), + }); const webSearchService = createHostWebSearchService({ policy: runtimePolicyStores.operations, }); const webFetchService = createHostWebFetchService({ policy: runtimePolicyStores.operations, }); + pluginWeb.bindRuntime({ + search: ({ query, limit, abortSignal }) => + webSearchService.search({ query, limit, ...(abortSignal ? { abortSignal } : {}) }), + fetch: (input) => webFetchService.fetch(input), + }); const historyTools = buildHistoryTools({ listSessions: () => requireSessionManager(manager).listSessions(), readMessages: async (sessionId, abortSignal) => { @@ -2528,6 +2650,26 @@ function requireGoal(coordinator: HostGoalCoordinator | undefined): HostGoalCoor return coordinator; } +function pluginAttachmentArtifactKind(mimeType: string, name: string): ArtifactKind { + const kind = attachmentKindFromMimeType(mimeType, name); + return kind === 'image' || kind === 'pdf' ? kind : 'file'; +} + +function pluginAttachmentRef(record: ArtifactRecord): AttachmentRef { + const mimeType = record.mimeType ?? 'application/octet-stream'; + return { + kind: attachmentKindFromMimeType(mimeType, record.name), + name: record.name, + mimeType, + bytes: record.sizeBytes, + ref: { + kind: 'session_file', + sessionId: record.sessionId, + relativePath: record.relativePath, + }, + }; +} + /** Every run this Session has opened, named by the event spine that defines it. */ async function sessionRunIds( runtimeEventStore: SessionInvocationLister, diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 8a9efd4a47..d351243a70 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -81,12 +81,16 @@ "./plan-tools": "./dist/plan-tools.js", "./plugin-composition-loader": "./dist/plugin-composition-loader.js", "./plugin-agent-service": "./dist/plugin-agent-service.js", + "./plugin-attachment-service": "./dist/plugin-attachment-service.js", "./plugin-approval-service": "./dist/plugin-approval-service.js", + "./plugin-fs-service": "./dist/plugin-fs-service.js", "./plugin-kernel": "./dist/plugin-kernel.js", "./plugin-runtime": "./dist/plugin-runtime.js", "./plugin-scope-registry": "./dist/plugin-scope-registry.js", "./plugin-system-prompt-service": "./dist/plugin-system-prompt-service.js", + "./plugin-shell-service": "./dist/plugin-shell-service.js", "./plugin-user-question-service": "./dist/plugin-user-question-service.js", + "./plugin-web-service": "./dist/plugin-web-service.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", diff --git a/packages/runtime/src/__tests__/plugin-resource-services.test.ts b/packages/runtime/src/__tests__/plugin-resource-services.test.ts new file mode 100644 index 0000000000..2ea5b298c5 --- /dev/null +++ b/packages/runtime/src/__tests__/plugin-resource-services.test.ts @@ -0,0 +1,99 @@ +/* + * 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 { PluginAgentService } from '../plugin-agent-service.js'; +import { PluginAttachmentService } from '../plugin-attachment-service.js'; +import { PluginFilesystemService } from '../plugin-fs-service.js'; +import { Context } from '../plugin-kernel.js'; +import { PluginShellService } from '../plugin-shell-service.js'; +import { PluginWebService } from '../plugin-web-service.js'; +import type { MakaToolContext } from '../tool-runtime.js'; + +test('resource services preserve the current Session and cancellation context', async () => { + const root = new Context(); + const agents = new PluginAgentService(root); + const fs = new PluginFilesystemService(root, agents); + const shell = new PluginShellService(root, agents); + const web = new PluginWebService(root, agents); + const attachments = new PluginAttachmentService(root, agents); + const calls: string[] = []; + fs.bindRuntime({ + execute: async (operation, invocation) => { + calls.push(`fs:${operation.kind}:${invocation.sessionId}`); + return operation; + }, + }); + shell.bindRuntime({ + run: async (options, invocation) => { + calls.push(`shell:${options.command}:${invocation.turnId}`); + return { ok: true }; + }, + }); + web.bindRuntime({ + search: async (input) => { + calls.push(`search:${input.query}:${input.sessionId}`); + return { ok: true, provider: 'tavily', results: [] }; + }, + fetch: async (input) => { + calls.push(`fetch:${input.url}:${input.sessionId}`); + return 'body'; + }, + }); + attachments.bindRuntime({ + create: async (input, invocation) => { + calls.push(`attachment:${input.name}:${invocation.turnId}`); + return { + kind: 'other', + name: input.name, + mimeType: input.mimeType, + bytes: 1, + ref: { kind: 'session_file', sessionId: invocation.sessionId, relativePath: 'a' }, + }; + }, + read: async () => new Uint8Array([1]), + list: async () => [], + }); + const context: MakaToolContext = { + sessionId: 'session-a', + turnId: 'turn-a', + cwd: '/workspace', + toolCallId: 'call-a', + abortSignal: new AbortController().signal, + emitOutput: () => undefined, + }; + + await agents.withInvocation(context, async () => { + await fs.read('README.md'); + await shell.run({ command: 'pwd' }); + await web.search(' maka '); + assert.equal(await web.fetch('https://example.com'), 'body'); + await attachments.create({ name: 'a.txt', mimeType: 'text/plain', content: 'a' }); + }); + + assert.deepEqual(calls, [ + 'fs:read:session-a', + 'shell:pwd:turn-a', + 'search:maka:session-a', + 'fetch:https://example.com/:session-a', + 'attachment:a.txt:turn-a', + ]); + await root.fiber.dispose(); +}); diff --git a/packages/runtime/src/plugin-agent-service.ts b/packages/runtime/src/plugin-agent-service.ts index ab660ed237..5348adb974 100644 --- a/packages/runtime/src/plugin-agent-service.ts +++ b/packages/runtime/src/plugin-agent-service.ts @@ -19,6 +19,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import type { PermissionMode } from '@maka/core/permission'; +import type { ExecutionBoundary } from '@maka/core/sandbox-boundary'; import { Service, type Context, type Disposable } from './plugin-kernel.js'; import type { MakaToolContext } from './tool-runtime.js'; @@ -35,6 +36,7 @@ export interface PluginAgentInvocation { readonly turnId: string; readonly cwd: string; readonly permissionMode?: PermissionMode; + readonly executionBoundary?: ExecutionBoundary; readonly toolCallId?: string; readonly abortSignal: AbortSignal; readonly toolContext?: MakaToolContext; @@ -163,6 +165,9 @@ export class PluginAgentService extends Service { turnId: toolContext.turnId, cwd: toolContext.cwd, ...(toolContext.permissionMode ? { permissionMode: toolContext.permissionMode } : {}), + ...(toolContext.executionBoundary + ? { executionBoundary: toolContext.executionBoundary } + : {}), toolCallId: toolContext.toolCallId, abortSignal: toolContext.abortSignal, toolContext, diff --git a/packages/runtime/src/plugin-attachment-service.ts b/packages/runtime/src/plugin-attachment-service.ts new file mode 100644 index 0000000000..3f139f4bde --- /dev/null +++ b/packages/runtime/src/plugin-attachment-service.ts @@ -0,0 +1,85 @@ +/* + * 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 type { AttachmentRef } from '@maka/core/events'; +import { Service, type Context, type Disposable } from './plugin-kernel.js'; +import type { PluginAgentInvocation, PluginAgentService } from './plugin-agent-service.js'; + +declare module './plugin-kernel.js' { + interface Context { + readonly attachments: PluginAttachmentService; + } +} + +export interface PluginAttachmentCreateInput { + readonly name: string; + readonly mimeType: string; + readonly content: string | Uint8Array; + readonly summary?: string; +} + +export interface PluginAttachmentRuntime { + create( + input: PluginAttachmentCreateInput, + invocation: PluginAgentInvocation, + ): Promise; + read(ref: AttachmentRef, invocation: PluginAgentInvocation): Promise; + list(invocation: PluginAgentInvocation): Promise; +} + +/** Session-owned rich result publication and retrieval. */ +export class PluginAttachmentService extends Service { + #runtime?: PluginAttachmentRuntime; + + constructor( + ctx: Context, + private readonly agents: PluginAgentService, + ) { + super(ctx, 'attachments'); + } + + bindRuntime(runtime: PluginAttachmentRuntime): Disposable> { + if (this.ctx.maka) throw new Error('Only the Host may bind the Attachment Runtime'); + if (this.#runtime) throw new Error('Plugin Attachment Runtime is already bound'); + this.#runtime = runtime; + return this.ctx.effect( + () => () => { + if (this.#runtime === runtime) this.#runtime = undefined; + }, + 'attachments.bindRuntime()', + ); + } + + create(input: PluginAttachmentCreateInput) { + return this.runtime().create(input, this.agents.requireInvocation()); + } + + read(ref: AttachmentRef) { + return this.runtime().read(ref, this.agents.requireInvocation()); + } + + list() { + return this.runtime().list(this.agents.requireInvocation()); + } + + private runtime(): PluginAttachmentRuntime { + if (!this.#runtime) throw new Error('Plugin Attachment Runtime is unavailable'); + return this.#runtime; + } +} diff --git a/packages/runtime/src/plugin-fs-service.ts b/packages/runtime/src/plugin-fs-service.ts new file mode 100644 index 0000000000..1e1788aadb --- /dev/null +++ b/packages/runtime/src/plugin-fs-service.ts @@ -0,0 +1,121 @@ +/* + * 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 { Service, type Context, type Disposable } from './plugin-kernel.js'; +import type { PluginAgentInvocation, PluginAgentService } from './plugin-agent-service.js'; + +declare module './plugin-kernel.js' { + interface Context { + readonly fs: PluginFilesystemService; + } +} + +export type PluginFilesystemOperation = + | { + readonly kind: 'read'; + readonly path: string; + readonly offset?: number; + readonly limit?: number; + } + | { readonly kind: 'write'; readonly path: string; readonly content: string } + | { + readonly kind: 'edit'; + readonly path: string; + readonly oldString: string; + readonly newString: string; + } + | { + readonly kind: 'glob'; + readonly path?: string; + readonly pattern: string; + readonly limit?: number; + } + | { + readonly kind: 'grep'; + readonly path?: string; + readonly pattern: string; + readonly glob?: string; + readonly maxCountPerFile?: number; + readonly limit?: number; + readonly timeoutMs?: number; + } + | { readonly kind: 'apply_patch'; readonly patch: string }; + +export interface PluginFilesystemRuntime { + execute( + operation: PluginFilesystemOperation, + invocation: PluginAgentInvocation, + ): Promise; +} + +/** Full-fidelity filesystem entry point bound to Maka's canonical workspace authority. */ +export class PluginFilesystemService extends Service { + #runtime?: PluginFilesystemRuntime; + + constructor( + ctx: Context, + private readonly agents: PluginAgentService, + ) { + super(ctx, 'fs'); + } + + bindRuntime(runtime: PluginFilesystemRuntime): Disposable> { + if (this.ctx.maka) throw new Error('Only the Host may bind the Filesystem Runtime'); + if (this.#runtime) throw new Error('Plugin Filesystem Runtime is already bound'); + this.#runtime = runtime; + return this.ctx.effect( + () => () => { + if (this.#runtime === runtime) this.#runtime = undefined; + }, + 'fs.bindRuntime()', + ); + } + + execute(operation: PluginFilesystemOperation): Promise { + if (!this.#runtime) throw new Error('Plugin Filesystem Runtime is unavailable'); + return this.#runtime.execute(operation, this.agents.requireInvocation()); + } + + read(path: string, options: { readonly offset?: number; readonly limit?: number } = {}) { + return this.execute({ kind: 'read', path, ...options }); + } + + write(path: string, content: string) { + return this.execute({ kind: 'write', path, content }); + } + + edit(path: string, oldString: string, newString: string) { + return this.execute({ kind: 'edit', path, oldString, newString }); + } + + glob(pattern: string, options: { readonly path?: string; readonly limit?: number } = {}) { + return this.execute({ kind: 'glob', pattern, ...options }); + } + + grep( + pattern: string, + options: Omit, 'kind' | 'pattern'> = {}, + ) { + return this.execute({ kind: 'grep', pattern, ...options }); + } + + applyPatch(patch: string) { + return this.execute({ kind: 'apply_patch', patch }); + } +} diff --git a/packages/runtime/src/plugin-shell-service.ts b/packages/runtime/src/plugin-shell-service.ts new file mode 100644 index 0000000000..afd3a750d4 --- /dev/null +++ b/packages/runtime/src/plugin-shell-service.ts @@ -0,0 +1,95 @@ +/* + * 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 { Service, type Context, type Disposable } from './plugin-kernel.js'; +import type { PluginAgentInvocation, PluginAgentService } from './plugin-agent-service.js'; + +declare module './plugin-kernel.js' { + interface Context { + readonly shell: PluginShellService; + } +} + +export interface PluginShellRunOptions { + readonly command: string; + readonly timeoutMs?: number; + readonly background?: boolean; + readonly pty?: boolean; +} + +export interface PluginShellRuntime { + run(options: PluginShellRunOptions, invocation: PluginAgentInvocation): Promise; + read?(ref: string, invocation: PluginAgentInvocation): Promise; + write?(ref: string, input: string, invocation: PluginAgentInvocation): Promise; + stop?(ref: string, invocation: PluginAgentInvocation): Promise; +} + +/** Streaming, cancellable foreground/background/PTY shell surface. */ +export class PluginShellService extends Service { + #runtime?: PluginShellRuntime; + + constructor( + ctx: Context, + private readonly agents: PluginAgentService, + ) { + super(ctx, 'shell'); + } + + bindRuntime(runtime: PluginShellRuntime): Disposable> { + if (this.ctx.maka) throw new Error('Only the Host may bind the Shell Runtime'); + if (this.#runtime) throw new Error('Plugin Shell Runtime is already bound'); + this.#runtime = runtime; + return this.ctx.effect( + () => () => { + if (this.#runtime === runtime) this.#runtime = undefined; + }, + 'shell.bindRuntime()', + ); + } + + run(options: PluginShellRunOptions): Promise { + return this.runtime().run(options, this.agents.requireInvocation()); + } + + read(ref: string): Promise { + const invocation = this.agents.requireInvocation(); + const read = this.runtime().read; + if (!read) throw new Error('Shell resource reads are unavailable'); + return read(ref, invocation); + } + + write(ref: string, input: string): Promise { + const invocation = this.agents.requireInvocation(); + const write = this.runtime().write; + if (!write) throw new Error('Shell PTY input is unavailable'); + return write(ref, input, invocation); + } + + stop(ref: string): Promise { + const invocation = this.agents.requireInvocation(); + const stop = this.runtime().stop; + if (!stop) throw new Error('Shell process cancellation is unavailable'); + return stop(ref, invocation); + } + + private runtime(): PluginShellRuntime { + if (!this.#runtime) throw new Error('Plugin Shell Runtime is unavailable'); + return this.#runtime; + } +} diff --git a/packages/runtime/src/plugin-web-service.ts b/packages/runtime/src/plugin-web-service.ts new file mode 100644 index 0000000000..99019bd2fa --- /dev/null +++ b/packages/runtime/src/plugin-web-service.ts @@ -0,0 +1,100 @@ +/* + * 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 type { WebSearchResponse } from '@maka/core/web-search'; +import { + WEB_SEARCH_DEFAULT_LIMIT, + normalizeWebSearchLimit, + normalizeWebSearchQuery, +} from '@maka/core/web-search'; +import { Service, type Context, type Disposable } from './plugin-kernel.js'; +import type { PluginAgentService } from './plugin-agent-service.js'; + +declare module './plugin-kernel.js' { + interface Context { + readonly web: PluginWebService; + } +} + +export interface PluginWebRuntime { + search(input: { + readonly query: string; + readonly limit: number; + readonly sessionId: string; + readonly abortSignal?: AbortSignal; + }): Promise; + fetch(input: { + readonly url: string; + readonly sessionId: string; + readonly abortSignal?: AbortSignal; + }): Promise; +} + +/** Provider-policy-aware web search and fetch surface. */ +export class PluginWebService extends Service { + #runtime?: PluginWebRuntime; + + constructor( + ctx: Context, + private readonly agents: PluginAgentService, + ) { + super(ctx, 'web'); + } + + bindRuntime(runtime: PluginWebRuntime): Disposable> { + if (this.ctx.maka) throw new Error('Only the Host may bind the Web Runtime'); + if (this.#runtime) throw new Error('Plugin Web Runtime is already bound'); + this.#runtime = runtime; + return this.ctx.effect( + () => () => { + if (this.#runtime === runtime) this.#runtime = undefined; + }, + 'web.bindRuntime()', + ); + } + + search(query: string, options: { readonly limit?: number; readonly signal?: AbortSignal } = {}) { + const invocation = this.agents.requireInvocation(); + const normalized = normalizeWebSearchQuery(query); + if (!normalized) throw new TypeError('Web search query is invalid'); + return this.runtime().search({ + query: normalized, + limit: normalizeWebSearchLimit(options.limit ?? WEB_SEARCH_DEFAULT_LIMIT), + sessionId: invocation.sessionId, + abortSignal: options.signal ?? invocation.abortSignal, + }); + } + + fetch(url: string, options: { readonly signal?: AbortSignal } = {}) { + const invocation = this.agents.requireInvocation(); + const parsed = new URL(url); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') + throw new TypeError('Web URL must use HTTP or HTTPS'); + return this.runtime().fetch({ + url: parsed.toString(), + sessionId: invocation.sessionId, + abortSignal: options.signal ?? invocation.abortSignal, + }); + } + + private runtime(): PluginWebRuntime { + if (!this.#runtime) throw new Error('Plugin Web Runtime is unavailable'); + return this.#runtime; + } +} From fc6c7665dd50b5449e7ac9da9519748dccb48107 Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:06:11 +0800 Subject: [PATCH 07/13] feat(plugins): add metered llm service --- .../src/server/execution-composition.ts | 20 +++ .../src/server/execution-model-authority.ts | 41 +++++- packages/runtime/package.json | 1 + .../src/__tests__/plugin-llm-service.test.ts | 53 ++++++++ packages/runtime/src/plugin-agent-service.ts | 18 +-- .../runtime/src/plugin-attachment-service.ts | 12 +- packages/runtime/src/plugin-fs-service.ts | 12 +- packages/runtime/src/plugin-llm-service.ts | 123 ++++++++++++++++++ packages/runtime/src/plugin-shell-service.ts | 12 +- packages/runtime/src/plugin-web-service.ts | 12 +- 10 files changed, 270 insertions(+), 34 deletions(-) create mode 100644 packages/runtime/src/__tests__/plugin-llm-service.test.ts create mode 100644 packages/runtime/src/plugin-llm-service.ts diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 95a025524d..d80c7a78f2 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -87,6 +87,7 @@ import { PluginAgentService } from '@maka/runtime/plugin-agent-service'; import { PluginAttachmentService } from '@maka/runtime/plugin-attachment-service'; import { PluginApprovalService } from '@maka/runtime/plugin-approval-service'; import { PluginFilesystemService } from '@maka/runtime/plugin-fs-service'; +import { PluginLlmService } from '@maka/runtime/plugin-llm-service'; import { PluginShellService } from '@maka/runtime/plugin-shell-service'; import { PluginUserQuestionService } from '@maka/runtime/plugin-user-question-service'; import { PluginWebService } from '@maka/runtime/plugin-web-service'; @@ -135,6 +136,7 @@ import { createHostGoalEvaluator, createHostDailyReviewModel, createHostMemoryExtractionModel, + createHostPluginModel, createHostSessionEffectModel, } from './execution-model-authority.js'; import { HostExecutionInspectCoordinator } from './execution-inspect-coordinator.js'; @@ -311,6 +313,7 @@ export async function createExecutionRuntimeHostComposition( new PluginApprovalService(pluginRoot, pluginAgents); new PluginUserQuestionService(pluginRoot, pluginAgents); const pluginFilesystem = new PluginFilesystemService(pluginRoot, pluginAgents); + const pluginLlm = new PluginLlmService(pluginRoot, pluginAgents); const pluginShell = new PluginShellService(pluginRoot, pluginAgents); const pluginWeb = new PluginWebService(pluginRoot, pluginAgents); const pluginTools = new PluginToolService(pluginRoot, { agents: pluginAgents }); @@ -1398,6 +1401,23 @@ export async function createExecutionRuntimeHostComposition( context.owner.capability.rootId, ); const coordinator = rootCoordinator; + const pluginModel = createHostPluginModel({ + runtimePolicy: runtimePolicyStores, + oauthCredentials, + usage: openedUsageStores, + requestDrain: context.requestDrain, + readSessionHeader: (sessionId) => stores.sessionStore.readHeaderSnapshot(sessionId), + }); + pluginLlm.bindRuntime({ + generate: (input, invocation) => + pluginModel.generate({ + sessionId: invocation.sessionId, + prompt: input.prompt, + ...(input.system ? { system: input.system } : {}), + ...(input.maxOutputTokens ? { maxOutputTokens: input.maxOutputTokens } : {}), + abortSignal: input.signal ?? invocation.abortSignal, + }), + }); const contextOperations = new HostContextCoordinator({ runtime: manager, executions: coordinator, diff --git a/packages/runtime-host/src/server/execution-model-authority.ts b/packages/runtime-host/src/server/execution-model-authority.ts index 71d1cf53f1..430c1a8cb7 100644 --- a/packages/runtime-host/src/server/execution-model-authority.ts +++ b/packages/runtime-host/src/server/execution-model-authority.ts @@ -128,6 +128,45 @@ export interface HostSessionEffectModel { export type HostSessionEffectModelInput = Omit; +export interface HostPluginModel { + generate(input: { + readonly sessionId: string; + readonly prompt: string; + readonly system?: string; + readonly maxOutputTokens?: number; + readonly abortSignal: AbortSignal; + }): Promise<{ readonly text: string; readonly modelId: string; readonly finishReason?: string }>; +} + +/** Canonical credential, transport, retry, pricing and telemetry path for plugin model calls. */ +export function createHostPluginModel(input: HostGoalEvaluatorInput): HostPluginModel { + const authority = createAuxiliaryModelCallAuthority(input); + return Object.freeze({ + generate: async ({ + sessionId, + prompt, + system, + maxOutputTokens, + abortSignal, + }: Parameters[0]) => { + const header = await input.readSessionHeader(sessionId); + return runHostAuxiliaryModelCall(authority, { + transportContextId: sessionId, + telemetrySessionId: sessionId, + header, + callKind: 'main', + callId: `plugin_${authority.newId()}`, + abortSignal, + buildRequest: () => ({ + prompt, + ...(system ? { system } : {}), + maxOutputTokens: maxOutputTokens ?? 2_048, + }), + }); + }, + }); +} + export type HostDailyReviewModelResult = | { readonly ok: true; readonly text: string; readonly modelKey: string } | { @@ -408,7 +447,7 @@ interface HostAuxiliaryModelCallInput { SessionHeader, 'llmConnectionId' | 'llmConnectionSlug' | 'model' | 'thinkingLevel' >; - readonly callKind: Exclude; + readonly callKind: ModelCallKind; readonly callId: string; readonly abortSignal: AbortSignal; readonly buildRequest: (target: ResolvedExecutionTarget) => AuxiliaryModelRequest; diff --git a/packages/runtime/package.json b/packages/runtime/package.json index d351243a70..86567c8f49 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -85,6 +85,7 @@ "./plugin-approval-service": "./dist/plugin-approval-service.js", "./plugin-fs-service": "./dist/plugin-fs-service.js", "./plugin-kernel": "./dist/plugin-kernel.js", + "./plugin-llm-service": "./dist/plugin-llm-service.js", "./plugin-runtime": "./dist/plugin-runtime.js", "./plugin-scope-registry": "./dist/plugin-scope-registry.js", "./plugin-system-prompt-service": "./dist/plugin-system-prompt-service.js", diff --git a/packages/runtime/src/__tests__/plugin-llm-service.test.ts b/packages/runtime/src/__tests__/plugin-llm-service.test.ts new file mode 100644 index 0000000000..78c5d9b163 --- /dev/null +++ b/packages/runtime/src/__tests__/plugin-llm-service.test.ts @@ -0,0 +1,53 @@ +/* + * 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 { PluginAgentService } from '../plugin-agent-service.js'; +import { Context } from '../plugin-kernel.js'; +import { PluginLlmService } from '../plugin-llm-service.js'; +import type { MakaToolContext } from '../tool-runtime.js'; + +test('llm generation uses Host authority unless a matching adapter overrides it', async () => { + const root = new Context(); + const agents = new PluginAgentService(root); + const llm = new PluginLlmService(root, agents); + llm.bindRuntime({ + generate: async (_input, invocation) => ({ text: invocation.sessionId, modelId: 'host' }), + }); + const plugin = root.extend({ rootId: 'profile', packageId: 'fixture', generation: 1 }); + plugin.llm.register({ + id: 'fixture.model', + supports: (model) => model === 'fixture/model', + generate: async () => ({ text: 'adapter', modelId: 'fixture/model' }), + }); + const context: MakaToolContext = { + sessionId: 'session-a', + turnId: 'turn-a', + cwd: '/workspace', + toolCallId: 'call-a', + abortSignal: new AbortController().signal, + emitOutput: () => undefined, + }; + await agents.withInvocation(context, async () => { + assert.equal((await llm.generate({ prompt: 'hello' })).text, 'session-a'); + assert.equal((await llm.generate({ prompt: 'hello', model: 'fixture/model' })).text, 'adapter'); + }); + await root.fiber.dispose(); +}); diff --git a/packages/runtime/src/plugin-agent-service.ts b/packages/runtime/src/plugin-agent-service.ts index 5348adb974..d5a6dc7fa1 100644 --- a/packages/runtime/src/plugin-agent-service.ts +++ b/packages/runtime/src/plugin-agent-service.ts @@ -126,8 +126,8 @@ export interface PluginAgent { /** Agent registry and invocation carrier exposed to trusted Host plugins. */ export class PluginAgentService extends Service { - readonly #invocations = new AsyncLocalStorage(); - #runtime: PluginAgentRuntime | undefined; + private readonly invocations = new AsyncLocalStorage(); + private agentRuntime: PluginAgentRuntime | undefined; constructor(ctx: Context) { super(ctx, 'agents'); @@ -138,18 +138,18 @@ export class PluginAgentService extends Service { bindRuntime(runtime: PluginAgentRuntime): Disposable> { if (this.ctx.maka) throw new Error('Only the Host may bind the Agent Runtime'); - if (this.#runtime) throw new Error('Plugin Agent Runtime is already bound'); - this.#runtime = runtime; + if (this.agentRuntime) throw new Error('Plugin Agent Runtime is already bound'); + this.agentRuntime = runtime; return this.ctx.effect( () => () => { - if (this.#runtime === runtime) this.#runtime = undefined; + if (this.agentRuntime === runtime) this.agentRuntime = undefined; }, 'agents.bindRuntime()', ); } currentInvocation(): PluginAgentInvocation | undefined { - return this.#invocations.getStore(); + return this.invocations.getStore(); } requireInvocation(): PluginAgentInvocation { @@ -172,7 +172,7 @@ export class PluginAgentService extends Service { abortSignal: toolContext.abortSignal, toolContext, }); - return this.#invocations.run(invocation, operation); + return this.invocations.run(invocation, operation); } current(): PluginAgent | undefined { @@ -216,8 +216,8 @@ export class PluginAgentService extends Service { } private runtime(): PluginAgentRuntime { - if (!this.#runtime) throw new Error('Plugin Agent Runtime is unavailable'); - return this.#runtime; + if (!this.agentRuntime) throw new Error('Plugin Agent Runtime is unavailable'); + return this.agentRuntime; } private handle(descriptor: PluginAgentDescriptor): PluginAgent { diff --git a/packages/runtime/src/plugin-attachment-service.ts b/packages/runtime/src/plugin-attachment-service.ts index 3f139f4bde..e1dbcf0991 100644 --- a/packages/runtime/src/plugin-attachment-service.ts +++ b/packages/runtime/src/plugin-attachment-service.ts @@ -45,7 +45,7 @@ export interface PluginAttachmentRuntime { /** Session-owned rich result publication and retrieval. */ export class PluginAttachmentService extends Service { - #runtime?: PluginAttachmentRuntime; + private attachmentRuntime?: PluginAttachmentRuntime; constructor( ctx: Context, @@ -56,11 +56,11 @@ export class PluginAttachmentService extends Service { bindRuntime(runtime: PluginAttachmentRuntime): Disposable> { if (this.ctx.maka) throw new Error('Only the Host may bind the Attachment Runtime'); - if (this.#runtime) throw new Error('Plugin Attachment Runtime is already bound'); - this.#runtime = runtime; + if (this.attachmentRuntime) throw new Error('Plugin Attachment Runtime is already bound'); + this.attachmentRuntime = runtime; return this.ctx.effect( () => () => { - if (this.#runtime === runtime) this.#runtime = undefined; + if (this.attachmentRuntime === runtime) this.attachmentRuntime = undefined; }, 'attachments.bindRuntime()', ); @@ -79,7 +79,7 @@ export class PluginAttachmentService extends Service { } private runtime(): PluginAttachmentRuntime { - if (!this.#runtime) throw new Error('Plugin Attachment Runtime is unavailable'); - return this.#runtime; + if (!this.attachmentRuntime) throw new Error('Plugin Attachment Runtime is unavailable'); + return this.attachmentRuntime; } } diff --git a/packages/runtime/src/plugin-fs-service.ts b/packages/runtime/src/plugin-fs-service.ts index 1e1788aadb..97ab578245 100644 --- a/packages/runtime/src/plugin-fs-service.ts +++ b/packages/runtime/src/plugin-fs-service.ts @@ -66,7 +66,7 @@ export interface PluginFilesystemRuntime { /** Full-fidelity filesystem entry point bound to Maka's canonical workspace authority. */ export class PluginFilesystemService extends Service { - #runtime?: PluginFilesystemRuntime; + private filesystemRuntime?: PluginFilesystemRuntime; constructor( ctx: Context, @@ -77,19 +77,19 @@ export class PluginFilesystemService extends Service { bindRuntime(runtime: PluginFilesystemRuntime): Disposable> { if (this.ctx.maka) throw new Error('Only the Host may bind the Filesystem Runtime'); - if (this.#runtime) throw new Error('Plugin Filesystem Runtime is already bound'); - this.#runtime = runtime; + if (this.filesystemRuntime) throw new Error('Plugin Filesystem Runtime is already bound'); + this.filesystemRuntime = runtime; return this.ctx.effect( () => () => { - if (this.#runtime === runtime) this.#runtime = undefined; + if (this.filesystemRuntime === runtime) this.filesystemRuntime = undefined; }, 'fs.bindRuntime()', ); } execute(operation: PluginFilesystemOperation): Promise { - if (!this.#runtime) throw new Error('Plugin Filesystem Runtime is unavailable'); - return this.#runtime.execute(operation, this.agents.requireInvocation()); + if (!this.filesystemRuntime) throw new Error('Plugin Filesystem Runtime is unavailable'); + return this.filesystemRuntime.execute(operation, this.agents.requireInvocation()); } read(path: string, options: { readonly offset?: number; readonly limit?: number } = {}) { diff --git a/packages/runtime/src/plugin-llm-service.ts b/packages/runtime/src/plugin-llm-service.ts new file mode 100644 index 0000000000..d3f68cb481 --- /dev/null +++ b/packages/runtime/src/plugin-llm-service.ts @@ -0,0 +1,123 @@ +/* + * 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 { Service, type Context, type Disposable } from './plugin-kernel.js'; +import type { PluginAgentInvocation, PluginAgentService } from './plugin-agent-service.js'; + +declare module './plugin-kernel.js' { + interface Context { + readonly llm: PluginLlmService; + } +} + +export interface PluginLlmGenerateInput { + readonly prompt: string; + readonly system?: string; + readonly maxOutputTokens?: number; + readonly signal?: AbortSignal; +} + +export interface PluginLlmGenerateResult { + readonly text: string; + readonly modelId: string; + readonly finishReason?: string; +} + +export interface PluginLlmRuntime { + generate( + input: PluginLlmGenerateInput, + invocation: PluginAgentInvocation, + ): Promise; +} + +export interface PluginLlmAdapter { + readonly id: string; + readonly priority?: number; + supports(model: string): boolean; + generate( + input: PluginLlmGenerateInput, + invocation: PluginAgentInvocation, + ): Promise; +} + +/** Metered Host model calls plus an ordered plugin adapter seam. */ +export class PluginLlmService extends Service { + private llmRuntime?: PluginLlmRuntime; + private readonly adapters: Array<{ adapter: PluginLlmAdapter; owner: Context }> = []; + + constructor( + ctx: Context, + private readonly agents: PluginAgentService, + ) { + super(ctx, 'llm'); + } + + bindRuntime(runtime: PluginLlmRuntime): Disposable> { + if (this.ctx.maka) throw new Error('Only the Host may bind the LLM Runtime'); + if (this.llmRuntime) throw new Error('Plugin LLM Runtime is already bound'); + this.llmRuntime = runtime; + return this.ctx.effect( + () => () => { + if (this.llmRuntime === runtime) this.llmRuntime = undefined; + }, + 'llm.bindRuntime()', + ); + } + + register(adapter: PluginLlmAdapter): Disposable> { + if (!adapter || !/^[A-Za-z][A-Za-z0-9._:-]{0,127}$/u.test(adapter.id)) { + throw new TypeError('LLM adapter id is invalid'); + } + if (typeof adapter.supports !== 'function' || typeof adapter.generate !== 'function') { + throw new TypeError(`LLM adapter implementation is invalid: ${adapter.id}`); + } + if ( + this.adapters.some( + (entry) => + entry.adapter.id === adapter.id && entry.owner.maka?.rootId === this.ctx.maka?.rootId, + ) + ) { + throw new Error(`LLM adapter is already registered in this scope: ${adapter.id}`); + } + const entry = { adapter, owner: this.ctx }; + this.adapters.push(entry); + return this.ctx.effect( + () => () => { + const index = this.adapters.indexOf(entry); + if (index >= 0) this.adapters.splice(index, 1); + }, + `llm.adapter:${adapter.id}`, + ); + } + + generate( + input: PluginLlmGenerateInput & { readonly model?: string }, + ): Promise { + const invocation = this.agents.requireInvocation(); + const adapter = input.model + ? [...this.adapters] + .filter((entry) => entry.adapter.supports(input.model!)) + .sort((left, right) => (right.adapter.priority ?? 0) - (left.adapter.priority ?? 0))[0] + ?.adapter + : undefined; + if (adapter) return adapter.generate(input, invocation); + if (!this.llmRuntime) throw new Error('Plugin LLM Runtime is unavailable'); + return this.llmRuntime.generate(input, invocation); + } +} diff --git a/packages/runtime/src/plugin-shell-service.ts b/packages/runtime/src/plugin-shell-service.ts index afd3a750d4..102a5115a7 100644 --- a/packages/runtime/src/plugin-shell-service.ts +++ b/packages/runtime/src/plugin-shell-service.ts @@ -42,7 +42,7 @@ export interface PluginShellRuntime { /** Streaming, cancellable foreground/background/PTY shell surface. */ export class PluginShellService extends Service { - #runtime?: PluginShellRuntime; + private shellRuntime?: PluginShellRuntime; constructor( ctx: Context, @@ -53,11 +53,11 @@ export class PluginShellService extends Service { bindRuntime(runtime: PluginShellRuntime): Disposable> { if (this.ctx.maka) throw new Error('Only the Host may bind the Shell Runtime'); - if (this.#runtime) throw new Error('Plugin Shell Runtime is already bound'); - this.#runtime = runtime; + if (this.shellRuntime) throw new Error('Plugin Shell Runtime is already bound'); + this.shellRuntime = runtime; return this.ctx.effect( () => () => { - if (this.#runtime === runtime) this.#runtime = undefined; + if (this.shellRuntime === runtime) this.shellRuntime = undefined; }, 'shell.bindRuntime()', ); @@ -89,7 +89,7 @@ export class PluginShellService extends Service { } private runtime(): PluginShellRuntime { - if (!this.#runtime) throw new Error('Plugin Shell Runtime is unavailable'); - return this.#runtime; + if (!this.shellRuntime) throw new Error('Plugin Shell Runtime is unavailable'); + return this.shellRuntime; } } diff --git a/packages/runtime/src/plugin-web-service.ts b/packages/runtime/src/plugin-web-service.ts index 99019bd2fa..ffcfd9204d 100644 --- a/packages/runtime/src/plugin-web-service.ts +++ b/packages/runtime/src/plugin-web-service.ts @@ -48,7 +48,7 @@ export interface PluginWebRuntime { /** Provider-policy-aware web search and fetch surface. */ export class PluginWebService extends Service { - #runtime?: PluginWebRuntime; + private webRuntime?: PluginWebRuntime; constructor( ctx: Context, @@ -59,11 +59,11 @@ export class PluginWebService extends Service { bindRuntime(runtime: PluginWebRuntime): Disposable> { if (this.ctx.maka) throw new Error('Only the Host may bind the Web Runtime'); - if (this.#runtime) throw new Error('Plugin Web Runtime is already bound'); - this.#runtime = runtime; + if (this.webRuntime) throw new Error('Plugin Web Runtime is already bound'); + this.webRuntime = runtime; return this.ctx.effect( () => () => { - if (this.#runtime === runtime) this.#runtime = undefined; + if (this.webRuntime === runtime) this.webRuntime = undefined; }, 'web.bindRuntime()', ); @@ -94,7 +94,7 @@ export class PluginWebService extends Service { } private runtime(): PluginWebRuntime { - if (!this.#runtime) throw new Error('Plugin Web Runtime is unavailable'); - return this.#runtime; + if (!this.webRuntime) throw new Error('Plugin Web Runtime is unavailable'); + return this.webRuntime; } } From 7624de2742908a68893eeda4e9a623c2615b1bc7 Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:09:22 +0800 Subject: [PATCH 08/13] feat(plugins): bind agent service to host runtime --- .../src/server/execution-composition.ts | 150 ++++++++++++++++++ packages/runtime/src/plugin-agent-service.ts | 2 + 2 files changed, 152 insertions(+) diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index d80c7a78f2..d031259e03 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1418,6 +1418,156 @@ export async function createExecutionRuntimeHostComposition( abortSignal: input.signal ?? invocation.abortSignal, }), }); + const visibleAgentSessions = async ( + initiator: import('@maka/runtime/plugin-agent-service').PluginAgentInvocation | undefined, + ) => { + const sessions = await manager!.listSessions(); + if (!initiator) return sessions; + const visible = new Set([initiator.sessionId]); + let changed = true; + while (changed) { + changed = false; + for (const session of sessions) { + if ( + !session.parentSessionId || + !visible.has(session.parentSessionId) || + visible.has(session.id) + ) + continue; + visible.add(session.id); + changed = true; + } + } + return sessions.filter((session) => visible.has(session.id)); + }; + const describeAgent = (session: Awaited>[number]) => ({ + id: session.id, + sessionId: session.id, + root: !session.parentSessionId, + status: session.runningTurnIds?.length ? 'running' : session.status, + ...(session.parentSessionId ? { ownerId: session.parentSessionId } : {}), + }); + const submitAgentMessage = async ( + id: string, + message: unknown, + placement: 'current_turn' | 'next_turn', + initiator: import('@maka/runtime/plugin-agent-service').PluginAgentInvocation | undefined, + ) => { + if (!(await visibleAgentSessions(initiator)).some((session) => session.id === id)) { + throw new Error('Agent is outside the current ownership tree'); + } + const content = normalizeMessageContent( + typeof message === 'string' ? { text: message } : (message as { text: string }), + ); + const result = await messages.handlers['turn.message.submit']( + { + originHostEpoch: context.hostEpoch, + sessionId: id, + messageId: randomUUID(), + content, + placement, + }, + { + hostEpoch: context.hostEpoch, + connectionId: 'plugin-agent', + principal: 'runtime_host', + acquireResidency: () => context.acquireResidency('plugin-agent'), + }, + ); + if (!result.ok) throw new Error(result.error.message); + return result.result; + }; + pluginAgents.bindRuntime({ + create: async (options, initiator) => { + const spawn = initiator?.toolContext?.spawnChildSession; + if (!spawn) throw new Error('Agent creation requires an active Tool invocation'); + if (!options.prompt?.trim()) throw new Error('Agent creation requires a prompt'); + return new Promise((resolve, reject) => { + void spawn({ + agentProfile: options.agentProfile ?? 'implementation', + prompt: options.prompt!, + ...(options.signal ? { abortSignal: options.signal } : {}), + onReady: (ready) => + resolve({ + id: ready.childSessionId, + sessionId: ready.childSessionId, + root: false, + status: 'running', + ownerId: initiator.sessionId, + }), + }).catch(reject); + }); + }, + resume: async (options, initiator) => { + if (options.prompt) + await submitAgentMessage(options.sessionId, options.prompt, 'next_turn', initiator); + const session = (await visibleAgentSessions(initiator)).find( + (item) => item.id === options.sessionId, + ); + if (!session) throw new Error('Agent was not found'); + return describeAgent(session); + }, + get: async (id, initiator) => { + const session = (await visibleAgentSessions(initiator)).find((item) => item.id === id); + return session ? describeAgent(session) : undefined; + }, + list: async (initiator) => (await visibleAgentSessions(initiator)).map(describeAgent), + roots: async (initiator) => + (await visibleAgentSessions(initiator)) + .filter((session) => !session.parentSessionId) + .map(describeAgent), + followup: (id, message, initiator) => submitAgentMessage(id, message, 'next_turn', initiator), + steer: (id, message, initiator) => submitAgentMessage(id, message, 'current_turn', initiator), + inject: (id, message, initiator) => + submitAgentMessage(id, message, 'current_turn', initiator), + cancel: async (id, initiator) => { + if (!(await visibleAgentSessions(initiator)).some((session) => session.id === id)) { + throw new Error('Agent is outside the current ownership tree'); + } + await coordinator.stopSession(id, { source: 'stop_button' }); + }, + whenIdle: async (id, signal) => { + const wait = coordinator.whenIdle(id); + if (!wait) return; + if (!signal) return wait; + await Promise.race([ + wait, + new Promise((_resolve, reject) => + signal.addEventListener('abort', () => reject(signal.reason), { once: true }), + ), + ]); + }, + snapshot: async (id, initiator) => { + const session = (await visibleAgentSessions(initiator)).find((item) => item.id === id); + if (!session) throw new Error('Agent was not found'); + return { agent: describeAgent(session), root: coordinator.readRootState(id) }; + }, + inbox: async (id, initiator) => { + if (!(await visibleAgentSessions(initiator)).some((session) => session.id === id)) + throw new Error('Agent was not found'); + return coordinator.readRootState(id); + }, + result: async (id, initiator) => { + if (!(await visibleAgentSessions(initiator)).some((session) => session.id === id)) + throw new Error('Agent was not found'); + return (await manager!.getMessages(id)).at(-1); + }, + artifacts: async (id, initiator) => { + if (!(await visibleAgentSessions(initiator)).some((session) => session.id === id)) + throw new Error('Agent was not found'); + return (await openedArtifactStore.listPage(id, { offset: 0, limit: 100 })).records; + }, + transcript: async (id, initiator) => { + if (!(await visibleAgentSessions(initiator)).some((session) => session.id === id)) + throw new Error('Agent was not found'); + return manager!.getMessages(id); + }, + dispose: async (id, initiator) => { + if (!(await visibleAgentSessions(initiator)).some((session) => session.id === id)) + throw new Error('Agent was not found'); + await coordinator.stopSession(id, { source: 'stop_button' }); + }, + }); const contextOperations = new HostContextCoordinator({ runtime: manager, executions: coordinator, diff --git a/packages/runtime/src/plugin-agent-service.ts b/packages/runtime/src/plugin-agent-service.ts index d5a6dc7fa1..7a254eb115 100644 --- a/packages/runtime/src/plugin-agent-service.ts +++ b/packages/runtime/src/plugin-agent-service.ts @@ -20,6 +20,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import type { PermissionMode } from '@maka/core/permission'; import type { ExecutionBoundary } from '@maka/core/sandbox-boundary'; +import type { AgentProfile } from './agent-catalog.js'; import { Service, type Context, type Disposable } from './plugin-kernel.js'; import type { MakaToolContext } from './tool-runtime.js'; @@ -54,6 +55,7 @@ export interface PluginAgentCreateOptions { readonly sessionId?: string; readonly cwd?: string; readonly prompt?: string; + readonly agentProfile?: AgentProfile; readonly model?: string; readonly permissionMode?: PermissionMode; readonly signal?: AbortSignal; From f5cb0722bdaf847d3eed6012cf42895fe9d7844e Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:12:08 +0800 Subject: [PATCH 09/13] fix(plugins): preserve scoped service bindings --- .../src/server/execution-composition.ts | 18 ++++++++++++------ .../src/__tests__/plugin-llm-service.test.ts | 4 +++- .../__tests__/plugin-resource-services.test.ts | 13 ++++++++----- packages/runtime/src/plugin-llm-service.ts | 17 +++++++++++++---- 4 files changed, 36 insertions(+), 16 deletions(-) diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index d031259e03..7e23ea72b1 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1530,12 +1530,18 @@ export async function createExecutionRuntimeHostComposition( const wait = coordinator.whenIdle(id); if (!wait) return; if (!signal) return wait; - await Promise.race([ - wait, - new Promise((_resolve, reject) => - signal.addEventListener('abort', () => reject(signal.reason), { once: true }), - ), - ]); + if (signal.aborted) throw signal.reason; + let rejectAbort: ((reason: unknown) => void) | undefined; + const aborted = new Promise((_resolve, reject) => { + rejectAbort = reject; + }); + const onAbort = () => rejectAbort?.(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + try { + await Promise.race([wait, aborted]); + } finally { + signal.removeEventListener('abort', onAbort); + } }, snapshot: async (id, initiator) => { const session = (await visibleAgentSessions(initiator)).find((item) => item.id === id); diff --git a/packages/runtime/src/__tests__/plugin-llm-service.test.ts b/packages/runtime/src/__tests__/plugin-llm-service.test.ts index 78c5d9b163..0cbf62c073 100644 --- a/packages/runtime/src/__tests__/plugin-llm-service.test.ts +++ b/packages/runtime/src/__tests__/plugin-llm-service.test.ts @@ -31,7 +31,9 @@ test('llm generation uses Host authority unless a matching adapter overrides it' llm.bindRuntime({ generate: async (_input, invocation) => ({ text: invocation.sessionId, modelId: 'host' }), }); - const plugin = root.extend({ rootId: 'profile', packageId: 'fixture', generation: 1 }); + const plugin = root.extend({ + maka: { rootId: 'profile', packageId: 'fixture', entryId: 'fixture', generation: 1 }, + }); plugin.llm.register({ id: 'fixture.model', supports: (model) => model === 'fixture/model', diff --git a/packages/runtime/src/__tests__/plugin-resource-services.test.ts b/packages/runtime/src/__tests__/plugin-resource-services.test.ts index 2ea5b298c5..8019cdf031 100644 --- a/packages/runtime/src/__tests__/plugin-resource-services.test.ts +++ b/packages/runtime/src/__tests__/plugin-resource-services.test.ts @@ -79,13 +79,16 @@ test('resource services preserve the current Session and cancellation context', abortSignal: new AbortController().signal, emitOutput: () => undefined, }; + const plugin = root.extend({ + maka: { rootId: 'profile', packageId: 'fixture', entryId: 'fixture', generation: 1 }, + }); await agents.withInvocation(context, async () => { - await fs.read('README.md'); - await shell.run({ command: 'pwd' }); - await web.search(' maka '); - assert.equal(await web.fetch('https://example.com'), 'body'); - await attachments.create({ name: 'a.txt', mimeType: 'text/plain', content: 'a' }); + await plugin.fs.read('README.md'); + await plugin.shell.run({ command: 'pwd' }); + await plugin.web.search(' maka '); + assert.equal(await plugin.web.fetch('https://example.com'), 'body'); + await plugin.attachments.create({ name: 'a.txt', mimeType: 'text/plain', content: 'a' }); }); assert.deepEqual(calls, [ diff --git a/packages/runtime/src/plugin-llm-service.ts b/packages/runtime/src/plugin-llm-service.ts index d3f68cb481..a00d7cc37e 100644 --- a/packages/runtime/src/plugin-llm-service.ts +++ b/packages/runtime/src/plugin-llm-service.ts @@ -110,11 +110,20 @@ export class PluginLlmService extends Service { input: PluginLlmGenerateInput & { readonly model?: string }, ): Promise { const invocation = this.agents.requireInvocation(); + const visibleAdapters = new Map(); + for (const entry of this.adapters) { + if (entry.owner.maka?.rootId === 'profile') + visibleAdapters.set(entry.adapter.id, entry.adapter); + } + for (const entry of this.adapters) { + if (entry.owner.maka?.rootId === `session:${invocation.sessionId}`) { + visibleAdapters.set(entry.adapter.id, entry.adapter); + } + } const adapter = input.model - ? [...this.adapters] - .filter((entry) => entry.adapter.supports(input.model!)) - .sort((left, right) => (right.adapter.priority ?? 0) - (left.adapter.priority ?? 0))[0] - ?.adapter + ? [...visibleAdapters.values()] + .filter((candidate) => candidate.supports(input.model!)) + .sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0))[0] : undefined; if (adapter) return adapter.generate(input, invocation); if (!this.llmRuntime) throw new Error('Plugin LLM Runtime is unavailable'); From 7473f461a8e919326f3f697b666c9ac1ce58b128 Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:32:30 +0800 Subject: [PATCH 10/13] test(plugins): cover scoped context services end to end --- .../src/__tests__/plugin-platform.test.ts | 591 ++++++++++++++++-- 1 file changed, 554 insertions(+), 37 deletions(-) diff --git a/packages/runtime-host/src/__tests__/plugin-platform.test.ts b/packages/runtime-host/src/__tests__/plugin-platform.test.ts index df5aead6fc..442017925a 100644 --- a/packages/runtime-host/src/__tests__/plugin-platform.test.ts +++ b/packages/runtime-host/src/__tests__/plugin-platform.test.ts @@ -23,10 +23,19 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; import { waitFor } from '@maka/core/test-only/async-primitives'; +import { PluginAgentService } from '@maka/runtime/plugin-agent-service'; +import { PluginAttachmentService } from '@maka/runtime/plugin-attachment-service'; +import { PluginApprovalService } from '@maka/runtime/plugin-approval-service'; import { MakaCompositionLoader } from '@maka/runtime/plugin-composition-loader'; +import { PluginFilesystemService } from '@maka/runtime/plugin-fs-service'; import { Context } from '@maka/runtime/plugin-kernel'; +import { PluginLlmService } from '@maka/runtime/plugin-llm-service'; +import { PluginShellService } from '@maka/runtime/plugin-shell-service'; import { PluginSystemPromptService } from '@maka/runtime/plugin-system-prompt-service'; import { PluginToolService } from '@maka/runtime/plugin-tool-service'; +import { PluginUserQuestionService } from '@maka/runtime/plugin-user-question-service'; +import { PluginWebService } from '@maka/runtime/plugin-web-service'; +import type { MakaToolContext } from '@maka/runtime/tool-runtime'; import { decodePluginCompositionApplyInput, decodeRequestFrame, @@ -140,7 +149,10 @@ test('a real package publishes an executable Tool and removes it on uninstall', 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 } }, + tool: { + name: 'lookup_inventory', + result: { sku: 'SKU-42', available: 7 }, + }, composition: [ { type: 'insert', @@ -149,7 +161,10 @@ test('a real package publishes an executable Tool and removes it on uninstall', }, ], }); - const platform = createPlatform(join(root, 'control'), { composition, tools }); + const platform = createPlatform(join(root, 'control'), { + composition, + tools, + }); await platform.recover(); const installed = await platform.installPackage(source); @@ -173,6 +188,270 @@ test('a real package publishes an executable Tool and removes it on uninstall', } }); +test('a real package reaches every scoped ctx service through one Agent Tool invocation', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-context-services-e2e-')); + const pluginRoot = new Context(); + const calls: string[] = []; + try { + const agents = new PluginAgentService(pluginRoot); + const attachments = new PluginAttachmentService(pluginRoot, agents); + new PluginApprovalService(pluginRoot, agents); + new PluginUserQuestionService(pluginRoot, agents); + const filesystem = new PluginFilesystemService(pluginRoot, agents); + const llm = new PluginLlmService(pluginRoot, agents); + const shell = new PluginShellService(pluginRoot, agents); + const web = new PluginWebService(pluginRoot, agents); + const tools = new PluginToolService(pluginRoot, { agents }); + const systemPrompt = new PluginSystemPromptService(pluginRoot); + const composition = new MakaCompositionLoader({ root: pluginRoot }); + + const descriptor = (id: string, rootAgent = false) => ({ + id, + sessionId: id, + root: rootAgent, + status: 'idle', + ...(rootAgent ? {} : { ownerId: 'session-e2e' }), + }); + agents.bindRuntime({ + create: async (_options, invocation) => { + calls.push(`agents.create:${invocation?.sessionId}`); + return descriptor('child-e2e'); + }, + resume: async (options, invocation) => { + calls.push(`agents.resume:${options.sessionId}:${invocation?.turnId}`); + return descriptor(options.sessionId); + }, + get: async (id, invocation) => { + calls.push(`agents.get:${id}:${invocation?.sessionId}`); + return descriptor(id, id === 'session-e2e'); + }, + list: async (invocation) => { + calls.push(`agents.list:${invocation?.sessionId}`); + return [descriptor('session-e2e', true)]; + }, + roots: async (invocation) => { + calls.push(`agents.roots:${invocation?.sessionId}`); + return [descriptor('session-e2e', true)]; + }, + followup: async (id, _message, invocation) => { + calls.push(`agent.followup:${id}:${invocation?.sessionId}`); + return { accepted: true }; + }, + steer: async (id, _message, invocation) => { + calls.push(`agent.steer:${id}:${invocation?.sessionId}`); + return { accepted: true }; + }, + inject: async (id, _message, invocation) => { + calls.push(`agent.inject:${id}:${invocation?.sessionId}`); + return { accepted: true }; + }, + cancel: async (id, invocation) => { + calls.push(`agent.cancel:${id}:${invocation?.sessionId}`); + }, + whenIdle: async (id, signal) => { + calls.push(`agent.whenIdle:${id}:${signal?.aborted ?? false}`); + }, + snapshot: async (id, invocation) => { + calls.push(`agent.snapshot:${id}:${invocation?.turnId}`); + return { id, status: 'idle' }; + }, + inbox: async (id, invocation) => { + calls.push(`agent.inbox:${id}:${invocation?.sessionId}`); + return []; + }, + result: async (id, invocation) => { + calls.push(`agent.result:${id}:${invocation?.sessionId}`); + return { id, text: 'done' }; + }, + artifacts: async (id, invocation) => { + calls.push(`agent.artifacts:${id}:${invocation?.sessionId}`); + return []; + }, + transcript: async (id, invocation) => { + calls.push(`agent.transcript:${id}:${invocation?.sessionId}`); + return []; + }, + dispose: async (id, invocation) => { + calls.push(`agent.dispose:${id}:${invocation?.sessionId}`); + }, + }); + filesystem.bindRuntime({ + execute: async (operation, invocation) => { + calls.push(`fs.${operation.kind}:${invocation.sessionId}`); + return operation; + }, + }); + shell.bindRuntime({ + run: async (_options, invocation) => { + calls.push(`shell.run:${invocation.turnId}`); + return { ref: 'pty-e2e' }; + }, + read: async (ref, invocation) => { + calls.push(`shell.read:${ref}:${invocation.sessionId}`); + return { output: 'ready' }; + }, + write: async (ref, input, invocation) => { + calls.push(`shell.write:${ref}:${input}:${invocation.sessionId}`); + return { written: true }; + }, + stop: async (ref, invocation) => { + calls.push(`shell.stop:${ref}:${invocation.sessionId}`); + return { stopped: true }; + }, + }); + web.bindRuntime({ + search: async (input) => { + calls.push(`web.search:${input.query}:${input.sessionId}`); + return { ok: true, provider: 'tavily', results: [] }; + }, + fetch: async (input) => { + calls.push(`web.fetch:${input.url}:${input.sessionId}`); + return 'fixture body'; + }, + }); + const attachment = { + kind: 'other' as const, + name: 'probe.txt', + mimeType: 'text/plain', + bytes: 3, + ref: { + kind: 'session_file' as const, + sessionId: 'session-e2e', + relativePath: 'probe.txt', + }, + }; + attachments.bindRuntime({ + create: async (input, invocation) => { + calls.push(`attachments.create:${input.name}:${invocation.turnId}`); + return attachment; + }, + read: async (_ref, invocation) => { + calls.push(`attachments.read:${invocation.sessionId}`); + return new Uint8Array([65, 66, 67]); + }, + list: async (invocation) => { + calls.push(`attachments.list:${invocation.sessionId}`); + return [attachment]; + }, + }); + llm.bindRuntime({ + generate: async (input, invocation) => { + calls.push(`llm.generate:${input.prompt}:${invocation.sessionId}`); + return { text: 'nested answer', modelId: 'host-e2e' }; + }, + }); + + const source = await writeContextServicesFixturePackage(root); + const platform = createPlatform(join(root, 'control'), { + composition, + tools, + systemPrompt, + }); + await platform.recover(); + assert.equal((await platform.installPackage(source)).convergence, 'converged'); + + const prompt = await systemPrompt.assemble( + { sessionId: 'session-e2e', turnId: 'turn-e2e', cwd: root }, + 'base', + ); + assert.deepEqual(prompt.contexts, [ + { + name: 'plugin:e2e-context', + text: 'context:session-e2e:turn-e2e', + }, + ]); + + const context: MakaToolContext = { + sessionId: 'session-e2e', + runId: 'run-e2e', + turnId: 'turn-e2e', + cwd: root, + toolCallId: 'tool-call-e2e', + abortSignal: new AbortController().signal, + emitOutput: () => undefined, + askUserQuestion: async (questions) => { + calls.push(`userQuestions.ask:${questions[0]?.question}`); + return { + answers: [{ question: questions[0]?.question ?? '', answer: 'yes' }], + }; + }, + requestUserForm: async (form) => { + calls.push(`userQuestions.requestForm:${form.message}`); + return { action: 'accept', values: { choice: 'yes' } }; + }, + requestSandboxBoundary: async (expansion, justification) => { + calls.push(`approval.request:${justification}`); + return { + request: { + sessionId: 'session-e2e', + requestId: 'approval-e2e', + status: 'approved', + baseRevision: 0, + expansion, + justification, + createdAt: 1, + settledAt: 2, + }, + boundary: { kind: 'bypass', revision: 1 }, + changed: true, + }; + }, + }; + const tool = tools.resolve('session-e2e', []).tools.find(({ name }) => name === 'ctx_e2e'); + assert.ok(tool); + const result = await tool.impl({}, context); + assert.deepEqual(result, { + currentAgent: 'session-e2e', + childAgent: 'child-e2e', + attachmentBytes: [65, 66, 67], + attachmentCount: 1, + llmText: 'nested answer', + }); + + assert.deepEqual(calls, [ + 'agents.list:session-e2e', + 'agents.roots:session-e2e', + 'agents.get:session-e2e:session-e2e', + 'agents.create:session-e2e', + 'agents.resume:child-e2e:turn-e2e', + 'agent.followup:child-e2e:session-e2e', + 'agent.steer:child-e2e:session-e2e', + 'agent.inject:child-e2e:session-e2e', + 'agent.whenIdle:child-e2e:false', + 'agent.snapshot:child-e2e:turn-e2e', + 'agent.inbox:child-e2e:session-e2e', + 'agent.result:child-e2e:session-e2e', + 'agent.artifacts:child-e2e:session-e2e', + 'agent.transcript:child-e2e:session-e2e', + 'agent.cancel:child-e2e:session-e2e', + 'agent.dispose:child-e2e:session-e2e', + 'fs.read:session-e2e', + 'fs.write:session-e2e', + 'fs.edit:session-e2e', + 'fs.glob:session-e2e', + 'fs.grep:session-e2e', + 'fs.apply_patch:session-e2e', + 'shell.run:turn-e2e', + 'shell.read:pty-e2e:session-e2e', + 'shell.write:pty-e2e:ping:session-e2e', + 'shell.stop:pty-e2e:session-e2e', + 'web.search:maka:session-e2e', + 'web.fetch:https://example.test/resource:session-e2e', + 'attachments.create:probe.txt:turn-e2e', + 'attachments.read:session-e2e', + 'attachments.list:session-e2e', + 'userQuestions.ask:Continue?', + 'userQuestions.requestForm:Choose', + 'approval.request:write output', + 'llm.generate:nested prompt:session-e2e', + ]); + await platform.close(); + } finally { + await pluginRoot.fiber.dispose(); + 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 { @@ -374,7 +653,9 @@ test('Plugin Platform cursors reject a changed query snapshot', async () => { if (!first.ok || first.result.view !== 'entries' || !first.result.nextCursor) { throw new Error('Expected a paged Entry snapshot'); } - await platform.apply({ operations: [{ type: 'insert', entry: { id: 'cursor-three' } }] }); + await platform.apply({ + operations: [{ type: 'insert', entry: { id: 'cursor-three' } }], + }); const stale = await coordinator.handlers['plugin.platform.query']( { view: 'entries', limit: 1, cursor: first.result.nextCursor }, null as never, @@ -461,7 +742,10 @@ test('Package replacement releases a single-provider Service before activating i await writeFixturePackage(root, 'service-package', 'first', { provideService: 'replacementService', composition: [ - { type: 'insert', entry: { id: 'service-entry', packageId: 'service-package' } }, + { + type: 'insert', + entry: { id: 'service-entry', packageId: 'service-package' }, + }, ], }), ); @@ -469,7 +753,10 @@ test('Package replacement releases a single-provider Service before activating i directorySuffix: 'replacement', provideService: 'replacementService', composition: [ - { type: 'insert', entry: { id: 'service-entry', packageId: 'service-package' } }, + { + type: 'insert', + entry: { id: 'service-entry', packageId: 'service-package' }, + }, ], }); const receipt = await platform.installPackage(replacement); @@ -490,13 +777,23 @@ test('Package lifecycle publishes and retires scoped System Prompt contributions const pluginRoot = new Context(); const systemPrompt = new PluginSystemPromptService(pluginRoot); const composition = new MakaCompositionLoader({ root: pluginRoot }); - const platform = createPlatform(join(root, 'control'), { composition, systemPrompt }); + const platform = createPlatform(join(root, 'control'), { + composition, + systemPrompt, + }); await platform.recover(); await platform.installPackage( await writeFixturePackage(root, 'prompt-package', 'prompt', { - systemPrompt: { name: 'plugin:fixture', order: 10, text: 'fixture prompt' }, + systemPrompt: { + name: 'plugin:fixture', + order: 10, + text: 'fixture prompt', + }, composition: [ - { type: 'insert', entry: { id: 'prompt-entry', packageId: 'prompt-package' } }, + { + type: 'insert', + entry: { id: 'prompt-entry', packageId: 'prompt-package' }, + }, ], }), ); @@ -556,14 +853,22 @@ test('package Composition layers override in install order and unwind on uninsta const overrideSource = await writeFixturePackage(root, 'layer-override', 'override', { structuralDependencies: ['layer-base'], composition: [ - { type: 'update', entryId: 'layer-entry', patch: { config: { theme: 'override' } } }, + { + type: 'update', + entryId: 'layer-entry', + patch: { config: { theme: 'override' } }, + }, ], }); await platform.installPackage(overrideSource); const tailSource = await writeFixturePackage(root, 'layer-tail', 'tail', { structuralDependencies: ['layer-base'], composition: [ - { type: 'update', entryId: 'layer-entry', patch: { config: { theme: 'tail' } } }, + { + type: 'update', + entryId: 'layer-entry', + patch: { config: { theme: 'tail' } }, + }, ], }); await platform.installPackage(tailSource); @@ -572,19 +877,31 @@ test('package Composition layers override in install order and unwind on uninsta theme: 'tail', }); await platform.installPackage(overrideSource); - assert.deepEqual(platform.desiredComposition().roots.profile[0]?.config, { theme: 'tail' }); + assert.deepEqual(platform.desiredComposition().roots.profile[0]?.config, { + theme: 'tail', + }); await platform.uninstallPackage('layer-tail'); await platform.uninstallPackage('layer-override'); - assert.deepEqual(platform.desiredComposition().roots.profile[0]?.config, { theme: 'base' }); + assert.deepEqual(platform.desiredComposition().roots.profile[0]?.config, { + theme: 'base', + }); await platform.installPackage(overrideSource); await platform.apply({ operations: [ - { type: 'update', entryId: 'layer-entry', patch: { config: { theme: 'user' } } }, + { + type: 'update', + entryId: 'layer-entry', + patch: { config: { theme: 'user' } }, + }, ], }); - assert.deepEqual(platform.desiredComposition().roots.profile[0]?.config, { theme: 'user' }); + assert.deepEqual(platform.desiredComposition().roots.profile[0]?.config, { + theme: 'user', + }); await platform.uninstallPackage('layer-override'); - assert.deepEqual(platform.desiredComposition().roots.profile[0]?.config, { theme: 'user' }); + assert.deepEqual(platform.desiredComposition().roots.profile[0]?.config, { + theme: 'user', + }); assert.deepEqual((await internals(platform).store.read())?.packageLayers, ['layer-base']); await platform.close(); } finally { @@ -608,7 +925,10 @@ test('invalid package Composition patch is rejected before package publication', composition: [ { type: 'insert', - entry: { id: 'missing-package-entry', packageId: 'missing-package' }, + entry: { + id: 'missing-package-entry', + packageId: 'missing-package', + }, }, ], }); @@ -969,7 +1289,13 @@ test('failed desired-state persistence leaves Runtime composition unchanged', as () => platform.apply({ baseGeneration: before.generation, - operations: [{ type: 'update', entryId: 'persistent-entry', patch: { disabled: true } }], + operations: [ + { + type: 'update', + entryId: 'persistent-entry', + patch: { disabled: true }, + }, + ], }), /Runtime state was not changed/u, ); @@ -994,7 +1320,12 @@ test('recovery loads installed packages that do not yet have an Entry', async () const recovered = createPlatform(control); await recovered.recover(); await recovered.apply({ - operations: [{ type: 'insert', entry: { id: 'later-entry', packageId: 'unused-package' } }], + operations: [ + { + type: 'insert', + entry: { id: 'later-entry', packageId: 'unused-package' }, + }, + ], }); assert.equal(recovered.inspect('profile')[0]?.status, 'active'); await recovered.close(); @@ -1012,18 +1343,28 @@ test('immutable package generation is owned by package lifetime across repeated await platform.installPackage(await writeFixturePackage(root, 'shared-package', 'shared')); await platform.apply({ operations: [ - { type: 'insert', entry: { id: 'shared-one', packageId: 'shared-package' } }, - { type: 'insert', entry: { id: 'shared-two', packageId: 'shared-package' } }, + { + type: 'insert', + entry: { id: 'shared-one', packageId: 'shared-package' }, + }, + { + type: 'insert', + entry: { id: 'shared-two', packageId: 'shared-package' }, + }, ], }); const generations = join(control, 'plugin-generations-v1'); assert.equal((await readdir(generations)).length, 1); - await platform.apply({ operations: [{ type: 'remove', entryId: 'shared-one' }] }); + await platform.apply({ + operations: [{ type: 'remove', entryId: 'shared-one' }], + }); assert.equal((await readdir(generations)).length, 1); assert.equal(internals(platform).composition.inspect('shared-two').status, 'active'); - await platform.apply({ operations: [{ type: 'remove', entryId: 'shared-two' }] }); + await platform.apply({ + operations: [{ type: 'remove', entryId: 'shared-two' }], + }); await platform.uninstallPackage('shared-package'); assert.deepEqual(await readdir(generations).catch(() => []), []); await platform.close(); @@ -1042,12 +1383,18 @@ test('unknown desired-state commit outcome fences mutation without inventing a r store.fail = true; await assert.rejects( - () => platform.apply({ operations: [{ type: 'insert', entry: { id: 'uncertain-entry' } }] }), + () => + platform.apply({ + operations: [{ type: 'insert', entry: { id: 'uncertain-entry' } }], + }), /commit outcome is unknown/u, ); assert.deepEqual(internals(platform).composition.compositionState().roots.profile, []); await assert.rejects( - () => platform.apply({ operations: [{ type: 'remove', entryId: 'uncertain-entry' }] }), + () => + platform.apply({ + operations: [{ type: 'remove', entryId: 'uncertain-entry' }], + }), /fenced/u, ); await platform.close(); @@ -1100,7 +1447,12 @@ test('failed uninstall keeps Package layers and desired state unchanged', async }), ); await platform.apply({ - operations: [{ type: 'insert', entry: { id: 'user-entry', packageId: 'uninstall-plan' } }], + operations: [ + { + type: 'insert', + entry: { id: 'user-entry', packageId: 'uninstall-plan' }, + }, + ], }); const authority = await internals(platform).store.read(); const desired = platform.desiredComposition(); @@ -1157,12 +1509,17 @@ test('composition authority commits before Runtime convergence and exposes diver const coordinator = new HostPluginPlatformCoordinator(platform); await platform.recover(); await platform.installPackage( - await writeFixturePackage(root, 'failing-package', 'failing', { throwOnApply: true }), + await writeFixturePackage(root, 'failing-package', 'failing', { + throwOnApply: true, + }), ); const receipt = await platform.apply({ operations: [ - { type: 'insert', entry: { id: 'desired-failure', packageId: 'failing-package' } }, + { + type: 'insert', + entry: { id: 'desired-failure', packageId: 'failing-package' }, + }, ], }); assert.equal(receipt.durability, 'committed'); @@ -1198,11 +1555,19 @@ test('recovery is fail-open for Host and isolates a broken desired Entry', async overlays: [ { type: 'insert', - entry: { id: 'healthy-entry', packageId: 'healthy-package', config: {} }, + entry: { + id: 'healthy-entry', + packageId: 'healthy-package', + config: {}, + }, }, { type: 'insert', - entry: { id: 'broken-entry', packageId: 'missing-package', config: {} }, + entry: { + id: 'broken-entry', + packageId: 'missing-package', + config: {}, + }, }, ], }); @@ -1299,7 +1664,13 @@ test('Manifest configuration is enforced before desired state is committed', asy () => platform.apply({ operations: [ - { type: 'insert', entry: { id: 'configured-entry', packageId: 'configured-package' } }, + { + type: 'insert', + entry: { + id: 'configured-entry', + packageId: 'configured-package', + }, + }, ], }), (error: unknown) => @@ -1332,10 +1703,15 @@ test('Manifest configuration defaults are committed to desired and live Entries' await platform.apply({ operations: [ - { type: 'insert', entry: { id: 'defaulted-entry', packageId: 'defaulted-package' } }, + { + type: 'insert', + entry: { id: 'defaulted-entry', packageId: 'defaulted-package' }, + }, ], }); - assert.deepEqual(platform.desiredComposition().roots.profile[0]?.config, { enabled: true }); + assert.deepEqual(platform.desiredComposition().roots.profile[0]?.config, { + enabled: true, + }); assert.deepEqual(internals(platform).composition.compositionState().roots.profile[0]?.config, { enabled: true, }); @@ -1417,7 +1793,10 @@ test('Manifest dependencies gate activation and protect required packages', asyn () => platform.apply({ operations: [ - { type: 'insert', entry: { id: 'dependent-entry', packageId: 'dependent-package' } }, + { + type: 'insert', + entry: { id: 'dependent-entry', packageId: 'dependent-package' }, + }, ], }), /Plugin composition mutation failed/u, @@ -1427,8 +1806,14 @@ test('Manifest dependencies gate activation and protect required packages', asyn await platform.installPackage(await writeFixturePackage(root, 'required-package', 'required')); await platform.apply({ operations: [ - { type: 'insert', entry: { id: 'required-entry', packageId: 'required-package' } }, - { type: 'insert', entry: { id: 'dependent-entry', packageId: 'dependent-package' } }, + { + type: 'insert', + entry: { id: 'required-entry', packageId: 'required-package' }, + }, + { + type: 'insert', + entry: { id: 'dependent-entry', packageId: 'dependent-package' }, + }, ], }); await assert.rejects( @@ -1528,7 +1913,11 @@ test('package storage retains a Package committed by the authority generation', test('package storage discards journal-less transaction remnants', async () => { const cases = [ { name: 'abandoned preparation', target: 'old', candidate: 'new' }, - { name: 'partially removed committed transaction', target: 'new', previous: 'old' }, + { + name: 'partially removed committed transaction', + target: 'new', + previous: 'old', + }, ] as const; for (const state of cases) { const root = await mkdtemp(join(tmpdir(), 'maka-plugin-package-journal-less-')); @@ -1567,7 +1956,11 @@ test('Plugin Platform close aggregates every resource failure', async () => { const packages = new PluginPackageStore(control); const composition = new FailingCloseCompositionLoader(); const packageLoader = new FailingClosePackageLoader(control, packages); - const platform = createPlatform(control, { composition, packages, packageLoader }); + const platform = createPlatform(control, { + composition, + packages, + packageLoader, + }); await platform.recover(); await assert.rejects( () => platform.close(), @@ -1691,6 +2084,130 @@ async function writeFixturePackage( return source; } +async function writeContextServicesFixturePackage(root: string): Promise { + const source = join(root, 'source-context-services-package'); + await mkdir(source, { recursive: true }); + await writeFile( + join(source, 'maka.extension.json'), + JSON.stringify({ + schemaVersion: 1, + id: 'context-services-package', + runtime: { entry: 'index.mjs' }, + composition: { + patch: 'maka.composition.yml', + structuralDependencies: [], + }, + }), + ); + await writeFile( + join(source, 'maka.composition.yml'), + JSON.stringify([ + { + type: 'insert', + rootId: 'profile', + entry: { + id: 'context-services-entry', + packageId: 'context-services-package', + }, + }, + ]), + ); + await writeFile( + join(source, 'index.mjs'), + `export default Object.freeze({ + packageId: 'context-services-package', + host: Object.freeze({ apply(ctx) { + ctx.systemPrompt.context(Object.freeze({ + name: 'plugin:e2e-context', + order: 7, + text: ({ sessionId, turnId }) => \`context:\${sessionId}:\${turnId}\`, + })); + ctx.tools.register(Object.freeze({ + name: 'ctx_e2e', + description: 'Exercise every public scoped context service', + parameters: {}, + impl: async () => { + const current = ctx.agent; + if (!current) throw new Error('ctx.agent is missing'); + await ctx.agents.list(); + await ctx.agents.roots(); + await ctx.agents.get(current.id); + const child = await ctx.agents.create({ prompt: 'child task' }); + await ctx.agents.resume({ sessionId: child.sessionId, prompt: 'resume task' }); + await child.followup('followup'); + await child.steer('steer'); + await child.inject('inject'); + await child.whenIdle(); + await child.snapshot(); + await child.inbox(); + await child.result(); + await child.artifacts(); + await child.transcript(); + await child.cancel(); + await child.dispose(); + + await ctx.fs.read('input.txt'); + await ctx.fs.write('output.txt', 'first'); + await ctx.fs.edit('output.txt', 'first', 'second'); + await ctx.fs.glob('*.txt'); + await ctx.fs.grep('second', { glob: '*.txt' }); + await ctx.fs.applyPatch('*** Begin Patch\\n*** End Patch'); + + const launched = await ctx.shell.run({ + command: 'fixture', + background: true, + pty: true, + }); + const ref = launched.ref; + await ctx.shell.read(ref); + await ctx.shell.write(ref, 'ping'); + await ctx.shell.stop(ref); + + await ctx.web.search(' maka ', { limit: 3 }); + await ctx.web.fetch('https://example.test/resource'); + + const attachment = await ctx.attachments.create({ + name: 'probe.txt', + mimeType: 'text/plain', + content: 'ABC', + }); + const attachmentBytes = [...await ctx.attachments.read(attachment)]; + const attachmentCount = (await ctx.attachments.list()).length; + + await ctx.userQuestions.ask([ + { question: 'Continue?', options: [{ label: 'yes' }, { label: 'no' }] }, + ]); + await ctx.userQuestions.requestForm({ + message: 'Choose', + requester: { name: 'fixture' }, + fields: [{ + kind: 'single_select', + name: 'choice', + label: 'Choice', + required: true, + options: [{ value: 'yes', label: 'Yes' }], + }], + }); + await ctx.approval.request({ + expansion: { kind: 'workspace_write', paths: ['.'] }, + justification: 'write output', + }); + const generated = await ctx.llm.generate({ prompt: 'nested prompt' }); + return { + currentAgent: current.id, + childAgent: child.id, + attachmentBytes, + attachmentCount, + llmText: generated.text, + }; + }, + })); + } }), + });\n`, + ); + return source; +} + class FailingCompositionStore extends HostPluginCompositionStore { fail = false; From 7c80ac053b56f6e70513164da77f57cd3b886122 Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:54:32 +0800 Subject: [PATCH 11/13] feat(plugins): add scoped P1 services --- .../interactive-run-composer.test.ts | 34 ++ .../src/__tests__/plugin-data-runtime.test.ts | 89 ++++ .../src/__tests__/plugin-platform.test.ts | 216 +++++++- packages/runtime-host/src/protocol/index.ts | 4 +- .../src/protocol/plugin-platform.ts | 50 +- .../src/server/execution-composition.ts | 131 ++++- .../src/server/interactive-run-composer.ts | 48 +- .../src/server/plugin-data-runtime.ts | 303 +++++++++++ .../src/server/plugin-platform-coordinator.ts | 16 +- .../src/server/plugin-platform.ts | 9 + packages/runtime/package.json | 7 + .../src/__tests__/plugin-p1-services.test.ts | 97 ++++ packages/runtime/src/builtin-tools.ts | 27 +- .../runtime/src/plugin-command-service.ts | 164 ++++++ packages/runtime/src/plugin-data-services.ts | 469 ++++++++++++++++++ packages/runtime/src/plugin-goal-service.ts | 91 ++++ packages/runtime/src/plugin-lsp-service.ts | 154 ++++++ .../src/plugin-session-query-service.ts | 135 +++++ .../runtime/src/plugin-shell-env-service.ts | 144 ++++++ packages/runtime/src/plugin-shell-service.ts | 14 +- packages/runtime/src/plugin-skill-service.ts | 154 ++++++ 21 files changed, 2333 insertions(+), 23 deletions(-) create mode 100644 packages/runtime-host/src/__tests__/plugin-data-runtime.test.ts create mode 100644 packages/runtime-host/src/server/plugin-data-runtime.ts create mode 100644 packages/runtime/src/__tests__/plugin-p1-services.test.ts create mode 100644 packages/runtime/src/plugin-command-service.ts create mode 100644 packages/runtime/src/plugin-data-services.ts create mode 100644 packages/runtime/src/plugin-goal-service.ts create mode 100644 packages/runtime/src/plugin-lsp-service.ts create mode 100644 packages/runtime/src/plugin-session-query-service.ts create mode 100644 packages/runtime/src/plugin-shell-env-service.ts create mode 100644 packages/runtime/src/plugin-skill-service.ts 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 8a7fc6383a..2a14f50fbe 100644 --- a/packages/runtime-host/src/__tests__/interactive-run-composer.test.ts +++ b/packages/runtime-host/src/__tests__/interactive-run-composer.test.ts @@ -154,6 +154,40 @@ test('the composer caches the Host base but reassembles scoped Plugin prompts ea ); }); +test('scoped Plugin Skill contributions join the canonical model inventory', async () => { + const composer = createFixtureComposer({ + skills: { + readCanonicalModelInventory: async ({ projectRoot }: { projectRoot: string }) => ({ + revision: 'base-revision', + projectRoot, + inventory: [], + diagnostics: [], + discoveryDiagnostics: [], + }), + } as unknown as HostSkillCatalogCoordinator, + pluginSkills: { + snapshot: (sessionId: string) => ({ + revision: 4, + skills: [ + { + name: 'plugin-probe', + description: `Scoped skill for ${sessionId}`, + instructions: 'PLUGIN_SKILL_INSTRUCTIONS', + }, + ], + }), + } as never, + }); + + const prompt = await composer.resolveSystemPrompt({ + sessionId: 'session-skill', + turnId: 'turn-skill', + cwd: '/workspace', + }); + assert.match(prompt.text ?? '', /plugin-probe/u); + assert.match(prompt.text ?? '', /Scoped skill for session-skill/u); +}); + function tool(name: string): MakaTool { return { name, diff --git a/packages/runtime-host/src/__tests__/plugin-data-runtime.test.ts b/packages/runtime-host/src/__tests__/plugin-data-runtime.test.ts new file mode 100644 index 0000000000..b7ed14ec45 --- /dev/null +++ b/packages/runtime-host/src/__tests__/plugin-data-runtime.test.ts @@ -0,0 +1,89 @@ +/* + * 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 { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { HostPluginDataRuntime } from '../server/plugin-data-runtime.js'; + +const namespace = Object.freeze({ extensionId: 'fixture.extension', scopeId: 'session:test' }); + +test('Plugin data persists CAS mutations and seals credentials at rest', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-data-')); + try { + const runtime = new HostPluginDataRuntime(root); + assert.deepEqual(await runtime.read(namespace, 'settings', 'mode'), { + revision: 0, + value: undefined, + }); + assert.deepEqual( + await runtime.mutate(namespace, 'settings', [ + { key: 'mode', value: 'strict', expectedRevision: 0 }, + ]), + { + mode: { revision: 1, value: 'strict' }, + }, + ); + await assert.rejects( + runtime.mutate(namespace, 'settings', [{ key: 'mode', value: 'loose', expectedRevision: 0 }]), + /revision conflict/u, + ); + await runtime.mutate(namespace, 'storage', [ + { key: 'state/count', value: 1 }, + { key: 'state/name', value: 'fixture' }, + ]); + await runtime.commitCredential(namespace, 'token', 'never-plaintext', { provider: 'fixture' }); + + const restarted = new HostPluginDataRuntime(root); + assert.deepEqual(await restarted.read(namespace, 'settings', 'mode'), { + revision: 1, + value: 'strict', + }); + assert.deepEqual(Object.keys(await restarted.list(namespace, 'storage', 'state/')), [ + 'state/count', + 'state/name', + ]); + assert.equal( + await restarted.useCredential(namespace, 'token', (secret) => secret), + 'never-plaintext', + ); + + const files = await findJson(root); + const disk = (await Promise.all(files.map((path) => readFile(path, 'utf8')))).join('\n'); + assert.equal(disk.includes('never-plaintext'), false); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +async function findJson(root: string): Promise { + const { readdir } = await import('node:fs/promises'); + const output: string[] = []; + const visit = async (directory: string): Promise => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) await visit(path); + else if (entry.name.endsWith('.json')) output.push(path); + } + }; + await visit(root); + return output; +} diff --git a/packages/runtime-host/src/__tests__/plugin-platform.test.ts b/packages/runtime-host/src/__tests__/plugin-platform.test.ts index 442017925a..286799d43a 100644 --- a/packages/runtime-host/src/__tests__/plugin-platform.test.ts +++ b/packages/runtime-host/src/__tests__/plugin-platform.test.ts @@ -35,6 +35,18 @@ import { PluginSystemPromptService } from '@maka/runtime/plugin-system-prompt-se import { PluginToolService } from '@maka/runtime/plugin-tool-service'; import { PluginUserQuestionService } from '@maka/runtime/plugin-user-question-service'; import { PluginWebService } from '@maka/runtime/plugin-web-service'; +import { PluginCommandService } from '@maka/runtime/plugin-command-service'; +import { + PluginAuthorizationService, + PluginCredentialService, + PluginSettingsService, + PluginStorageService, +} from '@maka/runtime/plugin-data-services'; +import { PluginGoalService } from '@maka/runtime/plugin-goal-service'; +import { PluginLspService } from '@maka/runtime/plugin-lsp-service'; +import { PluginSessionQueryService } from '@maka/runtime/plugin-session-query-service'; +import { PluginShellEnvService } from '@maka/runtime/plugin-shell-env-service'; +import { PluginSkillService } from '@maka/runtime/plugin-skill-service'; import type { MakaToolContext } from '@maka/runtime/tool-runtime'; import { decodePluginCompositionApplyInput, @@ -52,6 +64,7 @@ import { HostPluginPlatformCoordinator } from '../server/plugin-platform-coordin import { TrustedPluginPackageLoader } from '../server/plugin-package-loader.js'; import { PluginPackageStore } from '../server/plugin-package-store.js'; import { HostPluginPlatform, type HostPluginPlatformOptions } from '../server/plugin-platform.js'; +import { HostPluginDataRuntime } from '../server/plugin-data-runtime.js'; interface TestPlatformInternals { readonly composition: MakaCompositionLoader; @@ -77,6 +90,7 @@ function createPlatform( store, ...(options.tools ? { tools: options.tools } : {}), ...(options.systemPrompt ? { systemPrompt: options.systemPrompt } : {}), + ...(options.commands ? { commands: options.commands } : {}), }); testPlatformInternals.set(platform, { composition, packages, store }); return platform; @@ -199,8 +213,22 @@ test('a real package reaches every scoped ctx service through one Agent Tool inv new PluginUserQuestionService(pluginRoot, agents); const filesystem = new PluginFilesystemService(pluginRoot, agents); const llm = new PluginLlmService(pluginRoot, agents); - const shell = new PluginShellService(pluginRoot, agents); + const shellEnv = new PluginShellEnvService(pluginRoot); + const shell = new PluginShellService(pluginRoot, agents, shellEnv); const web = new PluginWebService(pluginRoot, agents); + const sessionQuery = new PluginSessionQueryService(pluginRoot, agents); + const goals = new PluginGoalService(pluginRoot, agents); + new PluginSkillService(pluginRoot); + const commands = new PluginCommandService(pluginRoot); + new PluginLspService(pluginRoot); + const settings = new PluginSettingsService(pluginRoot); + const storage = new PluginStorageService(pluginRoot); + const credentials = new PluginCredentialService(pluginRoot); + new PluginAuthorizationService(pluginRoot, credentials); + const data = new HostPluginDataRuntime(join(root, 'control')); + settings.bindRuntime(data); + storage.bindRuntime(data); + credentials.bindRuntime(data); const tools = new PluginToolService(pluginRoot, { agents }); const systemPrompt = new PluginSystemPromptService(pluginRoot); const composition = new MakaCompositionLoader({ root: pluginRoot }); @@ -282,8 +310,8 @@ test('a real package reaches every scoped ctx service through one Agent Tool inv }, }); shell.bindRuntime({ - run: async (_options, invocation) => { - calls.push(`shell.run:${invocation.turnId}`); + run: async (options, invocation) => { + calls.push(`shell.run:${invocation.turnId}:${options.environment?.MAKA_PLUGIN_PROBE}`); return { ref: 'pty-e2e' }; }, read: async (ref, invocation) => { @@ -340,15 +368,50 @@ test('a real package reaches every scoped ctx service through one Agent Tool inv return { text: 'nested answer', modelId: 'host-e2e' }; }, }); + sessionQuery.bindRuntime({ + list: async (caller) => { + calls.push(`sessionQuery.list:${caller.invocation?.sessionId}`); + return [{ id: 'session-e2e', title: 'E2E' }]; + }, + read: async (sessionId, caller) => { + calls.push(`sessionQuery.read:${sessionId}:${caller.invocation?.sessionId}`); + return { + session: { id: sessionId, title: 'E2E' }, + messages: [{ role: 'user', content: 'needle' }], + }; + }, + search: async (request, caller) => { + calls.push(`sessionQuery.search:${request.query}:${caller.invocation?.sessionId}`); + return { items: [{ id: 'session-e2e', title: 'E2E' }] }; + }, + }); + goals.bindRuntime({ + execute: async (operation, invocation) => { + calls.push(`goals.${operation.kind}:${invocation.sessionId}`); + return { kind: operation.kind }; + }, + }); const source = await writeContextServicesFixturePackage(root); const platform = createPlatform(join(root, 'control'), { composition, tools, systemPrompt, + commands, }); await platform.recover(); assert.equal((await platform.installPackage(source)).convergence, 'converged'); + assert.deepEqual(platform.inspectCommands('profile'), [ + { + entryId: 'context-services-entry', + scopeId: 'profile', + extensionId: 'context-services-package', + generation: 1, + name: 'probe-command', + description: 'Command probe', + aliases: ['pc'], + }, + ]); const prompt = await systemPrompt.assemble( { sessionId: 'session-e2e', turnId: 'turn-e2e', cwd: root }, @@ -406,6 +469,14 @@ test('a real package reaches every scoped ctx service through one Agent Tool inv attachmentBytes: [65, 66, 67], attachmentCount: 1, llmText: 'nested answer', + sessionCount: 1, + skillCount: 1, + command: { command: 'a:b' }, + initialSetting: 'default', + savedSetting: 'strict', + stored: 2, + credential: 'e2e', + lsp: { operation: 'hover', languageId: 'typescript' }, }); assert.deepEqual(calls, [ @@ -431,7 +502,7 @@ test('a real package reaches every scoped ctx service through one Agent Tool inv 'fs.glob:session-e2e', 'fs.grep:session-e2e', 'fs.apply_patch:session-e2e', - 'shell.run:turn-e2e', + 'shell.run:turn-e2e:enabled', 'shell.read:pty-e2e:session-e2e', 'shell.write:pty-e2e:ping:session-e2e', 'shell.stop:pty-e2e:session-e2e', @@ -444,6 +515,14 @@ test('a real package reaches every scoped ctx service through one Agent Tool inv 'userQuestions.requestForm:Choose', 'approval.request:write output', 'llm.generate:nested prompt:session-e2e', + 'sessionQuery.list:session-e2e', + 'sessionQuery.read:session-e2e:session-e2e', + 'sessionQuery.search:needle:session-e2e', + 'goals.get:session-e2e', + 'goals.create:session-e2e', + 'goals.pause:session-e2e', + 'goals.resume:session-e2e', + 'goals.clear:session-e2e', ]); await platform.close(); } finally { @@ -565,6 +644,56 @@ test('Plugin Platform query exposes bounded Tool contribution inspection', async } }); +test('Plugin Platform query projects scoped Command contributions for clients', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-command-inspection-')); + try { + const platform = createPlatform(join(root, 'control'), { + commands: { + inspect: () => [ + { + entryId: 'command-entry', + scopeId: 'profile', + extensionId: 'command-package', + generation: 2, + name: 'review', + description: 'Review the current change', + aliases: ['rv'], + }, + ], + }, + }); + const coordinator = new HostPluginPlatformCoordinator(platform); + await platform.recover(); + assert.deepEqual( + await coordinator.handlers['plugin.platform.query']( + { view: 'commands', rootId: 'profile' }, + null as never, + ), + { + ok: true, + result: { + view: 'commands', + items: [ + { + entryId: 'command-entry', + scopeId: 'profile', + extensionId: 'command-package', + generation: 2, + name: 'review', + description: 'Review the current change', + aliases: ['rv'], + }, + ], + 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 { @@ -2117,6 +2246,44 @@ async function writeContextServicesFixturePackage(root: string): Promise `export default Object.freeze({ packageId: 'context-services-package', host: Object.freeze({ apply(ctx) { + ctx.skills.register({ + name: 'plugin-probe', + description: 'Plugin skill probe', + instructions: '# Probe\\nUse the probe.', + declaredTools: ['ctx_e2e'], + }); + ctx.commands.register({ + name: 'probe-command', + description: 'Command probe', + aliases: ['pc'], + execute: ({ args }) => ({ command: args.join(':') }), + }); + ctx.settings.define({ + key: 'mode', + title: 'Mode', + defaultValue: 'default', + validate: value => typeof value === 'string', + }); + ctx.credentials.declare({ name: 'api-token', label: 'API token' }); + ctx.authorization.register({ + slot: 'api-token', + label: 'Authorize probe', + methods: [{ id: 'paste', label: 'Paste' }], + run: async ({ commit }) => { + await commit('secret-e2e', { provider: 'fixture' }); + return 'authorized'; + }, + }); + ctx.lsp.registerProvider({ + id: 'fixture-lsp', + extensionToLanguage: { '.ts': 'typescript' }, + query: async request => ({ operation: request.operation, languageId: request.languageId }), + }); + ctx.shellEnv.register({ + name: 'fixture-env', + variables: { MAKA_PLUGIN_PROBE: { description: 'Fixture marker' } }, + resolve: () => ({ MAKA_PLUGIN_PROBE: 'enabled' }), + }); ctx.systemPrompt.context(Object.freeze({ name: 'plugin:e2e-context', order: 7, @@ -2193,12 +2360,53 @@ async function writeContextServicesFixturePackage(root: string): Promise justification: 'write output', }); const generated = await ctx.llm.generate({ prompt: 'nested prompt' }); + + const sessionCount = (await ctx.sessionQuery.list()).length; + await ctx.sessionQuery.read(current.sessionId); + await ctx.sessionQuery.search({ query: 'needle', limit: 5 }); + await ctx.goals.get(); + await ctx.goals.create({ objective: 'finish probe' }); + await ctx.goals.pause(); + await ctx.goals.resume(); + await ctx.goals.clear(); + + const skillCount = ctx.skills.resolve(current.sessionId).length; + const command = await ctx.commands.execute('pc', { + sessionId: current.sessionId, + args: ['a', 'b'], + }); + const initialSetting = await ctx.settings.get('mode'); + const savedSetting = await ctx.settings.set('mode', 'strict', { + expectedRevision: initialSetting.revision, + }); + await ctx.storage.set('state/count', 1); + await ctx.storage.transaction([ + { key: 'state/count', value: 2, expectedRevision: 1 }, + { key: 'state/name', value: 'probe' }, + ]); + const stored = await ctx.storage.get('state/count'); + await ctx.authorization.begin('api-token', 'paste'); + const credential = await ctx.credentials.use('api-token', secret => secret.slice(-3)); + const lsp = await ctx.lsp.query({ + sessionId: current.sessionId, + filePath: 'src/index.ts', + position: { line: 0, character: 0 }, + operation: 'hover', + }); return { currentAgent: current.id, childAgent: child.id, attachmentBytes, attachmentCount, llmText: generated.text, + sessionCount, + skillCount, + command, + initialSetting: initialSetting.value, + savedSetting: savedSetting.value, + stored: stored.value, + credential, + lsp, }; }, })); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index c95ce0b669..ba98aea7f7 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,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 = 136 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 137 as const; +// 137: Plugin Platform queries expose scoped Command contribution projections. +// Epoch-136 peers reject the added query view and result shape. // 136: WorkHub transient proposals distinguish routing dispositions from linked // operations. Older peers expect replace/stop_work/resume_work dispositions. // 135: WorkHub model Turns replace direct action proposals with active-Turn task tools. diff --git a/packages/runtime-host/src/protocol/plugin-platform.ts b/packages/runtime-host/src/protocol/plugin-platform.ts index ee23deef9f..cf6914ff8b 100644 --- a/packages/runtime-host/src/protocol/plugin-platform.ts +++ b/packages/runtime-host/src/protocol/plugin-platform.ts @@ -27,6 +27,7 @@ import { type MakaPluginRootId, } from '@maka/runtime/plugin-runtime'; import type { PluginToolInspection } from '@maka/runtime/plugin-tool-service'; +import type { PluginCommandInspection } from '@maka/runtime/plugin-command-service'; import { requireCount, requireEncodedByteLimit, @@ -87,7 +88,7 @@ export interface PluginPackageProjection { } export interface PluginPlatformQueryInput { - readonly view: 'status' | 'packages' | 'entries' | 'tools' | 'failures'; + readonly view: 'status' | 'packages' | 'entries' | 'tools' | 'commands' | 'failures'; readonly rootId?: MakaPluginRootId; readonly cursor?: string; readonly limit?: number; @@ -121,6 +122,11 @@ export type PluginPlatformQueryResult = readonly items: readonly PluginToolInspection[]; readonly nextCursor: string | null; } + | { + readonly view: 'commands'; + readonly items: readonly PluginCommandInspection[]; + readonly nextCursor: string | null; + } | { readonly view: 'failures'; readonly items: readonly PluginPlatformFailureProjection[]; @@ -261,7 +267,11 @@ function decodePluginPlatformQueryInput(value: unknown): PluginPlatformQueryInpu ['view'], ['rootId', 'cursor', 'limit'], ); - if (!['status', 'packages', 'entries', 'tools', 'failures'].includes(input.view as string)) { + if ( + !['status', 'packages', 'entries', 'tools', 'commands', 'failures'].includes( + input.view as string, + ) + ) { throw invalidProtocolFrame('Invalid Plugin Platform query view'); } const view = input.view as PluginPlatformQueryInput['view']; @@ -282,8 +292,10 @@ function decodePluginPlatformQueryInput(value: unknown): PluginPlatformQueryInpu ) { throw invalidProtocolFrame('Plugin Platform status query does not accept paging'); } - if (input.rootId !== undefined && view !== 'entries' && view !== 'tools') { - throw invalidProtocolFrame('Plugin root identity is only valid for Entry and Tool queries'); + if (input.rootId !== undefined && view !== 'entries' && view !== 'tools' && view !== 'commands') { + throw invalidProtocolFrame( + 'Plugin root identity is only valid for Entry, Tool, and Command queries', + ); } let rootId: MakaPluginRootId | undefined; if (input.rootId !== undefined) { @@ -355,7 +367,7 @@ function decodePluginPlatformQueryResult(value: unknown): PluginPlatformQueryRes if ( !Array.isArray(output.items) || output.items.length > 64 || - !['packages', 'entries', 'tools', 'failures'].includes(view as string) + !['packages', 'entries', 'tools', 'commands', 'failures'].includes(view as string) ) { throw invalidProtocolFrame('Invalid Plugin Platform page'); } @@ -370,7 +382,9 @@ function decodePluginPlatformQueryResult(value: unknown): PluginPlatformQueryRes ? { view, items: decodeInspections(output.items), nextCursor } : view === 'tools' ? { view, items: output.items.map(decodeToolInspection), nextCursor } - : { view: 'failures', items: output.items.map(decodePlatformFailure), nextCursor }; + : view === 'commands' + ? { view, items: output.items.map(decodeCommandInspection), nextCursor } + : { view: 'failures', items: output.items.map(decodePlatformFailure), nextCursor }; } requireEncodedByteLimit( decoded, @@ -380,6 +394,30 @@ function decodePluginPlatformQueryResult(value: unknown): PluginPlatformQueryRes return decoded; } +function decodeCommandInspection(value: unknown): PluginCommandInspection { + const item = requireExactRecord(value, 'Plugin Command inspection', [ + 'entryId', + 'scopeId', + 'extensionId', + 'generation', + 'name', + 'description', + 'aliases', + ]); + if (!Array.isArray(item.aliases) || item.aliases.length > 64) { + throw invalidProtocolFrame('Invalid Plugin Command aliases'); + } + return { + entryId: requireId(item.entryId, 'Plugin Entry identity'), + scopeId: requireString(item.scopeId, 'Plugin scope identity', 256), + extensionId: requireId(item.extensionId, 'Plugin package identity'), + generation: requireCount(item.generation, 'Plugin generation'), + name: requireString(item.name, 'Plugin Command name', 128), + description: requireString(item.description, 'Plugin Command description', 4096), + aliases: item.aliases.map((alias) => requireString(alias, 'Plugin Command alias', 128)), + }; +} + function decodePlatformFailure(value: unknown): PluginPlatformFailureProjection { const failure = requireShapedRecord( value, diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 7e23ea72b1..163f3b3642 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -94,6 +94,18 @@ import { PluginWebService } from '@maka/runtime/plugin-web-service'; import { MakaCompositionLoader } from '@maka/runtime/plugin-composition-loader'; import { PluginToolService } from '@maka/runtime/plugin-tool-service'; import { PluginSystemPromptService } from '@maka/runtime/plugin-system-prompt-service'; +import { PluginCommandService } from '@maka/runtime/plugin-command-service'; +import { + PluginAuthorizationService, + PluginCredentialService, + PluginSettingsService, + PluginStorageService, +} from '@maka/runtime/plugin-data-services'; +import { PluginGoalService } from '@maka/runtime/plugin-goal-service'; +import { PluginLspService } from '@maka/runtime/plugin-lsp-service'; +import { PluginSessionQueryService } from '@maka/runtime/plugin-session-query-service'; +import { PluginShellEnvService } from '@maka/runtime/plugin-shell-env-service'; +import { PluginSkillService } from '@maka/runtime/plugin-skill-service'; import { type RuntimeHostedRootAuthority } from '@maka/runtime/message-authority'; import { isHostedExecutionTerminal } from './hosted-execution-authority.js'; import { createAgentGraphControlStore } from '@maka/storage/agent-graph-control-store'; @@ -108,6 +120,7 @@ import { openStorageWriterComposition } from '@maka/storage/storage-writer-compo import type { RuntimePolicyStoresWriter } from '@maka/storage/runtime-policy-stores'; import { resolveWorkspaceIdentity } from '@maka/storage/workspace-identity'; import { CanonicalSessionProjectionReader } from './canonical-session-projection.js'; +import { HostPluginDataRuntime } from './plugin-data-runtime.js'; import { bindHostChildAgentBackend, createHostChildAgentToolComposition, @@ -314,14 +327,29 @@ export async function createExecutionRuntimeHostComposition( new PluginUserQuestionService(pluginRoot, pluginAgents); const pluginFilesystem = new PluginFilesystemService(pluginRoot, pluginAgents); const pluginLlm = new PluginLlmService(pluginRoot, pluginAgents); - const pluginShell = new PluginShellService(pluginRoot, pluginAgents); + const pluginShellEnv = new PluginShellEnvService(pluginRoot); + const pluginShell = new PluginShellService(pluginRoot, pluginAgents, pluginShellEnv); const pluginWeb = new PluginWebService(pluginRoot, pluginAgents); + const pluginSessionQuery = new PluginSessionQueryService(pluginRoot, pluginAgents); + const pluginGoals = new PluginGoalService(pluginRoot, pluginAgents); + const pluginSkills = new PluginSkillService(pluginRoot); + const pluginCommands = new PluginCommandService(pluginRoot); + new PluginLspService(pluginRoot); + const pluginSettings = new PluginSettingsService(pluginRoot); + const pluginStorage = new PluginStorageService(pluginRoot); + const pluginCredentials = new PluginCredentialService(pluginRoot); + new PluginAuthorizationService(pluginRoot, pluginCredentials); + const pluginData = new HostPluginDataRuntime(context.owner.controlDirectory); + pluginSettings.bindRuntime(pluginData); + pluginStorage.bindRuntime(pluginData); + pluginCredentials.bindRuntime(pluginData); const pluginTools = new PluginToolService(pluginRoot, { agents: pluginAgents }); const pluginSystemPrompt = new PluginSystemPromptService(pluginRoot); pluginPlatform = new HostPluginPlatform(context.owner.controlDirectory, { composition: new MakaCompositionLoader({ root: pluginRoot }), tools: pluginTools, systemPrompt: pluginSystemPrompt, + commands: pluginCommands, }); const pluginPlatformCoordinator = new HostPluginPlatformCoordinator(pluginPlatform); const openedProjectCatalog = storage.projectCatalog; @@ -496,12 +524,14 @@ export async function createExecutionRuntimeHostComposition( name: string, args: unknown, invocation: import('@maka/runtime/plugin-agent-service').PluginAgentInvocation, + shellEnvironment?: Readonly>, ) => { if (!invocation.toolContext) throw new Error(`${name} requires an active Tool invocation`); const policy = await runtimePolicyStores.runtimePolicy.getSnapshot(); const tool = buildBuiltinTools({ ...builtinTools, shell: resolveTurnShellPlan(policy.policy.shell), + ...(shellEnvironment ? { shellEnvironment } : {}), }).find((candidate) => candidate.name === name); if (!tool) throw new Error(`Builtin capability is unavailable: ${name}`); return tool.impl(args, invocation.toolContext); @@ -551,6 +581,7 @@ export async function createExecutionRuntimeHostComposition( pty: options.pty, }, invocation, + options.environment, ), read: (ref, invocation) => runtimeResources.readRuntimeResource(invocation.sessionId, ref, invocation.abortSignal), @@ -898,6 +929,7 @@ export async function createExecutionRuntimeHostComposition( oauthCredentials, createRunComposer: createInteractiveRunComposerFactory({ skills, + pluginSkills, memory: requireMemory(memory), sessionTodo, clientCapabilities: requireClientCapabilities(clientCapabilities), @@ -1419,7 +1451,9 @@ export async function createExecutionRuntimeHostComposition( }), }); const visibleAgentSessions = async ( - initiator: import('@maka/runtime/plugin-agent-service').PluginAgentInvocation | undefined, + initiator: + | Pick + | undefined, ) => { const sessions = await manager!.listSessions(); if (!initiator) return sessions; @@ -1440,6 +1474,71 @@ export async function createExecutionRuntimeHostComposition( } return sessions.filter((session) => visible.has(session.id)); }; + const pluginSessionSummary = ( + session: Awaited>[number], + ) => + Object.freeze({ + id: session.id, + ...(session.name ? { title: session.name } : {}), + ...(session.cwd ? { cwd: session.cwd } : {}), + ...(session.status ? { status: session.status } : {}), + ...(session.parentSessionId ? { parentSessionId: session.parentSessionId } : {}), + ...(session.statusUpdatedAt || session.lastMessageAt + ? { updatedAt: session.statusUpdatedAt ?? session.lastMessageAt } + : {}), + }); + const sessionQueryInitiator = ( + caller: import('@maka/runtime/plugin-session-query-service').PluginSessionQueryCaller, + ): { readonly sessionId: string } | undefined => + caller.invocation ?? + (caller.scopeSessionId ? Object.freeze({ sessionId: caller.scopeSessionId }) : undefined); + pluginSessionQuery.bindRuntime({ + list: async (caller) => + Object.freeze( + (await visibleAgentSessions(sessionQueryInitiator(caller))).map(pluginSessionSummary), + ), + read: async (sessionId, caller) => { + const session = (await visibleAgentSessions(sessionQueryInitiator(caller))).find( + ({ id }) => id === sessionId, + ); + if (!session) return undefined; + return Object.freeze({ + session: pluginSessionSummary(session), + messages: Object.freeze([ + ...(await requireSessionManager(manager).getMessages(sessionId)), + ]), + }); + }, + search: async (request, caller) => { + const query = request.query.toLocaleLowerCase(); + const sessions = await visibleAgentSessions(sessionQueryInitiator(caller)); + const matches: typeof sessions = []; + for (const session of sessions) { + const headerText = `${session.name}\n${session.cwd ?? ''}`.toLocaleLowerCase(); + if (headerText.includes(query)) { + matches.push(session); + continue; + } + const messages = await requireSessionManager(manager).getMessages(session.id); + if ( + messages.some((message) => JSON.stringify(message).toLocaleLowerCase().includes(query)) + ) { + matches.push(session); + } + } + const offset = request.cursor ? Number.parseInt(request.cursor, 10) : 0; + if (!Number.isSafeInteger(offset) || offset < 0) + throw new TypeError('Invalid Session query cursor'); + const limit = request.limit ?? 20; + const page = matches.slice(offset, offset + limit); + return Object.freeze({ + items: Object.freeze(page.map(pluginSessionSummary)), + ...(offset + page.length < matches.length + ? { cursor: String(offset + page.length) } + : {}), + }); + }, + }); const describeAgent = (session: Awaited>[number]) => ({ id: session.id, sessionId: session.id, @@ -1686,6 +1785,34 @@ export async function createExecutionRuntimeHostComposition( onProjectionChanged: (sessionId) => continuityCoordinator.enqueueCanonicalRefresh(sessionId), requestDrain: context.requestDrain, }); + pluginGoals.bindRuntime({ + execute: async (operation, invocation) => { + const coordinator = requireGoal(goal); + if (operation.kind === 'get') return coordinator.readProjection(invocation.sessionId); + if (!invocation.toolContext) + throw new Error('Goal mutation requires an active Tool invocation'); + const toolName = + operation.kind === 'create' + ? 'GoalSet' + : operation.kind === 'clear' + ? 'GoalClear' + : operation.kind === 'pause' + ? 'GoalPause' + : 'GoalResume'; + const tool = coordinator.tools.find(({ name }) => name === toolName); + if (!tool) throw new Error(`Goal capability is unavailable: ${toolName}`); + const args = + operation.kind === 'create' + ? { + condition: operation.objective, + ...(operation.maxIterations ? { max_iterations: operation.maxIterations } : {}), + ...(operation.blockCap ? { block_cap: operation.blockCap } : {}), + ...(operation.tokenBudget ? { token_budget: operation.tokenBudget } : {}), + } + : {}; + return await tool.impl(args, invocation.toolContext); + }, + }); async function applyRuntimePolicyMutationEffects(): Promise { try { await requireMemory(memory).refreshAfterPolicyMutation(); diff --git a/packages/runtime-host/src/server/interactive-run-composer.ts b/packages/runtime-host/src/server/interactive-run-composer.ts index 840499de5f..f42a1b2fd9 100644 --- a/packages/runtime-host/src/server/interactive-run-composer.ts +++ b/packages/runtime-host/src/server/interactive-run-composer.ts @@ -28,6 +28,7 @@ import { } from '@maka/core/deep-research'; import { activePlanExecution, type PlanSessionState, type PlanStore } from '@maka/core/plan'; import type { PermissionMode } from '@maka/core/permission'; +import { createHash } from 'node:crypto'; import type { RuntimeExecutionConnection } from '@maka/core/llm-connections'; import type { RuntimePolicySnapshot } from '@maka/core/runtime-policy'; import type { SessionToolProfile } from '@maka/core/session'; @@ -59,6 +60,8 @@ import { renderPlanModePrompt, selectCollaborationTools } from '@maka/runtime/pl import { routeWebFetchTools } from '@maka/runtime/web-fetch-tool'; import { routeWebSearchTools } from '@maka/runtime/native-web-search-tool'; import { type MakaTool } from '@maka/runtime/tool-runtime'; +import type { PluginSkillService } from '@maka/runtime/plugin-skill-service'; +import type { ScannedSkill } from '@maka/runtime/skills'; import { type ToolGroup } from '@maka/runtime/tool-availability'; import { resolveTurnShellPlan, type TurnShellPlan } from '@maka/runtime/shell-detect'; import type { @@ -92,6 +95,7 @@ const CHILD_INSTRUCTION_BOUNDARY = [ export interface InteractiveRunComposerInput { readonly runtimePolicy: RuntimePolicySnapshot; readonly skills: HostSkillCatalogCoordinator; + readonly pluginSkills?: PluginSkillService; readonly memory: HostMemoryCoordinator; readonly sessionTodo: SessionTodoToolStore; readonly childInstruction?: string; @@ -135,7 +139,10 @@ export function createInteractiveRunComposer(input: InteractiveRunComposerInput) input.builtinTools && input.shell ? { ...input.builtinTools, shell: input.shell } : input.builtinTools; - const inventorySnapshotFor = createTurnSkillInventorySnapshotResolver(input.skills); + const inventorySnapshotFor = createTurnSkillInventorySnapshotResolver( + input.skills, + input.pluginSkills, + ); const inventoryFor: SkillInventoryResolver = async (context) => (await inventorySnapshotFor(context)).inventory; const hasToolCeiling = input.boundTools !== undefined || input.toolProfile !== undefined; @@ -424,6 +431,7 @@ export function createInteractiveRunComposerFactory( const composer = createInteractiveRunComposer({ runtimePolicy, skills: input.skills, + ...(input.pluginSkills ? { pluginSkills: input.pluginSkills } : {}), memory: input.memory, sessionTodo: input.sessionTodo, ...(backendContext.systemPrompt ? { childInstruction: backendContext.systemPrompt } : {}), @@ -609,6 +617,7 @@ function buildPlanTraceContext( function createTurnSkillInventorySnapshotResolver( skills: HostSkillCatalogCoordinator, + pluginSkills?: PluginSkillService, ): ( context: Pick, ) => Promise { @@ -617,7 +626,42 @@ function createTurnSkillInventorySnapshotResolver( const key = `${context.sessionId}\u0000${context.turnId}`; const cached = inventoryByTurn.get(key); if (cached) return await cached; - const pending = skills.readCanonicalModelInventory({ projectRoot: context.cwd }); + const pending = skills + .readCanonicalModelInventory({ projectRoot: context.cwd }) + .then((base) => { + if (!pluginSkills) return base; + const plugin = pluginSkills.snapshot(context.sessionId); + if (plugin.skills.length === 0) return base; + const additions: ScannedSkill[] = plugin.skills.map((skill, index) => { + const contentSha256 = createHash('sha256').update(skill.instructions).digest('hex'); + return Object.freeze({ + ref: `plugin:${skill.name}`, + id: skill.name, + name: skill.name, + description: skill.description, + path: `plugin://${skill.name}/SKILL.md`, + discoveryRoot: `plugin://${skill.name}`, + declaredTools: [...(skill.declaredTools ?? [])], + requiredTools: [...(skill.requiredTools ?? [])], + requiredCapabilities: [], + enabled: true, + pinned: false, + runtimeStatus: 'enabled' as const, + scope: 'custom' as const, + source: 'custom' as const, + precedence: -1_000 + index, + content: skill.instructions, + contentSha256, + }); + }); + return Object.freeze({ + ...base, + revision: createHash('sha256') + .update(`${base.revision}:${plugin.revision}`) + .digest('hex') as typeof base.revision, + inventory: Object.freeze([...additions, ...base.inventory]), + }); + }); inventoryByTurn.set(key, pending); if (inventoryByTurn.size > 100) { const oldest = inventoryByTurn.keys().next().value; diff --git a/packages/runtime-host/src/server/plugin-data-runtime.ts b/packages/runtime-host/src/server/plugin-data-runtime.ts new file mode 100644 index 0000000000..4eecd968e9 --- /dev/null +++ b/packages/runtime-host/src/server/plugin-data-runtime.ts @@ -0,0 +1,303 @@ +/* + * 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 { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; +import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import type { + PluginDataMutation, + PluginDataNamespace, + PluginDataRuntime, + PluginDataSnapshot, +} from '@maka/runtime/plugin-data-services'; + +interface DataDocument { + readonly schemaVersion: 1; + revision: number; + values: Record; +} +interface CredentialDocument { + readonly schemaVersion: 1; + revision: number; + values: Record< + string, + { + readonly ciphertext: string; + readonly iv: string; + readonly tag: string; + readonly metadata?: Readonly>; + } + >; +} + +const NAMESPACE_QUOTA_BYTES = 10 * 1024 * 1024; + +/** Durable per-extension data authority. JSON writes are atomic; secrets are AES-GCM sealed at rest. */ +export class HostPluginDataRuntime implements PluginDataRuntime { + readonly #root: string; + readonly #tails = new Map>(); + readonly #listeners = new Map void>>(); + #key: Promise | undefined; + + constructor(controlDirectory: string) { + this.#root = join(controlDirectory, 'plugin-data'); + } + + async read( + namespace: PluginDataNamespace, + domain: 'settings' | 'storage', + key: string, + ): Promise { + return await this.#withNamespace(namespace, async () => { + const item = (await this.#readData(namespace, domain)).values[key]; + return Object.freeze( + item + ? { revision: item.revision, value: clone(item.value) } + : { revision: 0, value: undefined }, + ); + }); + } + + async list( + namespace: PluginDataNamespace, + domain: 'settings' | 'storage', + prefix = '', + ): Promise>> { + return await this.#withNamespace(namespace, async () => + Object.freeze( + Object.fromEntries( + Object.entries((await this.#readData(namespace, domain)).values) + .filter(([key, item]) => key.startsWith(prefix) && item.value !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [ + key, + Object.freeze({ revision: item.revision, value: clone(item.value) }), + ]), + ), + ), + ); + } + + async mutate( + namespace: PluginDataNamespace, + domain: 'settings' | 'storage', + mutations: readonly PluginDataMutation[], + ): Promise>> { + return await this.#withNamespace(namespace, async () => { + const document = await this.#readData(namespace, domain); + const changed = new Set(); + for (const mutation of mutations) { + const current = document.values[mutation.key]; + const revision = current?.revision ?? 0; + if (mutation.expectedRevision !== undefined && mutation.expectedRevision !== revision) + throw new Error( + `Plugin data revision conflict for ${mutation.key}: expected ${mutation.expectedRevision}, current ${revision}`, + ); + const nextRevision = revision + 1; + if (mutation.value === undefined) + document.values[mutation.key] = { revision: nextRevision }; + else + document.values[mutation.key] = { revision: nextRevision, value: clone(mutation.value) }; + document.revision += 1; + changed.add(mutation.key); + } + const encoded = `${JSON.stringify(document)}\n`; + if (Buffer.byteLength(encoded, 'utf8') > NAMESPACE_QUOTA_BYTES) + throw new Error('Plugin data namespace exceeds 10 MiB quota'); + await atomicWrite(this.#dataPath(namespace, domain), encoded, 0o600); + const result = Object.freeze( + Object.fromEntries( + [...changed].map((key) => { + const item = document.values[key]!; + return [key, Object.freeze({ revision: item.revision, value: clone(item.value) })]; + }), + ), + ); + queueMicrotask(() => { + for (const listener of this.#listeners.get(this.#listenerKey(namespace, domain)) ?? []) + listener(Object.freeze([...changed].sort())); + }); + return result; + }); + } + + subscribe( + namespace: PluginDataNamespace, + domain: 'settings' | 'storage', + listener: (keys: readonly string[]) => void, + ): () => void { + const key = this.#listenerKey(namespace, domain); + let listeners = this.#listeners.get(key); + if (!listeners) { + listeners = new Set(); + this.#listeners.set(key, listeners); + } + listeners.add(listener); + return () => { + listeners!.delete(listener); + if (listeners!.size === 0) this.#listeners.delete(key); + }; + } + + async hasCredential(namespace: PluginDataNamespace, slot: string): Promise { + return await this.#withNamespace(namespace, async () => + Boolean((await this.#readCredentials(namespace)).values[slot]), + ); + } + async useCredential( + namespace: PluginDataNamespace, + slot: string, + use: (secret: string) => T | Promise, + ): Promise { + const secret = await this.#withNamespace(namespace, async () => { + const record = (await this.#readCredentials(namespace)).values[slot]; + if (!record) throw new Error(`Plugin credential is unavailable: ${slot}`); + const key = await this.#encryptionKey(); + const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(record.iv, 'base64')); + decipher.setAuthTag(Buffer.from(record.tag, 'base64')); + return Buffer.concat([ + decipher.update(Buffer.from(record.ciphertext, 'base64')), + decipher.final(), + ]).toString('utf8'); + }); + return await use(secret); + } + async commitCredential( + namespace: PluginDataNamespace, + slot: string, + secret: string, + metadata?: Readonly>, + ): Promise { + await this.#withNamespace(namespace, async () => { + const document = await this.#readCredentials(namespace); + const key = await this.#encryptionKey(); + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', key, iv); + const ciphertext = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]); + document.values[slot] = { + ciphertext: ciphertext.toString('base64'), + iv: iv.toString('base64'), + tag: cipher.getAuthTag().toString('base64'), + ...(metadata ? { metadata: Object.freeze({ ...metadata }) } : {}), + }; + document.revision += 1; + await atomicWrite(this.#credentialPath(namespace), `${JSON.stringify(document)}\n`, 0o600); + }); + } + async removeCredential(namespace: PluginDataNamespace, slot: string): Promise { + await this.#withNamespace(namespace, async () => { + const document = await this.#readCredentials(namespace); + if (!document.values[slot]) return; + delete document.values[slot]; + document.revision += 1; + await atomicWrite(this.#credentialPath(namespace), `${JSON.stringify(document)}\n`, 0o600); + }); + } + + async #readData( + namespace: PluginDataNamespace, + domain: 'settings' | 'storage', + ): Promise { + return await readJson(this.#dataPath(namespace, domain), () => ({ + schemaVersion: 1, + revision: 0, + values: Object.create(null), + })); + } + async #readCredentials(namespace: PluginDataNamespace): Promise { + return await readJson(this.#credentialPath(namespace), () => ({ + schemaVersion: 1, + revision: 0, + values: Object.create(null), + })); + } + #dataPath(namespace: PluginDataNamespace, domain: string): string { + return join(this.#root, digest(namespace), `${domain}.json`); + } + #credentialPath(namespace: PluginDataNamespace): string { + return join(this.#root, digest(namespace), 'credentials.json'); + } + #listenerKey(namespace: PluginDataNamespace, domain: string): string { + return `${digest(namespace)}:${domain}`; + } + async #encryptionKey(): Promise { + this.#key ??= (async () => { + const path = join(this.#root, '.credential-key'); + await mkdir(this.#root, { recursive: true, mode: 0o700 }); + try { + const value = await readFile(path); + if (value.length !== 32) throw new Error('Plugin credential key has invalid length'); + return value; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + const value = randomBytes(32); + await writeFile(path, value, { mode: 0o600, flag: 'wx' }).catch(async (writeError) => { + if ((writeError as NodeJS.ErrnoException).code !== 'EEXIST') throw writeError; + }); + await chmod(path, 0o600); + return await readFile(path); + } + })(); + return await this.#key; + } + async #withNamespace(namespace: PluginDataNamespace, operation: () => Promise): Promise { + validateNamespace(namespace); + const key = digest(namespace); + const previous = this.#tails.get(key) ?? Promise.resolve(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const tail = previous.then(() => gate); + this.#tails.set(key, tail); + await previous; + try { + return await operation(); + } finally { + release(); + if (this.#tails.get(key) === tail) this.#tails.delete(key); + } + } +} + +function validateNamespace(value: PluginDataNamespace): void { + if (!value.extensionId || !value.scopeId || /[\0\r\n]/u.test(value.extensionId + value.scopeId)) + throw new TypeError('Invalid Plugin data namespace'); +} +function digest(value: PluginDataNamespace): string { + return createHash('sha256').update(`${value.extensionId}\0${value.scopeId}`).digest('hex'); +} +function clone(value: T): T { + return value === undefined ? value : (JSON.parse(JSON.stringify(value)) as T); +} +async function readJson(path: string, fallback: () => T): Promise { + try { + return JSON.parse(await readFile(path, 'utf8')) as T; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return fallback(); + throw new Error(`Unable to read Plugin data: ${path}`, { cause: error }); + } +} +async function atomicWrite(path: string, content: string, mode: number): Promise { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + const candidate = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`; + await writeFile(candidate, content, { mode, flag: 'wx' }); + await rename(candidate, path); + await chmod(path, mode); +} diff --git a/packages/runtime-host/src/server/plugin-platform-coordinator.ts b/packages/runtime-host/src/server/plugin-platform-coordinator.ts index f117b526c2..415f092272 100644 --- a/packages/runtime-host/src/server/plugin-platform-coordinator.ts +++ b/packages/runtime-host/src/server/plugin-platform-coordinator.ts @@ -24,6 +24,7 @@ import { type MakaCompositionEntryInspection, } from '@maka/runtime/plugin-runtime'; import type { PluginToolInspection } from '@maka/runtime/plugin-tool-service'; +import type { PluginCommandInspection } from '@maka/runtime/plugin-command-service'; import type { OperationOutcome, PluginPackageExportInput, @@ -81,6 +82,12 @@ export class HostPluginPlatformCoordinator { result: boundedPage('tools', this.platform.inspectTools(input.rootId), input), }; } + if (input.view === 'commands') { + return { + ok: true, + result: boundedPage('commands', this.platform.inspectCommands(input.rootId), input), + }; + } if (input.view === 'failures') { return { ok: true, result: boundedPage('failures', failures, input) }; } @@ -184,13 +191,18 @@ function boundedPage( values: readonly PluginToolInspection[], input: PluginPlatformQueryInput, ): Extract; +function boundedPage( + view: 'commands', + values: readonly PluginCommandInspection[], + input: PluginPlatformQueryInput, +): Extract; function boundedPage( view: 'failures', values: readonly PluginPlatformFailureProjection[], input: PluginPlatformQueryInput, ): Extract; function boundedPage( - view: 'packages' | 'entries' | 'tools' | 'failures', + view: 'packages' | 'entries' | 'tools' | 'commands' | 'failures', values: readonly T[], input: PluginPlatformQueryInput, ): PluginPlatformQueryResult { @@ -230,7 +242,7 @@ function boundedPage( interface PageCursor { readonly version: 1; - readonly view: 'packages' | 'entries' | 'tools' | 'failures'; + readonly view: 'packages' | 'entries' | 'tools' | 'commands' | '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 5136cfdfe2..98c4a6309c 100644 --- a/packages/runtime-host/src/server/plugin-platform.ts +++ b/packages/runtime-host/src/server/plugin-platform.ts @@ -35,6 +35,7 @@ import { import type { ExtensionPackageManifest } from './extension-package-manifest.js'; import type { PluginToolInspection } from '@maka/runtime/plugin-tool-service'; import type { PluginSystemPromptInspection } from '@maka/runtime/plugin-system-prompt-service'; +import type { PluginCommandInspection } from '@maka/runtime/plugin-command-service'; import { validateExtensionConfiguration } from './extension-package-manifest.js'; import { recoverExtensionBundleImports } from './extension-bundle.js'; import { loadPluginCompositionPatch } from './plugin-composition-patch.js'; @@ -80,6 +81,7 @@ export interface HostPluginPlatformOptions { readonly systemPrompt?: { inspect(rootId?: MakaPluginRootId): readonly PluginSystemPromptInspection[]; }; + readonly commands?: { inspect(rootId?: MakaPluginRootId): readonly PluginCommandInspection[] }; } export interface HostPluginPlatformFailure { @@ -117,6 +119,7 @@ export class HostPluginPlatform { readonly #store: HostPluginCompositionStore; readonly #tools?: HostPluginPlatformOptions['tools']; readonly #systemPrompt?: HostPluginPlatformOptions['systemPrompt']; + readonly #commands?: HostPluginPlatformOptions['commands']; #authority: PersistedPluginComposition = emptyCompositionAuthority(); #desired: MakaCompositionState = emptyCompositionState(); @@ -143,6 +146,7 @@ export class HostPluginPlatform { this.#store = options.store ?? new HostPluginCompositionStore(controlDirectory); this.#tools = options.tools; this.#systemPrompt = options.systemPrompt; + this.#commands = options.commands; } async recover(): Promise { @@ -490,6 +494,11 @@ export class HostPluginPlatform { return this.#systemPrompt?.inspect(rootId) ?? Object.freeze([]); } + inspectCommands(rootId?: MakaPluginRootId): readonly PluginCommandInspection[] { + this.#assertReadable(); + return this.#commands?.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 86567c8f49..b393c5b603 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -80,6 +80,13 @@ "./plan-mode": "./dist/plan-mode.js", "./plan-tools": "./dist/plan-tools.js", "./plugin-composition-loader": "./dist/plugin-composition-loader.js", + "./plugin-command-service": "./dist/plugin-command-service.js", + "./plugin-data-services": "./dist/plugin-data-services.js", + "./plugin-goal-service": "./dist/plugin-goal-service.js", + "./plugin-lsp-service": "./dist/plugin-lsp-service.js", + "./plugin-session-query-service": "./dist/plugin-session-query-service.js", + "./plugin-shell-env-service": "./dist/plugin-shell-env-service.js", + "./plugin-skill-service": "./dist/plugin-skill-service.js", "./plugin-agent-service": "./dist/plugin-agent-service.js", "./plugin-attachment-service": "./dist/plugin-attachment-service.js", "./plugin-approval-service": "./dist/plugin-approval-service.js", diff --git a/packages/runtime/src/__tests__/plugin-p1-services.test.ts b/packages/runtime/src/__tests__/plugin-p1-services.test.ts new file mode 100644 index 0000000000..2a460cd310 --- /dev/null +++ b/packages/runtime/src/__tests__/plugin-p1-services.test.ts @@ -0,0 +1,97 @@ +/* + * 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 { PluginCommandService } from '../plugin-command-service.js'; +import { MakaCompositionLoader } from '../plugin-composition-loader.js'; +import { Context } from '../plugin-kernel.js'; +import { PluginLspService } from '../plugin-lsp-service.js'; +import { PluginShellEnvService } from '../plugin-shell-env-service.js'; +import { PluginSkillService } from '../plugin-skill-service.js'; + +test('P1 contributions inherit, shadow, and restore with their Plugin Fiber', async () => { + const root = new Context(); + const skills = new PluginSkillService(root); + const commands = new PluginCommandService(root); + const lsp = new PluginLspService(root); + const shellEnv = new PluginShellEnvService(root); + const loader = new MakaCompositionLoader({ root }); + const plugin = (marker: string) => ({ + apply(ctx: Context) { + ctx.skills.register({ + name: 'probe-skill', + description: marker, + instructions: `# ${marker}`, + }); + ctx.commands.register({ name: 'probe', description: marker, execute: () => marker }); + ctx.lsp.registerProvider({ + id: `lsp-${marker}`, + extensionToLanguage: { ts: `typescript-${marker}` }, + query: async ({ languageId }) => languageId, + }); + ctx.shellEnv.register({ + name: `env-${marker}`, + variables: { MAKA_PLUGIN_MARKER: { description: marker } }, + resolve: () => ({ MAKA_PLUGIN_MARKER: marker }), + }); + }, + }); + try { + await loader.install({ packageId: 'profile-package', host: plugin('profile') }); + await loader.install({ packageId: 'session-package', host: plugin('session') }); + await loader.create('profile', { id: 'profile-entry', packageId: 'profile-package' }); + await loader.create('session:alpha', { id: 'session-entry', packageId: 'session-package' }); + + assert.equal(skills.get('other', 'probe-skill')?.description, 'profile'); + assert.equal(skills.get('alpha', 'probe-skill')?.description, 'session'); + assert.equal(await commands.execute('probe', { sessionId: 'other', args: [] }), 'profile'); + assert.equal(await commands.execute('probe', { sessionId: 'alpha', args: [] }), 'session'); + assert.equal( + await lsp.query({ + sessionId: 'alpha', + filePath: 'index.TS', + position: { line: 0, character: 0 }, + operation: 'hover', + }), + 'typescript-session', + ); + assert.deepEqual(await shellEnv.collect(invocation('alpha')), { + MAKA_PLUGIN_MARKER: 'session', + }); + + await loader.remove('session-entry'); + assert.equal(skills.get('alpha', 'probe-skill')?.description, 'profile'); + assert.equal(await commands.execute('probe', { sessionId: 'alpha', args: [] }), 'profile'); + assert.deepEqual(await shellEnv.collect(invocation('alpha')), { + MAKA_PLUGIN_MARKER: 'profile', + }); + } finally { + await loader.close(); + } +}); + +function invocation(sessionId: string) { + return { + sessionId, + turnId: 'turn', + cwd: '/workspace', + abortSignal: new AbortController().signal, + }; +} diff --git a/packages/runtime/src/builtin-tools.ts b/packages/runtime/src/builtin-tools.ts index 18ddde6aff..7c5ce0490d 100644 --- a/packages/runtime/src/builtin-tools.ts +++ b/packages/runtime/src/builtin-tools.ts @@ -176,6 +176,8 @@ export interface BuildBuiltinToolsOptions { * `setupError` and fails closed at the Bash boundary. */ shell?: TurnShellPlan; + /** Host-only environment overlay for a pre-bound Plugin Shell invocation. */ + shellEnvironment?: Readonly>; permissionProfile?: PermissionProfile; sandboxManager?: SandboxManager; /** Sandboxed worker used for all local filesystem tools. */ @@ -299,8 +301,8 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT shell, ...(options.sandboxManager ? { - transformCommand: ({ command, pty, requiredBoundary, ctx }) => - sandboxCommand( + transformCommand: ({ command, pty, requiredBoundary, ctx }) => { + const transformed = sandboxCommand( options.sandboxManager!, options.permissionProfile, sandboxPlatform, @@ -309,9 +311,26 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT ctx, requiredBoundary, 'background_command', - ), + ); + if (!options.shellEnvironment) return transformed; + return { + ...(transformed ?? { cwd: ctx.cwd }), + env: { + ...process.env, + ...transformed?.env, + ...options.shellEnvironment, + }, + }; + }, } - : {}), + : options.shellEnvironment + ? { + transformCommand: ({ ctx }) => ({ + cwd: ctx.cwd, + env: { ...process.env, ...options.shellEnvironment }, + }), + } + : {}), }), ] : [ diff --git a/packages/runtime/src/plugin-command-service.ts b/packages/runtime/src/plugin-command-service.ts new file mode 100644 index 0000000000..033e492ea5 --- /dev/null +++ b/packages/runtime/src/plugin-command-service.ts @@ -0,0 +1,164 @@ +/* + * 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 { Service, type Context } from './plugin-kernel.js'; +import { PluginScopeRegistry } from './plugin-scope-registry.js'; +import { + pluginIdentity, + registerPluginContribution, + type MakaContributionIdentity, + type MakaPluginRootId, + MakaPluginRuntimeError, +} from './plugin-runtime.js'; + +declare module './plugin-kernel.js' { + interface Context { + readonly commands: PluginCommandService; + } +} + +export interface PluginCommandContext { + readonly sessionId: string; + readonly args: readonly string[]; + readonly signal?: AbortSignal; +} +export interface PluginCommandDefinition { + readonly name: string; + readonly description: string; + readonly aliases?: readonly string[]; + readonly execute: (context: PluginCommandContext) => unknown; +} +export interface PluginCommandSummary { + readonly name: string; + readonly description: string; + readonly aliases: readonly string[]; + readonly extensionId: string; +} +export interface PluginCommandInspection extends MakaContributionIdentity { + readonly name: string; + readonly description: string; + readonly aliases: readonly string[]; +} +interface RegisteredCommand extends MakaContributionIdentity { + readonly definition: PluginCommandDefinition; + readonly token: symbol; + retired: boolean; +} + +/** Scoped interactive command contribution registry. Client surfaces consume summaries only. */ +export class PluginCommandService extends Service { + private readonly registry = new PluginScopeRegistry(); + constructor(ctx: Context) { + super(ctx, 'commands'); + } + + register(definition: PluginCommandDefinition): () => Promise { + validate(definition); + const identity = pluginIdentity(this.ctx); + return registerPluginContribution( + this.ctx, + `commands.register(${JSON.stringify(definition.name)})`, + () => { + const rootId = identity.scopeId as MakaPluginRootId; + for (const key of keys(definition)) { + const existing = this.registry.get(rootId, key); + if (existing && existing.entryId !== identity.entryId) + throw new MakaPluginRuntimeError( + 'activation_failed', + `Plugin Command ${JSON.stringify(key)} is already registered by ${existing.entryId}`, + ); + } + const entry: RegisteredCommand = { + ...identity, + definition: Object.freeze({ + ...definition, + aliases: Object.freeze([...(definition.aliases ?? [])]), + }), + token: Symbol(definition.name), + retired: false, + }; + const disposers = keys(definition).map((key) => this.registry.publish(rootId, key, entry)); + return async () => { + await Promise.all(disposers.reverse().map((dispose) => dispose())); + }; + }, + ); + } + + list(sessionId: string): readonly PluginCommandSummary[] { + const visible = this.registry.visible(assertSessionId(sessionId)); + const seen = new Set(); + return Object.freeze( + [...visible.values()] + .filter((entry) => !seen.has(entry) && Boolean(seen.add(entry))) + .sort((a, b) => a.definition.name.localeCompare(b.definition.name)) + .map(({ definition, extensionId }) => + Object.freeze({ + name: definition.name, + description: definition.description, + aliases: Object.freeze([...(definition.aliases ?? [])]), + extensionId, + }), + ), + ); + } + + async execute(name: string, context: PluginCommandContext): Promise { + const entry = this.registry.visible(assertSessionId(context.sessionId)).get(name); + if (!entry) throw new Error(`Plugin Command is unavailable: ${name}`); + return await entry.definition.execute( + Object.freeze({ ...context, args: Object.freeze([...context.args]) }), + ); + } + + inspect(rootId?: MakaPluginRootId): readonly PluginCommandInspection[] { + const seen = new Set(); + return Object.freeze( + [...this.registry.entries(rootId)] + .filter((entry) => !seen.has(entry) && Boolean(seen.add(entry))) + .sort((a, b) => a.definition.name.localeCompare(b.definition.name)) + .map(({ definition, token: _token, retired: _retired, ...identity }) => + Object.freeze({ + ...identity, + name: definition.name, + description: definition.description, + aliases: Object.freeze([...(definition.aliases ?? [])]), + }), + ), + ); + } +} + +function keys(definition: PluginCommandDefinition): readonly string[] { + return Object.freeze([definition.name, ...(definition.aliases ?? [])]); +} +function validate(definition: PluginCommandDefinition): void { + const all = keys(definition); + if (!definition.description.trim() || typeof definition.execute !== 'function') + throw new TypeError('Plugin Command description and execute are required'); + if ( + new Set(all).size !== all.length || + all.some((name) => !/^[a-z][a-z0-9]*(?:[-_:][a-z0-9]+)*$/u.test(name)) + ) + throw new TypeError('Plugin Command names and aliases are invalid'); +} +function assertSessionId(value: string): string { + if (!value || /[\0\r\n]/u.test(value)) throw new TypeError('Session id is invalid'); + return value; +} diff --git a/packages/runtime/src/plugin-data-services.ts b/packages/runtime/src/plugin-data-services.ts new file mode 100644 index 0000000000..29a67b57f4 --- /dev/null +++ b/packages/runtime/src/plugin-data-services.ts @@ -0,0 +1,469 @@ +/* + * 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 { Service, type Context, type Disposable } from './plugin-kernel.js'; +import { pluginIdentity, registerPluginContribution } from './plugin-runtime.js'; + +declare module './plugin-kernel.js' { + interface Context { + readonly settings: PluginSettingsService; + readonly storage: PluginStorageService; + readonly credentials: PluginCredentialService; + readonly authorization: PluginAuthorizationService; + } +} + +export interface PluginDataNamespace { + readonly extensionId: string; + readonly scopeId: string; +} +export interface PluginDataSnapshot { + readonly revision: number; + readonly value: T | undefined; +} +export interface PluginDataMutation { + readonly key: string; + readonly value?: unknown; + readonly expectedRevision?: number; +} + +export interface PluginDataRuntime { + read( + namespace: PluginDataNamespace, + domain: 'settings' | 'storage', + key: string, + ): Promise; + list( + namespace: PluginDataNamespace, + domain: 'settings' | 'storage', + prefix?: string, + ): Promise>>; + mutate( + namespace: PluginDataNamespace, + domain: 'settings' | 'storage', + mutations: readonly PluginDataMutation[], + ): Promise>>; + subscribe?( + namespace: PluginDataNamespace, + domain: 'settings' | 'storage', + listener: (keys: readonly string[]) => void, + ): () => void; + hasCredential(namespace: PluginDataNamespace, slot: string): Promise; + useCredential( + namespace: PluginDataNamespace, + slot: string, + use: (secret: string) => T | Promise, + ): Promise; + commitCredential( + namespace: PluginDataNamespace, + slot: string, + secret: string, + metadata?: Readonly>, + ): Promise; + removeCredential(namespace: PluginDataNamespace, slot: string): Promise; +} + +export interface PluginSettingDefinition { + readonly key: string; + readonly title: string; + readonly description?: string; + readonly defaultValue?: T; + readonly validate?: (value: unknown) => value is T; + readonly secret?: boolean; +} + +class PluginNamespacedDataService extends Service { + private dataRuntime?: PluginDataRuntime; + protected constructor( + ctx: Context, + name: string, + private readonly domain: 'settings' | 'storage', + ) { + super(ctx, name); + } + bindRuntime(runtime: PluginDataRuntime): Disposable> { + if (this.ctx.maka) throw new Error(`Only the Host may bind the Plugin ${this.domain} Runtime`); + if (this.dataRuntime) throw new Error(`Plugin ${this.domain} Runtime is already bound`); + this.dataRuntime = runtime; + return this.ctx.effect( + () => () => { + if (this.dataRuntime === runtime) this.dataRuntime = undefined; + }, + `${this.domain}.bindRuntime()`, + ); + } + protected runtime(): PluginDataRuntime { + if (!this.dataRuntime) throw new Error(`Plugin ${this.domain} Runtime is unavailable`); + return this.dataRuntime; + } + protected namespace(): PluginDataNamespace { + const { extensionId, scopeId } = pluginIdentity(this.ctx); + return Object.freeze({ extensionId, scopeId }); + } + protected readValue(key: string): Promise { + return this.runtime().read(this.namespace(), this.domain, validKey(key)); + } + protected listValues(prefix?: string): Promise>> { + return this.runtime().list( + this.namespace(), + this.domain, + prefix === undefined ? undefined : validPrefix(prefix), + ); + } + protected mutateValues( + mutations: readonly PluginDataMutation[], + ): Promise>> { + return this.runtime().mutate( + this.namespace(), + this.domain, + Object.freeze( + mutations.map((mutation) => + Object.freeze({ + ...mutation, + key: validKey(mutation.key), + ...(mutation.value === undefined ? {} : { value: detached(mutation.value) }), + }), + ), + ), + ); + } + protected watchValues(listener: (keys: readonly string[]) => void): () => void { + const subscribe = this.runtime().subscribe; + if (!subscribe) throw new Error(`Plugin ${this.domain} subscriptions are unavailable`); + return subscribe(this.namespace(), this.domain, listener); + } +} + +/** Per-extension Settings with schema contributions, CAS revisions, and no global-policy access. */ +export class PluginSettingsService extends PluginNamespacedDataService { + private readonly definitions = new Map(); + constructor(ctx: Context) { + super(ctx, 'settings', 'settings'); + } + + define(definition: PluginSettingDefinition): () => Promise { + validateSettingDefinition(definition); + const identity = pluginIdentity(this.ctx); + return registerPluginContribution( + this.ctx, + `settings.define(${JSON.stringify(definition.key)})`, + () => { + const id = `${identity.scopeId}\0${identity.extensionId}\0${definition.key}`; + if (this.definitions.has(id)) + throw new Error(`Plugin Setting is already defined: ${definition.key}`); + this.definitions.set( + id, + Object.freeze({ + ...definition, + ...(definition.defaultValue === undefined + ? {} + : { defaultValue: detached(definition.defaultValue) }), + }), + ); + return () => { + this.definitions.delete(id); + }; + }, + ); + } + + async get(key: string): Promise> { + const snapshot = await this.readValue(key); + if (snapshot.value !== undefined) return snapshot as PluginDataSnapshot; + const definition = this.definition(key); + return definition?.defaultValue === undefined + ? (snapshot as PluginDataSnapshot) + : Object.freeze({ + revision: snapshot.revision, + value: detached(definition.defaultValue) as T, + }); + } + async set( + key: string, + value: T, + options: { readonly expectedRevision?: number } = {}, + ): Promise> { + const definition = this.definition(key); + if (!definition) throw new Error(`Plugin Setting is not defined: ${key}`); + if (definition.secret) throw new Error(`Secret Setting must use ctx.authorization: ${key}`); + if (definition.validate && !definition.validate(value)) + throw new TypeError(`Plugin Setting failed validation: ${key}`); + const result = await this.mutateValues([{ key, value, ...options }]); + return result[key] as PluginDataSnapshot; + } + async delete( + key: string, + options: { readonly expectedRevision?: number } = {}, + ): Promise { + const result = await this.mutateValues([{ key, ...options }]); + return result[key]!; + } + list(prefix?: string) { + return this.listValues(prefix); + } + watch(listener: (keys: readonly string[]) => void): () => void { + return this.watchValues(listener); + } + private definition(key: string): PluginSettingDefinition | undefined { + const identity = pluginIdentity(this.ctx); + return this.definitions.get(`${identity.scopeId}\0${identity.extensionId}\0${validKey(key)}`); + } +} + +/** Per-extension durable KV/blob-safe JSON surface with atomic batch mutations. */ +export class PluginStorageService extends PluginNamespacedDataService { + constructor(ctx: Context) { + super(ctx, 'storage', 'storage'); + } + get(key: string): Promise> { + return this.readValue(key) as Promise>; + } + async set( + key: string, + value: T, + options: { readonly expectedRevision?: number } = {}, + ): Promise> { + const result = await this.mutateValues([{ key, value, ...options }]); + return result[key] as PluginDataSnapshot; + } + async delete( + key: string, + options: { readonly expectedRevision?: number } = {}, + ): Promise { + const result = await this.mutateValues([{ key, ...options }]); + return result[key]!; + } + list(prefix?: string) { + return this.listValues(prefix); + } + transaction(mutations: readonly PluginDataMutation[]) { + if (!mutations.length) throw new TypeError('Plugin Storage transaction must not be empty'); + return this.mutateValues(mutations); + } + watch(listener: (keys: readonly string[]) => void): () => void { + return this.watchValues(listener); + } +} + +export interface PluginCredentialSlot { + readonly name: string; + readonly label: string; + readonly description?: string; +} +interface RegisteredCredentialSlot { + readonly identity: PluginDataNamespace; + readonly definition: PluginCredentialSlot; +} +const authorizationCommit = Symbol('authorizationCommit'); + +/** Declared Secret Slots. Values stay in Host custody and are never enumerable. */ +export class PluginCredentialService extends Service { + private dataRuntime?: PluginDataRuntime; + private readonly slots = new Map(); + constructor(ctx: Context) { + super(ctx, 'credentials'); + } + bindRuntime(runtime: PluginDataRuntime): Disposable> { + if (this.ctx.maka) throw new Error('Only the Host may bind the Credential Runtime'); + if (this.dataRuntime) throw new Error('Plugin Credential Runtime is already bound'); + this.dataRuntime = runtime; + return this.ctx.effect( + () => () => { + if (this.dataRuntime === runtime) this.dataRuntime = undefined; + }, + 'credentials.bindRuntime()', + ); + } + declare(slot: PluginCredentialSlot): () => Promise { + validateSlot(slot); + const identity = namespace(this.ctx); + const id = slotId(identity, slot.name); + return registerPluginContribution( + this.ctx, + `credentials.declare(${JSON.stringify(slot.name)})`, + () => { + if (this.slots.has(id)) + throw new Error(`Credential Slot is already declared: ${slot.name}`); + this.slots.set(id, { identity, definition: Object.freeze({ ...slot }) }); + return () => { + this.slots.delete(id); + }; + }, + ); + } + has(name: string): Promise { + const slot = this.requireSlot(name); + return this.runtime().hasCredential(slot.identity, slot.definition.name); + } + use(name: string, operation: (secret: string) => T | Promise): Promise { + if (typeof operation !== 'function') throw new TypeError('Credential use callback is required'); + const slot = this.requireSlot(name); + return this.runtime().useCredential(slot.identity, slot.definition.name, operation); + } + remove(name: string): Promise { + const slot = this.requireSlot(name); + return this.runtime().removeCredential(slot.identity, slot.definition.name); + } + async [authorizationCommit]( + name: string, + secret: string, + metadata?: Readonly>, + ): Promise { + if (!secret) throw new TypeError('Credential secret must not be empty'); + const slot = this.requireSlot(name); + await this.runtime().commitCredential(slot.identity, slot.definition.name, secret, metadata); + } + private requireSlot(name: string): RegisteredCredentialSlot { + const identity = namespace(this.ctx); + const slot = this.slots.get(slotId(identity, validName(name))); + if (!slot) throw new Error(`Credential Slot is not declared: ${name}`); + return slot; + } + private runtime(): PluginDataRuntime { + if (!this.dataRuntime) throw new Error('Plugin Credential Runtime is unavailable'); + return this.dataRuntime; + } +} + +export interface PluginAuthorizationMethod { + readonly id: string; + readonly label: string; +} +export interface PluginAuthorizationFlow { + readonly slot: string; + readonly label: string; + readonly methods: readonly PluginAuthorizationMethod[]; + readonly run: (input: { + readonly method: string; + readonly signal: AbortSignal; + readonly commit: (secret: string, metadata?: Readonly>) => Promise; + }) => Promise<'authorized' | 'cancelled'>; +} + +/** Effect-owned authorization flows with one cancellable attempt per Secret Slot. */ +export class PluginAuthorizationService extends Service { + private readonly flows = new Map(); + private readonly active = new Map(); + constructor(ctx: Context, _credentials?: PluginCredentialService) { + super(ctx, 'authorization'); + } + register(flow: PluginAuthorizationFlow): () => Promise { + validateFlow(flow); + const identity = namespace(this.ctx); + const id = slotId(identity, flow.slot); + return registerPluginContribution( + this.ctx, + `authorization.register(${JSON.stringify(flow.slot)})`, + () => { + if (this.flows.has(id)) + throw new Error(`Authorization Flow is already registered: ${flow.slot}`); + this.flows.set(id, flow); + return () => { + this.active.get(id)?.abort(new Error('Authorization Flow disposed')); + this.active.delete(id); + this.flows.delete(id); + }; + }, + ); + } + async begin( + slot: string, + method?: string, + signal?: AbortSignal, + ): Promise<'authorized' | 'cancelled'> { + const identity = namespace(this.ctx); + const id = slotId(identity, validName(slot)); + const flow = this.flows.get(id); + if (!flow) throw new Error(`Authorization Flow is unavailable: ${slot}`); + if (this.active.has(id)) throw new Error(`Authorization is already in progress: ${slot}`); + const selected = method ?? flow.methods[0]!.id; + if (!flow.methods.some(({ id }) => id === selected)) + throw new Error(`Authorization method is unavailable: ${selected}`); + const controller = new AbortController(); + const onAbort = () => controller.abort(signal?.reason); + signal?.addEventListener('abort', onAbort, { once: true }); + this.active.set(id, controller); + let committed = false; + try { + const outcome = await flow.run({ + method: selected, + signal: controller.signal, + commit: async (secret, metadata) => { + await this.ctx.credentials[authorizationCommit](slot, secret, metadata); + committed = true; + }, + }); + if (outcome === 'authorized' && !committed) + throw new Error('Authorization Flow returned authorized without committing a credential'); + return outcome; + } finally { + signal?.removeEventListener('abort', onAbort); + if (this.active.get(id) === controller) this.active.delete(id); + } + } + cancel(slot: string): void { + const identity = namespace(this.ctx); + this.active.get(slotId(identity, validName(slot)))?.abort(new Error('Authorization cancelled')); + } +} + +function namespace(ctx: Context): PluginDataNamespace { + const { extensionId, scopeId } = pluginIdentity(ctx); + return Object.freeze({ extensionId, scopeId }); +} +function slotId(identity: PluginDataNamespace, name: string): string { + return `${identity.scopeId}\0${identity.extensionId}\0${name}`; +} +function validName(value: string): string { + if (!/^[a-z][a-z0-9]*(?:[-_.][a-z0-9]+)*$/u.test(value)) + throw new TypeError(`Invalid plugin name: ${value}`); + return value; +} +function validKey(value: string): string { + if (!/^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,255}$/u.test(value) || value.includes('..')) + throw new TypeError(`Invalid plugin data key: ${value}`); + return value; +} +function validPrefix(value: string): string { + if (value === '') return value; + return validKey(value); +} +function detached(value: T): T { + const json = JSON.stringify(value); + if (json === undefined) throw new TypeError('Plugin data must be JSON-serializable'); + if (Buffer.byteLength(json, 'utf8') > 1024 * 1024) + throw new TypeError('Plugin data value exceeds 1 MiB'); + return JSON.parse(json) as T; +} +function validateSettingDefinition(value: PluginSettingDefinition): void { + validKey(value.key); + if (!value.title.trim()) throw new TypeError('Plugin Setting title must not be empty'); +} +function validateSlot(value: PluginCredentialSlot): void { + validName(value.name); + if (!value.label.trim()) throw new TypeError('Credential Slot label must not be empty'); +} +function validateFlow(value: PluginAuthorizationFlow): void { + validName(value.slot); + if (!value.label.trim() || typeof value.run !== 'function' || value.methods.length === 0) + throw new TypeError('Invalid Authorization Flow'); + const ids = value.methods.map(({ id }) => validName(id)); + if (new Set(ids).size !== ids.length || value.methods.some(({ label }) => !label.trim())) + throw new TypeError('Invalid Authorization methods'); +} diff --git a/packages/runtime/src/plugin-goal-service.ts b/packages/runtime/src/plugin-goal-service.ts new file mode 100644 index 0000000000..499703e893 --- /dev/null +++ b/packages/runtime/src/plugin-goal-service.ts @@ -0,0 +1,91 @@ +/* + * 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 { Service, type Context, type Disposable } from './plugin-kernel.js'; +import type { PluginAgentInvocation, PluginAgentService } from './plugin-agent-service.js'; + +declare module './plugin-kernel.js' { + interface Context { + readonly goals: PluginGoalService; + } +} + +export interface PluginGoalCreateInput { + readonly objective: string; + readonly maxIterations?: number; + readonly blockCap?: number; + readonly tokenBudget?: number; +} + +export type PluginGoalOperation = + | { readonly kind: 'get' } + | ({ readonly kind: 'create' } & PluginGoalCreateInput) + | { readonly kind: 'clear' } + | { readonly kind: 'pause' } + | { readonly kind: 'resume' }; + +export interface PluginGoalRuntime { + execute(operation: PluginGoalOperation, invocation: PluginAgentInvocation): Promise; +} + +/** Current-Session Goal facade; mutations retain Maka's Turn lease and revision authority. */ +export class PluginGoalService extends Service { + private goalRuntime?: PluginGoalRuntime; + + constructor( + ctx: Context, + private readonly agents: PluginAgentService, + ) { + super(ctx, 'goals'); + } + + bindRuntime(runtime: PluginGoalRuntime): Disposable> { + if (this.ctx.maka) throw new Error('Only the Host may bind the Goal Runtime'); + if (this.goalRuntime) throw new Error('Plugin Goal Runtime is already bound'); + this.goalRuntime = runtime; + return this.ctx.effect( + () => () => { + if (this.goalRuntime === runtime) this.goalRuntime = undefined; + }, + 'goals.bindRuntime()', + ); + } + + get(): Promise { + return this.execute({ kind: 'get' }); + } + create(input: PluginGoalCreateInput): Promise { + if (!input.objective.trim()) throw new TypeError('Goal objective must not be empty'); + return this.execute({ ...input, kind: 'create', objective: input.objective.trim() }); + } + clear(): Promise { + return this.execute({ kind: 'clear' }); + } + pause(): Promise { + return this.execute({ kind: 'pause' }); + } + resume(): Promise { + return this.execute({ kind: 'resume' }); + } + + private execute(operation: PluginGoalOperation): Promise { + if (!this.goalRuntime) throw new Error('Plugin Goal Runtime is unavailable'); + return this.goalRuntime.execute(operation, this.agents.requireInvocation()); + } +} diff --git a/packages/runtime/src/plugin-lsp-service.ts b/packages/runtime/src/plugin-lsp-service.ts new file mode 100644 index 0000000000..b92635ffe2 --- /dev/null +++ b/packages/runtime/src/plugin-lsp-service.ts @@ -0,0 +1,154 @@ +/* + * 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 { Service, type Context } from './plugin-kernel.js'; +import { PluginScopeRegistry } from './plugin-scope-registry.js'; +import { + pluginIdentity, + registerPluginContribution, + type MakaContributionIdentity, + type MakaPluginRootId, + MakaPluginRuntimeError, +} from './plugin-runtime.js'; + +declare module './plugin-kernel.js' { + interface Context { + readonly lsp: PluginLspService; + } +} + +export type PluginLspOperation = 'definition' | 'references' | 'implementation' | 'hover'; +export interface PluginLspPosition { + readonly line: number; + readonly character: number; +} +export interface PluginLspRequest { + readonly sessionId: string; + readonly filePath: string; + readonly position: PluginLspPosition; + readonly operation: PluginLspOperation; +} +export interface PluginLspProvider { + readonly id: string; + readonly extensionToLanguage: Readonly>; + query( + request: PluginLspRequest & { readonly languageId: string }, + signal?: AbortSignal, + ): Promise; +} +interface RegisteredRoute extends MakaContributionIdentity { + readonly provider: PluginLspProvider; + readonly languageId: string; + readonly token: symbol; + retired: boolean; +} + +/** High-level LSP router; providers own processes, callers receive no JSON-RPC escape hatch. */ +export class PluginLspService extends Service { + private readonly routes = new PluginScopeRegistry(); + constructor(ctx: Context) { + super(ctx, 'lsp'); + } + + registerProvider(provider: PluginLspProvider): () => Promise { + validateProvider(provider); + const identity = pluginIdentity(this.ctx); + if (identity.scopeId === 'desktop-ui') + throw new MakaPluginRuntimeError( + 'activation_failed', + 'desktop-ui plugins cannot register Host LSP providers', + ); + return registerPluginContribution( + this.ctx, + `lsp.registerProvider(${JSON.stringify(provider.id)})`, + () => { + const rootId = identity.scopeId as MakaPluginRootId; + const pending = Object.entries(provider.extensionToLanguage).map( + ([extension, languageId]) => [normalizeExtension(extension), languageId] as const, + ); + for (const [extension] of pending) { + const existing = this.routes.get(rootId, extension); + if (existing && existing.entryId !== identity.entryId) + throw new MakaPluginRuntimeError( + 'activation_failed', + `LSP extension ${JSON.stringify(extension)} is already registered by ${existing.entryId}`, + ); + } + const disposers = pending.map(([extension, languageId]) => + this.routes.publish(rootId, extension, { + ...identity, + provider, + languageId, + token: Symbol(`${provider.id}:${extension}`), + retired: false, + }), + ); + return async () => { + await Promise.all(disposers.reverse().map((dispose) => dispose())); + }; + }, + ); + } + + async query(request: PluginLspRequest, signal?: AbortSignal): Promise { + validateRequest(request); + const route = this.routes.visible(request.sessionId).get(finalExtension(request.filePath)); + if (!route) throw new Error(`No LSP provider handles ${JSON.stringify(request.filePath)}`); + if (signal?.aborted) throw signal.reason ?? new Error('LSP query aborted'); + return await route.provider.query( + Object.freeze({ ...request, languageId: route.languageId }), + signal, + ); + } +} + +export function finalExtension(filePath: string): string { + const base = filePath.slice(Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\')) + 1); + const dot = base.lastIndexOf('.'); + return dot <= 0 ? '' : base.slice(dot).toLowerCase(); +} +function normalizeExtension(value: string): string { + const extension = value.toLowerCase(); + const result = extension.startsWith('.') ? extension : `.${extension}`; + if (!/^\.[^./\\]+$/u.test(result)) throw new TypeError(`Invalid LSP extension: ${value}`); + return result; +} +function validateProvider(provider: PluginLspProvider): void { + if ( + !provider.id.trim() || + typeof provider.query !== 'function' || + Object.keys(provider.extensionToLanguage).length === 0 + ) + throw new TypeError('Invalid LSP provider'); + for (const [extension, language] of Object.entries(provider.extensionToLanguage)) { + normalizeExtension(extension); + if (!language.trim()) throw new TypeError('LSP language id must not be empty'); + } +} +function validateRequest(request: PluginLspRequest): void { + if ( + !request.sessionId || + !request.filePath || + !Number.isSafeInteger(request.position.line) || + request.position.line < 0 || + !Number.isSafeInteger(request.position.character) || + request.position.character < 0 + ) + throw new TypeError('Invalid LSP query'); +} diff --git a/packages/runtime/src/plugin-session-query-service.ts b/packages/runtime/src/plugin-session-query-service.ts new file mode 100644 index 0000000000..3bb7e123a1 --- /dev/null +++ b/packages/runtime/src/plugin-session-query-service.ts @@ -0,0 +1,135 @@ +/* + * 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 { Service, type Context, type Disposable } from './plugin-kernel.js'; +import type { PluginAgentInvocation, PluginAgentService } from './plugin-agent-service.js'; + +declare module './plugin-kernel.js' { + interface Context { + readonly sessionQuery: PluginSessionQueryService; + } +} + +export interface PluginSessionSummary { + readonly id: string; + readonly title?: string; + readonly cwd?: string; + readonly status?: string; + readonly parentSessionId?: string; + readonly updatedAt?: string | number; +} + +export interface PluginSessionSnapshot { + readonly session: PluginSessionSummary; + readonly messages: readonly unknown[]; +} + +export interface PluginSessionSearchRequest { + readonly query: string; + readonly limit?: number; + readonly cursor?: string; +} + +export interface PluginSessionSearchPage { + readonly items: readonly PluginSessionSummary[]; + readonly cursor?: string; +} + +export interface PluginSessionQueryCaller { + readonly invocation?: PluginAgentInvocation; + /** Session-root activation is confined even when it is outside an Agent Tool call. */ + readonly scopeSessionId?: string; +} + +export interface PluginSessionQueryRuntime { + list(caller: PluginSessionQueryCaller): Promise; + read( + sessionId: string, + caller: PluginSessionQueryCaller, + ): Promise; + search( + request: PluginSessionSearchRequest, + caller: PluginSessionQueryCaller, + ): Promise; +} + +/** Read-only, paged Session projection. It never exposes the mutable Session Store. */ +export class PluginSessionQueryService extends Service { + private queryRuntime?: PluginSessionQueryRuntime; + + constructor( + ctx: Context, + private readonly agents: PluginAgentService, + ) { + super(ctx, 'sessionQuery'); + } + + bindRuntime(runtime: PluginSessionQueryRuntime): Disposable> { + if (this.ctx.maka) throw new Error('Only the Host may bind the Session Query Runtime'); + if (this.queryRuntime) throw new Error('Plugin Session Query Runtime is already bound'); + this.queryRuntime = runtime; + return this.ctx.effect( + () => () => { + if (this.queryRuntime === runtime) this.queryRuntime = undefined; + }, + 'sessionQuery.bindRuntime()', + ); + } + + list(): Promise { + return this.runtime().list(this.caller()); + } + + read(sessionId: string): Promise { + return this.runtime().read(assertSessionId(sessionId), this.caller()); + } + + search(request: PluginSessionSearchRequest): Promise { + if (!request.query.trim()) throw new TypeError('Session query must not be empty'); + if ( + request.limit !== undefined && + (!Number.isSafeInteger(request.limit) || request.limit < 1 || request.limit > 100) + ) { + throw new TypeError('Session query limit must be an integer from 1 to 100'); + } + return this.runtime().search( + Object.freeze({ ...request, query: request.query.trim() }), + this.caller(), + ); + } + + private runtime(): PluginSessionQueryRuntime { + if (!this.queryRuntime) throw new Error('Plugin Session Query Runtime is unavailable'); + return this.queryRuntime; + } + + private caller(): PluginSessionQueryCaller { + const invocation = this.agents.currentInvocation(); + if (invocation) return Object.freeze({ invocation }); + const rootId = this.ctx.maka?.rootId; + return Object.freeze( + rootId?.startsWith('session:') ? { scopeSessionId: rootId.slice('session:'.length) } : {}, + ); + } +} + +function assertSessionId(value: string): string { + if (!value || /[\0\r\n]/u.test(value)) throw new TypeError('Session id is invalid'); + return value; +} diff --git a/packages/runtime/src/plugin-shell-env-service.ts b/packages/runtime/src/plugin-shell-env-service.ts new file mode 100644 index 0000000000..11dedfdfae --- /dev/null +++ b/packages/runtime/src/plugin-shell-env-service.ts @@ -0,0 +1,144 @@ +/* + * 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 { Service, type Context } from './plugin-kernel.js'; +import { PluginScopeRegistry } from './plugin-scope-registry.js'; +import type { PluginAgentInvocation } from './plugin-agent-service.js'; +import { + pluginIdentity, + registerPluginContribution, + type MakaContributionIdentity, + type MakaPluginRootId, + MakaPluginRuntimeError, +} from './plugin-runtime.js'; + +declare module './plugin-kernel.js' { + interface Context { + readonly shellEnv: PluginShellEnvService; + } +} + +export interface PluginShellEnvVariable { + readonly description: string; + readonly sensitive?: boolean; +} +export interface PluginShellEnvContributor { + readonly name: string; + readonly variables: Readonly>; + readonly resolve: ( + invocation: PluginAgentInvocation, + ) => + | Readonly> + | Promise>>; +} +interface RegisteredVariable extends MakaContributionIdentity { + readonly contributor: PluginShellEnvContributor; + readonly key: string; + readonly token: symbol; + retired: boolean; +} + +/** Deterministic, declared environment overlay rebuilt for each Shell execution. */ +export class PluginShellEnvService extends Service { + private readonly variables = new PluginScopeRegistry(); + constructor(ctx: Context) { + super(ctx, 'shellEnv'); + } + + register(contributor: PluginShellEnvContributor): () => Promise { + validateContributor(contributor); + const identity = pluginIdentity(this.ctx); + if (identity.scopeId === 'desktop-ui') + throw new MakaPluginRuntimeError( + 'activation_failed', + 'desktop-ui plugins cannot contribute Shell environment', + ); + return registerPluginContribution( + this.ctx, + `shellEnv.register(${JSON.stringify(contributor.name)})`, + () => { + const rootId = identity.scopeId as MakaPluginRootId; + const keys = Object.keys(contributor.variables).sort(); + for (const key of keys) { + const existing = this.variables.get(rootId, key); + if (existing && existing.entryId !== identity.entryId) + throw new MakaPluginRuntimeError( + 'activation_failed', + `Shell environment key ${JSON.stringify(key)} is already registered by ${existing.entryId}`, + ); + } + const disposers = keys.map((key) => + this.variables.publish(rootId, key, { + ...identity, + contributor, + key, + token: Symbol(`${contributor.name}:${key}`), + retired: false, + }), + ); + return async () => { + await Promise.all(disposers.reverse().map((dispose) => dispose())); + }; + }, + ); + } + + async collect(invocation: PluginAgentInvocation): Promise>> { + const visible = this.variables.visible(invocation.sessionId); + const contributors = [ + ...new Set([...visible.values()].map(({ contributor }) => contributor)), + ].sort((a, b) => a.name.localeCompare(b.name)); + const output: Record = Object.create(null); + for (const contributor of contributors) { + const resolved = await contributor.resolve(invocation); + for (const [key, value] of Object.entries(resolved)) { + if (!Object.hasOwn(contributor.variables, key)) + throw new Error( + `Shell environment contributor ${JSON.stringify(contributor.name)} returned undeclared key ${JSON.stringify(key)}`, + ); + if (value !== undefined && typeof value !== 'string') + throw new TypeError(`Shell environment value ${JSON.stringify(key)} must be a string`); + if (value !== undefined) output[key] = value; + } + } + return Object.freeze( + Object.fromEntries( + Object.entries(output).sort(([left], [right]) => left.localeCompare(right)), + ), + ); + } +} + +function validateContributor(value: PluginShellEnvContributor): void { + if (!/^[a-z][a-z0-9-]*$/u.test(value.name) || typeof value.resolve !== 'function') + throw new TypeError('Invalid Shell environment contributor'); + const keys = Object.keys(value.variables); + if (keys.length === 0) + throw new TypeError('Shell environment contributor must declare at least one variable'); + for (const key of keys) { + if (!/^MAKA_PLUGIN_[A-Z][A-Z0-9_]*$/u.test(key)) + throw new TypeError( + `Plugin Shell environment key is outside the MAKA_PLUGIN_* namespace: ${key}`, + ); + if (!value.variables[key]!.description.trim()) + throw new TypeError(`Plugin Shell environment key must have a description: ${key}`); + if (/^(?:PATH|HOME|SHELL|NODE_OPTIONS|LD_|DYLD_)/u.test(key.slice('MAKA_PLUGIN_'.length))) + throw new TypeError(`Plugin Shell environment key is reserved: ${key}`); + } +} diff --git a/packages/runtime/src/plugin-shell-service.ts b/packages/runtime/src/plugin-shell-service.ts index 102a5115a7..06a9ea31ec 100644 --- a/packages/runtime/src/plugin-shell-service.ts +++ b/packages/runtime/src/plugin-shell-service.ts @@ -19,6 +19,7 @@ import { Service, type Context, type Disposable } from './plugin-kernel.js'; import type { PluginAgentInvocation, PluginAgentService } from './plugin-agent-service.js'; +import type { PluginShellEnvService } from './plugin-shell-env-service.js'; declare module './plugin-kernel.js' { interface Context { @@ -31,6 +32,8 @@ export interface PluginShellRunOptions { readonly timeoutMs?: number; readonly background?: boolean; readonly pty?: boolean; + /** Host-populated scoped overlay; callers cannot provide arbitrary ambient variables. */ + readonly environment?: Readonly>; } export interface PluginShellRuntime { @@ -47,6 +50,7 @@ export class PluginShellService extends Service { constructor( ctx: Context, private readonly agents: PluginAgentService, + private readonly shellEnv?: PluginShellEnvService, ) { super(ctx, 'shell'); } @@ -63,8 +67,14 @@ export class PluginShellService extends Service { ); } - run(options: PluginShellRunOptions): Promise { - return this.runtime().run(options, this.agents.requireInvocation()); + async run(options: PluginShellRunOptions): Promise { + if (options.environment !== undefined) throw new TypeError('Shell environment is Host-managed'); + const invocation = this.agents.requireInvocation(); + const environment = await this.shellEnv?.collect(invocation); + return this.runtime().run( + environment && Object.keys(environment).length > 0 ? { ...options, environment } : options, + invocation, + ); } read(ref: string): Promise { diff --git a/packages/runtime/src/plugin-skill-service.ts b/packages/runtime/src/plugin-skill-service.ts new file mode 100644 index 0000000000..ac0b246b6d --- /dev/null +++ b/packages/runtime/src/plugin-skill-service.ts @@ -0,0 +1,154 @@ +/* + * 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 { Service, type Context } from './plugin-kernel.js'; +import { PluginScopeRegistry } from './plugin-scope-registry.js'; +import { + pluginIdentity, + registerPluginContribution, + type MakaContributionIdentity, + type MakaPluginRootId, + MakaPluginRuntimeError, +} from './plugin-runtime.js'; + +declare module './plugin-kernel.js' { + interface Context { + readonly skills: PluginSkillService; + } +} + +export interface PluginSkillDefinition { + readonly name: string; + readonly description: string; + readonly instructions: string; + readonly declaredTools?: readonly string[]; + readonly requiredTools?: readonly string[]; + readonly modelInvocable?: boolean; + readonly userInvocable?: boolean; +} + +export interface PluginSkillInspection extends MakaContributionIdentity { + readonly name: string; +} + +interface RegisteredSkill extends MakaContributionIdentity { + readonly definition: PluginSkillDefinition; + readonly token: symbol; + retired: boolean; +} + +/** Profile/Session-scoped Skill contributions owned by the registering Fiber. */ +export class PluginSkillService extends Service { + private readonly registry = new PluginScopeRegistry(); + private revision = 0; + + constructor(ctx: Context) { + super(ctx, 'skills'); + } + + register(definition: PluginSkillDefinition): () => Promise { + validateSkill(definition); + const identity = pluginIdentity(this.ctx); + if (identity.scopeId === 'desktop-ui') + throw new MakaPluginRuntimeError( + 'activation_failed', + 'desktop-ui plugins cannot register Host skills', + ); + return registerPluginContribution( + this.ctx, + `skills.register(${JSON.stringify(definition.name)})`, + () => { + const rootId = identity.scopeId as MakaPluginRootId; + const existing = this.registry.get(rootId, definition.name); + if (existing && existing.entryId !== identity.entryId) + throw new MakaPluginRuntimeError( + 'activation_failed', + `Plugin Skill ${JSON.stringify(definition.name)} is already registered by ${existing.entryId}`, + ); + const dispose = this.registry.publish(rootId, definition.name, { + ...identity, + definition: freezeSkill(definition), + token: Symbol(definition.name), + retired: false, + }); + this.revision += 1; + return async () => { + await dispose(); + this.revision += 1; + }; + }, + ); + } + + resolve(sessionId: string): readonly PluginSkillDefinition[] { + return Object.freeze( + [...this.registry.visible(assertScope(sessionId)).values()] + .sort(compareIdentity) + .map(({ definition }) => definition), + ); + } + + get(sessionId: string, name: string): PluginSkillDefinition | undefined { + return this.registry.visible(assertScope(sessionId)).get(name)?.definition; + } + + snapshot(sessionId: string): { + readonly revision: number; + readonly skills: readonly PluginSkillDefinition[]; + } { + return Object.freeze({ revision: this.revision, skills: this.resolve(sessionId) }); + } + + inspect(rootId?: MakaPluginRootId): readonly PluginSkillInspection[] { + return Object.freeze( + [...this.registry.entries(rootId)] + .sort(compareIdentity) + .map(({ definition, ...identity }) => + Object.freeze({ ...identity, name: definition.name }), + ), + ); + } +} + +function validateSkill(value: PluginSkillDefinition): void { + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(value.name)) + throw new TypeError('Plugin Skill name must be lower-kebab-case'); + if (!value.description.trim() || !value.instructions.trim()) + throw new TypeError('Plugin Skill description and instructions are required'); + if (value.instructions.length > 256 * 1024) + throw new TypeError('Plugin Skill instructions exceed 256 KiB'); +} + +function freezeSkill(value: PluginSkillDefinition): PluginSkillDefinition { + return Object.freeze({ + ...value, + description: value.description.trim(), + instructions: value.instructions, + declaredTools: Object.freeze([...(value.declaredTools ?? [])]), + requiredTools: Object.freeze([...(value.requiredTools ?? [])]), + }); +} + +function assertScope(value: string): string { + if (!value || /[\0\r\n]/u.test(value)) throw new TypeError('Session id is invalid'); + return value; +} +function compareIdentity(left: MakaContributionIdentity, right: MakaContributionIdentity): number { + return left.entryId.localeCompare(right.entryId) || left.generation - right.generation; +} From c16b53c3a023f8f2681f774ef7213c7d4062f04a Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:21:47 +0800 Subject: [PATCH 12/13] fix(plugins): enforce scoped prompt and agent context --- .../interactive-run-composer.test.ts | 19 ++++ .../src/server/execution-composition.ts | 11 ++- .../src/server/interactive-run-composer.ts | 1 + .../__tests__/plugin-agent-service.test.ts | 39 +++++++- packages/runtime/src/plugin-agent-service.ts | 98 +++++++++---------- 5 files changed, 112 insertions(+), 56 deletions(-) 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 2a14f50fbe..cfec6c35e0 100644 --- a/packages/runtime-host/src/__tests__/interactive-run-composer.test.ts +++ b/packages/runtime-host/src/__tests__/interactive-run-composer.test.ts @@ -154,6 +154,25 @@ test('the composer caches the Host base but reassembles scoped Plugin prompts ea ); }); +test('the composer preserves scoped dynamic contexts for each model step', async () => { + const contexts = [{ name: 'plugin:context', text: 'EPHEMERAL_CONTEXT' }]; + const composer = createFixtureComposer({ + resolveAdditionalSystemPrompt: async (_context, baseText) => ({ + text: baseText, + contexts, + sourceRevisions: [], + }), + }); + + const prompt = await composer.resolveSystemPrompt({ + sessionId: 'session', + turnId: 'turn', + cwd: '/workspace', + }); + + assert.deepEqual(prompt.contexts, contexts); +}); + test('scoped Plugin Skill contributions join the canonical model inventory', async () => { const composer = createFixtureComposer({ skills: { diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 163f3b3642..9d6147430a 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1456,7 +1456,7 @@ export async function createExecutionRuntimeHostComposition( | undefined, ) => { const sessions = await manager!.listSessions(); - if (!initiator) return sessions; + if (!initiator) return []; const visible = new Set([initiator.sessionId]); let changed = true; while (changed) { @@ -1550,7 +1550,7 @@ export async function createExecutionRuntimeHostComposition( id: string, message: unknown, placement: 'current_turn' | 'next_turn', - initiator: import('@maka/runtime/plugin-agent-service').PluginAgentInvocation | undefined, + initiator: import('@maka/runtime/plugin-agent-service').PluginAgentInvocation, ) => { if (!(await visibleAgentSessions(initiator)).some((session) => session.id === id)) { throw new Error('Agent is outside the current ownership tree'); @@ -1578,7 +1578,7 @@ export async function createExecutionRuntimeHostComposition( }; pluginAgents.bindRuntime({ create: async (options, initiator) => { - const spawn = initiator?.toolContext?.spawnChildSession; + const spawn = initiator.toolContext?.spawnChildSession; if (!spawn) throw new Error('Agent creation requires an active Tool invocation'); if (!options.prompt?.trim()) throw new Error('Agent creation requires a prompt'); return new Promise((resolve, reject) => { @@ -1625,7 +1625,10 @@ export async function createExecutionRuntimeHostComposition( } await coordinator.stopSession(id, { source: 'stop_button' }); }, - whenIdle: async (id, signal) => { + whenIdle: async (id, signal, initiator) => { + if (!(await visibleAgentSessions(initiator)).some((session) => session.id === id)) { + throw new Error('Agent is outside the current ownership tree'); + } const wait = coordinator.whenIdle(id); if (!wait) return; if (!signal) return wait; diff --git a/packages/runtime-host/src/server/interactive-run-composer.ts b/packages/runtime-host/src/server/interactive-run-composer.ts index f42a1b2fd9..6ecb9f9697 100644 --- a/packages/runtime-host/src/server/interactive-run-composer.ts +++ b/packages/runtime-host/src/server/interactive-run-composer.ts @@ -287,6 +287,7 @@ export function createInteractiveRunComposer(input: InteractiveRunComposerInput) const plugin = await input.resolveAdditionalSystemPrompt(context, base.text); return Object.freeze({ text: plugin.text, + ...(plugin.contexts ? { contexts: plugin.contexts } : {}), sourceRevisions: mergeSourceRevisions(base.sourceRevisions, plugin.sourceRevisions), }); }; diff --git a/packages/runtime/src/__tests__/plugin-agent-service.test.ts b/packages/runtime/src/__tests__/plugin-agent-service.test.ts index 7738826554..64bfb691b0 100644 --- a/packages/runtime/src/__tests__/plugin-agent-service.test.ts +++ b/packages/runtime/src/__tests__/plugin-agent-service.test.ts @@ -66,7 +66,8 @@ test('Agent handles expose the complete control and query surface', async () => }, }; agents.bindRuntime(runtime); - const agent = await agents.create(); + const invocation = toolContext('session-a'); + const agent = await agents.withInvocation(invocation, () => agents.create()); await agent.followup('next'); await agent.steer('now'); await agent.inject('context'); @@ -94,6 +95,42 @@ test('Agent handles expose the complete control and query surface', async () => await root.fiber.dispose(); }); +test('Agent access fails closed without an invocation and handles retain their authority', async () => { + const root = new Context(); + const agents = new PluginAgentService(root); + const observed: string[] = []; + const descriptor = { id: 'child', sessionId: 'child', root: false }; + agents.bindRuntime({ + create: async (_options, initiator) => { + observed.push(`create:${initiator.sessionId}`); + return descriptor; + }, + resume: async () => descriptor, + get: async () => descriptor, + list: async () => [descriptor], + roots: async () => [], + followup: async (_id, _message, initiator) => { + observed.push(`followup:${initiator.sessionId}`); + }, + steer: async () => undefined, + inject: async () => undefined, + cancel: async () => undefined, + whenIdle: async () => undefined, + snapshot: async () => undefined, + inbox: async () => undefined, + result: async () => undefined, + artifacts: async () => undefined, + transcript: async () => undefined, + dispose: async () => undefined, + }); + + await assert.rejects(() => agents.list(), /requires an active Agent invocation/u); + const handle = await agents.withInvocation(toolContext('session-a'), () => agents.create()); + await agents.withInvocation(toolContext('session-b'), () => handle.followup('next')); + assert.deepEqual(observed, ['create:session-a', 'followup:session-a']); + await root.fiber.dispose(); +}); + function toolContext(sessionId: string): MakaToolContext { return { sessionId, diff --git a/packages/runtime/src/plugin-agent-service.ts b/packages/runtime/src/plugin-agent-service.ts index 7a254eb115..3f6ae13cc4 100644 --- a/packages/runtime/src/plugin-agent-service.ts +++ b/packages/runtime/src/plugin-agent-service.ts @@ -70,41 +70,30 @@ export interface PluginAgentResumeOptions { export interface PluginAgentRuntime { create( options: PluginAgentCreateOptions, - initiator: PluginAgentInvocation | undefined, + initiator: PluginAgentInvocation, ): Promise; resume( options: PluginAgentResumeOptions, - initiator: PluginAgentInvocation | undefined, + initiator: PluginAgentInvocation, ): Promise; - get( + get(id: string, initiator: PluginAgentInvocation): Promise; + list(initiator: PluginAgentInvocation): Promise; + roots(initiator: PluginAgentInvocation): Promise; + followup(id: string, message: unknown, initiator: PluginAgentInvocation): Promise; + steer(id: string, message: unknown, initiator: PluginAgentInvocation): Promise; + inject(id: string, message: unknown, initiator: PluginAgentInvocation): Promise; + cancel(id: string, initiator: PluginAgentInvocation): Promise; + whenIdle( id: string, - initiator: PluginAgentInvocation | undefined, - ): Promise; - list(initiator: PluginAgentInvocation | undefined): Promise; - roots(initiator: PluginAgentInvocation | undefined): Promise; - followup( - id: string, - message: unknown, - initiator: PluginAgentInvocation | undefined, - ): Promise; - steer( - id: string, - message: unknown, - initiator: PluginAgentInvocation | undefined, - ): Promise; - inject( - id: string, - message: unknown, - initiator: PluginAgentInvocation | undefined, - ): Promise; - cancel(id: string, initiator: PluginAgentInvocation | undefined): Promise; - whenIdle(id: string, signal: AbortSignal | undefined): Promise; - snapshot(id: string, initiator: PluginAgentInvocation | undefined): Promise; - inbox(id: string, initiator: PluginAgentInvocation | undefined): Promise; - result(id: string, initiator: PluginAgentInvocation | undefined): Promise; - artifacts(id: string, initiator: PluginAgentInvocation | undefined): Promise; - transcript(id: string, initiator: PluginAgentInvocation | undefined): Promise; - dispose(id: string, initiator: PluginAgentInvocation | undefined): Promise; + signal: AbortSignal | undefined, + initiator: PluginAgentInvocation, + ): Promise; + snapshot(id: string, initiator: PluginAgentInvocation): Promise; + inbox(id: string, initiator: PluginAgentInvocation): Promise; + result(id: string, initiator: PluginAgentInvocation): Promise; + artifacts(id: string, initiator: PluginAgentInvocation): Promise; + transcript(id: string, initiator: PluginAgentInvocation): Promise; + dispose(id: string, initiator: PluginAgentInvocation): Promise; } export interface PluginAgent { @@ -189,30 +178,35 @@ export class PluginAgentService extends Service { } async create(options: PluginAgentCreateOptions = {}): Promise { - return this.handle(await this.runtime().create(options, this.currentInvocation())); + const invocation = this.requireInvocation(); + return this.handle(await this.runtime().create(options, invocation), invocation); } async resume(options: PluginAgentResumeOptions): Promise { - return this.handle(await this.runtime().resume(options, this.currentInvocation())); + const invocation = this.requireInvocation(); + return this.handle(await this.runtime().resume(options, invocation), invocation); } async get(id: string): Promise { - const descriptor = await this.runtime().get(assertId(id), this.currentInvocation()); - return descriptor ? this.handle(descriptor) : undefined; + const invocation = this.requireInvocation(); + const descriptor = await this.runtime().get(assertId(id), invocation); + return descriptor ? this.handle(descriptor, invocation) : undefined; } async list(): Promise { + const invocation = this.requireInvocation(); return Object.freeze( - (await this.runtime().list(this.currentInvocation())).map((descriptor) => - this.handle(descriptor), + (await this.runtime().list(invocation)).map((descriptor) => + this.handle(descriptor, invocation), ), ); } async roots(): Promise { + const invocation = this.requireInvocation(); return Object.freeze( - (await this.runtime().roots(this.currentInvocation())).map((descriptor) => - this.handle(descriptor), + (await this.runtime().roots(invocation)).map((descriptor) => + this.handle(descriptor, invocation), ), ); } @@ -222,25 +216,27 @@ export class PluginAgentService extends Service { return this.agentRuntime; } - private handle(descriptor: PluginAgentDescriptor): PluginAgent { + private handle( + descriptor: PluginAgentDescriptor, + invocation: PluginAgentInvocation = this.requireInvocation(), + ): PluginAgent { const service = this; const id = assertId(descriptor.id); - const invoke = () => service.currentInvocation(); return Object.freeze({ ...descriptor, id, - followup: (message: unknown) => service.runtime().followup(id, message, invoke()), - steer: (message: unknown) => service.runtime().steer(id, message, invoke()), - inject: (message: unknown) => service.runtime().inject(id, message, invoke()), - cancel: () => service.runtime().cancel(id, invoke()), + followup: (message: unknown) => service.runtime().followup(id, message, invocation), + steer: (message: unknown) => service.runtime().steer(id, message, invocation), + inject: (message: unknown) => service.runtime().inject(id, message, invocation), + cancel: () => service.runtime().cancel(id, invocation), whenIdle: (signal?: AbortSignal) => - service.runtime().whenIdle(id, signal ?? invoke()?.abortSignal), - snapshot: () => service.runtime().snapshot(id, invoke()), - inbox: () => service.runtime().inbox(id, invoke()), - result: () => service.runtime().result(id, invoke()), - artifacts: () => service.runtime().artifacts(id, invoke()), - transcript: () => service.runtime().transcript(id, invoke()), - dispose: () => service.runtime().dispose(id, invoke()), + service.runtime().whenIdle(id, signal ?? invocation.abortSignal, invocation), + snapshot: () => service.runtime().snapshot(id, invocation), + inbox: () => service.runtime().inbox(id, invocation), + result: () => service.runtime().result(id, invocation), + artifacts: () => service.runtime().artifacts(id, invocation), + transcript: () => service.runtime().transcript(id, invocation), + dispose: () => service.runtime().dispose(id, invocation), }); } } From b906800154ed198c8c73eb4254e83a3ef94a15b6 Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:49:13 +0800 Subject: [PATCH 13/13] fix(plugins): preserve contribution and cancellation authority --- .../__tests__/plugin-agent-service.test.ts | 47 ++++++++ .../plugin-interaction-services.test.ts | 33 ++++++ .../src/__tests__/plugin-llm-service.test.ts | 110 ++++++++++++++++++ .../plugin-resource-services.test.ts | 43 +++++++ packages/runtime/src/plugin-agent-service.ts | 21 +++- .../runtime/src/plugin-invocation-signal.ts | 27 +++++ packages/runtime/src/plugin-llm-service.ts | 72 +++++++----- .../src/plugin-user-question-service.ts | 5 +- packages/runtime/src/plugin-web-service.ts | 5 +- 9 files changed, 327 insertions(+), 36 deletions(-) create mode 100644 packages/runtime/src/plugin-invocation-signal.ts diff --git a/packages/runtime/src/__tests__/plugin-agent-service.test.ts b/packages/runtime/src/__tests__/plugin-agent-service.test.ts index 64bfb691b0..a4aba47b0b 100644 --- a/packages/runtime/src/__tests__/plugin-agent-service.test.ts +++ b/packages/runtime/src/__tests__/plugin-agent-service.test.ts @@ -131,6 +131,53 @@ test('Agent access fails closed without an invocation and handles retain their a await root.fiber.dispose(); }); +test('Agent custom cancellation preserves the originating invocation cancellation', async () => { + const root = new Context(); + const agents = new PluginAgentService(root); + const descriptor = { id: 'child', sessionId: 'child', root: false }; + const signals: AbortSignal[] = []; + agents.bindRuntime({ + create: async (options) => { + if (options.signal) signals.push(options.signal); + return descriptor; + }, + resume: async (options) => { + if (options.signal) signals.push(options.signal); + return descriptor; + }, + get: async () => descriptor, + list: async () => [descriptor], + roots: async () => [], + followup: async () => undefined, + steer: async () => undefined, + inject: async () => undefined, + cancel: async () => undefined, + whenIdle: async (_id, signal) => { + if (signal) signals.push(signal); + }, + snapshot: async () => undefined, + inbox: async () => undefined, + result: async () => undefined, + artifacts: async () => undefined, + transcript: async () => undefined, + dispose: async () => undefined, + }); + const hostAbort = new AbortController(); + const pluginAbort = new AbortController(); + const invocation = { ...toolContext('session-a'), abortSignal: hostAbort.signal }; + + await agents.withInvocation(invocation, async () => { + const created = await agents.create({ signal: pluginAbort.signal }); + await agents.resume({ sessionId: 'child', signal: pluginAbort.signal }); + await created.whenIdle(pluginAbort.signal); + }); + hostAbort.abort(new Error('Host stopped')); + assert.equal(signals.length, 3); + assert.ok(signals.every((signal) => signal.aborted)); + assert.equal(pluginAbort.signal.aborted, false); + await root.fiber.dispose(); +}); + function toolContext(sessionId: string): MakaToolContext { return { sessionId, diff --git a/packages/runtime/src/__tests__/plugin-interaction-services.test.ts b/packages/runtime/src/__tests__/plugin-interaction-services.test.ts index 54694edcfe..afc6f2b1f5 100644 --- a/packages/runtime/src/__tests__/plugin-interaction-services.test.ts +++ b/packages/runtime/src/__tests__/plugin-interaction-services.test.ts @@ -102,3 +102,36 @@ test('interaction services reject calls outside an Agent invocation', async () = ); await root.fiber.dispose(); }); + +test('form custom cancellation preserves Host invocation cancellation', async () => { + const root = new Context(); + const agents = new PluginAgentService(root); + const questions = new PluginUserQuestionService(root, agents); + const hostAbort = new AbortController(); + const pluginAbort = new AbortController(); + let observed: AbortSignal | undefined; + const context: MakaToolContext = { + sessionId: 'session-a', + turnId: 'turn-a', + cwd: '/workspace', + toolCallId: 'call-a', + abortSignal: hostAbort.signal, + emitOutput: () => undefined, + requestUserForm: async (_form, options) => { + observed = options?.cancellationSignal; + return { action: 'cancel', values: {} }; + }, + }; + + await agents.withInvocation(context, () => + questions.requestForm( + { message: 'Choose', requester: { name: 'fixture' }, fields: [] }, + { signal: pluginAbort.signal }, + ), + ); + assert.equal(observed?.aborted, false); + hostAbort.abort(new Error('Host stopped')); + assert.equal(observed?.aborted, true); + assert.equal(pluginAbort.signal.aborted, false); + await root.fiber.dispose(); +}); diff --git a/packages/runtime/src/__tests__/plugin-llm-service.test.ts b/packages/runtime/src/__tests__/plugin-llm-service.test.ts index 0cbf62c073..9603b6ff41 100644 --- a/packages/runtime/src/__tests__/plugin-llm-service.test.ts +++ b/packages/runtime/src/__tests__/plugin-llm-service.test.ts @@ -22,6 +22,7 @@ import { test } from 'node:test'; import { PluginAgentService } from '../plugin-agent-service.js'; import { Context } from '../plugin-kernel.js'; import { PluginLlmService } from '../plugin-llm-service.js'; +import { MakaPluginTransactionBuffer } from '../plugin-runtime.js'; import type { MakaToolContext } from '../tool-runtime.js'; test('llm generation uses Host authority unless a matching adapter overrides it', async () => { @@ -53,3 +54,112 @@ test('llm generation uses Host authority unless a matching adapter overrides it' }); await root.fiber.dispose(); }); + +test('LLM adapters publish atomically across hot reload and never revive retired generations', async () => { + const root = new Context(); + const agents = new PluginAgentService(root); + const llm = new PluginLlmService(root, agents); + llm.bindRuntime({ + generate: async () => ({ text: 'host', modelId: 'host' }), + }); + const previous = root.extend({ + maka: { rootId: 'profile', packageId: 'fixture', entryId: 'fixture', generation: 1 }, + }); + const disposePrevious = previous.llm.register(adapter('previous')); + const candidateOwner = root.extend({ + maka: { rootId: 'profile', packageId: 'fixture', entryId: 'fixture', generation: 2 }, + }); + const transaction = new MakaPluginTransactionBuffer(candidateOwner); + const candidate = candidateOwner.extend({ makaTransaction: transaction }); + const disposeCandidate = candidate.llm.register(adapter('candidate')); + + assert.equal(await generate(agents, llm), 'previous', 'staged candidates stay invisible'); + await transaction.commit(); + assert.equal(await generate(agents, llm), 'candidate'); + + await disposePrevious(); + assert.equal( + await generate(agents, llm), + 'candidate', + 'retiring the old generation keeps the new', + ); + await disposeCandidate(); + assert.equal(await generate(agents, llm), 'host', 'unload cannot revive the retired generation'); + await root.fiber.dispose(); +}); + +test('failed LLM adapter publication restores the live generation', async () => { + const root = new Context(); + const agents = new PluginAgentService(root); + const llm = new PluginLlmService(root, agents); + llm.bindRuntime({ + generate: async () => ({ text: 'host', modelId: 'host' }), + }); + const previous = root.extend({ + maka: { rootId: 'profile', packageId: 'fixture', entryId: 'fixture', generation: 1 }, + }); + previous.llm.register(adapter('previous')); + const candidateOwner = root.extend({ + maka: { rootId: 'profile', packageId: 'fixture', entryId: 'fixture', generation: 2 }, + }); + const transaction = new MakaPluginTransactionBuffer(candidateOwner); + const candidate = candidateOwner.extend({ makaTransaction: transaction }); + candidate.llm.register(adapter('candidate')); + transaction.stage('fixture.failure', () => { + throw new Error('candidate activation failed'); + }); + + await assert.rejects(() => transaction.commit(), /candidate activation failed/u); + assert.equal(await generate(agents, llm), 'previous'); + await root.fiber.dispose(); +}); + +test('LLM adapter custom cancellation preserves Host cancellation', async () => { + const root = new Context(); + const agents = new PluginAgentService(root); + const llm = new PluginLlmService(root, agents); + const hostAbort = new AbortController(); + const pluginAbort = new AbortController(); + let observed: AbortSignal | undefined; + llm.bindRuntime({ + generate: async (input) => { + observed = input.signal; + return { text: 'host', modelId: 'host' }; + }, + }); + + await agents.withInvocation(toolContext(hostAbort.signal), () => + llm.generate({ prompt: 'hello', signal: pluginAbort.signal }), + ); + assert.equal(observed?.aborted, false); + hostAbort.abort(new Error('Host stopped')); + assert.equal(observed?.aborted, true); + assert.equal(pluginAbort.signal.aborted, false); + await root.fiber.dispose(); +}); + +function adapter(text: string) { + return { + id: 'fixture.model', + supports: (model: string) => model === 'fixture/model', + generate: async () => ({ text, modelId: 'fixture/model' }), + }; +} + +async function generate(agents: PluginAgentService, llm: PluginLlmService): Promise { + return await agents.withInvocation( + toolContext(), + async () => (await llm.generate({ prompt: 'hello', model: 'fixture/model' })).text, + ); +} + +function toolContext(abortSignal = new AbortController().signal): MakaToolContext { + return { + sessionId: 'session-a', + turnId: 'turn-a', + cwd: '/workspace', + toolCallId: 'call-a', + abortSignal, + emitOutput: () => undefined, + }; +} diff --git a/packages/runtime/src/__tests__/plugin-resource-services.test.ts b/packages/runtime/src/__tests__/plugin-resource-services.test.ts index 8019cdf031..e83d8504f7 100644 --- a/packages/runtime/src/__tests__/plugin-resource-services.test.ts +++ b/packages/runtime/src/__tests__/plugin-resource-services.test.ts @@ -100,3 +100,46 @@ test('resource services preserve the current Session and cancellation context', ]); await root.fiber.dispose(); }); + +test('Web custom cancellation cannot replace Host invocation cancellation', async () => { + const root = new Context(); + const agents = new PluginAgentService(root); + const web = new PluginWebService(root, agents); + const hostAbort = new AbortController(); + const pluginAbort = new AbortController(); + const signals: AbortSignal[] = []; + web.bindRuntime({ + search: async (input) => { + if (input.abortSignal) signals.push(input.abortSignal); + return { ok: true, provider: 'tavily', results: [] }; + }, + fetch: async (input) => { + if (input.abortSignal) signals.push(input.abortSignal); + return 'body'; + }, + }); + const context: MakaToolContext = { + sessionId: 'session-a', + turnId: 'turn-a', + cwd: '/workspace', + toolCallId: 'call-a', + abortSignal: hostAbort.signal, + emitOutput: () => undefined, + }; + + await agents.withInvocation(context, async () => { + await web.search('maka', { signal: pluginAbort.signal }); + await web.fetch('https://example.com', { signal: pluginAbort.signal }); + }); + assert.deepEqual( + signals.map((signal) => signal.aborted), + [false, false], + ); + hostAbort.abort(new Error('Host stopped')); + assert.deepEqual( + signals.map((signal) => signal.aborted), + [true, true], + ); + assert.equal(pluginAbort.signal.aborted, false); + await root.fiber.dispose(); +}); diff --git a/packages/runtime/src/plugin-agent-service.ts b/packages/runtime/src/plugin-agent-service.ts index 3f6ae13cc4..727ee4639b 100644 --- a/packages/runtime/src/plugin-agent-service.ts +++ b/packages/runtime/src/plugin-agent-service.ts @@ -22,6 +22,7 @@ import type { PermissionMode } from '@maka/core/permission'; import type { ExecutionBoundary } from '@maka/core/sandbox-boundary'; import type { AgentProfile } from './agent-catalog.js'; import { Service, type Context, type Disposable } from './plugin-kernel.js'; +import { pluginInvocationSignal } from './plugin-invocation-signal.js'; import type { MakaToolContext } from './tool-runtime.js'; declare module './plugin-kernel.js' { @@ -179,12 +180,24 @@ export class PluginAgentService extends Service { async create(options: PluginAgentCreateOptions = {}): Promise { const invocation = this.requireInvocation(); - return this.handle(await this.runtime().create(options, invocation), invocation); + return this.handle( + await this.runtime().create( + { ...options, signal: pluginInvocationSignal(invocation.abortSignal, options.signal) }, + invocation, + ), + invocation, + ); } async resume(options: PluginAgentResumeOptions): Promise { const invocation = this.requireInvocation(); - return this.handle(await this.runtime().resume(options, invocation), invocation); + return this.handle( + await this.runtime().resume( + { ...options, signal: pluginInvocationSignal(invocation.abortSignal, options.signal) }, + invocation, + ), + invocation, + ); } async get(id: string): Promise { @@ -230,7 +243,9 @@ export class PluginAgentService extends Service { inject: (message: unknown) => service.runtime().inject(id, message, invocation), cancel: () => service.runtime().cancel(id, invocation), whenIdle: (signal?: AbortSignal) => - service.runtime().whenIdle(id, signal ?? invocation.abortSignal, invocation), + service + .runtime() + .whenIdle(id, pluginInvocationSignal(invocation.abortSignal, signal), invocation), snapshot: () => service.runtime().snapshot(id, invocation), inbox: () => service.runtime().inbox(id, invocation), result: () => service.runtime().result(id, invocation), diff --git a/packages/runtime/src/plugin-invocation-signal.ts b/packages/runtime/src/plugin-invocation-signal.ts new file mode 100644 index 0000000000..694402caf4 --- /dev/null +++ b/packages/runtime/src/plugin-invocation-signal.ts @@ -0,0 +1,27 @@ +/* + * 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. + */ + +/** Preserve Host cancellation when a plugin adds its own cancellation source. */ +export function pluginInvocationSignal( + invocationSignal: AbortSignal, + pluginSignal?: AbortSignal, +): AbortSignal { + if (!pluginSignal || pluginSignal === invocationSignal) return invocationSignal; + return AbortSignal.any([invocationSignal, pluginSignal]); +} diff --git a/packages/runtime/src/plugin-llm-service.ts b/packages/runtime/src/plugin-llm-service.ts index a00d7cc37e..63e351d401 100644 --- a/packages/runtime/src/plugin-llm-service.ts +++ b/packages/runtime/src/plugin-llm-service.ts @@ -19,6 +19,15 @@ import { Service, type Context, type Disposable } from './plugin-kernel.js'; import type { PluginAgentInvocation, PluginAgentService } from './plugin-agent-service.js'; +import { pluginInvocationSignal } from './plugin-invocation-signal.js'; +import { + MakaPluginRuntimeError, + pluginIdentity, + registerPluginContribution, + type MakaContributionIdentity, + type MakaPluginRootId, +} from './plugin-runtime.js'; +import { PluginScopeRegistry } from './plugin-scope-registry.js'; declare module './plugin-kernel.js' { interface Context { @@ -56,10 +65,16 @@ export interface PluginLlmAdapter { ): Promise; } +interface RegisteredAdapter extends MakaContributionIdentity { + readonly adapter: PluginLlmAdapter; + readonly token: symbol; + retired: boolean; +} + /** Metered Host model calls plus an ordered plugin adapter seam. */ export class PluginLlmService extends Service { private llmRuntime?: PluginLlmRuntime; - private readonly adapters: Array<{ adapter: PluginLlmAdapter; owner: Context }> = []; + private readonly adapters = new PluginScopeRegistry(); constructor( ctx: Context, @@ -87,46 +102,43 @@ export class PluginLlmService extends Service { if (typeof adapter.supports !== 'function' || typeof adapter.generate !== 'function') { throw new TypeError(`LLM adapter implementation is invalid: ${adapter.id}`); } - if ( - this.adapters.some( - (entry) => - entry.adapter.id === adapter.id && entry.owner.maka?.rootId === this.ctx.maka?.rootId, - ) - ) { - throw new Error(`LLM adapter is already registered in this scope: ${adapter.id}`); - } - const entry = { adapter, owner: this.ctx }; - this.adapters.push(entry); - return this.ctx.effect( - () => () => { - const index = this.adapters.indexOf(entry); - if (index >= 0) this.adapters.splice(index, 1); - }, - `llm.adapter:${adapter.id}`, - ); + const identity = pluginIdentity(this.ctx); + return registerPluginContribution(this.ctx, `llm.adapter:${adapter.id}`, () => { + const rootId = identity.scopeId as MakaPluginRootId; + const existing = this.adapters.get(rootId, adapter.id); + if (existing && existing.entryId !== identity.entryId) { + throw new MakaPluginRuntimeError( + 'activation_failed', + `LLM adapter is already registered in this scope: ${adapter.id}`, + ); + } + const entry: RegisteredAdapter = { + ...identity, + adapter, + token: Symbol(adapter.id), + retired: false, + }; + return this.adapters.publish(rootId, adapter.id, entry); + }); } generate( input: PluginLlmGenerateInput & { readonly model?: string }, ): Promise { const invocation = this.agents.requireInvocation(); - const visibleAdapters = new Map(); - for (const entry of this.adapters) { - if (entry.owner.maka?.rootId === 'profile') - visibleAdapters.set(entry.adapter.id, entry.adapter); - } - for (const entry of this.adapters) { - if (entry.owner.maka?.rootId === `session:${invocation.sessionId}`) { - visibleAdapters.set(entry.adapter.id, entry.adapter); - } - } + const effectiveInput = Object.freeze({ + ...input, + signal: pluginInvocationSignal(invocation.abortSignal, input.signal), + }); + const visibleAdapters = this.adapters.visible(invocation.sessionId); const adapter = input.model ? [...visibleAdapters.values()] + .map((entry) => entry.adapter) .filter((candidate) => candidate.supports(input.model!)) .sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0))[0] : undefined; - if (adapter) return adapter.generate(input, invocation); + if (adapter) return adapter.generate(effectiveInput, invocation); if (!this.llmRuntime) throw new Error('Plugin LLM Runtime is unavailable'); - return this.llmRuntime.generate(input, invocation); + return this.llmRuntime.generate(effectiveInput, invocation); } } diff --git a/packages/runtime/src/plugin-user-question-service.ts b/packages/runtime/src/plugin-user-question-service.ts index 4bfe408845..9499519f90 100644 --- a/packages/runtime/src/plugin-user-question-service.ts +++ b/packages/runtime/src/plugin-user-question-service.ts @@ -21,6 +21,7 @@ import type { InteractionFormInput, InteractionFormResult } from '@maka/core/int import type { UserQuestion, UserQuestionResult } from '@maka/core/user-question'; import { Service, type Context } from './plugin-kernel.js'; import type { PluginAgentService } from './plugin-agent-service.js'; +import { pluginInvocationSignal } from './plugin-invocation-signal.js'; declare module './plugin-kernel.js' { interface Context { @@ -50,6 +51,8 @@ export class PluginUserQuestionService extends Service { const invocation = this.agents.requireInvocation(); const request = invocation.toolContext?.requestUserForm; if (!request) throw new Error('Structured user forms are unavailable on this Agent surface'); - return request(form, { cancellationSignal: options.signal ?? invocation.abortSignal }); + return request(form, { + cancellationSignal: pluginInvocationSignal(invocation.abortSignal, options.signal), + }); } } diff --git a/packages/runtime/src/plugin-web-service.ts b/packages/runtime/src/plugin-web-service.ts index ffcfd9204d..ad4c8277c9 100644 --- a/packages/runtime/src/plugin-web-service.ts +++ b/packages/runtime/src/plugin-web-service.ts @@ -25,6 +25,7 @@ import { } from '@maka/core/web-search'; import { Service, type Context, type Disposable } from './plugin-kernel.js'; import type { PluginAgentService } from './plugin-agent-service.js'; +import { pluginInvocationSignal } from './plugin-invocation-signal.js'; declare module './plugin-kernel.js' { interface Context { @@ -77,7 +78,7 @@ export class PluginWebService extends Service { query: normalized, limit: normalizeWebSearchLimit(options.limit ?? WEB_SEARCH_DEFAULT_LIMIT), sessionId: invocation.sessionId, - abortSignal: options.signal ?? invocation.abortSignal, + abortSignal: pluginInvocationSignal(invocation.abortSignal, options.signal), }); } @@ -89,7 +90,7 @@ export class PluginWebService extends Service { return this.runtime().fetch({ url: parsed.toString(), sessionId: invocation.sessionId, - abortSignal: options.signal ?? invocation.abortSignal, + abortSignal: pluginInvocationSignal(invocation.abortSignal, options.signal), }); }