From c6727723a75eb4c450a6f86d45c55f37adde1a83 Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:38:41 +0800 Subject: [PATCH 1/2] feat(runtime): add plugin-backed Session executors Allow Host plugins to register scoped black-box executors through ctx.executors and route their output through the existing Session event stream. Persist executor selection across ordinary Sessions, WorkHub-created work, child agents, Graph operators, and Session revisions without exposing Maka tools to the external runtime. Generated-by: Codex --- ...time-host-session-catalog-ipc-main.test.ts | 20 ++ .../src/main/__tests__/session-local.test.ts | 41 +++ .../runtime-host-session-catalog-ipc-main.ts | 11 +- .../desktop/src/main/session-local-service.ts | 16 +- .../__tests__/agent-graph-schedule.test.ts | 23 ++ .../__tests__/session-send-projection.test.ts | 17 + packages/core/src/agent-graph-schedule.ts | 24 +- packages/core/src/runtime-event.ts | 2 +- packages/core/src/runtime-inputs.ts | 10 +- packages/core/src/session-send-projection.ts | 12 +- packages/core/src/session.ts | 18 +- packages/eval/src/harbor-maka-subject.ts | 5 +- .../host-session-availability.test.ts | 16 + .../src/__tests__/plugin-platform.test.ts | 50 +++ .../session-catalog-coordinator.test.ts | 48 +++ .../session-catalog-protocol.test.ts | 44 +++ .../workhub-coordination-protocol.test.ts | 24 ++ .../src/client/session-catalog-summary.ts | 1 + packages/runtime-host/src/protocol/index.ts | 3 +- .../src/protocol/plugin-platform.ts | 54 ++- .../src/protocol/session-catalog.ts | 42 ++- .../src/server/child-agent-composition.ts | 1 + .../src/server/execution-composition.ts | 55 ++- .../src/server/host-session-availability.ts | 4 +- .../src/server/plugin-platform-coordinator.ts | 16 +- .../src/server/plugin-platform.ts | 11 + .../src/server/session-catalog-coordinator.ts | 88 ++++- .../server/session-revision-coordinator.ts | 1 + packages/runtime/package.json | 2 + .../__tests__/plugin-executor-backend.test.ts | 107 ++++++ .../__tests__/plugin-executor-service.test.ts | 144 ++++++++ .../src/__tests__/session-manager.test.ts | 124 ++++++- .../src/__tests__/subagent-tools.test.ts | 4 + packages/runtime/src/plugin-agent-service.ts | 1 + .../runtime/src/plugin-executor-backend.ts | 255 ++++++++++++++ .../runtime/src/plugin-executor-service.ts | 325 ++++++++++++++++++ packages/runtime/src/runtime-kernel.ts | 6 + packages/runtime/src/session-manager.ts | 168 +++++---- .../src/stream-graph-schedule-reconcile.ts | 1 + .../src/stream-graph-supervisor-tools.ts | 34 +- packages/runtime/src/subagent-tools.ts | 7 + packages/runtime/src/tool-runtime.ts | 3 + .../src/__tests__/session-store.test.ts | 28 ++ packages/storage/src/legacy-run-header.ts | 2 +- packages/storage/src/session-store.ts | 17 +- .../src/sqlite-session-metadata-store.ts | 3 +- 46 files changed, 1748 insertions(+), 140 deletions(-) create mode 100644 packages/runtime/src/__tests__/plugin-executor-backend.test.ts create mode 100644 packages/runtime/src/__tests__/plugin-executor-service.test.ts create mode 100644 packages/runtime/src/plugin-executor-backend.ts create mode 100644 packages/runtime/src/plugin-executor-service.ts diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-catalog-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-ipc-main.test.ts index 9cb9e8b6ec..0f7cff7040 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-catalog-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-ipc-main.test.ts @@ -62,6 +62,26 @@ test('session creation forwards the caller name for a mode that carries none', a ); }); +test('session creation forwards a plugin executor without a model target', async () => { + const creates: SessionCreateInput[] = []; + const ipc = ipcHarness(); + registerRuntimeHostSessionCatalogIpc(createDeps(creates), ipc as unknown as IpcMain); + + await ipc.invoke('sessions:create', { executorId: 'codex.app-server' }); + + assert.equal(creates[0]?.executorId, 'codex.app-server'); + assert.equal(creates[0]?.modelTarget, undefined); + await assert.rejects( + ipc.invoke('sessions:create', { + executorId: 'codex', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai', + model: 'gpt-5', + }), + /cannot include a model target/, + ); +}); + type IpcHandler = Parameters['handle']>[1]; function ipcHarness() { diff --git a/apps/desktop/src/main/__tests__/session-local.test.ts b/apps/desktop/src/main/__tests__/session-local.test.ts index 3e5e33ecd2..7e05626ee1 100644 --- a/apps/desktop/src/main/__tests__/session-local.test.ts +++ b/apps/desktop/src/main/__tests__/session-local.test.ts @@ -638,6 +638,47 @@ test('attachment retries across restart reuse committed uploads and release stag assert.deepEqual(db.store.stagedAttachments('authority', 'message-1'), []); }); +test('local creation preserves a plugin executor in the pending Session projection', async (t) => { + const { store, beforeClose } = await database(t); + const target: DesktopSessionLocalTarget = { + partition: 'authority', + profileId: 'profile', + scope: { hostId: 'root', targetEpoch: 'target' }, + }; + const service = new DesktopSessionLocalService(store, { + targets: () => [target], + changed() {}, + onError: (error) => assert.fail(String(error)), + }); + beforeClose.push(() => service.close()); + type Ipc = Parameters[0]['ipcMain']; + let create!: Parameters[1]; + registerDesktopSessionLocalIpc({ + ipcMain: { + handle: (channel, handler) => { + if (channel === 'session-local:create') create = handler; + }, + }, + service, + approvals: createAttachmentApprovalRegistry(), + resizeImage: async (bytes) => bytes, + resolveWorkspace: async () => ({ kind: 'host_path', path: '/workspace' }), + changed() {}, + }); + + const summary = (await create( + {} as IpcMainInvokeEvent, + target.scope, + { executorId: 'codex.app-server' }, + )) as DesktopSessionSummaryInput; + assert.equal(summary.backend, 'plugin-executor'); + assert.equal(summary.executorId, 'codex.app-server'); + assert.equal(summary.llmConnectionId, undefined); + assert.equal(summary.llmConnectionSlug, 'executor:codex.app-server'); + assert.equal(summary.model, 'codex.app-server'); + assert.equal(store.creation(target.partition, summary.id)?.executorId, 'codex.app-server'); +}); + test('local submit preserves picked-file approvals until durable admission succeeds', async (t) => { const { store, path, beforeClose } = await database(t); const file = join(path, '..', 'picked.txt'); diff --git a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts index a539f2aaef..a221e8fc03 100644 --- a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts @@ -322,12 +322,21 @@ function normalizeSessionListFilter(value: unknown): SessionListFilter | undefin export function resolveDesktopSessionCreateInput(input: CreateSessionRequestInput | undefined, sessionId: string, workspace: WorkspaceTarget): SessionCreateInput { const request = resolveCreateSessionRequest(input); + const executorId = normalizeOptionalString(input?.executorId, 'executor id'); + if ( + executorId && + (input?.llmConnectionId !== undefined || + input?.llmConnectionSlug !== undefined || + input?.model !== undefined) + ) { + throw new Error('Plugin executor selection cannot include a model target'); + } return { sessionId, workspace, ...(request.mode === undefined ? {} : { mode: request.mode }), name: request.name, ...(request.labels === undefined ? {} : { labels: request.labels }), - modelTarget: normalizeModelTarget(input), + ...(executorId ? { executorId } : { modelTarget: normalizeModelTarget(input) }), ...normalizeCreateThinkingLevel(input?.thinkingLevel), ...(request.mode !== undefined || request.permissionMode === undefined ? {} : { permissionMode: request.permissionMode }), collaborationMode: request.collaborationMode, diff --git a/apps/desktop/src/main/session-local-service.ts b/apps/desktop/src/main/session-local-service.ts index 15805d74b6..a8624a04fb 100644 --- a/apps/desktop/src/main/session-local-service.ts +++ b/apps/desktop/src/main/session-local-service.ts @@ -537,10 +537,18 @@ export function registerDesktopSessionLocalIpc(deps: { labels: [...(creation.labels ?? [])], hasUnread: false, status: 'active', - backend: 'ai-sdk', - llmConnectionSlug: input.llmConnectionSlug ?? '', - model: input.model ?? '', - ...(input.llmConnectionId ? { llmConnectionId: input.llmConnectionId } : {}), + backend: creation.executorId ? 'plugin-executor' : 'ai-sdk', + ...(creation.executorId + ? { + executorId: creation.executorId, + llmConnectionSlug: `executor:${creation.executorId}`, + model: creation.executorId, + } + : { + llmConnectionSlug: input.llmConnectionSlug ?? '', + model: input.model ?? '', + ...(input.llmConnectionId ? { llmConnectionId: input.llmConnectionId } : {}), + }), connectionLocked: false, permissionMode: creation.permissionMode ?? 'ask', collaborationMode: creation.collaborationMode, diff --git a/packages/core/src/__tests__/agent-graph-schedule.test.ts b/packages/core/src/__tests__/agent-graph-schedule.test.ts index c50b36440f..01d8d6efe2 100644 --- a/packages/core/src/__tests__/agent-graph-schedule.test.ts +++ b/packages/core/src/__tests__/agent-graph-schedule.test.ts @@ -34,6 +34,29 @@ describe('agent graph schedule contract', () => { assert.equal(isAgentGraphScheduleUpdateRequest(request), true); }); + test('accepts a plugin executor only on newly created graph targets', () => { + const request = scheduleRequest(); + request.addWork[0]!.target = { + kind: 'agent', + agentId: 'fact-checker', + executorId: 'codex.app-server', + }; + assert.equal(isAgentGraphScheduleUpdateRequest(request), true); + + request.addWork[0]!.target = { + kind: 'agent', + agentId: 'fact-checker', + executorId: 'invalid executor', + }; + assert.equal(isAgentGraphScheduleUpdateRequest(request), false); + request.addWork[0]!.target = { + kind: 'operator', + operatorId: 'existing-operator', + executorId: 'codex', + } as never; + assert.equal(isAgentGraphScheduleUpdateRequest(request), false); + }); + test('rejects ambiguous, duplicate, empty, and add-plus-finish updates', () => { assert.equal( isAgentGraphScheduleUpdateRequest({ diff --git a/packages/core/src/__tests__/session-send-projection.test.ts b/packages/core/src/__tests__/session-send-projection.test.ts index d450ff8d0c..45527b69b5 100644 --- a/packages/core/src/__tests__/session-send-projection.test.ts +++ b/packages/core/src/__tests__/session-send-projection.test.ts @@ -62,6 +62,23 @@ describe('projectSessionSendOutcome — exact Connection identity', () => { assert.deepEqual(projectSessionSendOutcome(input()), { kind: 'ready' }); }); + it('does not require a Maka model connection for a plugin executor Session', () => { + assert.deepEqual( + projectSessionSendOutcome( + input({ + session: { + backend: 'plugin-executor', + llmConnectionSlug: 'executor:codex', + model: 'codex', + connectionLocked: false, + }, + connections: [], + }), + ), + { kind: 'ready' }, + ); + }); + it('blocks a legacy Session until the user explicitly selects an account', () => { const current = input(); assert.deepEqual( diff --git a/packages/core/src/agent-graph-schedule.ts b/packages/core/src/agent-graph-schedule.ts index a41849a58f..3c051a5f18 100644 --- a/packages/core/src/agent-graph-schedule.ts +++ b/packages/core/src/agent-graph-schedule.ts @@ -48,10 +48,12 @@ export type AgentGraphWorkTarget = | { kind: 'agent'; agentId: string; + executorId?: string; } | { kind: 'preset'; presetId: string; + executorId?: string; } | { kind: 'operator'; @@ -346,16 +348,26 @@ function isSelectedResultInput(value: unknown): value is AgentGraphSelectedResul function isWorkTarget(value: unknown): value is AgentGraphWorkTarget { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; if ( - isExactRecord(value, ['kind', 'agentId']) && + isExactRecord(value, [ + 'kind', + 'agentId', + ...(hasOwn(value, 'executorId') ? ['executorId'] : []), + ]) && value.kind === 'agent' && - isOpaqueIdentity(value.agentId) + isOpaqueIdentity(value.agentId) && + (value.executorId === undefined || isExecutorId(value.executorId)) ) { return true; } if ( - isExactRecord(value, ['kind', 'presetId']) && + isExactRecord(value, [ + 'kind', + 'presetId', + ...(hasOwn(value, 'executorId') ? ['executorId'] : []), + ]) && value.kind === 'preset' && - isOpaqueIdentity(value.presetId) + isOpaqueIdentity(value.presetId) && + (value.executorId === undefined || isExecutorId(value.executorId)) ) { return true; } @@ -406,6 +418,10 @@ function isOpaqueIdentity(value: unknown): value is string { ); } +function isExecutorId(value: unknown): value is string { + return typeof value === 'string' && /^[A-Za-z][A-Za-z0-9._:-]{0,127}$/u.test(value); +} + function isSha256Fingerprint(value: unknown): value is string { return typeof value === 'string' && /^sha256:[a-f0-9]{64}$/.test(value); } diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 635b5257fe..f5eccc2482 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -1167,7 +1167,7 @@ function isRuntimeInvocationRoute(value: unknown): value is RuntimeInvocationRou } function isPersistedBackendKind(value: unknown): value is PersistedBackendKind { - return value === 'ai-sdk' || value === 'fake'; + return value === 'ai-sdk' || value === 'plugin-executor' || value === 'fake'; } function isRuntimeInvocationConfiguration(value: unknown): value is RuntimeInvocationConfiguration { diff --git a/packages/core/src/runtime-inputs.ts b/packages/core/src/runtime-inputs.ts index 42bccd337a..caecc3ae68 100644 --- a/packages/core/src/runtime-inputs.ts +++ b/packages/core/src/runtime-inputs.ts @@ -53,14 +53,8 @@ export interface CreateSessionInput { projectId?: string | null; /** If omitted, runtime auto-derives a placeholder; users may rename later. */ name?: string; - /** - * No `backend`: a live build has exactly one, so the field carried no choice - * — only the chance of writing the retired `'fake'` into a new row (#3211). - * The store stamps every new header instead. Sessions derived from an older - * one (branch, revision, subagent) no longer inherit its backend; a copy of a - * legacy row is a real session whose connection slug resolves to nothing, - * which is what the readiness projection already says about it. - */ + /** Named plugin executor. When present, model fields are retained only as display placeholders. */ + executorId?: string; /** Immutable Connection entity identity. Omitted only while copying legacy state. */ llmConnectionId?: string; llmConnectionSlug: string; diff --git a/packages/core/src/session-send-projection.ts b/packages/core/src/session-send-projection.ts index 83648d8270..dfe52ed341 100644 --- a/packages/core/src/session-send-projection.ts +++ b/packages/core/src/session-send-projection.ts @@ -27,11 +27,12 @@ * whether that exact target looks usable for presentation and readiness checks. * * The compatibility rules are: - * 1. The session's own connection must pass `isConnectionReady` with + * 1. Plugin executor Sessions do not depend on Maka model connections. + * 2. The session's own connection must pass `isConnectionReady` with * the sticky session model. - * 2. Legacy Sessions without an immutable connection id are blocked until + * 3. Legacy Sessions without an immutable connection id are blocked until * the user explicitly selects an account. - * 3. A missing id, slug mismatch, or unusable exact connection is blocked. + * 4. A missing id, slug mismatch, or unusable exact connection is blocked. * * `lastTestStatus` deliberately plays no part here (E4): telemetry about * a past credential test must not gate send, so it must not gate the @@ -46,8 +47,8 @@ export interface SessionSendProjectionSession { /** * Session backend kind. `string` (not `PersistedBackendKind`) so legacy * on-disk values like `'claude'` are surfaced exactly as the JSONL stored - * them; only `'fake'` is special-cased, everything else goes through the - * normal connection readiness gate. + * them; known backends are handled explicitly and unknown legacy values go + * through the normal connection readiness gate. */ backend: string; llmConnectionId?: string; @@ -127,6 +128,7 @@ function ownConnectionBlockReason( | 'legacy_connection_identity' | 'connection_identity_mismatch' | undefined { + if (session.backend === 'plugin-executor') return undefined; if (session.backend === 'fake') return 'fake_backend'; if (!session.llmConnectionId) return 'legacy_connection_identity'; const identified = diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 76bcc27a4c..2a2c522012 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -297,6 +297,8 @@ export interface SessionHeader { // Backend / model config backend: PersistedBackendKind; + /** Named black-box executor contributed by a plugin. Present exactly for plugin-executor. */ + executorId?: string; /** Immutable Connection entity identity. Optional only on legacy Session records. */ llmConnectionId?: string; llmConnectionSlug: string; @@ -344,7 +346,7 @@ export function isWorkHubCoordinationSessionTarget( * shipped build may choose it, so it is not a member here. Values read back * from durable state use {@link PersistedBackendKind} instead. */ -export type BackendKind = 'ai-sdk'; +export type BackendKind = 'ai-sdk' | 'plugin-executor'; /** * The backend value a persisted record may carry. @@ -408,6 +410,7 @@ export interface SessionSummary { revisionIndex?: number; revisionState?: 'preparing' | 'committed'; backend: PersistedBackendKind; + executorId?: string; /** Immutable Connection entity identity. Optional only on legacy summaries. */ llmConnectionId?: string; llmConnectionSlug: string; @@ -945,6 +948,8 @@ export type WorkHubDelegationWorkspace = /** User-selected creation defaults; never applied to an existing Work. */ export interface WorkHubCreateDefaults { + /** Named plugin executor for the new Session. Mutually exclusive with model. */ + readonly executorId?: string; readonly model?: { readonly llmConnectionId: string; readonly llmConnectionSlug: string; @@ -956,10 +961,19 @@ export interface WorkHubCreateDefaults { export function isWorkHubCreateDefaults(value: unknown): value is WorkHubCreateDefaults { if ( !isRecord(value) || - Object.keys(value).some((key) => key !== 'model' && key !== 'permissionMode') + Object.keys(value).some( + (key) => key !== 'executorId' && key !== 'model' && key !== 'permissionMode', + ) ) return false; if (value.permissionMode !== undefined && !isPermissionMode(value.permissionMode)) return false; + if ( + value.executorId !== undefined && + (typeof value.executorId !== 'string' || + !/^[A-Za-z][A-Za-z0-9._:-]{0,127}$/u.test(value.executorId)) + ) + return false; + if (value.executorId !== undefined && value.model !== undefined) return false; if (value.model === undefined) return true; const model = value.model; return ( diff --git a/packages/eval/src/harbor-maka-subject.ts b/packages/eval/src/harbor-maka-subject.ts index fbd82b2b2c..38981900b1 100644 --- a/packages/eval/src/harbor-maka-subject.ts +++ b/packages/eval/src/harbor-maka-subject.ts @@ -19,8 +19,7 @@ import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; -import { runHostedExecution } from '@maka/runtime-host/client'; -import type { HostedExecutionStartInput } from '@maka/runtime-host/protocol'; +import { runHostedExecution, type RunHostedExecutionInput } from '@maka/runtime-host/client'; import { captureMakaRuntimeArtifacts, writeMakaArtifactCollectionError } from './maka-artifacts.js'; import { makaEvalRuntimePolicyDocument } from './maka-runtime-policy.js'; import { takeRelayResultToken, writeRelayResult } from './relay-result-frame.js'; @@ -32,7 +31,7 @@ const payload = JSON.parse(Buffer.from(process.argv[2] ?? '', 'base64url').toStr artifactRoot: string; baseUrl: string; hostSettlementTimeoutMs: number; - execution: HostedExecutionStartInput; + execution: RunHostedExecutionInput['execution']; }; const abort = new AbortController(); let artifactCapture = Promise.resolve(); diff --git a/packages/runtime-host/src/__tests__/host-session-availability.test.ts b/packages/runtime-host/src/__tests__/host-session-availability.test.ts index bed59d36f9..79cf43a82e 100644 --- a/packages/runtime-host/src/__tests__/host-session-availability.test.ts +++ b/packages/runtime-host/src/__tests__/host-session-availability.test.ts @@ -140,6 +140,22 @@ test('legacy Session identity cannot enter Host execution before explicit accoun ); }); +test('plugin executor Sessions do not require a Maka model connection identity', () => { + assert.equal( + runtimeHostExecutionUnavailableReason( + { + ...base, + id: 'plugin-executor-session', + role: undefined, + llmConnectionId: undefined, + backend: 'plugin-executor', + }, + { kind: 'external_message' }, + ), + undefined, + ); +}); + test('legacy Session identity cannot resume a safe-boundary continuation', () => { assert.equal( runtimeHostSafeBoundaryContinuationUnavailableReason({ diff --git a/packages/runtime-host/src/__tests__/plugin-platform.test.ts b/packages/runtime-host/src/__tests__/plugin-platform.test.ts index c250197926..80d05965a8 100644 --- a/packages/runtime-host/src/__tests__/plugin-platform.test.ts +++ b/packages/runtime-host/src/__tests__/plugin-platform.test.ts @@ -93,6 +93,7 @@ function createPlatform( ...(options.tools ? { tools: options.tools } : {}), ...(options.systemPrompt ? { systemPrompt: options.systemPrompt } : {}), ...(options.commands ? { commands: options.commands } : {}), + ...(options.executors ? { executors: options.executors } : {}), }); testPlatformInternals.set(platform, { composition, packages, store }); return platform; @@ -696,6 +697,55 @@ test('Plugin Platform query projects scoped Command contributions for clients', } }); +test('Plugin Platform query projects scoped Executor contributions for clients', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-executor-inspection-')); + try { + const platform = createPlatform(join(root, 'control'), { + executors: { + inspect: () => [ + { + entryId: 'executor-entry', + scopeId: 'profile', + extensionId: 'executor-package', + generation: 4, + id: 'codex', + displayName: 'Codex', + }, + ], + }, + }); + const coordinator = new HostPluginPlatformCoordinator(platform); + await platform.recover(); + + assert.deepEqual( + await coordinator.handlers['plugin.platform.query']( + { view: 'executors', rootId: 'profile' }, + null as never, + ), + { + ok: true, + result: { + view: 'executors', + items: [ + { + entryId: 'executor-entry', + scopeId: 'profile', + extensionId: 'executor-package', + generation: 4, + id: 'codex', + displayName: 'Codex', + }, + ], + nextCursor: null, + }, + }, + ); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('Plugin Platform query pages reserve a cursor even when the complete final result fits', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-plugin-query-budget-')); try { diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index 4e4dfd44b3..6bbc71da3d 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -747,6 +747,54 @@ test('creation on a relay connection honours declared levels via the catalog pro assert.equal(persistedConnectionId, 'connection-1'); }); +test('plugin executor creation bypasses model resolution and persists the executor route', async () => { + let persistedInput: Parameters[0]['input'] | undefined; + const externalHeader = (sessionId: string): SessionHeader => { + const { llmConnectionId: _connectionId, ...base } = sessionHeader(sessionId, ['user-label']); + return { + ...base, + backend: 'plugin-executor', + executorId: 'codex', + llmConnectionSlug: 'executor:codex', + model: 'codex', + }; + }; + const fixture = createFixture({ + connection: { + onResolve: () => assert.fail('Plugin executor creation must not resolve a Maka model'), + }, + stores: { + createStableSession: async (args) => { + persistedInput = args.input; + return { + kind: 'existing' as const, + record: headerSnapshot(externalHeader(args.sessionId), 1), + }; + }, + readCatalogRecord: async (sessionId) => catalogRecord(externalHeader(sessionId), 1), + }, + }); + + const outcome = await fixture.coordinator.handlers['session.create']( + { + sessionId: fixture.sessionId, + workspace: { kind: 'host_path', path: process.cwd() }, + executorId: 'codex', + }, + context, + ); + + assert.equal(outcome.ok, true, JSON.stringify(outcome)); + assert.equal(persistedInput?.executorId, 'codex'); + assert.equal(persistedInput?.llmConnectionId, undefined); + assert.equal(persistedInput?.llmConnectionSlug, 'executor:codex'); + assert.equal(persistedInput?.model, 'codex'); + if (outcome.ok && !('kind' in outcome.result)) { + assert.equal(outcome.result.backend, 'plugin-executor'); + assert.equal(outcome.result.executorId, 'codex'); + } +}); + test('creation admits the enabled bootstrap DeepSeek model before discovery', async () => { const modelId = 'deepseek-v4-flash'; let createAttempts = 0; diff --git a/packages/runtime-host/src/__tests__/session-catalog-protocol.test.ts b/packages/runtime-host/src/__tests__/session-catalog-protocol.test.ts index c70ebb66ce..f1c05b5e1b 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-protocol.test.ts @@ -269,6 +269,50 @@ describe('Session catalog protocol', () => { }, ); + assert.deepEqual( + decodeClientFrame({ + requestId: 'request-executor', + operation: 'session.create', + input: { + sessionId: 'session-executor', + workspace: { kind: 'project', projectId: 'project-1' }, + executorId: 'codex.app-server', + }, + }), + { + requestId: 'request-executor', + operation: 'session.create', + input: { + sessionId: 'session-executor', + workspace: { kind: 'project', projectId: 'project-1' }, + executorId: 'codex.app-server', + }, + }, + ); + + for (const input of [ + { + sessionId: 'session-missing-route', + workspace: { kind: 'project', projectId: 'project-1' }, + }, + { + sessionId: 'session-ambiguous-route', + workspace: { kind: 'project', projectId: 'project-1' }, + executorId: 'codex', + modelTarget: { kind: 'default' }, + }, + ]) { + assert.throws( + () => + decodeClientFrame({ + requestId: 'request-invalid-executor', + operation: 'session.create', + input, + }), + isProtocolError, + ); + } + assert.deepEqual( decodeClientFrame({ requestId: 'request-3', diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts index 79863e3b81..eafaffab63 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts @@ -106,6 +106,30 @@ test('WorkHub model actions cannot supply user authority or attachment locators' assert.equal(REMOTE_OWNER_OPERATION_GRANTS.includes('workhub.coordination.actFromTurn'), true); }); +test('WorkHub new Sessions accept a plugin executor as their creation default', () => { + const input = { + turnId: 'active-model-turn', + actionId: 'tool-call-external', + proposal: { disposition: 'create_new', title: 'External audit' }, + delegationText: 'Inspect the login retries', + create: { workspace: { kind: 'project', projectId: 'maka' } }, + newWorkDefaults: { executorId: 'codex.app-server', permissionMode: 'ask' }, + }; + assert.deepEqual(decodeWorkHubCoordinationActFromTurnInput(input), input); + for (const newWorkDefaults of [ + { + executorId: 'codex', + model: { llmConnectionId: 'conn', llmConnectionSlug: 'test', model: 'model' }, + }, + { executorId: 'invalid executor' }, + ]) { + assert.throws( + () => decodeWorkHubCoordinationActFromTurnInput({ ...input, newWorkDefaults }), + RuntimeHostProtocolError, + ); + } +}); + test('delegation content is optional, bounded, and unavailable to stop or resume', () => { const input = { actionId: 'delegate-content', diff --git a/packages/runtime-host/src/client/session-catalog-summary.ts b/packages/runtime-host/src/client/session-catalog-summary.ts index 10d50521db..49752c1394 100644 --- a/packages/runtime-host/src/client/session-catalog-summary.ts +++ b/packages/runtime-host/src/client/session-catalog-summary.ts @@ -60,6 +60,7 @@ export function projectSessionCatalogSummary( ...(session.revisionIndex === undefined ? {} : { revisionIndex: session.revisionIndex }), ...(session.revisionState === undefined ? {} : { revisionState: session.revisionState }), backend: session.backend, + ...(session.executorId ? { executorId: session.executorId } : {}), ...(session.llmConnectionId === null ? {} : { llmConnectionId: session.llmConnectionId }), llmConnectionSlug: session.llmConnectionSlug, connectionLocked: session.connectionLocked, diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 3372e7bd30..e2c277cc06 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,8 @@ 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 = 152 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 153 as const; +// 153: Sessions may select plugin executors and Plugin Platform queries expose them. // 152: Assistant completions and transcript rows preserve interrupted responses. // 151: WorkHub selects and delegates through a durable Host Form interaction. // 150: Message admission accepts an empty-text Message that carries a quote or diff --git a/packages/runtime-host/src/protocol/plugin-platform.ts b/packages/runtime-host/src/protocol/plugin-platform.ts index cf6914ff8b..c4a6821b22 100644 --- a/packages/runtime-host/src/protocol/plugin-platform.ts +++ b/packages/runtime-host/src/protocol/plugin-platform.ts @@ -28,6 +28,7 @@ import { } 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 { PluginExecutorInspection } from '@maka/runtime/plugin-executor-service'; import { requireCount, requireEncodedByteLimit, @@ -88,7 +89,14 @@ export interface PluginPackageProjection { } export interface PluginPlatformQueryInput { - readonly view: 'status' | 'packages' | 'entries' | 'tools' | 'commands' | 'failures'; + readonly view: + | 'status' + | 'packages' + | 'entries' + | 'tools' + | 'commands' + | 'executors' + | 'failures'; readonly rootId?: MakaPluginRootId; readonly cursor?: string; readonly limit?: number; @@ -127,6 +135,11 @@ export type PluginPlatformQueryResult = readonly items: readonly PluginCommandInspection[]; readonly nextCursor: string | null; } + | { + readonly view: 'executors'; + readonly items: readonly PluginExecutorInspection[]; + readonly nextCursor: string | null; + } | { readonly view: 'failures'; readonly items: readonly PluginPlatformFailureProjection[]; @@ -268,7 +281,7 @@ function decodePluginPlatformQueryInput(value: unknown): PluginPlatformQueryInpu ['rootId', 'cursor', 'limit'], ); if ( - !['status', 'packages', 'entries', 'tools', 'commands', 'failures'].includes( + !['status', 'packages', 'entries', 'tools', 'commands', 'executors', 'failures'].includes( input.view as string, ) ) { @@ -292,9 +305,15 @@ function decodePluginPlatformQueryInput(value: unknown): PluginPlatformQueryInpu ) { throw invalidProtocolFrame('Plugin Platform status query does not accept paging'); } - if (input.rootId !== undefined && view !== 'entries' && view !== 'tools' && view !== 'commands') { + if ( + input.rootId !== undefined && + view !== 'entries' && + view !== 'tools' && + view !== 'commands' && + view !== 'executors' + ) { throw invalidProtocolFrame( - 'Plugin root identity is only valid for Entry, Tool, and Command queries', + 'Plugin root identity is only valid for Entry, Tool, Command, and Executor queries', ); } let rootId: MakaPluginRootId | undefined; @@ -367,7 +386,9 @@ function decodePluginPlatformQueryResult(value: unknown): PluginPlatformQueryRes if ( !Array.isArray(output.items) || output.items.length > 64 || - !['packages', 'entries', 'tools', 'commands', 'failures'].includes(view as string) + !['packages', 'entries', 'tools', 'commands', 'executors', 'failures'].includes( + view as string, + ) ) { throw invalidProtocolFrame('Invalid Plugin Platform page'); } @@ -384,7 +405,9 @@ function decodePluginPlatformQueryResult(value: unknown): PluginPlatformQueryRes ? { view, items: output.items.map(decodeToolInspection), nextCursor } : view === 'commands' ? { view, items: output.items.map(decodeCommandInspection), nextCursor } - : { view: 'failures', items: output.items.map(decodePlatformFailure), nextCursor }; + : view === 'executors' + ? { view, items: output.items.map(decodeExecutorInspection), nextCursor } + : { view: 'failures', items: output.items.map(decodePlatformFailure), nextCursor }; } requireEncodedByteLimit( decoded, @@ -418,6 +441,25 @@ function decodeCommandInspection(value: unknown): PluginCommandInspection { }; } +function decodeExecutorInspection(value: unknown): PluginExecutorInspection { + const item = requireExactRecord(value, 'Plugin Executor inspection', [ + 'entryId', + 'scopeId', + 'extensionId', + 'generation', + 'id', + 'displayName', + ]); + 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'), + id: requireString(item.id, 'Plugin Executor id', 128), + displayName: requireString(item.displayName, 'Plugin Executor display name', 256), + }; +} + function decodePlatformFailure(value: unknown): PluginPlatformFailureProjection { const failure = requireShapedRecord( value, diff --git a/packages/runtime-host/src/protocol/session-catalog.ts b/packages/runtime-host/src/protocol/session-catalog.ts index 0820a6b693..469210762a 100644 --- a/packages/runtime-host/src/protocol/session-catalog.ts +++ b/packages/runtime-host/src/protocol/session-catalog.ts @@ -123,6 +123,7 @@ const PROJECTION_FIELDS = [ 'revisionOfTurnId', 'revisionIndex', 'revisionState', + 'executorId', 'thinkingLevel', 'lastReadMessageId', 'liveRunState', @@ -154,7 +155,10 @@ export interface SessionCreateInput { readonly mode?: SessionStartMode; readonly name?: string; readonly labels?: readonly string[]; - readonly modelTarget: SessionModelTarget; + /** Required for native execution and omitted for a plugin executor. */ + readonly modelTarget?: SessionModelTarget; + /** Named black-box executor contributed by a Host plugin. */ + readonly executorId?: string; readonly thinkingLevel?: ThinkingLevel; readonly toolProfile?: SessionToolProfile; readonly permissionMode?: PermissionMode; @@ -236,6 +240,7 @@ export interface SessionCatalogProjection { readonly revisionIndex?: number; readonly revisionState?: 'preparing' | 'committed'; readonly backend: PersistedBackendKind; + readonly executorId?: string; readonly llmConnectionId: string | null; readonly llmConnectionSlug: string; readonly connectionLocked: boolean; @@ -513,11 +518,13 @@ export function decodeSessionCreateInput(value: unknown): SessionCreateInput { const input = requireShapedRecord( value, 'Session create input', - ['sessionId', 'workspace', 'modelTarget'], + ['sessionId', 'workspace'], [ 'mode', 'name', 'labels', + 'modelTarget', + 'executorId', 'thinkingLevel', 'toolProfile', 'permissionMode', @@ -525,13 +532,21 @@ export function decodeSessionCreateInput(value: unknown): SessionCreateInput { 'orchestrationMode', ], ); + const executorId = Object.hasOwn(input, 'executorId') + ? executorIdValue(input.executorId) + : undefined; + const target = Object.hasOwn(input, 'modelTarget') ? modelTarget(input.modelTarget) : undefined; + if ((executorId === undefined) === (target === undefined)) { + throw invalidProtocolFrame('Session creation requires exactly one model target or executor id'); + } return { sessionId: requireEntityId(input.sessionId, 'sessionId'), workspace: decodeWorkspaceTarget(input.workspace), ...(Object.hasOwn(input, 'mode') ? { mode: sessionStartMode(input.mode) } : {}), ...(Object.hasOwn(input, 'name') ? { name: sessionName(input.name) } : {}), ...(Object.hasOwn(input, 'labels') ? { labels: labels(input.labels) } : {}), - modelTarget: modelTarget(input.modelTarget), + ...(target ? { modelTarget: target } : {}), + ...(executorId ? { executorId } : {}), ...(Object.hasOwn(input, 'thinkingLevel') ? { thinkingLevel: thinkingLevel(input.thinkingLevel) } : {}), @@ -760,6 +775,7 @@ export function decodeSessionCatalogProjection(value: unknown): SessionCatalogPr ...optionalRevisionIndex(record), ...optionalRevisionState(record), backend: backend(record.backend), + ...optionalExecutorId(record), llmConnectionId: record.llmConnectionId === null ? null @@ -776,6 +792,9 @@ export function decodeSessionCatalogProjection(value: unknown): SessionCatalogPr collaborationMode: collaborationMode(record.collaborationMode), orchestrationMode: orchestrationMode(record.orchestrationMode), }; + if ((projection.backend === 'plugin-executor') !== (projection.executorId !== undefined)) { + throw invalidProtocolFrame('Session executor identity does not match its backend'); + } requireEncodedByteLimit( projection, 'Session catalog projection', @@ -997,12 +1016,27 @@ function optionalThinkingLevel( // header's durable backend, and rows written by builds that shipped // FakeBackend still hold it (#3211). function backend(value: unknown): SessionCatalogProjection['backend'] { - if (value !== 'ai-sdk' && value !== 'fake') { + if (value !== 'ai-sdk' && value !== 'plugin-executor' && value !== 'fake') { throw invalidProtocolFrame('Invalid Session backend'); } return value; } +function executorIdValue(value: unknown): string { + const id = requireUtf8String(value, 'Executor id', 128); + if (!/^[A-Za-z][A-Za-z0-9._:-]{0,127}$/u.test(id)) { + throw invalidProtocolFrame('Invalid Executor id'); + } + return id; +} + +function optionalExecutorId( + record: Record, +): Pick | Record { + if (record.executorId === undefined) return {}; + return { executorId: executorIdValue(record.executorId) }; +} + function thinkingLevel(value: unknown): ThinkingLevel { if (!isThinkingLevel(value)) throw invalidProtocolFrame('Invalid Session thinking level'); return value; diff --git a/packages/runtime-host/src/server/child-agent-composition.ts b/packages/runtime-host/src/server/child-agent-composition.ts index 461da00d2d..793b65a084 100644 --- a/packages/runtime-host/src/server/child-agent-composition.ts +++ b/packages/runtime-host/src/server/child-agent-composition.ts @@ -77,6 +77,7 @@ export function bindHostChildAgentBackend( }, agentProfile: input.agentProfile, ...(input.subagentId ? { subagentId: input.subagentId } : {}), + ...(input.executorId ? { executorId: input.executorId } : {}), prompt: input.prompt, ...(input.swarm ? { swarm: input.swarm } : {}), abortSignal: input.abortSignal, diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index b989d93587..d98441e5fa 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -91,6 +91,8 @@ 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 { PluginExecutorBackend } from '@maka/runtime/plugin-executor-backend'; +import { PluginExecutorService } from '@maka/runtime/plugin-executor-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'; @@ -345,6 +347,7 @@ export async function createExecutionRuntimeHostComposition( new PluginUserQuestionService(pluginRoot, pluginAgents); const pluginFilesystem = new PluginFilesystemService(pluginRoot, pluginAgents); const pluginLlm = new PluginLlmService(pluginRoot, pluginAgents); + const pluginExecutors = new PluginExecutorService(pluginRoot); const pluginShellEnv = new PluginShellEnvService(pluginRoot); const pluginShell = new PluginShellService(pluginRoot, pluginAgents, pluginShellEnv); const pluginWeb = new PluginWebService(pluginRoot, pluginAgents); @@ -368,6 +371,7 @@ export async function createExecutionRuntimeHostComposition( tools: pluginTools, systemPrompt: pluginSystemPrompt, commands: pluginCommands, + executors: pluginExecutors, }); const pluginPlatformCoordinator = new HostPluginPlatformCoordinator(pluginPlatform); const openedProjectCatalog = storage.projectCatalog; @@ -1049,6 +1053,35 @@ export async function createExecutionRuntimeHostComposition( prepare: (backendContext) => prepareHostAiSdkBackend(hostAiSdkBackendInput(backendContext)), }, ); + backends.register('plugin-executor', { + prepare: async (backendContext) => { + const executorId = backendContext.header.executorId; + if (!executorId) throw new Error('Plugin executor Session is missing its executor id'); + const identity = pluginExecutors.identity(backendContext.sessionId, executorId); + const providerStateIdentity = `sha256:${createHash('sha256') + .update( + JSON.stringify([ + 'plugin-executor.v1', + identity.id, + identity.extensionId, + identity.entryId, + identity.generation, + ]), + ) + .digest('hex')}` as const; + return { + providerStateIdentity, + build: (factoryContext) => + new PluginExecutorBackend({ + sessionId: factoryContext.sessionId, + cwd: factoryContext.header.cwd, + executorId, + ...(factoryContext.systemPrompt ? { instructions: factoryContext.systemPrompt } : {}), + service: pluginExecutors, + }), + }; + }, + }); const runtimeAuthority: RuntimeHostedRootAuthority = { bindRun: (identity) => messages.bindRun(identity), executeRoot: (input) => @@ -1102,6 +1135,7 @@ export async function createExecutionRuntimeHostComposition( }; resolveAvailableToolNames = async (sessionId: string): Promise => { const header = await stores.sessionStore.readHeaderSnapshot(sessionId); + if (header.backend === 'plugin-executor') return []; if (header.subagentRuntime) { if (!header.subagentParent) { throw new Error('Subagent runtime snapshot requires a linked child session'); @@ -1678,6 +1712,7 @@ export async function createExecutionRuntimeHostComposition( return new Promise((resolve, reject) => { void spawn({ agentProfile: options.agentProfile ?? 'implementation', + ...(options.executorId ? { executorId: options.executorId } : {}), prompt: options.prompt!, ...(options.signal ? { abortSignal: options.signal } : {}), onReady: (ready) => @@ -2129,14 +2164,18 @@ export async function createExecutionRuntimeHostComposition( sessionId: input.targetSessionId, workspace: input.create.workspace, name: input.create.title, - modelTarget: input.create.defaults?.model - ? { - kind: 'explicit', - connectionId: input.create.defaults.model.llmConnectionId, - connectionSlug: input.create.defaults.model.llmConnectionSlug, - model: input.create.defaults.model.model, - } - : { kind: 'default' }, + ...(input.create.defaults?.executorId + ? { executorId: input.create.defaults.executorId } + : { + modelTarget: input.create.defaults?.model + ? { + kind: 'explicit' as const, + connectionId: input.create.defaults.model.llmConnectionId, + connectionSlug: input.create.defaults.model.llmConnectionSlug, + model: input.create.defaults.model.model, + } + : ({ kind: 'default' } as const), + }), ...(input.create.defaults?.permissionMode ? { permissionMode: input.create.defaults.permissionMode } : {}), diff --git a/packages/runtime-host/src/server/host-session-availability.ts b/packages/runtime-host/src/server/host-session-availability.ts index 2cff50c8f0..c8b2d5ce57 100644 --- a/packages/runtime-host/src/server/host-session-availability.ts +++ b/packages/runtime-host/src/server/host-session-availability.ts @@ -64,7 +64,7 @@ export function runtimeHostSafeBoundaryContinuationUnavailableReason( ? WORKHUB_COORDINATION_EXECUTION_UNAVAILABLE_REASON : undefined) ?? (header.transcriptLedgerVersion === 0 ? IMPORT_STAGING_UNAVAILABLE_REASON : undefined) ?? - (header.llmConnectionId === undefined && header.backend !== 'fake' + (header.llmConnectionId === undefined && header.backend === 'ai-sdk' ? LEGACY_CONNECTION_IDENTITY_EXECUTION_UNAVAILABLE_REASON : undefined) ?? (header.subagentParent ? CHILD_CONTINUATION_UNAVAILABLE_REASON : undefined) @@ -111,7 +111,7 @@ export function runtimeHostExecutionUnavailableReason( ? WORKHUB_COORDINATION_EXECUTION_UNAVAILABLE_REASON : undefined) ?? (header.transcriptLedgerVersion === 0 ? IMPORT_STAGING_UNAVAILABLE_REASON : undefined) ?? - (header.llmConnectionId === undefined && header.backend !== 'fake' + (header.llmConnectionId === undefined && header.backend === 'ai-sdk' ? LEGACY_CONNECTION_IDENTITY_EXECUTION_UNAVAILABLE_REASON : undefined) ?? (header.collaborationMode === 'plan' && diff --git a/packages/runtime-host/src/server/plugin-platform-coordinator.ts b/packages/runtime-host/src/server/plugin-platform-coordinator.ts index 889fd266e0..3ad2b6528d 100644 --- a/packages/runtime-host/src/server/plugin-platform-coordinator.ts +++ b/packages/runtime-host/src/server/plugin-platform-coordinator.ts @@ -27,6 +27,7 @@ import { } 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 { PluginExecutorInspection } from '@maka/runtime/plugin-executor-service'; import type { OperationOutcome, PluginPackageExportInput, @@ -90,6 +91,12 @@ export class HostPluginPlatformCoordinator { result: boundedPage('commands', this.platform.inspectCommands(input.rootId), input), }; } + if (input.view === 'executors') { + return { + ok: true, + result: boundedPage('executors', this.platform.inspectExecutors(input.rootId), input), + }; + } if (input.view === 'failures') { return { ok: true, result: boundedPage('failures', failures, input) }; } @@ -198,13 +205,18 @@ function boundedPage( values: readonly PluginCommandInspection[], input: PluginPlatformQueryInput, ): Extract; +function boundedPage( + view: 'executors', + values: readonly PluginExecutorInspection[], + input: PluginPlatformQueryInput, +): Extract; function boundedPage( view: 'failures', values: readonly PluginPlatformFailureProjection[], input: PluginPlatformQueryInput, ): Extract; function boundedPage( - view: 'packages' | 'entries' | 'tools' | 'commands' | 'failures', + view: 'packages' | 'entries' | 'tools' | 'commands' | 'executors' | 'failures', values: readonly T[], input: PluginPlatformQueryInput, ): PluginPlatformQueryResult { @@ -240,7 +252,7 @@ function boundedPage( interface PageCursor { readonly version: 1; - readonly view: 'packages' | 'entries' | 'tools' | 'commands' | 'failures'; + readonly view: 'packages' | 'entries' | 'tools' | 'commands' | 'executors' | '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 98c4a6309c..cdf59765e3 100644 --- a/packages/runtime-host/src/server/plugin-platform.ts +++ b/packages/runtime-host/src/server/plugin-platform.ts @@ -36,6 +36,7 @@ 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 type { PluginExecutorInspection } from '@maka/runtime/plugin-executor-service'; import { validateExtensionConfiguration } from './extension-package-manifest.js'; import { recoverExtensionBundleImports } from './extension-bundle.js'; import { loadPluginCompositionPatch } from './plugin-composition-patch.js'; @@ -82,6 +83,9 @@ export interface HostPluginPlatformOptions { inspect(rootId?: MakaPluginRootId): readonly PluginSystemPromptInspection[]; }; readonly commands?: { inspect(rootId?: MakaPluginRootId): readonly PluginCommandInspection[] }; + readonly executors?: { + inspect(rootId?: MakaPluginRootId): readonly PluginExecutorInspection[]; + }; } export interface HostPluginPlatformFailure { @@ -120,6 +124,7 @@ export class HostPluginPlatform { readonly #tools?: HostPluginPlatformOptions['tools']; readonly #systemPrompt?: HostPluginPlatformOptions['systemPrompt']; readonly #commands?: HostPluginPlatformOptions['commands']; + readonly #executors?: HostPluginPlatformOptions['executors']; #authority: PersistedPluginComposition = emptyCompositionAuthority(); #desired: MakaCompositionState = emptyCompositionState(); @@ -147,6 +152,7 @@ export class HostPluginPlatform { this.#tools = options.tools; this.#systemPrompt = options.systemPrompt; this.#commands = options.commands; + this.#executors = options.executors; } async recover(): Promise { @@ -499,6 +505,11 @@ export class HostPluginPlatform { return this.#commands?.inspect(rootId) ?? Object.freeze([]); } + inspectExecutors(rootId?: MakaPluginRootId): readonly PluginExecutorInspection[] { + this.#assertReadable(); + return this.#executors?.inspect(rootId) ?? Object.freeze([]); + } + async status(): Promise<{ readonly phase: PluginPlatformPhase; readonly authorityEpoch: number; diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index face1032bd..1697427862 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -138,8 +138,9 @@ type SessionConfigurationAuthority = Pick< type SessionContinuity = Pick; interface ResolvedSessionConfiguration { - readonly backend: 'ai-sdk'; - readonly llmConnectionId?: string; + readonly backend: 'ai-sdk' | 'plugin-executor'; + readonly executorId: string | undefined; + readonly llmConnectionId: string | undefined; readonly llmConnectionSlug: string; readonly model: string; readonly thinkingLevel: SessionHeader['thinkingLevel']; @@ -368,7 +369,7 @@ export class HostSessionCatalogCoordinator { const prepared = await prepareCreate(input); return this.#workspaceResolver.runWithUsageRecorded(input.workspace, async (workspace) => { const [model, policy] = await Promise.all([ - this.#resolveModel(input.modelTarget, input.thinkingLevel), + this.#resolveCreateExecution(input), this.#readRuntimePolicy(), ]); return { @@ -379,7 +380,8 @@ export class HostSessionCatalogCoordinator { ...(workspace.projectId === null ? {} : { projectId: workspace.projectId }), name: prepared.name, labels: [...prepared.labels], - llmConnectionId: model.connectionId, + ...(model.executorId ? { executorId: model.executorId } : {}), + ...(model.connectionId ? { llmConnectionId: model.connectionId } : {}), llmConnectionSlug: model.connectionSlug, model: model.model, ...(input.thinkingLevel === undefined ? {} : { thinkingLevel: input.thinkingLevel }), @@ -607,7 +609,7 @@ export class HostSessionCatalogCoordinator { input.workspace, async (workspace) => { const [model, policy] = await Promise.all([ - this.#resolveModel(input.modelTarget, input.thinkingLevel), + this.#resolveCreateExecution(input), this.#readRuntimePolicy(), ]); const createInput: CreateSessionInput = { @@ -615,7 +617,8 @@ export class HostSessionCatalogCoordinator { ...(workspace.projectId === null ? {} : { projectId: workspace.projectId }), name: prepared.name, labels: [...prepared.labels], - llmConnectionId: model.connectionId, + ...(model.executorId ? { executorId: model.executorId } : {}), + ...(model.connectionId ? { llmConnectionId: model.connectionId } : {}), llmConnectionSlug: model.connectionSlug, model: model.model, ...(input.thinkingLevel === undefined ? {} : { thinkingLevel: input.thinkingLevel }), @@ -1211,7 +1214,17 @@ export class HostSessionCatalogCoordinator { current: SessionHeader, patch: SessionConfigurationUpdateInput['patch'], ): Promise { - if (current.llmConnectionId === undefined && patch.modelTarget === undefined) { + if (current.backend === 'fake' && patch.modelTarget === undefined) { + throw new SessionOperationFailure( + 'operation_conflict', + 'Legacy test backend configuration requires an explicit account selection', + ); + } + if ( + current.backend !== 'plugin-executor' && + current.llmConnectionId === undefined && + patch.modelTarget === undefined + ) { throw new SessionOperationFailure( 'operation_conflict', 'Legacy Session configuration requires an explicit account selection', @@ -1245,8 +1258,9 @@ export class HostSessionCatalogCoordinator { ); } return { - backend: 'ai-sdk', - ...(model.connectionId === undefined ? {} : { llmConnectionId: model.connectionId }), + backend: patch.modelTarget || current.backend === 'ai-sdk' ? 'ai-sdk' : 'plugin-executor', + executorId: patch.modelTarget ? undefined : current.executorId, + llmConnectionId: model.connectionId, llmConnectionSlug: model.connectionSlug, model: model.model, thinkingLevel, @@ -1257,6 +1271,28 @@ export class HostSessionCatalogCoordinator { }; } + async #resolveCreateExecution(input: SessionCreateInput): Promise<{ + readonly executorId?: string; + readonly connectionId?: string; + readonly connectionSlug: string; + readonly model: string; + }> { + if (input.executorId) { + return { + executorId: input.executorId, + connectionSlug: `executor:${input.executorId}`, + model: input.executorId, + }; + } + if (!input.modelTarget) { + throw new SessionOperationFailure( + 'invalid_request', + 'Session creation requires a model target or executor id', + ); + } + return await this.#resolveModel(input.modelTarget, input.thinkingLevel); + } + async #readRuntimePolicy(): Promise< Awaited> > { @@ -1273,7 +1309,8 @@ function sessionConfigurationMatches( configuration: ResolvedSessionConfiguration, ): boolean { return ( - header.backend === 'ai-sdk' && + header.backend === configuration.backend && + header.executorId === configuration.executorId && header.llmConnectionId === configuration.llmConnectionId && header.llmConnectionSlug === configuration.llmConnectionSlug && header.model === configuration.model && @@ -1302,6 +1339,18 @@ interface PreparedSessionCreate { } async function prepareCreate(input: SessionCreateInput): Promise { + if ((input.executorId === undefined) === (input.modelTarget === undefined)) { + throw new SessionOperationFailure( + 'invalid_request', + 'Session creation requires exactly one model target or executor id', + ); + } + if ( + input.executorId !== undefined && + !/^[A-Za-z][A-Za-z0-9._:-]{0,127}$/u.test(input.executorId) + ) { + throw new SessionOperationFailure('invalid_request', 'Session executor id is invalid'); + } if (input.labels?.some(isExecutionSemanticLabel)) { throw new SessionOperationFailure( 'invalid_request', @@ -1337,14 +1386,16 @@ function createRequestFingerprint( : ['host_path', input.workspace.path], prepared.name, prepared.labels, - input.modelTarget.kind === 'default' - ? ['default'] - : [ - 'explicit', - input.modelTarget.connectionId, - input.modelTarget.connectionSlug, - input.modelTarget.model, - ], + input.executorId + ? ['executor', input.executorId] + : input.modelTarget?.kind === 'default' + ? ['default'] + : [ + 'explicit', + input.modelTarget!.connectionId, + input.modelTarget!.connectionSlug, + input.modelTarget!.model, + ], input.thinkingLevel ?? null, input.toolProfile ?? null, prepared.permissionMode ?? ['runtime_default'], @@ -1417,6 +1468,7 @@ export function projectSessionCatalogRecord( ...(header.revisionIndex === undefined ? {} : { revisionIndex: header.revisionIndex }), ...(header.revisionState === undefined ? {} : { revisionState: header.revisionState }), backend: header.backend, + ...(header.executorId ? { executorId: header.executorId } : {}), llmConnectionId: header.llmConnectionId ?? null, llmConnectionSlug: header.llmConnectionSlug, connectionLocked: header.connectionLocked, diff --git a/packages/runtime-host/src/server/session-revision-coordinator.ts b/packages/runtime-host/src/server/session-revision-coordinator.ts index 74625e621f..925c1d3559 100644 --- a/packages/runtime-host/src/server/session-revision-coordinator.ts +++ b/packages/runtime-host/src/server/session-revision-coordinator.ts @@ -711,6 +711,7 @@ export class HostSessionRevisionCoordinator { const common: ConversationCopyCreateInput = { cwd: source.cwd, ...(source.projectId !== undefined ? { projectId: source.projectId } : {}), + ...(source.executorId === undefined ? {} : { executorId: source.executorId }), ...(source.llmConnectionId === undefined ? {} : { llmConnectionId: source.llmConnectionId }), llmConnectionSlug: source.llmConnectionSlug, model: source.model, diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 5c27fdefa1..187becf338 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -83,6 +83,8 @@ "./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-executor-backend": "./dist/plugin-executor-backend.js", + "./plugin-executor-service": "./dist/plugin-executor-service.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", diff --git a/packages/runtime/src/__tests__/plugin-executor-backend.test.ts b/packages/runtime/src/__tests__/plugin-executor-backend.test.ts new file mode 100644 index 0000000000..7699f531bb --- /dev/null +++ b/packages/runtime/src/__tests__/plugin-executor-backend.test.ts @@ -0,0 +1,107 @@ +/* + * 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 { SessionEvent } from '@maka/core/events'; +import { PluginExecutorBackend } from '../plugin-executor-backend.js'; +import { Context } from '../plugin-kernel.js'; +import { PluginExecutorService } from '../plugin-executor-service.js'; + +test('executor backend converts plugin output and result to ordinary Session events', async () => { + const { root, service } = fixture(async (request, context) => { + assert.equal(request.instructions, 'child instructions'); + context.emit({ type: 'output_delta', text: 'hel' }); + return { status: 'completed', text: 'hello' }; + }); + const backend = new PluginExecutorBackend({ + sessionId: 'session-a', + cwd: '/workspace', + executorId: 'remote', + instructions: 'child instructions', + service, + newId: ids(), + now: () => 42, + }); + + const events = await collect(backend.send({ turnId: 'turn-a', runId: 'run-a', text: 'task' })); + assert.deepEqual( + events.map((event) => event.type), + ['text_delta', 'text_complete', 'complete'], + ); + assert.equal(events[0]?.turnId, 'turn-a'); + assert.equal(events[0]?.type === 'text_delta' ? events[0].text : undefined, 'hel'); + assert.equal(events[1]?.type === 'text_complete' ? events[1].text : undefined, 'hello'); + assert.equal(events[2]?.type === 'complete' ? events[2].stopReason : undefined, 'end_turn'); + await root.fiber.dispose(); +}); + +test('executor backend turns stop into abort and terminal events', async () => { + let started!: () => void; + const ready = new Promise((resolve) => { + started = resolve; + }); + const { root, service } = fixture(async (_request, context) => { + started(); + await new Promise((resolve) => context.signal.addEventListener('abort', () => resolve())); + return { status: 'cancelled' }; + }); + const backend = new PluginExecutorBackend({ + sessionId: 'session-a', + cwd: '/workspace', + executorId: 'remote', + service, + }); + + const eventsPromise = collect(backend.send({ turnId: 'turn-a', text: 'task' })); + await ready; + await backend.stop('user_stop'); + const events = await eventsPromise; + assert.deepEqual( + events.map((event) => event.type), + ['abort', 'complete'], + ); + assert.equal(events[1]?.type === 'complete' ? events[1].stopReason : undefined, 'user_stop'); + await root.fiber.dispose(); +}); + +function fixture(execute: Parameters[0]['execute']): { + root: Context; + service: PluginExecutorService; +} { + const root = new Context(); + const service = new PluginExecutorService(root); + root + .extend({ + maka: { rootId: 'profile', packageId: 'fixture', entryId: 'provider', generation: 1 }, + }) + .executors.register({ id: 'remote', execute }); + return { root, service }; +} + +function ids(): () => string { + let value = 0; + return () => `id-${++value}`; +} + +async function collect(events: AsyncIterable): Promise { + const result: SessionEvent[] = []; + for await (const event of events) result.push(event); + return result; +} diff --git a/packages/runtime/src/__tests__/plugin-executor-service.test.ts b/packages/runtime/src/__tests__/plugin-executor-service.test.ts new file mode 100644 index 0000000000..aa63218e3b --- /dev/null +++ b/packages/runtime/src/__tests__/plugin-executor-service.test.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 assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { Context } from '../plugin-kernel.js'; +import { PluginExecutorService } from '../plugin-executor-service.js'; +import { MakaPluginTransactionBuffer } from '../plugin-runtime.js'; + +test('executors are scoped and pass black-box output without an Agent invocation', async () => { + const root = new Context(); + const service = new PluginExecutorService(root); + const profile = plugin(root, 'profile', 'profile-provider', 1); + const session = plugin(root, 'session:session-a', 'session-provider', 1); + profile.executors.register({ + id: 'remote', + displayName: 'Remote', + execute: async (request, context) => { + context.emit({ type: 'output_delta', text: 'working' }); + return { status: 'completed', text: `${request.sessionId}:${request.text}` }; + }, + }); + session.executors.register({ + id: 'private', + execute: async () => ({ status: 'completed', text: 'private' }), + }); + + const output: string[] = []; + assert.deepEqual( + await service.execute('remote', request('session-b'), { + onEvent: (event) => output.push(event.text), + }), + { status: 'completed', text: 'session-b:hello' }, + ); + assert.deepEqual(output, ['working']); + assert.deepEqual( + service.list('session-a').map((item) => item.id), + ['private', 'remote'], + ); + assert.deepEqual( + service.list('session-b').map((item) => item.id), + ['remote'], + ); + await assert.rejects(() => service.execute('private', request('session-b')), /unavailable/u); + await root.fiber.dispose(); +}); + +test('executor registration is transactional across hot reload', async () => { + const root = new Context(); + const service = new PluginExecutorService(root); + const previous = plugin(root, 'profile', 'provider', 1); + const disposePrevious = previous.executors.register(provider('previous')); + const candidateOwner = plugin(root, 'profile', 'provider', 2); + const transaction = new MakaPluginTransactionBuffer(candidateOwner); + const candidate = candidateOwner.extend({ makaTransaction: transaction }); + const disposeCandidate = candidate.executors.register(provider('candidate')); + + assert.equal(await executeText(service), 'previous'); + await transaction.commit(); + assert.equal(await executeText(service), 'candidate'); + await disposePrevious(); + assert.equal(await executeText(service), 'candidate'); + await disposeCandidate(); + await assert.rejects(() => executeText(service), /unavailable/u); + await root.fiber.dispose(); +}); + +test('retiring an executor aborts and drains its active calls', async () => { + const root = new Context(); + const service = new PluginExecutorService(root); + const owner = plugin(root, 'profile', 'provider', 1); + let observed: AbortSignal | undefined; + let started!: () => void; + const startedPromise = new Promise((resolve) => { + started = resolve; + }); + const dispose = owner.executors.register({ + id: 'remote', + execute: async (_request, context) => { + observed = context.signal; + started(); + await new Promise((resolve) => + context.signal.addEventListener('abort', () => resolve()), + ); + return { status: 'cancelled' }; + }, + }); + const execution = service.execute('remote', request('session-a')); + await startedPromise; + await dispose(); + assert.equal(observed?.aborted, true); + assert.deepEqual(await execution, { status: 'cancelled' }); + await root.fiber.dispose(); +}); + +function plugin( + root: Context, + rootId: 'profile' | `session:${string}`, + entryId: string, + generation: number, +) { + return root.extend({ + maka: { rootId, packageId: 'fixture', entryId, generation }, + }); +} + +function provider(text: string) { + return { + id: 'remote', + execute: async () => ({ status: 'completed' as const, text }), + }; +} + +function request(sessionId: string) { + return { + sessionId, + turnId: 'turn-a', + conversationKey: sessionId, + text: 'hello', + cwd: '/workspace', + }; +} + +async function executeText(service: PluginExecutorService): Promise { + const result = await service.execute('remote', request('session-a')); + assert.equal(result.status, 'completed'); + return result.text; +} diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 7c525616ec..e7e1dfcc1d 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -747,6 +747,51 @@ describe('SessionManager graph operator provisioning', () => { assert.deepStrictEqual(await runStore.listSessionInvocations(result.header.id), []); }); + test('provisions a graph child on an explicit plugin executor without Maka tools', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const manager = new SessionManager({ + store, + runStore, + runtimeEventStore: runStore, + backends: new BackendRegistry(), + childTools: [], + newId: nextId(), + now: nextNow(40), + }); + const parent = await manager.createSession(makeInput()); + await seedInvocationFromHeader( + runStore, + makeRunHeader({ + sessionId: parent.id, + runId: 'supervisor-run', + turnId: 'supervisor-turn', + }), + ); + + const result = await manager.provisionAgentGraphOperator({ + graphId: 'graph-external', + workId: `graph_work_${'4'.repeat(32)}`, + agentId: LOCAL_READ_AGENT_ID, + executorId: 'codex', + operatorId: `graph_operator_${'5'.repeat(32)}`, + source: { + sessionId: parent.id, + runId: 'supervisor-run', + turnId: 'supervisor-turn', + toolCallId: 'schedule-tool', + }, + edges: [], + expectedScheduleRevision: 1, + }); + + assert.strictEqual(result.header.backend, 'plugin-executor'); + assert.strictEqual(result.header.executorId, 'codex'); + assert.strictEqual(result.header.llmConnectionId, undefined); + assert.strictEqual(result.header.llmConnectionSlug, 'executor:codex'); + assert.deepStrictEqual(result.header.subagentRuntime?.toolNames, []); + }); + test('keeps four large graph branches and a replacement off the supervisor data plane', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); @@ -2273,6 +2318,82 @@ describe('SessionManager claimed graph intent execution', () => { }); describe('SessionManager child-session runtime primitive', () => { + test('runs an explicitly delegated child through a plugin executor with no Maka tools', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const backends = new BackendRegistry(); + const parentGate = makeGate(); + let childContext: BackendFactoryContext | undefined; + backends.register('ai-sdk', (ctx) => new TestBackend(ctx, parentGate)); + backends.register('plugin-executor', (ctx) => { + childContext = ctx; + return { + kind: 'plugin-executor' as const, + sessionId: ctx.sessionId, + async *send(input: BackendSendInput): AsyncIterable { + yield { + type: 'text_complete', + id: 'external-complete-text', + turnId: input.turnId, + ts: 1, + messageId: 'external-message', + text: 'external result', + }; + yield { + type: 'complete', + id: 'external-complete', + turnId: input.turnId, + ts: 2, + stopReason: 'end_turn', + }; + }, + async stop() {}, + async respondToSandboxBoundary() {}, + async dispose() {}, + }; + }); + const manager = new SessionManager({ + store, + runStore, + runtimeEventStore: runStore, + backends, + childTools: [], + newId: nextId(), + now: nextNow(80), + }); + const parent = await manager.createSession(makeInput()); + const parentTurn = manager + .sendMessage(parent.id, { turnId: 'parent-turn', text: 'delegate externally' }) + [Symbol.asyncIterator](); + await parentTurn.next(); + const [parentRun] = await runStore.listSessionInvocations(parent.id); + if (!parentRun) throw new Error('parent run was not recorded'); + + const result = await manager.spawnChildSession(parent.id, { + spawnedBy: { + parentRunId: parentRun.runId, + parentTurnId: parentRun.turnId, + toolCallId: 'tool-call-external', + }, + agentProfile: LOCAL_READ_AGENT_PROFILE, + executorId: 'codex', + prompt: 'inspect through codex', + }); + const child = await store.readHeader(result.childSessionId); + + assert.strictEqual(result.status, 'completed'); + assert.strictEqual(result.summary, 'external result'); + assert.strictEqual(child.backend, 'plugin-executor'); + assert.strictEqual(child.executorId, 'codex'); + assert.strictEqual(child.llmConnectionId, undefined); + assert.deepStrictEqual(child.subagentRuntime?.toolNames, []); + assert.deepStrictEqual(childContext?.tools, []); + assert.strictEqual(childContext?.systemPrompt, LOCAL_READ_AGENT_DEFINITION.systemPrompt); + + parentGate.release(); + while (!(await parentTurn.next()).done) {} + }); + test('creates a fresh read-only child with a session-inline first run and no parent history', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); @@ -13756,7 +13877,8 @@ class MemorySessionStore implements SessionStore { ...(input.revisionIndex !== undefined ? { revisionIndex: input.revisionIndex } : {}), ...(input.revisionState ? { revisionState: input.revisionState } : {}), hasUnread: false, - backend: 'ai-sdk', + backend: input.executorId ? 'plugin-executor' : 'ai-sdk', + ...(input.executorId ? { executorId: input.executorId } : {}), ...(input.llmConnectionId === undefined ? {} : { llmConnectionId: input.llmConnectionId }), llmConnectionSlug: input.llmConnectionSlug, connectionLocked: input.subagentParent !== undefined, diff --git a/packages/runtime/src/__tests__/subagent-tools.test.ts b/packages/runtime/src/__tests__/subagent-tools.test.ts index 0a26942d44..08434de0f7 100644 --- a/packages/runtime/src/__tests__/subagent-tools.test.ts +++ b/packages/runtime/src/__tests__/subagent-tools.test.ts @@ -111,6 +111,7 @@ describe('subagent tools', () => { assert.deepStrictEqual(Object.keys(await advertisedProperties(buildSubagentSpawnTool())), [ 'profile', 'subagent_id', + 'executor_id', 'task', 'write_back', 'isolation', @@ -412,6 +413,7 @@ describe('subagent tools', () => { const result = await tool.impl( { profile: LOCAL_READ_AGENT_PROFILE, + executor_id: 'codex', task: 'Inspect the runtime tests.', }, { @@ -464,10 +466,12 @@ describe('subagent tools', () => { assert.strictEqual(calls.length, 1); const call = calls[0] as { agentProfile: string; + executorId?: string; prompt: string; onEvent?: (event: SessionEvent) => void; }; assert.strictEqual(call.agentProfile, LOCAL_READ_AGENT_PROFILE); + assert.strictEqual(call.executorId, 'codex'); assert.strictEqual(call.prompt, 'Inspect the runtime tests.'); assert.strictEqual(typeof call.onEvent, 'function'); assert.deepStrictEqual(output, [ diff --git a/packages/runtime/src/plugin-agent-service.ts b/packages/runtime/src/plugin-agent-service.ts index 727ee4639b..ea298d1da1 100644 --- a/packages/runtime/src/plugin-agent-service.ts +++ b/packages/runtime/src/plugin-agent-service.ts @@ -57,6 +57,7 @@ export interface PluginAgentCreateOptions { readonly cwd?: string; readonly prompt?: string; readonly agentProfile?: AgentProfile; + readonly executorId?: string; readonly model?: string; readonly permissionMode?: PermissionMode; readonly signal?: AbortSignal; diff --git a/packages/runtime/src/plugin-executor-backend.ts b/packages/runtime/src/plugin-executor-backend.ts new file mode 100644 index 0000000000..c97590db3e --- /dev/null +++ b/packages/runtime/src/plugin-executor-backend.ts @@ -0,0 +1,255 @@ +/* + * 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, randomUUID } from 'node:crypto'; +import type { SessionEvent } from '@maka/core/events'; +import type { AgentBackend, BackendSendInput } from '@maka/core/backend-types'; +import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; +import type { UserQuestionResponse } from '@maka/core/user-question'; +import { AsyncEventQueue } from './async-queue.js'; +import type { PluginExecutorResult, PluginExecutorService } from './plugin-executor-service.js'; + +interface ActiveExecution { + readonly abort: AbortController; + readonly settled: Promise; +} + +export interface PluginExecutorBackendInput { + readonly sessionId: string; + readonly cwd: string; + readonly executorId: string; + readonly instructions?: string; + readonly service: PluginExecutorService; + readonly newId?: () => string; + readonly now?: () => number; +} + +/** Converts a small plugin executor contract into Maka's existing Run event stream. */ +export class PluginExecutorBackend implements AgentBackend { + readonly kind = 'plugin-executor' as const; + readonly sessionId: string; + readonly #cwd: string; + readonly #executorId: string; + readonly #instructions?: string; + readonly #service: PluginExecutorService; + readonly #newId: () => string; + readonly #now: () => number; + readonly #active = new Set(); + #disposed = false; + + constructor(input: PluginExecutorBackendInput) { + this.sessionId = input.sessionId; + this.#cwd = input.cwd; + this.#executorId = input.executorId; + this.#instructions = input.instructions; + this.#service = input.service; + this.#newId = input.newId ?? randomUUID; + this.#now = input.now ?? Date.now; + } + + providerStateIdentity(): `sha256:${string}` { + const identity = this.#service.identity(this.sessionId, this.#executorId); + return `sha256:${createHash('sha256') + .update( + JSON.stringify([ + 'plugin-executor.v1', + identity.id, + identity.extensionId, + identity.entryId, + identity.generation, + ]), + ) + .digest('hex')}`; + } + + async *send(input: BackendSendInput): AsyncIterable { + if (this.#disposed) throw new Error('Plugin executor backend is disposed'); + const abort = new AbortController(); + const queue = new AsyncEventQueue(); + const messageId = this.#newId(); + const producer = this.#produce(input, messageId, abort.signal, queue).finally(() => + queue.close(), + ); + const active: ActiveExecution = { abort, settled: producer }; + this.#active.add(active); + try { + for await (const event of queue) { + yield event; + queue.ackConsumed(); + } + await producer; + } finally { + queue.noteConsumerDetached(); + abort.abort(new Error('Plugin executor event consumer detached')); + await producer.catch(() => undefined); + this.#active.delete(active); + } + } + + async stop(reason: 'user_stop' | 'redirect'): Promise { + const active = [...this.#active]; + for (const execution of active) execution.abort.abort(new Error(reason)); + await Promise.allSettled(active.map((execution) => execution.settled)); + } + + async respondToSandboxBoundary(_response: SandboxBoundaryResponse): Promise { + throw new Error('Plugin executor does not expose Maka sandbox-boundary requests'); + } + + async respondToUserQuestion(_response: UserQuestionResponse): Promise { + throw new Error('Plugin executor does not expose Maka user-question requests'); + } + + async dispose(): Promise { + if (this.#disposed) return; + this.#disposed = true; + await this.stop('user_stop'); + } + + async #produce( + input: BackendSendInput, + messageId: string, + signal: AbortSignal, + queue: AsyncEventQueue, + ): Promise { + const turnId = input.turnId; + try { + const result = await this.#service.execute( + this.#executorId, + { + sessionId: this.sessionId, + turnId, + ...(input.runId ? { runId: input.runId } : {}), + conversationKey: this.sessionId, + text: input.text, + cwd: this.#cwd, + ...(this.#instructions ? { instructions: this.#instructions } : {}), + ...(input.attachments ? { attachments: input.attachments } : {}), + ...(input.directoryReferences ? { directoryReferences: input.directoryReferences } : {}), + ...(input.quotes ? { quotes: input.quotes } : {}), + }, + { + signal, + onEvent: (event) => { + if (!event.text) return; + queue.push({ + type: 'text_delta', + id: this.#newId(), + turnId, + ts: this.#now(), + messageId, + text: event.text, + }); + }, + }, + ); + this.#publishResult(turnId, messageId, result, queue); + } catch (error) { + if (signal.aborted) { + this.#publishCancellation(turnId, queue); + return; + } + this.#publishFailure( + turnId, + error instanceof Error ? error.message : 'External executor failed', + undefined, + false, + queue, + ); + } + } + + #publishResult( + turnId: string, + messageId: string, + result: PluginExecutorResult, + queue: AsyncEventQueue, + ): void { + if (result.status === 'completed') { + queue.push({ + type: 'text_complete', + id: this.#newId(), + turnId, + ts: this.#now(), + messageId, + text: result.text, + }); + queue.push({ + type: 'complete', + id: this.#newId(), + turnId, + ts: this.#now(), + stopReason: 'end_turn', + }); + return; + } + if (result.status === 'cancelled') { + this.#publishCancellation(turnId, queue); + return; + } + this.#publishFailure(turnId, result.message, result.code, result.recoverable ?? false, queue); + } + + #publishCancellation(turnId: string, queue: AsyncEventQueue): void { + queue.push({ + type: 'abort', + id: this.#newId(), + turnId, + ts: this.#now(), + reason: 'user_stop', + }); + queue.push({ + type: 'complete', + id: this.#newId(), + turnId, + ts: this.#now(), + stopReason: 'user_stop', + }); + } + + #publishFailure( + turnId: string, + message: string, + code: string | undefined, + recoverable: boolean, + queue: AsyncEventQueue, + ): void { + queue.push({ + type: 'error', + id: this.#newId(), + turnId, + ts: this.#now(), + recoverable, + ...(code ? { code, reason: code } : {}), + message: boundedMessage(message), + }); + queue.push({ + type: 'complete', + id: this.#newId(), + turnId, + ts: this.#now(), + stopReason: 'error', + }); + } +} + +function boundedMessage(value: string): string { + if (value.length <= 8_192) return value; + return `${value.slice(0, 8_191)}…`; +} diff --git a/packages/runtime/src/plugin-executor-service.ts b/packages/runtime/src/plugin-executor-service.ts new file mode 100644 index 0000000000..9204f2e7fe --- /dev/null +++ b/packages/runtime/src/plugin-executor-service.ts @@ -0,0 +1,325 @@ +/* + * 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, DirectoryReference, QuoteRef } from '@maka/core/events'; +import { Service, type Context, type Disposable } from './plugin-kernel.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 { + readonly executors: PluginExecutorService; + } +} + +const EXECUTOR_ID_PATTERN = /^[A-Za-z][A-Za-z0-9._:-]{0,127}$/u; + +export interface PluginExecutorRequest { + readonly sessionId: string; + readonly turnId: string; + readonly runId?: string; + /** Stable key a provider may use to retain its own external conversation. */ + readonly conversationKey: string; + readonly text: string; + readonly cwd: string; + /** Child-agent instruction when this request belongs to a linked child Session. */ + readonly instructions?: string; + readonly attachments?: readonly AttachmentRef[]; + readonly directoryReferences?: readonly DirectoryReference[]; + readonly quotes?: readonly QuoteRef[]; +} + +export interface PluginExecutorOutputEvent { + readonly type: 'output_delta'; + readonly text: string; +} + +export type PluginExecutorResult = + | { readonly status: 'completed'; readonly text: string } + | { readonly status: 'cancelled'; readonly reason?: string } + | { + readonly status: 'failed'; + readonly message: string; + readonly code?: string; + readonly recoverable?: boolean; + }; + +export interface PluginExecutorContext { + readonly signal: AbortSignal; + emit(event: PluginExecutorOutputEvent): void; +} + +/** A black-box executor contributed by one Host plugin. */ +export interface PluginExecutorProvider { + readonly id: string; + readonly displayName?: string; + execute( + request: Readonly, + context: PluginExecutorContext, + ): Promise; +} + +export interface PluginExecutorExecutionOptions { + readonly signal?: AbortSignal; + readonly onEvent?: (event: PluginExecutorOutputEvent) => void; +} + +export interface PluginExecutorInspection extends MakaContributionIdentity { + readonly id: string; + readonly displayName: string; +} + +interface RegisteredExecutor extends MakaContributionIdentity { + readonly provider: PluginExecutorProvider; + readonly token: symbol; + readonly active: Set; + retired: boolean; +} + +interface ActiveExecution { + readonly abort: AbortController; + readonly settled: Promise; +} + +/** + * Scoped black-box execution registry. + * + * The service owns only registration, visibility, cancellation, and result + * validation. Protocol processes, credentials, external conversation ids, and + * tools remain private to the contributing plugin. + */ +export class PluginExecutorService extends Service { + private readonly registry = new PluginScopeRegistry(); + + constructor(ctx: Context) { + super(ctx, 'executors'); + } + + register(provider: PluginExecutorProvider): Disposable> { + validateProvider(provider); + const identity = pluginIdentity(this.ctx); + return registerPluginContribution(this.ctx, `executors.register(${provider.id})`, () => { + const rootId = identity.scopeId as MakaPluginRootId; + const existing = this.registry.get(rootId, provider.id); + if (existing && existing.entryId !== identity.entryId) { + throw new MakaPluginRuntimeError( + 'activation_failed', + `Executor is already registered in this scope: ${provider.id}`, + ); + } + const entry: RegisteredExecutor = { + ...identity, + provider: Object.freeze({ ...provider }), + token: Symbol(provider.id), + active: new Set(), + retired: false, + }; + return this.registry.publish(rootId, provider.id, entry, { + onRetired: async (retired) => { + for (const execution of retired.active) { + execution.abort.abort(new Error(`Executor was retired: ${retired.provider.id}`)); + } + await Promise.allSettled([...retired.active].map((execution) => execution.settled)); + }, + }); + }); + } + + list(sessionId: string): readonly PluginExecutorInspection[] { + assertSessionId(sessionId); + return Object.freeze( + [...this.registry.visible(sessionId).values()] + .sort((left, right) => left.provider.id.localeCompare(right.provider.id)) + .map(({ provider, token: _token, active: _active, retired: _retired, ...identity }) => + Object.freeze({ + ...identity, + id: provider.id, + displayName: provider.displayName?.trim() || provider.id, + }), + ), + ); + } + + inspect(rootId?: MakaPluginRootId): readonly PluginExecutorInspection[] { + const seen = new Set(); + return Object.freeze( + [...this.registry.entries(rootId)] + .filter((entry) => !seen.has(entry) && Boolean(seen.add(entry))) + .sort((left, right) => left.provider.id.localeCompare(right.provider.id)) + .map(({ provider, token: _token, active: _active, retired: _retired, ...identity }) => + Object.freeze({ + ...identity, + id: provider.id, + displayName: provider.displayName?.trim() || provider.id, + }), + ), + ); + } + + identity(sessionId: string, executorId: string): PluginExecutorInspection { + const entry = this.entry(sessionId, executorId); + return Object.freeze({ + entryId: entry.entryId, + scopeId: entry.scopeId, + extensionId: entry.extensionId, + generation: entry.generation, + id: entry.provider.id, + displayName: entry.provider.displayName?.trim() || entry.provider.id, + }); + } + + async execute( + executorId: string, + request: PluginExecutorRequest, + options: PluginExecutorExecutionOptions = {}, + ): Promise { + const normalizedRequest = normalizeRequest(request); + const entry = this.entry(normalizedRequest.sessionId, executorId); + const abort = new AbortController(); + const signal = options.signal ? AbortSignal.any([options.signal, abort.signal]) : abort.signal; + let settle!: () => void; + const settled = new Promise((resolve) => { + settle = resolve; + }); + const active: ActiveExecution = { abort, settled }; + entry.active.add(active); + try { + if (entry.retired) throw new Error(`Executor is unavailable: ${executorId}`); + const result = await entry.provider.execute(normalizedRequest, { + signal, + emit: (event) => { + if (signal.aborted || entry.retired) return; + const normalized = normalizeOutputEvent(event); + try { + options.onEvent?.(normalized); + } catch { + // A presentation observer must not change external execution. + } + }, + }); + return normalizeResult(result); + } finally { + entry.active.delete(active); + settle(); + } + } + + private entry(sessionId: string, executorId: string): RegisteredExecutor { + assertSessionId(sessionId); + assertExecutorId(executorId); + const entry = this.registry.visible(sessionId).get(executorId); + if (!entry || entry.retired) throw new Error(`Executor is unavailable: ${executorId}`); + return entry; + } +} + +function validateProvider(provider: PluginExecutorProvider): void { + if (!provider || typeof provider !== 'object') + throw new TypeError('Executor provider is required'); + assertExecutorId(provider.id); + if (typeof provider.execute !== 'function') { + throw new TypeError(`Executor implementation is invalid: ${provider.id}`); + } + if ( + provider.displayName !== undefined && + (typeof provider.displayName !== 'string' || !provider.displayName.trim()) + ) { + throw new TypeError(`Executor display name is invalid: ${provider.id}`); + } +} + +function assertExecutorId(value: string): void { + if (typeof value !== 'string' || !EXECUTOR_ID_PATTERN.test(value)) { + throw new TypeError('Executor id is invalid'); + } +} + +function assertSessionId(value: string): void { + if (!value || /[\0\r\n]/u.test(value)) throw new TypeError('Session id is invalid'); +} + +function normalizeRequest(request: PluginExecutorRequest): Readonly { + if (!request || typeof request !== 'object') throw new TypeError('Executor request is required'); + assertSessionId(request.sessionId); + for (const [label, value] of [ + ['turnId', request.turnId], + ['conversationKey', request.conversationKey], + ['cwd', request.cwd], + ] as const) { + if (!value || /[\0\r\n]/u.test(value)) throw new TypeError(`Executor ${label} is invalid`); + } + if (typeof request.text !== 'string') throw new TypeError('Executor request text is invalid'); + if (request.runId !== undefined && (!request.runId || /[\0\r\n]/u.test(request.runId))) { + throw new TypeError('Executor runId is invalid'); + } + if (request.instructions !== undefined && typeof request.instructions !== 'string') { + throw new TypeError('Executor instructions are invalid'); + } + return Object.freeze({ + ...request, + ...(request.attachments ? { attachments: Object.freeze([...request.attachments]) } : {}), + ...(request.directoryReferences + ? { directoryReferences: Object.freeze([...request.directoryReferences]) } + : {}), + ...(request.quotes ? { quotes: Object.freeze([...request.quotes]) } : {}), + }); +} + +function normalizeOutputEvent(event: PluginExecutorOutputEvent): PluginExecutorOutputEvent { + if (!event || event.type !== 'output_delta' || typeof event.text !== 'string') { + throw new TypeError('Executor output event is invalid'); + } + return Object.freeze({ type: 'output_delta', text: event.text }); +} + +function normalizeResult(result: PluginExecutorResult): PluginExecutorResult { + if (!result || typeof result !== 'object') throw new TypeError('Executor result is invalid'); + if (result.status === 'completed' && typeof result.text === 'string') { + return Object.freeze({ status: result.status, text: result.text }); + } + if ( + result.status === 'cancelled' && + (result.reason === undefined || typeof result.reason === 'string') + ) { + return Object.freeze({ + status: result.status, + ...(result.reason === undefined ? {} : { reason: result.reason }), + }); + } + if ( + result.status === 'failed' && + typeof result.message === 'string' && + (result.code === undefined || typeof result.code === 'string') && + (result.recoverable === undefined || typeof result.recoverable === 'boolean') + ) { + return Object.freeze({ + status: result.status, + message: result.message, + ...(result.code === undefined ? {} : { code: result.code }), + ...(result.recoverable === undefined ? {} : { recoverable: result.recoverable }), + }); + } + throw new TypeError('Executor result is invalid'); +} diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 83217db13c..7f3abb5471 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -2679,6 +2679,12 @@ export class RuntimeKernel implements RuntimeKernelLike { if (!header.subagentParent) { throw new Error('Subagent runtime snapshot requires a linked child session'); } + if (header.backend === 'plugin-executor') { + return { + systemPrompt: snapshot.systemPrompt, + tools: [], + }; + } const snapshotDefinition = { id: snapshot.agentId, permissionMode: header.permissionMode, diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index f6635e2a3c..98cca2a229 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -308,6 +308,8 @@ export interface SpawnChildSessionInput { agentProfile: AgentProfile; /** User-approved catalog selector. The runtime resolves its frozen model target. */ subagentId?: string; + /** Optional plugin executor. Non-preset children inherit the parent's executor. */ + executorId?: string; prompt: string; name?: string; turnId?: string; @@ -353,6 +355,7 @@ export interface ProvisionAgentGraphOperatorInput { workId: string; agentId?: string; subagentId?: string; + executorId?: string; operatorId: string; source: AgentGraphScheduleUpdateSource; edges: AgentGraphProvisionedEdge[]; @@ -524,6 +527,7 @@ export interface SessionConfigurationStoreUpdate { readonly expectedVersion: number; readonly configuration: { readonly backend: SessionHeader['backend']; + readonly executorId?: string; readonly llmConnectionId?: string; readonly llmConnectionSlug: string; readonly connectionLocked: boolean; @@ -1035,6 +1039,20 @@ export class SessionManager { }); } + private async resolveChildToolNames( + parentSessionId: string, + parentHeader: SessionHeader, + definition: AgentDefinition, + ): Promise { + const availableChildTools = await this.childToolsForSession(parentSessionId); + assertAgentDefinitionRunnable({ + definition, + tools: availableChildTools, + worktreeChildExecutorAvailable: await this.isWorktreeChildExecutorAvailable(parentHeader), + }); + return buildToolsForAgentDefinition(availableChildTools, definition).map((tool) => tool.name); + } + private async finalizeAndListChildTurnArtifacts( sessionId: string, turnId: string, @@ -2610,15 +2628,10 @@ export class SessionManager { const definition = resolvedPreset ? requireBuiltinAgentDefinitionByProfile(resolvedPreset.profile) : requireBuiltinAgentDefinition(input.agentId!); - const availableChildTools = await this.childToolsForSession(input.source.sessionId); - assertAgentDefinitionRunnable({ - definition, - tools: availableChildTools, - worktreeChildExecutorAvailable: await this.isWorktreeChildExecutorAvailable(parentHeader), - }); - const resolvedToolNames = buildToolsForAgentDefinition(availableChildTools, definition).map( - (tool) => tool.name, - ); + const executorId = input.executorId ?? (resolvedPreset ? undefined : parentHeader.executorId); + const resolvedToolNames = executorId + ? [] + : await this.resolveChildToolNames(input.source.sessionId, parentHeader, definition); const childPermissionMode = parentHeader.permissionMode === 'bypass' ? 'bypass' : definition.permissionMode; @@ -2646,6 +2659,7 @@ export class SessionManager { toolNames: resolvedToolNames, categoryPolicy: {}, systemPrompt: definition.systemPrompt, + executorId: executorId ?? null, ...(resolvedPreset ? { preset: { @@ -2682,20 +2696,28 @@ export class SessionManager { cwd: workspace?.worktreePath ?? parentHeader.cwd, ...(parentHeader.projectId !== undefined ? { projectId: parentHeader.projectId } : {}), name: resolvedPreset?.name ?? definition.name, - ...(resolvedPreset - ? { llmConnectionId: resolvedPreset.connectionId } - : parentHeader.llmConnectionId === undefined - ? {} - : { llmConnectionId: parentHeader.llmConnectionId }), - llmConnectionSlug: resolvedPreset?.connectionSlug ?? parentHeader.llmConnectionSlug, - model: resolvedPreset?.model ?? parentHeader.model, - ...(resolvedPreset - ? resolvedPreset.thinkingLevel !== undefined - ? { thinkingLevel: resolvedPreset.thinkingLevel } - : {} - : parentHeader.thinkingLevel !== undefined - ? { thinkingLevel: parentHeader.thinkingLevel } - : {}), + ...(executorId + ? { + executorId, + llmConnectionSlug: `executor:${executorId}`, + model: executorId, + } + : { + ...(resolvedPreset + ? { llmConnectionId: resolvedPreset.connectionId } + : parentHeader.llmConnectionId === undefined + ? {} + : { llmConnectionId: parentHeader.llmConnectionId }), + llmConnectionSlug: resolvedPreset?.connectionSlug ?? parentHeader.llmConnectionSlug, + model: resolvedPreset?.model ?? parentHeader.model, + ...(resolvedPreset + ? resolvedPreset.thinkingLevel !== undefined + ? { thinkingLevel: resolvedPreset.thinkingLevel } + : {} + : parentHeader.thinkingLevel !== undefined + ? { thinkingLevel: parentHeader.thinkingLevel } + : {}), + }), permissionMode: childPermissionMode, collaborationMode: 'agent', orchestrationMode: 'default', @@ -3203,15 +3225,11 @@ export class SessionManager { this.assertActiveParentRun(parentSessionId, parentRun, input.spawnedBy.parentTurnId); const definition = requireBuiltinAgentDefinitionByProfile(input.agentProfile); - const availableChildTools = await this.childToolsForSession(parentSessionId); - assertAgentDefinitionRunnable({ - definition, - tools: availableChildTools, - worktreeChildExecutorAvailable: await this.isWorktreeChildExecutorAvailable(parentHeader), - }); - const resolvedToolNames = buildToolsForAgentDefinition(availableChildTools, definition).map( - (tool) => tool.name, - ); + const executorId = + input.executorId ?? (input.resolvedPreset ? undefined : parentHeader.executorId); + const resolvedToolNames = executorId + ? [] + : await this.resolveChildToolNames(parentSessionId, parentHeader, definition); const proposedTurnId = input.turnId ?? this.deps.newId(); const proposedRunId = input.runId ?? this.deps.newId(); @@ -3225,20 +3243,29 @@ export class SessionManager { cwd: workspace?.worktreePath ?? parentHeader.cwd, ...(parentHeader.projectId !== undefined ? { projectId: parentHeader.projectId } : {}), name: input.name ?? input.resolvedPreset?.name ?? definition.name, - ...(input.resolvedPreset - ? { llmConnectionId: input.resolvedPreset.connectionId } - : parentHeader.llmConnectionId === undefined - ? {} - : { llmConnectionId: parentHeader.llmConnectionId }), - llmConnectionSlug: input.resolvedPreset?.connectionSlug ?? parentHeader.llmConnectionSlug, - model: input.resolvedPreset?.model ?? parentHeader.model, - ...(input.resolvedPreset - ? input.resolvedPreset.thinkingLevel !== undefined - ? { thinkingLevel: input.resolvedPreset.thinkingLevel } - : {} - : parentHeader.thinkingLevel !== undefined - ? { thinkingLevel: parentHeader.thinkingLevel } - : {}), + ...(executorId + ? { + executorId, + llmConnectionSlug: `executor:${executorId}`, + model: executorId, + } + : { + ...(input.resolvedPreset + ? { llmConnectionId: input.resolvedPreset.connectionId } + : parentHeader.llmConnectionId === undefined + ? {} + : { llmConnectionId: parentHeader.llmConnectionId }), + llmConnectionSlug: + input.resolvedPreset?.connectionSlug ?? parentHeader.llmConnectionSlug, + model: input.resolvedPreset?.model ?? parentHeader.model, + ...(input.resolvedPreset + ? input.resolvedPreset.thinkingLevel !== undefined + ? { thinkingLevel: input.resolvedPreset.thinkingLevel } + : {} + : parentHeader.thinkingLevel !== undefined + ? { thinkingLevel: parentHeader.thinkingLevel } + : {}), + }), permissionMode: definition.permissionMode, collaborationMode: 'agent', orchestrationMode: 'default', @@ -5029,6 +5056,7 @@ export function headerToSummary(h: SessionHeader): SessionSummary { ...(h.revisionIndex !== undefined ? { revisionIndex: h.revisionIndex } : {}), ...(h.revisionState ? { revisionState: h.revisionState } : {}), backend: h.backend, + ...(h.executorId ? { executorId: h.executorId } : {}), ...(h.llmConnectionId === undefined ? {} : { llmConnectionId: h.llmConnectionId }), llmConnectionSlug: h.llmConnectionSlug, connectionLocked: h.connectionLocked, @@ -5124,33 +5152,47 @@ function childSessionRequestFingerprint( parentSessionId: string, input: Pick< ResolvedSpawnChildSessionInput, - 'spawnedBy' | 'agentProfile' | 'prompt' | 'swarm' | 'resolvedPreset' + 'spawnedBy' | 'agentProfile' | 'executorId' | 'prompt' | 'swarm' | 'resolvedPreset' >, ): string { - const payload = input.resolvedPreset + const payload = input.executorId ? [ - 2, + 3, parentSessionId, input.spawnedBy.parentRunId, input.spawnedBy.parentTurnId, input.spawnedBy.toolCallId, input.agentProfile, - input.resolvedPreset, + input.executorId, + input.resolvedPreset ?? null, input.prompt, input.swarm?.swarmId ?? null, input.swarm?.itemId ?? null, ] - : [ - 1, - parentSessionId, - input.spawnedBy.parentRunId, - input.spawnedBy.parentTurnId, - input.spawnedBy.toolCallId, - input.agentProfile, - input.prompt, - input.swarm?.swarmId ?? null, - input.swarm?.itemId ?? null, - ]; + : input.resolvedPreset + ? [ + 2, + parentSessionId, + input.spawnedBy.parentRunId, + input.spawnedBy.parentTurnId, + input.spawnedBy.toolCallId, + input.agentProfile, + input.resolvedPreset, + input.prompt, + input.swarm?.swarmId ?? null, + input.swarm?.itemId ?? null, + ] + : [ + 1, + parentSessionId, + input.spawnedBy.parentRunId, + input.spawnedBy.parentTurnId, + input.spawnedBy.toolCallId, + input.agentProfile, + input.prompt, + input.swarm?.swarmId ?? null, + input.swarm?.itemId ?? null, + ]; return createHash('sha256').update(JSON.stringify(payload)).digest('hex'); } @@ -5201,7 +5243,8 @@ function sessionConfigurationWithPermissionMode( ): SessionConfigurationTransitionRequest['configuration'] { return { backend: header.backend, - ...(header.llmConnectionId === undefined ? {} : { llmConnectionId: header.llmConnectionId }), + executorId: header.executorId, + llmConnectionId: header.llmConnectionId, llmConnectionSlug: header.llmConnectionSlug, connectionLocked: header.connectionLocked, model: header.model, @@ -5218,6 +5261,7 @@ function sessionConfigurationMatchesExceptPermissionMode( ): boolean { return ( header.backend === configuration.backend && + header.executorId === configuration.executorId && header.llmConnectionId === configuration.llmConnectionId && header.llmConnectionSlug === configuration.llmConnectionSlug && header.connectionLocked === configuration.connectionLocked && diff --git a/packages/runtime/src/stream-graph-schedule-reconcile.ts b/packages/runtime/src/stream-graph-schedule-reconcile.ts index f282f2fb55..78e30b63e9 100644 --- a/packages/runtime/src/stream-graph-schedule-reconcile.ts +++ b/packages/runtime/src/stream-graph-schedule-reconcile.ts @@ -958,6 +958,7 @@ function buildOperatorProvisionInput( ...(work.target.kind === 'preset' ? { subagentId: work.target.presetId } : { agentId: work.target.agentId }), + ...(work.target.executorId ? { executorId: work.target.executorId } : {}), operatorId, source, edges, diff --git a/packages/runtime/src/stream-graph-supervisor-tools.ts b/packages/runtime/src/stream-graph-supervisor-tools.ts index fef66a8d9c..29926b1d90 100644 --- a/packages/runtime/src/stream-graph-supervisor-tools.ts +++ b/packages/runtime/src/stream-graph-supervisor-tools.ts @@ -65,6 +65,10 @@ const identitySchema = z .max(256) .refine((value) => !/[\u0000-\u001f\u007f]/.test(value), 'Identity contains control characters'); +const executorIdSchema = z + .string() + .regex(/^[A-Za-z][A-Za-z0-9._:-]{0,127}$/u, 'Invalid plugin executor id'); + const cursorSchema = z .string() .trim() @@ -95,6 +99,9 @@ const addWorkSchema = z.preprocess( .describe( 'Runtime id of an EXISTING graph operator returned by view_agent_graph. Use only for follow-up work; set operator_id OR agent_id, never both.', ), + executor_id: executorIdSchema + .optional() + .describe('Plugin executor id for a newly created agent or preset target.'), instruction: z.string().trim().min(1).max(AGENT_GRAPH_SCHEDULE_MAX_INSTRUCTION_CHARS), input_ids: z .array(identitySchema) @@ -332,6 +339,7 @@ function cleanAddWorkInput(input: unknown): unknown { if (cleaned.target_kind === 'existing_operator') { delete cleaned.agent_id; delete cleaned.subagent_id; + delete cleaned.executor_id; } if (cleaned.replacement_mode === 'none') delete cleaned.replaces; return cleaned; @@ -373,6 +381,7 @@ export interface UpdateAgentGraphToolInput { agent_id?: string; subagent_id?: string; operator_id?: string; + executor_id?: string; instruction: string; input_ids?: string[]; selected_result_inputs?: Array<{ @@ -1101,12 +1110,21 @@ function normalizeWorkTarget(input: { agent_id?: string; subagent_id?: string; operator_id?: string; + executor_id?: string; }): AgentGraphWorkTarget { if (input.target_kind === 'new_agent') { - return { kind: 'agent', agentId: requireIdentity(input.agent_id, 'agent id') }; + return { + kind: 'agent', + agentId: requireIdentity(input.agent_id, 'agent id'), + ...(input.executor_id ? { executorId: input.executor_id } : {}), + }; } if (input.target_kind === 'new_preset') { - return { kind: 'preset', presetId: requireIdentity(input.subagent_id, 'subagent preset id') }; + return { + kind: 'preset', + presetId: requireIdentity(input.subagent_id, 'subagent preset id'), + ...(input.executor_id ? { executorId: input.executor_id } : {}), + }; } if (input.target_kind === 'existing_operator') { return { @@ -1120,10 +1138,18 @@ function normalizeWorkTarget(input: { throw new Error('Exactly one of subagent_id, agent_id, or operator_id is required'); } if (input.subagent_id) { - return { kind: 'preset', presetId: requireIdentity(input.subagent_id, 'subagent preset id') }; + return { + kind: 'preset', + presetId: requireIdentity(input.subagent_id, 'subagent preset id'), + ...(input.executor_id ? { executorId: input.executor_id } : {}), + }; } return input.agent_id - ? { kind: 'agent', agentId: requireIdentity(input.agent_id, 'agent id') } + ? { + kind: 'agent', + agentId: requireIdentity(input.agent_id, 'agent id'), + ...(input.executor_id ? { executorId: input.executor_id } : {}), + } : { kind: 'operator', operatorId: requireIdentity(input.operator_id, 'operator id') }; } diff --git a/packages/runtime/src/subagent-tools.ts b/packages/runtime/src/subagent-tools.ts index ade65bd679..c2bdd5d28e 100644 --- a/packages/runtime/src/subagent-tools.ts +++ b/packages/runtime/src/subagent-tools.ts @@ -93,6 +93,7 @@ export function buildSubagentSpawnTool( { profile?: string; subagent_id?: string; + executor_id?: string; task: string; write_back?: string; isolation?: string; @@ -118,6 +119,11 @@ export function buildSubagentSpawnTool( .refine(isSafeSubagentPresetId) .optional() .describe('User-approved subagent preset id from agent_list.'), + executor_id: z + .string() + .regex(/^[A-Za-z][A-Za-z0-9._:-]{0,127}$/u) + .optional() + .describe('Plugin executor id for this child task.'), task: z .string() .min(1) @@ -202,6 +208,7 @@ export function buildSubagentSpawnTool( await ctx.spawnChildSession({ agentProfile: definition.profile, ...(input.subagent_id ? { subagentId: input.subagent_id } : {}), + ...(input.executor_id ? { executorId: input.executor_id } : {}), prompt: input.task, onEvent: (event) => progress.observe(event), }), diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index fa8b628c89..b282185fe5 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -282,6 +282,7 @@ export interface MakaToolContext { spawnChildSession?: (input: { agentProfile: AgentProfile; subagentId?: string; + executorId?: string; prompt: string; /** Optional swarm identity, scoped to the owning tool call. */ swarm?: { @@ -419,6 +420,7 @@ export interface ToolRuntimeInput { toolCallId: string; agentProfile: AgentProfile; subagentId?: string; + executorId?: string; prompt: string; swarm?: { swarmId: string; @@ -2572,6 +2574,7 @@ export class ToolRuntime { toolCallId: input.toolUseId, agentProfile: spawnInput.agentProfile, ...(spawnInput.subagentId ? { subagentId: spawnInput.subagentId } : {}), + ...(spawnInput.executorId ? { executorId: spawnInput.executorId } : {}), prompt: spawnInput.prompt, ...(spawnInput.swarm ? { swarm: spawnInput.swarm } : {}), abortSignal, diff --git a/packages/storage/src/__tests__/session-store.test.ts b/packages/storage/src/__tests__/session-store.test.ts index 582590be52..dbad1e6bd0 100644 --- a/packages/storage/src/__tests__/session-store.test.ts +++ b/packages/storage/src/__tests__/session-store.test.ts @@ -58,6 +58,34 @@ describe('SQLite SessionStore', () => { } }); + test('persists a plugin executor route across reloads and catalog projection', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-executor-route-')); + let store = createSessionStore(root); + try { + const created = await store.create( + makeInput({ + cwd: root, + executorId: 'codex', + llmConnectionSlug: 'executor:codex', + model: 'codex', + }), + ); + assert.equal(created.backend, 'plugin-executor'); + assert.equal(created.executorId, 'codex'); + assert.equal(created.llmConnectionId, undefined); + + await store.close?.(); + store = createSessionStore(root); + const reloaded = await store.readHeader(created.id); + assert.equal(reloaded.backend, 'plugin-executor'); + assert.equal(reloaded.executorId, 'codex'); + assert.equal((await store.list())[0]?.executorId, 'codex'); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + test('requires the reserved WorkHub Coordination identity and role together', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-workhub-coordination-identity-role-')); const store = createSessionStore(root); diff --git a/packages/storage/src/legacy-run-header.ts b/packages/storage/src/legacy-run-header.ts index 7136b7df95..cd5f9aca29 100644 --- a/packages/storage/src/legacy-run-header.ts +++ b/packages/storage/src/legacy-run-header.ts @@ -446,7 +446,7 @@ function isLegacyContinuationSource(value: unknown): value is LegacyContinuation /** `'fake'` stays accepted: runs written by builds that shipped FakeBackend must keep decoding (#3211). */ function isPersistedBackendKind(value: unknown): value is PersistedBackendKind { - return value === 'ai-sdk' || value === 'fake'; + return value === 'ai-sdk' || value === 'plugin-executor' || value === 'fake'; } function isSha256Digest(value: unknown): value is `sha256:${string}` { diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index 2679e6b21e..e934d41509 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -1340,7 +1340,8 @@ function buildSessionHeader( ...(input.revisionIndex !== undefined ? { revisionIndex: input.revisionIndex } : {}), ...(input.revisionState ? { revisionState: input.revisionState } : {}), hasUnread: false, - backend: 'ai-sdk', + backend: input.executorId ? 'plugin-executor' : 'ai-sdk', + ...(input.executorId ? { executorId: input.executorId } : {}), ...(input.llmConnectionId === undefined ? {} : { llmConnectionId: input.llmConnectionId }), llmConnectionSlug: input.llmConnectionSlug, // A subagent Session's route is chosen by the spawn that created it and is @@ -1405,6 +1406,7 @@ export function normalizeSessionHeader( (header.lastReadMessageId === undefined || typeof header.lastReadMessageId === 'string') && typeof header.hasUnread === 'boolean' && isPersistedBackendKind(header.backend) && + isValidExecutorSelection(header) && (header.llmConnectionId === undefined || (typeof header.llmConnectionId === 'string' && header.llmConnectionId.length > 0)) && typeof header.llmConnectionSlug === 'string' && @@ -1566,7 +1568,17 @@ function isValidSubagentSessionLineage(header: SessionHeader): boolean { * FakeBackend fail `normalizeSessionHeader` and read back as malformed (#3211). */ function isPersistedBackendKind(value: unknown): value is SessionHeader['backend'] { - return value === 'ai-sdk' || value === 'fake'; + return value === 'ai-sdk' || value === 'plugin-executor' || value === 'fake'; +} + +function isValidExecutorSelection(header: SessionHeader): boolean { + if (header.backend === 'plugin-executor') { + return ( + typeof header.executorId === 'string' && + /^[A-Za-z][A-Za-z0-9._:-]{0,127}$/u.test(header.executorId) + ); + } + return header.executorId === undefined; } function isFiniteNumber(value: unknown): value is number { @@ -1641,6 +1653,7 @@ function toSummary(header: SessionHeader): SessionSummary { ...(header.revisionIndex !== undefined ? { revisionIndex: header.revisionIndex } : {}), ...(header.revisionState ? { revisionState: header.revisionState } : {}), backend: header.backend, + ...(header.executorId ? { executorId: header.executorId } : {}), ...(header.llmConnectionId === undefined ? {} : { llmConnectionId: header.llmConnectionId }), llmConnectionSlug: header.llmConnectionSlug, connectionLocked: header.connectionLocked, diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index 73d1194aa6..fe7ca77ff3 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -391,7 +391,8 @@ export interface SessionConfigurationMetadataUpdate { readonly expectedVersion: number; readonly configuration: { readonly backend: SessionHeader['backend']; - readonly llmConnectionId: string; + readonly executorId?: string; + readonly llmConnectionId?: string; readonly llmConnectionSlug: string; readonly connectionLocked: boolean; readonly model: string; From 8bb9854e5411dbccd22c74b614f8f02a499ee2e3 Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:09:47 +0800 Subject: [PATCH 2/2] feat(runtime): harden plugin executor capabilities --- packages/core/package.json | 1 + .../core/src/__tests__/executor-id.test.ts | 30 ++ .../runtime-invocation-opened.test.ts | 22 ++ packages/core/src/agent-graph-schedule.ts | 5 +- packages/core/src/executor-id.ts | 24 ++ packages/core/src/runtime-event.ts | 38 ++- packages/core/src/session.ts | 8 +- .../host-session-availability.test.ts | 25 ++ .../src/__tests__/plugin-platform.test.ts | 2 + .../session-catalog-coordinator.test.ts | 34 ++ .../src/protocol/plugin-platform.ts | 15 + .../src/protocol/session-catalog.ts | 3 +- .../src/server/execution-composition.ts | 37 ++- .../src/server/host-session-availability.ts | 13 + .../src/server/session-catalog-coordinator.ts | 17 +- .../server/session-revision-coordinator.ts | 4 +- .../__tests__/plugin-executor-backend.test.ts | 124 +++++++- .../__tests__/plugin-executor-service.test.ts | 94 +++++- .../src/__tests__/session-manager.test.ts | 19 +- packages/runtime/src/agent-run.ts | 18 +- packages/runtime/src/ai-sdk-compaction.ts | 5 +- packages/runtime/src/history-compaction.ts | 7 +- .../runtime/src/plugin-executor-backend.ts | 219 ++++++++++--- .../runtime/src/plugin-executor-service.ts | 290 +++++++++++++++--- .../runtime/src/runtime-invocation-route.ts | 58 ++++ packages/runtime/src/runtime-kernel.ts | 20 +- packages/runtime/src/session-manager.ts | 8 +- .../src/stream-graph-supervisor-tools.ts | 5 +- packages/runtime/src/subagent-tools.ts | 3 +- packages/storage/src/legacy-run-header.ts | 2 +- packages/storage/src/session-store.ts | 6 +- 31 files changed, 972 insertions(+), 184 deletions(-) create mode 100644 packages/core/src/__tests__/executor-id.test.ts create mode 100644 packages/core/src/executor-id.ts create mode 100644 packages/runtime/src/runtime-invocation-route.ts diff --git a/packages/core/package.json b/packages/core/package.json index 4a167a083f..331eec147c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -7,6 +7,7 @@ "sideEffects": false, "private": true, "exports": { + "./executor-id": "./dist/executor-id.js", "./durable-tool-result-projection": "./dist/durable-tool-result-projection.js", "./model-projection-transition": "./dist/model-projection-transition.js", "./canonical-runtime-event": "./dist/canonical-runtime-event.js", diff --git a/packages/core/src/__tests__/executor-id.test.ts b/packages/core/src/__tests__/executor-id.test.ts new file mode 100644 index 0000000000..9abbe94066 --- /dev/null +++ b/packages/core/src/__tests__/executor-id.test.ts @@ -0,0 +1,30 @@ +/* + * 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 { isExecutorId } from '../executor-id.js'; + +test('executor ids use one bounded canonical grammar', () => { + assert.equal(isExecutorId('codex.app-server:v1'), true); + assert.equal(isExecutorId(`a${'b'.repeat(127)}`), true); + for (const value of ['', '1codex', 'codex/app', `a${'b'.repeat(128)}`, null]) { + assert.equal(isExecutorId(value), false); + } +}); diff --git a/packages/core/src/__tests__/runtime-invocation-opened.test.ts b/packages/core/src/__tests__/runtime-invocation-opened.test.ts index 205b345a3d..910b72ed4b 100644 --- a/packages/core/src/__tests__/runtime-invocation-opened.test.ts +++ b/packages/core/src/__tests__/runtime-invocation-opened.test.ts @@ -117,6 +117,28 @@ describe('invocation_opened content contract', () => { ); }); + test('binds a plugin executor route to its exact activation generation', () => { + const route = { + provenance: 'runtime', + backendKind: 'plugin-executor', + executorId: 'codex.app-server', + llmConnectionSlug: 'executor:codex.app-server', + modelId: 'codex.app-server', + providerStateIdentity: DIGEST, + } as const; + assert.deepEqual(decodeRuntimeInvocationOpened(opening({ route })).route, route); + assert.throws(() => + decodeRuntimeInvocationOpened( + opening({ route: { ...route, providerStateIdentity: undefined } as never }), + ), + ); + assert.throws(() => + decodeRuntimeInvocationOpened( + opening({ route: { ...route, llmConnectionId: 'not-an-executor-route' } as never }), + ), + ); + }); + test('accepts every root authority the runtime can open, and no mixture of them', () => { for (const root of [ { kind: 'user' }, diff --git a/packages/core/src/agent-graph-schedule.ts b/packages/core/src/agent-graph-schedule.ts index 3c051a5f18..2e60348949 100644 --- a/packages/core/src/agent-graph-schedule.ts +++ b/packages/core/src/agent-graph-schedule.ts @@ -24,6 +24,7 @@ import type { } from './agent-graph-control.js'; import type { AgentGraphTopologyStore } from './agent-graph-topology.js'; import type { OrchestrationMode } from './orchestration.js'; +import { isExecutorId } from './executor-id.js'; export const AGENT_GRAPH_SCHEDULE_UPDATE_SCHEMA_VERSION = 1 as const; @@ -418,10 +419,6 @@ function isOpaqueIdentity(value: unknown): value is string { ); } -function isExecutorId(value: unknown): value is string { - return typeof value === 'string' && /^[A-Za-z][A-Za-z0-9._:-]{0,127}$/u.test(value); -} - function isSha256Fingerprint(value: unknown): value is string { return typeof value === 'string' && /^sha256:[a-f0-9]{64}$/.test(value); } diff --git a/packages/core/src/executor-id.ts b/packages/core/src/executor-id.ts new file mode 100644 index 0000000000..db59c2ca3a --- /dev/null +++ b/packages/core/src/executor-id.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ + +export const EXECUTOR_ID_PATTERN = /^[A-Za-z][A-Za-z0-9._:-]{0,127}$/u; + +export function isExecutorId(value: unknown): value is string { + return typeof value === 'string' && EXECUTOR_ID_PATTERN.test(value); +} diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index f5eccc2482..77643ee0c4 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -70,6 +70,7 @@ import { type OrchestrationMode, } from './orchestration.js'; import { isToolMode, type ToolMode } from './tool-mode.js'; +import { isExecutorId } from './executor-id.js'; import { isRuntimeSystemNoteKind, type PersistedBackendKind, @@ -267,13 +268,22 @@ export interface RuntimeEventErrorContent { export type RuntimeInvocationRoute = | { provenance: 'runtime'; - backendKind: PersistedBackendKind; + backendKind: Exclude; llmConnectionId: string; llmConnectionSlug: string; modelId: string; /** Frozen provider endpoint and credential ownership; absent on non-provider runs. */ providerStateIdentity?: `sha256:${string}`; } + | { + provenance: 'runtime'; + backendKind: 'plugin-executor'; + executorId: string; + llmConnectionSlug: string; + modelId: string; + /** Frozen plugin package, entry point, and activation generation. */ + providerStateIdentity: `sha256:${string}`; + } | { provenance: 'unknown'; backendKind: PersistedBackendKind; @@ -760,12 +770,25 @@ const INVOCATION_OPENED_CONTENT_SHAPE = defineObjectShape +const INVOCATION_ROUTE_RUNTIME_MODEL_SHAPE = defineObjectShape< + Extract >()( ['provenance', 'backendKind', 'llmConnectionId', 'llmConnectionSlug', 'modelId'], ['providerStateIdentity'], ); +const INVOCATION_ROUTE_RUNTIME_EXECUTOR_SHAPE = defineObjectShape< + Extract +>()( + [ + 'provenance', + 'backendKind', + 'executorId', + 'llmConnectionSlug', + 'modelId', + 'providerStateIdentity', + ], + [], +); const INVOCATION_ROUTE_UNKNOWN_SHAPE = defineObjectShape< Extract >()(['provenance', 'backendKind', 'llmConnectionSlug', 'modelId'], []); @@ -1157,8 +1180,15 @@ function isRuntimeInvocationRoute(value: unknown): value is RuntimeInvocationRou return false; } if (value.provenance === 'runtime') { + if (value.backendKind === 'plugin-executor') { + return ( + hasExactShape(value, INVOCATION_ROUTE_RUNTIME_EXECUTOR_SHAPE) && + isExecutorId(value.executorId) && + isSha256Digest(value.providerStateIdentity) + ); + } return ( - hasExactShape(value, INVOCATION_ROUTE_RUNTIME_SHAPE) && + hasExactShape(value, INVOCATION_ROUTE_RUNTIME_MODEL_SHAPE) && isNonEmptyString(value.llmConnectionId) && (value.providerStateIdentity === undefined || isSha256Digest(value.providerStateIdentity)) ); diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 2a2c522012..1ced6886bd 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -18,6 +18,7 @@ */ import { isWorkHubActionReceipt, type WorkHubActionReceipt } from './workhub-action-result.js'; +import { isExecutorId } from './executor-id.js'; import { MODEL_FAILURE_MESSAGE_MAX_BYTES, @@ -967,12 +968,7 @@ export function isWorkHubCreateDefaults(value: unknown): value is WorkHubCreateD ) return false; if (value.permissionMode !== undefined && !isPermissionMode(value.permissionMode)) return false; - if ( - value.executorId !== undefined && - (typeof value.executorId !== 'string' || - !/^[A-Za-z][A-Za-z0-9._:-]{0,127}$/u.test(value.executorId)) - ) - return false; + if (value.executorId !== undefined && !isExecutorId(value.executorId)) return false; if (value.executorId !== undefined && value.model !== undefined) return false; if (value.model === undefined) return true; const model = value.model; diff --git a/packages/runtime-host/src/__tests__/host-session-availability.test.ts b/packages/runtime-host/src/__tests__/host-session-availability.test.ts index 79cf43a82e..5bf3190cd0 100644 --- a/packages/runtime-host/src/__tests__/host-session-availability.test.ts +++ b/packages/runtime-host/src/__tests__/host-session-availability.test.ts @@ -25,8 +25,11 @@ import { } from '@maka/core/session'; import { runtimeHostExecutionUnavailableReason, + runtimeHostConversationCopyUnavailableReason, runtimeHostSafeBoundaryContinuationUnavailableReason, LEGACY_CONNECTION_IDENTITY_EXECUTION_UNAVAILABLE_REASON, + PLUGIN_EXECUTOR_CONTINUATION_UNAVAILABLE_REASON, + PLUGIN_EXECUTOR_COPY_UNAVAILABLE_REASON, WORKHUB_COORDINATION_EXECUTION_UNAVAILABLE_REASON, WORKHUB_COORDINATION_TARGET_UNAVAILABLE_REASON, } from '../server/host-session-availability.js'; @@ -156,6 +159,28 @@ test('plugin executor Sessions do not require a Maka model connection identity', ); }); +test('plugin executor Sessions fail closed for safe-boundary continuation', () => { + assert.equal( + runtimeHostSafeBoundaryContinuationUnavailableReason({ + ...base, + id: 'plugin-executor-session', + role: undefined, + subagentParent: undefined, + llmConnectionId: undefined, + backend: 'plugin-executor', + }), + PLUGIN_EXECUTOR_CONTINUATION_UNAVAILABLE_REASON, + ); +}); + +test('plugin executor Sessions fail closed for branch and revision copies', () => { + assert.equal( + runtimeHostConversationCopyUnavailableReason({ backend: 'plugin-executor' }), + PLUGIN_EXECUTOR_COPY_UNAVAILABLE_REASON, + ); + assert.equal(runtimeHostConversationCopyUnavailableReason({ backend: 'ai-sdk' }), undefined); +}); + test('legacy Session identity cannot resume a safe-boundary continuation', () => { assert.equal( runtimeHostSafeBoundaryContinuationUnavailableReason({ diff --git a/packages/runtime-host/src/__tests__/plugin-platform.test.ts b/packages/runtime-host/src/__tests__/plugin-platform.test.ts index 80d05965a8..b93bcd1a0c 100644 --- a/packages/runtime-host/src/__tests__/plugin-platform.test.ts +++ b/packages/runtime-host/src/__tests__/plugin-platform.test.ts @@ -710,6 +710,7 @@ test('Plugin Platform query projects scoped Executor contributions for clients', generation: 4, id: 'codex', displayName: 'Codex', + capabilities: { thinking: true, toolActivity: false }, }, ], }, @@ -734,6 +735,7 @@ test('Plugin Platform query projects scoped Executor contributions for clients', generation: 4, id: 'codex', displayName: 'Codex', + capabilities: { thinking: true, toolActivity: false }, }, ], nextCursor: null, diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index 6bbc71da3d..409d09698e 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -795,6 +795,36 @@ test('plugin executor creation bypasses model resolution and persists the execut } }); +test('plugin executor creation fails before persistence when the executor is unavailable', async () => { + let createAttempts = 0; + const fixture = createFixture({ + assertExecutorAvailable: () => { + throw new Error('not installed'); + }, + stores: { + createStableSession: async () => { + createAttempts += 1; + throw new Error('must not persist'); + }, + }, + }); + + const outcome = await fixture.coordinator.handlers['session.create']( + { + sessionId: fixture.sessionId, + workspace: { kind: 'host_path', path: process.cwd() }, + executorId: 'missing', + }, + context, + ); + + assert.deepEqual(outcome, { + ok: false, + error: { code: 'operation_unavailable', message: 'Plugin executor is unavailable: missing' }, + }); + assert.equal(createAttempts, 0); +}); + test('creation admits the enabled bootstrap DeepSeek model before discovery', async () => { const modelId = 'deepseek-v4-flash'; let createAttempts = 0; @@ -1943,6 +1973,7 @@ function createFixture( readonly onProjectChanged?: () => void; readonly legacyConnectionIdentity?: boolean; readonly header?: Partial; + readonly assertExecutorAvailable?: (sessionId: string, executorId: string) => void; } = {}, ) { const sessionId = 'session-1'; @@ -2032,6 +2063,9 @@ function createFixture( requestDrain: () => { drains += 1; }, + ...(options.assertExecutorAvailable + ? { assertExecutorAvailable: options.assertExecutorAvailable } + : {}), }); return { coordinator, diff --git a/packages/runtime-host/src/protocol/plugin-platform.ts b/packages/runtime-host/src/protocol/plugin-platform.ts index c4a6821b22..1215195fd1 100644 --- a/packages/runtime-host/src/protocol/plugin-platform.ts +++ b/packages/runtime-host/src/protocol/plugin-platform.ts @@ -449,7 +449,18 @@ function decodeExecutorInspection(value: unknown): PluginExecutorInspection { 'generation', 'id', 'displayName', + 'capabilities', ]); + const capabilities = requireExactRecord(item.capabilities, 'Plugin Executor capabilities', [ + 'thinking', + 'toolActivity', + ]); + if ( + typeof capabilities.thinking !== 'boolean' || + typeof capabilities.toolActivity !== 'boolean' + ) { + throw invalidProtocolFrame('Invalid Plugin Executor capabilities'); + } return { entryId: requireId(item.entryId, 'Plugin Entry identity'), scopeId: requireString(item.scopeId, 'Plugin scope identity', 256), @@ -457,6 +468,10 @@ function decodeExecutorInspection(value: unknown): PluginExecutorInspection { generation: requireCount(item.generation, 'Plugin generation'), id: requireString(item.id, 'Plugin Executor id', 128), displayName: requireString(item.displayName, 'Plugin Executor display name', 256), + capabilities: { + thinking: capabilities.thinking, + toolActivity: capabilities.toolActivity, + }, }; } diff --git a/packages/runtime-host/src/protocol/session-catalog.ts b/packages/runtime-host/src/protocol/session-catalog.ts index 469210762a..eb65be7e54 100644 --- a/packages/runtime-host/src/protocol/session-catalog.ts +++ b/packages/runtime-host/src/protocol/session-catalog.ts @@ -31,6 +31,7 @@ import { type SessionToolProfile, } from '@maka/core/session'; import { isThinkingLevel, type ThinkingLevel } from '@maka/core/model-thinking'; +import { isExecutorId } from '@maka/core/executor-id'; import type { ExecutionBoundarySummary } from '@maka/core/sandbox-boundary'; export type { ExecutionBoundarySummary } from '@maka/core/sandbox-boundary'; import { @@ -1024,7 +1025,7 @@ function backend(value: unknown): SessionCatalogProjection['backend'] { function executorIdValue(value: unknown): string { const id = requireUtf8String(value, 'Executor id', 128); - if (!/^[A-Za-z][A-Za-z0-9._:-]{0,127}$/u.test(id)) { + if (!isExecutorId(id)) { throw invalidProtocolFrame('Invalid Executor id'); } return id; diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index d98441e5fa..3f3d32821f 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -337,6 +337,7 @@ export async function createExecutionRuntimeHostComposition( let goalExecutions: HostGoalExecutionCoordinator | undefined; let pluginPlatform: HostPluginPlatform | undefined; let manager: SessionManager | undefined; + let invalidatePluginExecutorBackends: () => void = () => undefined; let modelMetadataRefresh: ReturnType | undefined; let archiveEvidence: Awaited> | undefined; try { @@ -347,7 +348,11 @@ export async function createExecutionRuntimeHostComposition( new PluginUserQuestionService(pluginRoot, pluginAgents); const pluginFilesystem = new PluginFilesystemService(pluginRoot, pluginAgents); const pluginLlm = new PluginLlmService(pluginRoot, pluginAgents); - const pluginExecutors = new PluginExecutorService(pluginRoot); + const pluginExecutors = new PluginExecutorService(pluginRoot, { + // Retirement aborts its generation before backend disposal begins, so the + // terminal event preserves executor-retired instead of looking like Stop. + onChanged: () => queueMicrotask(invalidatePluginExecutorBackends), + }); const pluginShellEnv = new PluginShellEnvService(pluginRoot); const pluginShell = new PluginShellService(pluginRoot, pluginAgents, pluginShellEnv); const pluginWeb = new PluginWebService(pluginRoot, pluginAgents); @@ -1057,27 +1062,15 @@ export async function createExecutionRuntimeHostComposition( prepare: async (backendContext) => { const executorId = backendContext.header.executorId; if (!executorId) throw new Error('Plugin executor Session is missing its executor id'); - const identity = pluginExecutors.identity(backendContext.sessionId, executorId); - const providerStateIdentity = `sha256:${createHash('sha256') - .update( - JSON.stringify([ - 'plugin-executor.v1', - identity.id, - identity.extensionId, - identity.entryId, - identity.generation, - ]), - ) - .digest('hex')}` as const; + const binding = pluginExecutors.bind(backendContext.sessionId, executorId); return { - providerStateIdentity, + providerStateIdentity: binding.providerStateIdentity, build: (factoryContext) => new PluginExecutorBackend({ sessionId: factoryContext.sessionId, cwd: factoryContext.header.cwd, - executorId, ...(factoryContext.systemPrompt ? { instructions: factoryContext.systemPrompt } : {}), - service: pluginExecutors, + binding, }), }; }, @@ -1328,6 +1321,14 @@ export async function createExecutionRuntimeHostComposition( toolBoundaryProtocol: stores.runtimeEventStore.toolBoundaryProtocol, backends, subagentCatalog, + assertChildExecutorAvailable: (parentSessionId, executorId) => { + const identity = pluginExecutors.identity(parentSessionId, executorId); + if (identity.scopeId !== 'profile') { + throw new Error( + `Session-scoped executor cannot be inherited by a child Session: ${executorId}`, + ); + } + }, newId: randomUUID, now: Date.now, safeBoundaryResumeEnabled: process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME === '1', @@ -1429,6 +1430,7 @@ export async function createExecutionRuntimeHostComposition( const registerBackendInvalidation = (): void => { observeBackendInvalidation(requireSessionManager(manager).refreshIdleBackends()); }; + invalidatePluginExecutorBackends = registerBackendInvalidation; const registerConfigurationMutation = (): void => { hostChanges.publishConfiguration(); registerBackendInvalidation(); @@ -1969,6 +1971,9 @@ export async function createExecutionRuntimeHostComposition( continuity: continuityCoordinator, workspaceResolver, requestDrain: context.requestDrain, + assertExecutorAvailable: (sessionId, executorId) => { + pluginExecutors.identity(sessionId, executorId); + }, ...(context.sessionAccessAuthority ? { sessionAccessAuthority: context.sessionAccessAuthority } : {}), diff --git a/packages/runtime-host/src/server/host-session-availability.ts b/packages/runtime-host/src/server/host-session-availability.ts index c8b2d5ce57..5874aea198 100644 --- a/packages/runtime-host/src/server/host-session-availability.ts +++ b/packages/runtime-host/src/server/host-session-availability.ts @@ -37,6 +37,16 @@ export const WORKHUB_COORDINATION_TARGET_UNAVAILABLE_REASON = 'WorkHub Coordination execution requires the reserved Coordination Session'; export const LEGACY_CONNECTION_IDENTITY_EXECUTION_UNAVAILABLE_REASON = 'This Session requires an explicit account selection before it can run.'; +export const PLUGIN_EXECUTOR_CONTINUATION_UNAVAILABLE_REASON = + 'Plugin executor Sessions cannot resume without provider-owned durable continuation support.'; +export const PLUGIN_EXECUTOR_COPY_UNAVAILABLE_REASON = + 'Plugin executor Sessions cannot be copied without provider-owned conversation cloning'; + +export function runtimeHostConversationCopyUnavailableReason( + header: Pick, +): string | undefined { + return header.backend === 'plugin-executor' ? PLUGIN_EXECUTOR_COPY_UNAVAILABLE_REASON : undefined; +} export function runtimeHostExternalTurnUnavailableReason( header: Pick< @@ -67,6 +77,9 @@ export function runtimeHostSafeBoundaryContinuationUnavailableReason( (header.llmConnectionId === undefined && header.backend === 'ai-sdk' ? LEGACY_CONNECTION_IDENTITY_EXECUTION_UNAVAILABLE_REASON : undefined) ?? + (header.backend === 'plugin-executor' + ? PLUGIN_EXECUTOR_CONTINUATION_UNAVAILABLE_REASON + : undefined) ?? (header.subagentParent ? CHILD_CONTINUATION_UNAVAILABLE_REASON : undefined) ); } diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index 1697427862..a9a62b22c1 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -30,6 +30,7 @@ import { type ExecutionBoundarySummary, } from '@maka/core/sandbox-boundary'; import type { CreateSessionInput } from '@maka/core/runtime-inputs'; +import { isExecutorId } from '@maka/core/executor-id'; import type { ToolMode } from '@maka/core/tool-mode'; import type { ConnectionCatalogEntry, ConnectionCatalogSnapshot } from '@maka/core/runtime-policy'; import { DEFAULT_SESSION_NAME, normalizeUserSessionName } from '@maka/core/session-name'; @@ -189,6 +190,7 @@ export interface HostSessionCatalogCoordinatorOptions { readonly continuity: SessionContinuity; readonly workspaceResolver: HostWorkspaceResolver; readonly requestDrain: () => void; + readonly assertExecutorAvailable?: (sessionId: string, executorId: string) => void; readonly sessionAccessAuthority?: Pick< RuntimeHostAccessAuthority, 'activeSessionGrantForPrincipal' @@ -300,6 +302,7 @@ export class HostSessionCatalogCoordinator { readonly #continuity: SessionContinuity; readonly #workspaceResolver: HostWorkspaceResolver; readonly #requestDrain: () => void; + readonly #assertExecutorAvailable: ((sessionId: string, executorId: string) => void) | undefined; readonly #sessionAccessAuthority: | Pick | undefined; @@ -313,6 +316,7 @@ export class HostSessionCatalogCoordinator { this.#continuity = options.continuity; this.#workspaceResolver = options.workspaceResolver; this.#requestDrain = options.requestDrain; + this.#assertExecutorAvailable = options.assertExecutorAvailable; this.#sessionAccessAuthority = options.sessionAccessAuthority; } @@ -1278,6 +1282,14 @@ export class HostSessionCatalogCoordinator { readonly model: string; }> { if (input.executorId) { + try { + this.#assertExecutorAvailable?.(input.sessionId, input.executorId); + } catch { + throw new SessionOperationFailure( + 'operation_unavailable', + `Plugin executor is unavailable: ${input.executorId}`, + ); + } return { executorId: input.executorId, connectionSlug: `executor:${input.executorId}`, @@ -1345,10 +1357,7 @@ async function prepareCreate(input: SessionCreateInput): Promise { - const { root, service } = fixture(async (request, context) => { + const { root, binding } = fixture(async (request, context) => { assert.equal(request.instructions, 'child instructions'); context.emit({ type: 'output_delta', text: 'hel' }); return { status: 'completed', text: 'hello' }; @@ -33,9 +33,8 @@ test('executor backend converts plugin output and result to ordinary Session eve const backend = new PluginExecutorBackend({ sessionId: 'session-a', cwd: '/workspace', - executorId: 'remote', instructions: 'child instructions', - service, + binding, newId: ids(), now: () => 42, }); @@ -57,7 +56,7 @@ test('executor backend turns stop into abort and terminal events', async () => { const ready = new Promise((resolve) => { started = resolve; }); - const { root, service } = fixture(async (_request, context) => { + const { root, binding } = fixture(async (_request, context) => { started(); await new Promise((resolve) => context.signal.addEventListener('abort', () => resolve())); return { status: 'cancelled' }; @@ -65,8 +64,7 @@ test('executor backend turns stop into abort and terminal events', async () => { const backend = new PluginExecutorBackend({ sessionId: 'session-a', cwd: '/workspace', - executorId: 'remote', - service, + binding, }); const eventsPromise = collect(backend.send({ turnId: 'turn-a', text: 'task' })); @@ -81,18 +79,122 @@ test('executor backend turns stop into abort and terminal events', async () => { await root.fiber.dispose(); }); -function fixture(execute: Parameters[0]['execute']): { +test('executor backend projects optional thinking and external tool activity', async () => { + const { root, binding } = fixture( + async (_request, context) => { + context.emit({ type: 'thinking_delta', text: 'considering' }); + context.emit({ + type: 'tool_start', + toolCallId: 'external-1', + name: 'search', + input: { query: 'maka' }, + activityKind: 'search', + }); + context.emit({ type: 'tool_progress', toolCallId: 'external-1', text: 'working' }); + context.emit({ type: 'tool_result', toolCallId: 'external-1', text: 'found' }); + return { status: 'completed', text: 'done' }; + }, + { thinking: true, toolActivity: true }, + ); + const backend = new PluginExecutorBackend({ + sessionId: 'session-a', + cwd: '/workspace', + binding, + newId: ids(), + now: () => 42, + }); + + const events = await collect(backend.send({ turnId: 'turn-a', text: 'task' })); + assert.deepEqual( + events.map((event) => event.type), + [ + 'thinking_delta', + 'tool_start', + 'tool_progress', + 'tool_result', + 'thinking_complete', + 'text_complete', + 'complete', + ], + ); + assert.equal(events[1]?.type === 'tool_start' ? events[1].providerExecuted : undefined, true); + const stepId = events[0]?.type === 'thinking_delta' ? events[0].messageId : undefined; + assert.equal(events[1]?.type === 'tool_start' ? events[1].stepId : undefined, stepId); + assert.equal(events[4]?.type === 'thinking_complete' ? events[4].messageId : undefined, stepId); + assert.equal(events[5]?.type === 'text_complete' ? events[5].messageId : undefined, stepId); + await root.fiber.dispose(); +}); + +test('executor retirement remains cancellation and is surfaced as a crash abort', async () => { + let started!: () => void; + const ready = new Promise((resolve) => { + started = resolve; + }); + const { root, binding, dispose } = fixture(async (_request, context) => { + started(); + await new Promise((_resolve, reject) => + context.signal.addEventListener('abort', () => reject(context.signal.reason)), + ); + return { status: 'completed', text: 'unreachable' }; + }); + const backend = new PluginExecutorBackend({ + sessionId: 'session-a', + cwd: '/workspace', + binding, + }); + + const eventsPromise = collect(backend.send({ turnId: 'turn-a', text: 'task' })); + await ready; + await dispose(); + const events = await eventsPromise; + assert.equal(events[0]?.type === 'abort' ? events[0].reason : undefined, 'crash'); + assert.equal(events[1]?.type === 'complete' ? events[1].stopReason : undefined, 'user_stop'); + await root.fiber.dispose(); +}); + +test('executor failure closes rich output before publishing its terminal error', async () => { + const { root, binding } = fixture( + async (_request, context) => { + context.emit({ type: 'thinking_delta', text: 'partial thought' }); + context.emit({ type: 'tool_start', toolCallId: 'external-1', name: 'search' }); + throw new Error('provider crashed'); + }, + { thinking: true, toolActivity: true }, + ); + const backend = new PluginExecutorBackend({ + sessionId: 'session-a', + cwd: '/workspace', + binding, + newId: ids(), + now: () => 42, + }); + + const events = await collect(backend.send({ turnId: 'turn-a', text: 'task' })); + assert.deepEqual( + events.map((event) => event.type), + ['thinking_delta', 'tool_start', 'thinking_complete', 'tool_result', 'error', 'complete'], + ); + assert.equal(events[3]?.type === 'tool_result' ? events[3].isError : undefined, true); + assert.equal(events[4]?.type === 'error' ? events[4].message : undefined, 'provider crashed'); + await root.fiber.dispose(); +}); + +function fixture( + execute: Parameters[0]['execute'], + capabilities?: Parameters[0]['capabilities'], +): { root: Context; - service: PluginExecutorService; + binding: ReturnType; + dispose: ReturnType; } { const root = new Context(); const service = new PluginExecutorService(root); - root + const dispose = root .extend({ maka: { rootId: 'profile', packageId: 'fixture', entryId: 'provider', generation: 1 }, }) - .executors.register({ id: 'remote', execute }); - return { root, service }; + .executors.register({ id: 'remote', execute, ...(capabilities ? { capabilities } : {}) }); + return { root, binding: service.bind('session-a', 'remote'), dispose }; } function ids(): () => string { diff --git a/packages/runtime/src/__tests__/plugin-executor-service.test.ts b/packages/runtime/src/__tests__/plugin-executor-service.test.ts index aa63218e3b..bd2862e8d3 100644 --- a/packages/runtime/src/__tests__/plugin-executor-service.test.ts +++ b/packages/runtime/src/__tests__/plugin-executor-service.test.ts @@ -44,7 +44,9 @@ test('executors are scoped and pass black-box output without an Agent invocation const output: string[] = []; assert.deepEqual( await service.execute('remote', request('session-b'), { - onEvent: (event) => output.push(event.text), + onEvent: (event) => { + if (event.type === 'output_delta') output.push(event.text); + }, }), { status: 'completed', text: 'session-b:hello' }, ); @@ -105,7 +107,95 @@ test('retiring an executor aborts and drains its active calls', async () => { await startedPromise; await dispose(); assert.equal(observed?.aborted, true); - assert.deepEqual(await execution, { status: 'cancelled' }); + assert.deepEqual(await execution, { + status: 'cancelled', + source: 'executor_retired', + reason: 'Executor was retired: remote', + }); + await root.fiber.dispose(); +}); + +test('executor registration binds prototype methods to the provider instance', async () => { + class ClassExecutor { + readonly id = 'remote'; + readonly prefix = 'class'; + + async execute() { + return { status: 'completed' as const, text: `${this.prefix}:ok` }; + } + } + + const root = new Context(); + const service = new PluginExecutorService(root); + plugin(root, 'profile', 'provider', 1).executors.register(new ClassExecutor()); + assert.equal(await executeText(service), 'class:ok'); + await root.fiber.dispose(); +}); + +test('executor bindings pin one provider generation', async () => { + const root = new Context(); + const service = new PluginExecutorService(root); + const previous = plugin(root, 'profile', 'provider', 1); + const disposePrevious = previous.executors.register(provider('previous')); + const previousBinding = service.bind('session-a', 'remote'); + const candidateOwner = plugin(root, 'profile', 'provider', 2); + const transaction = new MakaPluginTransactionBuffer(candidateOwner); + const candidate = candidateOwner.extend({ makaTransaction: transaction }); + candidate.executors.register(provider('candidate')); + await transaction.commit(); + + assert.equal((await previousBinding.execute(request('session-a'))).status, 'completed'); + assert.equal(await executeText(service), 'candidate'); + await disposePrevious(); + assert.deepEqual(await previousBinding.execute(request('session-a')), { + status: 'cancelled', + source: 'executor_retired', + reason: 'Executor was retired: remote', + }); + await root.fiber.dispose(); +}); + +test('executor completion after caller cancellation is normalized to cancelled', async () => { + const root = new Context(); + const service = new PluginExecutorService(root); + const owner = plugin(root, 'profile', 'provider', 1); + let started!: () => void; + const ready = new Promise((resolve) => { + started = resolve; + }); + owner.executors.register({ + id: 'remote', + execute: async (_request, context) => { + started(); + await new Promise((resolve) => + context.signal.addEventListener('abort', () => resolve()), + ); + return { status: 'completed', text: 'late success' }; + }, + }); + const abort = new AbortController(); + const execution = service.execute('remote', request('session-a'), { signal: abort.signal }); + await ready; + abort.abort(new Error('redirect')); + assert.deepEqual(await execution, { status: 'cancelled', source: 'caller', reason: 'redirect' }); + await root.fiber.dispose(); +}); + +test('executor rich events require an explicitly declared capability', async () => { + const root = new Context(); + const service = new PluginExecutorService(root); + plugin(root, 'profile', 'provider', 1).executors.register({ + id: 'remote', + execute: async (_request, context) => { + context.emit({ type: 'thinking_delta', text: 'undeclared' }); + return { status: 'completed', text: 'unreachable' }; + }, + }); + + await assert.rejects( + () => service.execute('remote', request('session-a')), + /invalid or undeclared/u, + ); await root.fiber.dispose(); }); diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index e7e1dfcc1d..9204c0994a 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -750,12 +750,16 @@ describe('SessionManager graph operator provisioning', () => { test('provisions a graph child on an explicit plugin executor without Maka tools', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); + const checkedExecutors: string[] = []; const manager = new SessionManager({ store, runStore, runtimeEventStore: runStore, backends: new BackendRegistry(), childTools: [], + assertChildExecutorAvailable: (parentSessionId, executorId) => { + checkedExecutors.push(`${parentSessionId}:${executorId}`); + }, newId: nextId(), now: nextNow(40), }); @@ -790,6 +794,7 @@ describe('SessionManager graph operator provisioning', () => { assert.strictEqual(result.header.llmConnectionId, undefined); assert.strictEqual(result.header.llmConnectionSlug, 'executor:codex'); assert.deepStrictEqual(result.header.subagentRuntime?.toolNames, []); + assert.deepStrictEqual(checkedExecutors, [`${parent.id}:codex`]); }); test('keeps four large graph branches and a replacement off the supervisor data plane', async () => { @@ -2324,6 +2329,7 @@ describe('SessionManager child-session runtime primitive', () => { const backends = new BackendRegistry(); const parentGate = makeGate(); let childContext: BackendFactoryContext | undefined; + const checkedExecutors: string[] = []; backends.register('ai-sdk', (ctx) => new TestBackend(ctx, parentGate)); backends.register('plugin-executor', (ctx) => { childContext = ctx; @@ -2358,6 +2364,9 @@ describe('SessionManager child-session runtime primitive', () => { runtimeEventStore: runStore, backends, childTools: [], + assertChildExecutorAvailable: (parentSessionId, executorId) => { + checkedExecutors.push(`${parentSessionId}:${executorId}`); + }, newId: nextId(), now: nextNow(80), }); @@ -2389,6 +2398,7 @@ describe('SessionManager child-session runtime primitive', () => { assert.deepStrictEqual(child.subagentRuntime?.toolNames, []); assert.deepStrictEqual(childContext?.tools, []); assert.strictEqual(childContext?.systemPrompt, LOCAL_READ_AGENT_DEFINITION.systemPrompt); + assert.deepStrictEqual(checkedExecutors, [`${parent.id}:codex`]); parentGate.release(); while (!(await parentTurn.next()).done) {} @@ -3637,7 +3647,9 @@ describe('SessionManager manual compaction and quiescent session changes', () => { runId: sourceRun.runId, connectionId: - sourceRoute.provenance === 'runtime' ? sourceRoute.llmConnectionId : undefined, + sourceRoute.provenance === 'runtime' && sourceRoute.backendKind !== 'plugin-executor' + ? sourceRoute.llmConnectionId + : undefined, modelId: sourceRoute.modelId, }, ], @@ -13061,7 +13073,8 @@ class CompactingTestBackend extends TestBackend { runtimeContextCount: input.runtimeContext.length, sourceRoutes: (input.runtimeContextInvocations ?? []).map((run) => ({ runId: run.runId, - ...(run.opening.route.provenance === 'runtime' + ...(run.opening.route.provenance === 'runtime' && + run.opening.route.backendKind !== 'plugin-executor' ? { connectionId: run.opening.route.llmConnectionId } : {}), modelId: run.opening.route.modelId, @@ -15215,7 +15228,7 @@ function testInvocationOpening(header: TestRunHeader): RuntimeEventInvocationOpe kind: 'invocation_opened', protocol: 'invocation_opened_v1', route: - header.llmConnectionId === undefined + header.llmConnectionId === undefined || header.backendKind === 'plugin-executor' ? { provenance: 'unknown', backendKind: header.backendKind, diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index e693903960..eac4fbaed6 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -29,6 +29,7 @@ import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; import { isRuntimeHandoffPause, type RuntimeHandoffIntent } from '@maka/core/runtime-handoff'; import { RunHandoffGate, type RunHandoffRequest } from './run-handoff-gate.js'; import { preserveHandoffOpening } from './runtime-resume.js'; +import { runtimeInvocationRouteForHeader } from './runtime-invocation-route.js'; import type { RequestCompositionSnapshot, RequestCompositionSnapshotInput, @@ -1444,22 +1445,7 @@ export class AgentRun { const opening: RuntimeEventInvocationOpenedContent = { kind: 'invocation_opened', protocol: 'invocation_opened_v1', - route: - this.header.llmConnectionId === undefined - ? { - provenance: 'unknown', - backendKind: this.header.backend, - llmConnectionSlug: this.header.llmConnectionSlug, - modelId: this.header.model, - } - : { - provenance: 'runtime', - backendKind: this.header.backend, - llmConnectionId: this.header.llmConnectionId, - llmConnectionSlug: this.header.llmConnectionSlug, - modelId: this.header.model, - ...(providerStateIdentity ? { providerStateIdentity } : {}), - }, + route: runtimeInvocationRouteForHeader(this.header, providerStateIdentity), configuration: { cwd: this.header.cwd, permissionMode: this.header.permissionMode, diff --git a/packages/runtime/src/ai-sdk-compaction.ts b/packages/runtime/src/ai-sdk-compaction.ts index a515fe02bd..6c5004ee37 100644 --- a/packages/runtime/src/ai-sdk-compaction.ts +++ b/packages/runtime/src/ai-sdk-compaction.ts @@ -492,7 +492,9 @@ export class AiSdkCompaction { const route = invocation.opening.route; return { runId: invocation.runId, - ...(route.provenance === 'runtime' ? { connectionId: route.llmConnectionId } : {}), + ...(route.provenance === 'runtime' && route.backendKind !== 'plugin-executor' + ? { connectionId: route.llmConnectionId } + : {}), modelId: route.modelId, }; }) @@ -1498,6 +1500,7 @@ function persistedRequestAnchor( const route = invocations.find((candidate) => candidate.runId === event?.runId)?.opening.route; if ( route?.provenance !== 'runtime' || + route.backendKind === 'plugin-executor' || route.modelId !== modelId || route.llmConnectionId !== connectionId ) { diff --git a/packages/runtime/src/history-compaction.ts b/packages/runtime/src/history-compaction.ts index 69210d4893..6c4a15edc1 100644 --- a/packages/runtime/src/history-compaction.ts +++ b/packages/runtime/src/history-compaction.ts @@ -255,7 +255,12 @@ function acceptedInputBoundary( const onRoute = (event: RuntimeEvent | undefined): boolean => { if (event?.role !== 'model') return false; const opened = invocations.find((candidate) => candidate.runId === event.runId)?.opening.route; - if (opened?.provenance !== 'runtime' || opened.modelId !== route.modelId) return false; + if ( + opened?.provenance !== 'runtime' || + opened.backendKind === 'plugin-executor' || + opened.modelId !== route.modelId + ) + return false; return opened.llmConnectionId === route.connectionId; }; let index = -1; diff --git a/packages/runtime/src/plugin-executor-backend.ts b/packages/runtime/src/plugin-executor-backend.ts index c97590db3e..4bedece094 100644 --- a/packages/runtime/src/plugin-executor-backend.ts +++ b/packages/runtime/src/plugin-executor-backend.ts @@ -17,13 +17,17 @@ * under the License. */ -import { createHash, randomUUID } from 'node:crypto'; +import { randomUUID } from 'node:crypto'; import type { SessionEvent } from '@maka/core/events'; import type { AgentBackend, BackendSendInput } from '@maka/core/backend-types'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { UserQuestionResponse } from '@maka/core/user-question'; import { AsyncEventQueue } from './async-queue.js'; -import type { PluginExecutorResult, PluginExecutorService } from './plugin-executor-service.js'; +import type { + PluginExecutorBinding, + PluginExecutorOutputEvent, + PluginExecutorResult, +} from './plugin-executor-service.js'; interface ActiveExecution { readonly abort: AbortController; @@ -33,9 +37,8 @@ interface ActiveExecution { export interface PluginExecutorBackendInput { readonly sessionId: string; readonly cwd: string; - readonly executorId: string; readonly instructions?: string; - readonly service: PluginExecutorService; + readonly binding: PluginExecutorBinding; readonly newId?: () => string; readonly now?: () => number; } @@ -45,9 +48,8 @@ export class PluginExecutorBackend implements AgentBackend { readonly kind = 'plugin-executor' as const; readonly sessionId: string; readonly #cwd: string; - readonly #executorId: string; readonly #instructions?: string; - readonly #service: PluginExecutorService; + readonly #binding: PluginExecutorBinding; readonly #newId: () => string; readonly #now: () => number; readonly #active = new Set(); @@ -56,28 +58,12 @@ export class PluginExecutorBackend implements AgentBackend { constructor(input: PluginExecutorBackendInput) { this.sessionId = input.sessionId; this.#cwd = input.cwd; - this.#executorId = input.executorId; this.#instructions = input.instructions; - this.#service = input.service; + this.#binding = input.binding; this.#newId = input.newId ?? randomUUID; this.#now = input.now ?? Date.now; } - providerStateIdentity(): `sha256:${string}` { - const identity = this.#service.identity(this.sessionId, this.#executorId); - return `sha256:${createHash('sha256') - .update( - JSON.stringify([ - 'plugin-executor.v1', - identity.id, - identity.extensionId, - identity.entryId, - identity.generation, - ]), - ) - .digest('hex')}`; - } - async *send(input: BackendSendInput): AsyncIterable { if (this.#disposed) throw new Error('Plugin executor backend is disposed'); const abort = new AbortController(); @@ -129,9 +115,13 @@ export class PluginExecutorBackend implements AgentBackend { queue: AsyncEventQueue, ): Promise { const turnId = input.turnId; + let thinkingText = ''; + const toolUseIds = new Map(); + let result: PluginExecutorResult | undefined; + let failure: unknown; + let failed = false; try { - const result = await this.#service.execute( - this.#executorId, + result = await this.#binding.execute( { sessionId: this.sessionId, turnId, @@ -147,31 +137,71 @@ export class PluginExecutorBackend implements AgentBackend { { signal, onEvent: (event) => { - if (!event.text) return; - queue.push({ - type: 'text_delta', - id: this.#newId(), - turnId, - ts: this.#now(), - messageId, - text: event.text, - }); + if (event.type === 'thinking_delta') thinkingText += event.text; + this.#publishOutputEvent(turnId, messageId, event, toolUseIds, queue); }, }, ); - this.#publishResult(turnId, messageId, result, queue); } catch (error) { - if (signal.aborted) { - this.#publishCancellation(turnId, queue); - return; - } + failed = true; + failure = error; + } + + this.#closeOptionalOutput(turnId, messageId, thinkingText, toolUseIds, queue); + if (failed) { + if (signal.aborted) + this.#publishCancellation(turnId, { status: 'cancelled', source: 'caller' }, queue); + else + this.#publishFailure( + turnId, + failure instanceof Error ? failure.message : 'External executor failed', + undefined, + false, + queue, + ); + return; + } + if (result === undefined) { this.#publishFailure( turnId, - error instanceof Error ? error.message : 'External executor failed', + 'External executor returned no terminal result', undefined, false, queue, ); + return; + } + this.#publishResult(turnId, messageId, result, queue); + } + + #closeOptionalOutput( + turnId: string, + messageId: string, + thinkingText: string, + toolUseIds: Map, + queue: AsyncEventQueue, + ): void { + if (thinkingText) { + queue.push({ + type: 'thinking_complete', + id: this.#newId(), + turnId, + ts: this.#now(), + messageId, + text: thinkingText, + }); + } + for (const toolUseId of toolUseIds.values()) { + queue.push({ + type: 'tool_result', + id: this.#newId(), + turnId, + ts: this.#now(), + toolUseId, + providerExecuted: true, + isError: true, + content: { kind: 'text', text: 'External executor ended before reporting a tool result' }, + }); } } @@ -200,19 +230,24 @@ export class PluginExecutorBackend implements AgentBackend { return; } if (result.status === 'cancelled') { - this.#publishCancellation(turnId, queue); + this.#publishCancellation(turnId, result, queue); return; } this.#publishFailure(turnId, result.message, result.code, result.recoverable ?? false, queue); } - #publishCancellation(turnId: string, queue: AsyncEventQueue): void { + #publishCancellation( + turnId: string, + result: Extract, + queue: AsyncEventQueue, + ): void { + const reason = cancellationEventReason(result); queue.push({ type: 'abort', id: this.#newId(), turnId, ts: this.#now(), - reason: 'user_stop', + reason, }); queue.push({ type: 'complete', @@ -223,6 +258,95 @@ export class PluginExecutorBackend implements AgentBackend { }); } + #publishOutputEvent( + turnId: string, + messageId: string, + event: PluginExecutorOutputEvent, + toolUseIds: Map, + queue: AsyncEventQueue, + ): void { + if (event.type === 'output_delta') { + if (!event.text) return; + queue.push({ + type: 'text_delta', + id: this.#newId(), + turnId, + ts: this.#now(), + messageId, + text: event.text, + }); + return; + } + if (event.type === 'thinking_delta') { + if (!event.text) return; + queue.push({ + type: 'thinking_delta', + id: this.#newId(), + turnId, + ts: this.#now(), + messageId, + text: event.text, + }); + return; + } + if (event.type === 'tool_start') { + const previousToolUseId = toolUseIds.get(event.toolCallId); + if (previousToolUseId) { + queue.push({ + type: 'tool_result', + id: this.#newId(), + turnId, + ts: this.#now(), + toolUseId: previousToolUseId, + providerExecuted: true, + isError: true, + content: { kind: 'text', text: 'External executor reused an active tool call id' }, + }); + } + const toolUseId = this.#newId(); + toolUseIds.set(event.toolCallId, toolUseId); + queue.push({ + type: 'tool_start', + id: this.#newId(), + turnId, + ts: this.#now(), + toolUseId, + toolName: event.name, + args: event.input ?? {}, + providerExecuted: true, + stepId: messageId, + ...(event.displayName === undefined ? {} : { displayName: event.displayName }), + ...(event.activityKind === undefined ? {} : { activityKind: event.activityKind }), + }); + return; + } + const toolUseId = toolUseIds.get(event.toolCallId); + if (!toolUseId) return; + if (event.type === 'tool_progress') { + if (!event.text) return; + queue.push({ + type: 'tool_progress', + id: this.#newId(), + turnId, + ts: this.#now(), + toolUseId, + chunk: event.text, + }); + return; + } + toolUseIds.delete(event.toolCallId); + queue.push({ + type: 'tool_result', + id: this.#newId(), + turnId, + ts: this.#now(), + toolUseId, + providerExecuted: true, + isError: event.isError ?? false, + content: { kind: 'text', text: event.text }, + }); + } + #publishFailure( turnId: string, message: string, @@ -253,3 +377,12 @@ function boundedMessage(value: string): string { if (value.length <= 8_192) return value; return `${value.slice(0, 8_191)}…`; } + +function cancellationEventReason( + result: Extract, +): 'user_stop' | 'redirect' | 'timeout' | 'crash' { + if (result.reason === 'redirect') return 'redirect'; + if (result.reason === 'timeout') return 'timeout'; + if (result.source === 'executor_retired') return 'crash'; + return 'user_stop'; +} diff --git a/packages/runtime/src/plugin-executor-service.ts b/packages/runtime/src/plugin-executor-service.ts index 9204f2e7fe..26eb19fe39 100644 --- a/packages/runtime/src/plugin-executor-service.ts +++ b/packages/runtime/src/plugin-executor-service.ts @@ -17,7 +17,15 @@ * under the License. */ -import type { AttachmentRef, DirectoryReference, QuoteRef } from '@maka/core/events'; +import { createHash } from 'node:crypto'; +import type { + AttachmentRef, + DirectoryReference, + QuoteRef, + ToolActivityKind, +} from '@maka/core/events'; +import { TOOL_ACTIVITY_KINDS } from '@maka/core/events'; +import { isExecutorId } from '@maka/core/executor-id'; import { Service, type Context, type Disposable } from './plugin-kernel.js'; import { MakaPluginRuntimeError, @@ -34,8 +42,6 @@ declare module './plugin-kernel.js' { } } -const EXECUTOR_ID_PATTERN = /^[A-Za-z][A-Za-z0-9._:-]{0,127}$/u; - export interface PluginExecutorRequest { readonly sessionId: string; readonly turnId: string; @@ -51,14 +57,41 @@ export interface PluginExecutorRequest { readonly quotes?: readonly QuoteRef[]; } -export interface PluginExecutorOutputEvent { - readonly type: 'output_delta'; - readonly text: string; +/** Optional presentation capabilities. Text output and terminal results are always supported. */ +export interface PluginExecutorCapabilities { + readonly thinking?: boolean; + readonly toolActivity?: boolean; } +export type PluginExecutorOutputEvent = + | { readonly type: 'output_delta'; readonly text: string } + | { readonly type: 'thinking_delta'; readonly text: string } + | { + readonly type: 'tool_start'; + readonly toolCallId: string; + readonly name: string; + readonly input?: unknown; + readonly displayName?: string; + readonly activityKind?: ToolActivityKind; + } + | { readonly type: 'tool_progress'; readonly toolCallId: string; readonly text: string } + | { + readonly type: 'tool_result'; + readonly toolCallId: string; + readonly text: string; + readonly isError?: boolean; + }; + +export type PluginExecutorCancellationSource = 'provider' | 'caller' | 'executor_retired'; + export type PluginExecutorResult = | { readonly status: 'completed'; readonly text: string } - | { readonly status: 'cancelled'; readonly reason?: string } + | { + readonly status: 'cancelled'; + readonly reason?: string; + /** Service-owned provenance; provider-supplied values are ignored. */ + readonly source?: PluginExecutorCancellationSource; + } | { readonly status: 'failed'; readonly message: string; @@ -75,6 +108,7 @@ export interface PluginExecutorContext { export interface PluginExecutorProvider { readonly id: string; readonly displayName?: string; + readonly capabilities?: PluginExecutorCapabilities; execute( request: Readonly, context: PluginExecutorContext, @@ -89,6 +123,21 @@ export interface PluginExecutorExecutionOptions { export interface PluginExecutorInspection extends MakaContributionIdentity { readonly id: string; readonly displayName: string; + readonly capabilities: Readonly>; +} + +/** Generation-pinned handle used by one prepared backend instance. */ +export interface PluginExecutorBinding { + readonly identity: PluginExecutorInspection; + readonly providerStateIdentity: `sha256:${string}`; + execute( + request: PluginExecutorRequest, + options?: PluginExecutorExecutionOptions, + ): Promise; +} + +export interface PluginExecutorServiceOptions { + readonly onChanged?: (rootId: MakaPluginRootId) => void; } interface RegisteredExecutor extends MakaContributionIdentity { @@ -103,6 +152,13 @@ interface ActiveExecution { readonly settled: Promise; } +class ExecutorRetiredAbort extends Error { + constructor(executorId: string) { + super(`Executor was retired: ${executorId}`); + this.name = 'ExecutorRetiredAbort'; + } +} + /** * Scoped black-box execution registry. * @@ -112,9 +168,11 @@ interface ActiveExecution { */ export class PluginExecutorService extends Service { private readonly registry = new PluginScopeRegistry(); + private readonly onChanged: ((rootId: MakaPluginRootId) => void) | undefined; - constructor(ctx: Context) { + constructor(ctx: Context, options: PluginExecutorServiceOptions = {}) { super(ctx, 'executors'); + this.onChanged = options.onChanged; } register(provider: PluginExecutorProvider): Disposable> { @@ -129,17 +187,25 @@ export class PluginExecutorService extends Service { `Executor is already registered in this scope: ${provider.id}`, ); } + const capabilities = normalizeCapabilities(provider.capabilities); + const registeredProvider: PluginExecutorProvider = Object.freeze({ + id: provider.id, + ...(provider.displayName === undefined ? {} : { displayName: provider.displayName }), + capabilities, + execute: provider.execute.bind(provider), + }); const entry: RegisteredExecutor = { ...identity, - provider: Object.freeze({ ...provider }), + provider: registeredProvider, token: Symbol(provider.id), active: new Set(), retired: false, }; return this.registry.publish(rootId, provider.id, entry, { + ...(this.onChanged ? { onChanged: this.onChanged } : {}), onRetired: async (retired) => { for (const execution of retired.active) { - execution.abort.abort(new Error(`Executor was retired: ${retired.provider.id}`)); + execution.abort.abort(new ExecutorRetiredAbort(retired.provider.id)); } await Promise.allSettled([...retired.active].map((execution) => execution.settled)); }, @@ -157,6 +223,7 @@ export class PluginExecutorService extends Service { ...identity, id: provider.id, displayName: provider.displayName?.trim() || provider.id, + capabilities: normalizeCapabilities(provider.capabilities), }), ), ); @@ -173,20 +240,28 @@ export class PluginExecutorService extends Service { ...identity, id: provider.id, displayName: provider.displayName?.trim() || provider.id, + capabilities: normalizeCapabilities(provider.capabilities), }), ), ); } identity(sessionId: string, executorId: string): PluginExecutorInspection { + return this.identityForEntry(this.entry(sessionId, executorId)); + } + + bind(sessionId: string, executorId: string): PluginExecutorBinding { const entry = this.entry(sessionId, executorId); + const identity = this.identityForEntry(entry); return Object.freeze({ - entryId: entry.entryId, - scopeId: entry.scopeId, - extensionId: entry.extensionId, - generation: entry.generation, - id: entry.provider.id, - displayName: entry.provider.displayName?.trim() || entry.provider.id, + identity, + providerStateIdentity: providerStateIdentity(identity), + execute: (request: PluginExecutorRequest, options: PluginExecutorExecutionOptions = {}) => { + if (request.sessionId !== sessionId) { + throw new Error('Executor binding cannot cross Session scope'); + } + return this.executeEntry(entry, request, options); + }, }); } @@ -196,7 +271,19 @@ export class PluginExecutorService extends Service { options: PluginExecutorExecutionOptions = {}, ): Promise { const normalizedRequest = normalizeRequest(request); - const entry = this.entry(normalizedRequest.sessionId, executorId); + return this.executeEntry( + this.entry(normalizedRequest.sessionId, executorId), + normalizedRequest, + options, + ); + } + + private async executeEntry( + entry: RegisteredExecutor, + request: PluginExecutorRequest, + options: PluginExecutorExecutionOptions, + ): Promise { + const normalizedRequest = normalizeRequest(request); const abort = new AbortController(); const signal = options.signal ? AbortSignal.any([options.signal, abort.signal]) : abort.signal; let settle!: () => void; @@ -206,20 +293,26 @@ export class PluginExecutorService extends Service { const active: ActiveExecution = { abort, settled }; entry.active.add(active); try { - if (entry.retired) throw new Error(`Executor is unavailable: ${executorId}`); - const result = await entry.provider.execute(normalizedRequest, { - signal, - emit: (event) => { - if (signal.aborted || entry.retired) return; - const normalized = normalizeOutputEvent(event); - try { - options.onEvent?.(normalized); - } catch { - // A presentation observer must not change external execution. - } - }, - }); - return normalizeResult(result); + if (entry.retired) return cancelledResult(new ExecutorRetiredAbort(entry.provider.id)); + try { + const result = await entry.provider.execute(normalizedRequest, { + signal, + emit: (event) => { + if (signal.aborted || entry.retired) return; + const normalized = normalizeOutputEvent(event, entry.provider.capabilities); + try { + options.onEvent?.(normalized); + } catch { + // A presentation observer must not change external execution. + } + }, + }); + if (signal.aborted) return cancelledResult(signal.reason); + return normalizeResult(result); + } catch (error) { + if (signal.aborted) return cancelledResult(signal.reason); + throw error; + } } finally { entry.active.delete(active); settle(); @@ -228,17 +321,29 @@ export class PluginExecutorService extends Service { private entry(sessionId: string, executorId: string): RegisteredExecutor { assertSessionId(sessionId); - assertExecutorId(executorId); + if (!isExecutorId(executorId)) throw new TypeError('Executor id is invalid'); const entry = this.registry.visible(sessionId).get(executorId); if (!entry || entry.retired) throw new Error(`Executor is unavailable: ${executorId}`); return entry; } + + private identityForEntry(entry: RegisteredExecutor): PluginExecutorInspection { + return Object.freeze({ + entryId: entry.entryId, + scopeId: entry.scopeId, + extensionId: entry.extensionId, + generation: entry.generation, + id: entry.provider.id, + displayName: entry.provider.displayName?.trim() || entry.provider.id, + capabilities: normalizeCapabilities(entry.provider.capabilities), + }); + } } function validateProvider(provider: PluginExecutorProvider): void { if (!provider || typeof provider !== 'object') throw new TypeError('Executor provider is required'); - assertExecutorId(provider.id); + if (!isExecutorId(provider.id)) throw new TypeError('Executor id is invalid'); if (typeof provider.execute !== 'function') { throw new TypeError(`Executor implementation is invalid: ${provider.id}`); } @@ -250,12 +355,6 @@ function validateProvider(provider: PluginExecutorProvider): void { } } -function assertExecutorId(value: string): void { - if (typeof value !== 'string' || !EXECUTOR_ID_PATTERN.test(value)) { - throw new TypeError('Executor id is invalid'); - } -} - function assertSessionId(value: string): void { if (!value || /[\0\r\n]/u.test(value)) throw new TypeError('Session id is invalid'); } @@ -287,11 +386,79 @@ function normalizeRequest(request: PluginExecutorRequest): Readonly> { + if ( + value !== undefined && + (!value || + typeof value !== 'object' || + (value.thinking !== undefined && typeof value.thinking !== 'boolean') || + (value.toolActivity !== undefined && typeof value.toolActivity !== 'boolean')) + ) { + throw new TypeError('Executor capabilities are invalid'); + } + return Object.freeze({ + thinking: value?.thinking === true, + toolActivity: value?.toolActivity === true, + }); +} + +function normalizeOutputEvent( + event: PluginExecutorOutputEvent, + capabilities: PluginExecutorCapabilities | undefined, +): PluginExecutorOutputEvent { + if (!event || typeof event !== 'object') throw new TypeError('Executor output event is invalid'); + if (event.type === 'output_delta' && typeof event.text === 'string') { + return Object.freeze({ type: event.type, text: event.text }); + } + if ( + event.type === 'thinking_delta' && + capabilities?.thinking === true && + isSafeEventText(event.text) + ) { + return Object.freeze({ type: event.type, text: event.text }); + } + if ( + event.type === 'tool_start' && + capabilities?.toolActivity === true && + isSafeEventId(event.toolCallId) && + isSafeEventId(event.name) && + (event.displayName === undefined || isSafeEventText(event.displayName)) && + (event.activityKind === undefined || TOOL_ACTIVITY_KINDS.includes(event.activityKind)) + ) { + return Object.freeze({ + type: event.type, + toolCallId: event.toolCallId, + name: event.name, + ...(event.input === undefined ? {} : { input: structuredClone(event.input) }), + ...(event.displayName === undefined ? {} : { displayName: event.displayName }), + ...(event.activityKind === undefined ? {} : { activityKind: event.activityKind }), + }); + } + if ( + event.type === 'tool_progress' && + capabilities?.toolActivity === true && + isSafeEventId(event.toolCallId) && + isSafeEventText(event.text) + ) { + return Object.freeze({ type: event.type, toolCallId: event.toolCallId, text: event.text }); + } + if ( + event.type === 'tool_result' && + capabilities?.toolActivity === true && + isSafeEventId(event.toolCallId) && + isSafeEventText(event.text) && + (event.isError === undefined || typeof event.isError === 'boolean') + ) { + return Object.freeze({ + type: event.type, + toolCallId: event.toolCallId, + text: event.text, + ...(event.isError === undefined ? {} : { isError: event.isError }), + }); } - return Object.freeze({ type: 'output_delta', text: event.text }); + throw new TypeError('Executor output event is invalid or undeclared'); } function normalizeResult(result: PluginExecutorResult): PluginExecutorResult { @@ -306,6 +473,7 @@ function normalizeResult(result: PluginExecutorResult): PluginExecutorResult { return Object.freeze({ status: result.status, ...(result.reason === undefined ? {} : { reason: result.reason }), + source: 'provider', }); } if ( @@ -323,3 +491,39 @@ function normalizeResult(result: PluginExecutorResult): PluginExecutorResult { } throw new TypeError('Executor result is invalid'); } + +function providerStateIdentity(identity: PluginExecutorInspection): `sha256:${string}` { + return `sha256:${createHash('sha256') + .update( + JSON.stringify([ + 'plugin-executor.v1', + identity.id, + identity.extensionId, + identity.entryId, + identity.generation, + ]), + ) + .digest('hex')}`; +} + +function cancelledResult(reason: unknown): PluginExecutorResult { + const source: PluginExecutorCancellationSource = + reason instanceof ExecutorRetiredAbort ? 'executor_retired' : 'caller'; + const message = + reason instanceof Error ? reason.message : typeof reason === 'string' ? reason : undefined; + return Object.freeze({ + status: 'cancelled', + source, + ...(message ? { reason: message } : {}), + }); +} + +function isSafeEventId(value: unknown): value is string { + return ( + typeof value === 'string' && value.length > 0 && value.length <= 256 && !/[\0\r\n]/u.test(value) + ); +} + +function isSafeEventText(value: unknown): value is string { + return typeof value === 'string' && value.length <= 8_192 && !/[\0\r]/u.test(value); +} diff --git a/packages/runtime/src/runtime-invocation-route.ts b/packages/runtime/src/runtime-invocation-route.ts new file mode 100644 index 0000000000..87e53cff8a --- /dev/null +++ b/packages/runtime/src/runtime-invocation-route.ts @@ -0,0 +1,58 @@ +/* + * 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 { isExecutorId } from '@maka/core/executor-id'; +import type { RuntimeInvocationRoute } from '@maka/core/runtime-event'; +import type { SessionHeader } from '@maka/core/session'; + +export function runtimeInvocationRouteForHeader( + header: SessionHeader, + providerStateIdentity: `sha256:${string}` | undefined, +): RuntimeInvocationRoute { + if ( + header.backend === 'plugin-executor' && + isExecutorId(header.executorId) && + providerStateIdentity + ) { + return { + provenance: 'runtime', + backendKind: header.backend, + executorId: header.executorId, + llmConnectionSlug: header.llmConnectionSlug, + modelId: header.model, + providerStateIdentity, + }; + } + if (header.backend !== 'plugin-executor' && header.llmConnectionId !== undefined) { + return { + provenance: 'runtime', + backendKind: header.backend, + llmConnectionId: header.llmConnectionId, + llmConnectionSlug: header.llmConnectionSlug, + modelId: header.model, + ...(providerStateIdentity ? { providerStateIdentity } : {}), + }; + } + return { + provenance: 'unknown', + backendKind: header.backend, + llmConnectionSlug: header.llmConnectionSlug, + modelId: header.model, + }; +} diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 7f3abb5471..efb5327a28 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -151,6 +151,7 @@ import { import { DeliveryAckQueue, isDeliveryAckQueueClosed } from './delivery-ack-queue.js'; import { runtimeHandoffPause, type RuntimeHandoffIntent } from '@maka/core/runtime-handoff'; import { preserveHandoffOpening } from './runtime-resume.js'; +import { runtimeInvocationRouteForHeader } from './runtime-invocation-route.js'; import type { AgentRunHandoffRequest } from './agent-run.js'; export interface RuntimeKernelLike { @@ -3152,24 +3153,7 @@ function continuationTargetOpeningForExecution(input: { const opening: RuntimeEventInvocationOpenedContent = { kind: 'invocation_opened', protocol: 'invocation_opened_v1', - route: - sessionHeader.llmConnectionId === undefined - ? { - provenance: 'unknown', - backendKind: sessionHeader.backend, - llmConnectionSlug: sessionHeader.llmConnectionSlug, - modelId: sessionHeader.model, - } - : { - provenance: 'runtime', - backendKind: sessionHeader.backend, - llmConnectionId: sessionHeader.llmConnectionId, - llmConnectionSlug: sessionHeader.llmConnectionSlug, - modelId: sessionHeader.model, - ...(input.targetProviderStateIdentity - ? { providerStateIdentity: input.targetProviderStateIdentity } - : {}), - }, + route: runtimeInvocationRouteForHeader(sessionHeader, input.targetProviderStateIdentity), configuration: { cwd: sessionHeader.cwd, permissionMode: sessionHeader.permissionMode, diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 98cca2a229..214c6efc2c 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -824,6 +824,8 @@ interface SessionManagerBaseDeps { list(): Promise; resolve(id: string): Promise; }; + /** Host gate for an executor that must remain visible in a newly created child Session. */ + assertChildExecutorAvailable?: (parentSessionId: string, executorId: string) => void; /** Host-owned filesystem isolation for worktree-backed child Sessions. */ worktreeChildExecutor?: SubagentWorktreeExecutor; listArtifactsForTurn?: (sessionId: string, turnId: string) => Promise; @@ -2629,6 +2631,9 @@ export class SessionManager { ? requireBuiltinAgentDefinitionByProfile(resolvedPreset.profile) : requireBuiltinAgentDefinition(input.agentId!); const executorId = input.executorId ?? (resolvedPreset ? undefined : parentHeader.executorId); + if (executorId) { + this.deps.assertChildExecutorAvailable?.(input.source.sessionId, executorId); + } const resolvedToolNames = executorId ? [] : await this.resolveChildToolNames(input.source.sessionId, parentHeader, definition); @@ -3227,6 +3232,7 @@ export class SessionManager { const definition = requireBuiltinAgentDefinitionByProfile(input.agentProfile); const executorId = input.executorId ?? (input.resolvedPreset ? undefined : parentHeader.executorId); + if (executorId) this.deps.assertChildExecutorAvailable?.(parentSessionId, executorId); const resolvedToolNames = executorId ? [] : await this.resolveChildToolNames(parentSessionId, parentHeader, definition); @@ -3983,7 +3989,7 @@ export class SessionManager { kind: 'invocation_opened', protocol: 'invocation_opened_v1', route: - session.llmConnectionId === undefined + session.llmConnectionId === undefined || session.backend === 'plugin-executor' ? { provenance: 'unknown', backendKind: session.backend, diff --git a/packages/runtime/src/stream-graph-supervisor-tools.ts b/packages/runtime/src/stream-graph-supervisor-tools.ts index 29926b1d90..b93fba2ece 100644 --- a/packages/runtime/src/stream-graph-supervisor-tools.ts +++ b/packages/runtime/src/stream-graph-supervisor-tools.ts @@ -18,6 +18,7 @@ */ import { z } from 'zod'; +import { isExecutorId } from '@maka/core/executor-id'; import { AGENT_GRAPH_SCHEDULE_MAX_ADD_WORK, AGENT_GRAPH_SCHEDULE_MAX_INPUT_IDS, @@ -65,9 +66,7 @@ const identitySchema = z .max(256) .refine((value) => !/[\u0000-\u001f\u007f]/.test(value), 'Identity contains control characters'); -const executorIdSchema = z - .string() - .regex(/^[A-Za-z][A-Za-z0-9._:-]{0,127}$/u, 'Invalid plugin executor id'); +const executorIdSchema = z.string().refine(isExecutorId, 'Invalid plugin executor id'); const cursorSchema = z .string() diff --git a/packages/runtime/src/subagent-tools.ts b/packages/runtime/src/subagent-tools.ts index c2bdd5d28e..29982e8dba 100644 --- a/packages/runtime/src/subagent-tools.ts +++ b/packages/runtime/src/subagent-tools.ts @@ -18,6 +18,7 @@ */ import { z } from 'zod'; +import { isExecutorId } from '@maka/core/executor-id'; import { decodeCanonicalToolResultContent } from '@maka/core/tool-result-record-schema'; import { isSafeSubagentPresetId } from '@maka/core/subagent-settings'; import { type ToolResultContent } from '@maka/core/events'; @@ -121,7 +122,7 @@ export function buildSubagentSpawnTool( .describe('User-approved subagent preset id from agent_list.'), executor_id: z .string() - .regex(/^[A-Za-z][A-Za-z0-9._:-]{0,127}$/u) + .refine(isExecutorId) .optional() .describe('Plugin executor id for this child task.'), task: z diff --git a/packages/storage/src/legacy-run-header.ts b/packages/storage/src/legacy-run-header.ts index cd5f9aca29..38e4bf3403 100644 --- a/packages/storage/src/legacy-run-header.ts +++ b/packages/storage/src/legacy-run-header.ts @@ -353,7 +353,7 @@ export function invocationOpeningFromLegacyRunHeader( } function invocationRouteFromLegacyRunHeader(header: LegacyRunHeader): RuntimeInvocationRoute { - if (header.llmConnectionId === undefined) { + if (header.llmConnectionId === undefined || header.backendKind === 'plugin-executor') { return { provenance: 'unknown', backendKind: header.backendKind, diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index e934d41509..9ddf142933 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -36,6 +36,7 @@ import { OPERATIONAL_STATE_DATABASE_NAME, } from './operational-state-store.js'; import { DEFAULT_SESSION_NAME, normalizeUserSessionName } from '@maka/core/session-name'; +import { isExecutorId } from '@maka/core/executor-id'; import { decodeCanonicalMessage, deriveTurnRecords, @@ -1573,10 +1574,7 @@ function isPersistedBackendKind(value: unknown): value is SessionHeader['backend function isValidExecutorSelection(header: SessionHeader): boolean { if (header.backend === 'plugin-executor') { - return ( - typeof header.executorId === 'string' && - /^[A-Za-z][A-Za-z0-9._:-]{0,127}$/u.test(header.executorId) - ); + return isExecutorId(header.executorId); } return header.executorId === undefined; }