diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 8eefd9ac2f..38039f8ab3 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -414,11 +414,20 @@ function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQues * which soft-falls to no view. Presenter or JSON.parse throws also soft-fall: * the client's documented default (generic JSON card) covers every miss. */ -function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => unknown): ToolEventView | undefined { +function viewFor( + ctx: Context, + event: SessionEvent, + argsFor: (callId: string) => unknown, + // The presenter lives with the definition, and definitions are per agent + // now: a preset registers its tools into that agent's layer, leaving the + // global layer empty. Looking one up without the owner finds nothing, and + // every card silently degrades to the generic renderer. + agent?: Agent, +): ToolEventView | undefined { try { if (event.type === 'tool/call') { const { name, arguments: raw } = event.data as ToolCallData - const view = ctx.tools.get(name)?.presentCall?.(JSON.parse(raw)) + const view = ctx.tools.get(name, agent)?.presentCall?.(JSON.parse(raw)) return view === undefined ? undefined : { for: 'call', view } } if (event.type === 'tool/result') { @@ -427,7 +436,7 @@ function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => const callId = message.source.callId const call = argsFor(callId) as { name: string; args: unknown } | undefined if (call === undefined) return undefined - const view = ctx.tools.get(call.name)?.presentResult?.(call.args, { + const view = ctx.tools.get(call.name, agent)?.presentResult?.(call.args, { content: result.content, isError: result.isError === true, ...meta === undefined ? {} : { meta }, @@ -470,11 +479,12 @@ function historyPage( events: readonly SessionEvent[], beforeSeq: number | undefined, maxMessages: number | undefined, + agent?: Agent, ): { events: HistoryEntry[]; hasMore: boolean } { const page = paginate(events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES) return { events: page.events.map((event) => { - const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId)) + const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId), agent) return { event, ...view === undefined ? {} : { view } } }), hasMore: page.hasMore, @@ -1090,10 +1100,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (publishedSession !== undefined && hasSubagentOwner(publishedSession, publishedAgent)) { throw new SubagentSessionOwnership(sessionId) } + // Cold resume composes the preset the session recorded, for the + // same reason `session.create` does: its history was produced under + // that composition. Every generic entry point — prompt, models, + // commands — arrives here, so leaving it out meant a session opened + // after a restart ran on host tools and the deployment persona. const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions, - setup: installTarget, + setup: (await composeAgent(inspected.meta.agentPreset)).setup, }) return handle.agent } finally { @@ -1354,11 +1369,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return items } - /** Resolve the goal service; absent = the deployment did not compose @deepseek-ai/dsh-goal. */ - function goalService(): NonNullable>> | { error: RpcError } { - const goals = ctx.get('goals') + /** + * Resolve the goal service THIS agent runs. + * + * The service is per session: an agent preset mounts it behind an `isolate` + * realm, which no host context resolves. Reading it from the root would + * answer "absent" for a session whose composition mounts it — so the lookup + * is keyed by the agent, and only a deployment composing it nowhere is + * genuinely absent. + */ + function goalServiceFor(agent: Agent): NonNullable>> | { error: RpcError } { + const presets = ctx.get('agentPresets') + const goals = presets?.serviceFor(agent, 'goals') ?? ctx.get('goals') if (goals === undefined) { - return { error: { code: 'internal', message: 'goal service is absent: this deployment does not mount @deepseek-ai/dsh-goal in its composition (cordis.yml or explicit assembly)', details: {} } } + return { error: { code: 'internal', message: 'goal service is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-goal', details: {} } } } return goals } @@ -1374,10 +1398,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro request: RpcRequest<{ sessionId: SessionId }>, mutation: (goals: NonNullable>>, agent: Agent) => CoreGoalRef, ): Promise> { - const goals = goalService() - if ('error' in goals) return err(request, goals.error) const found = await agentFor(request.payload.sessionId) if ('error' in found) return err(request, found.error) + const goals = goalServiceFor(found.agent) + if ('error' in goals) return err(request, goals.error) try { const ref = mutation(goals, found.agent) return ok(request, { ref: { id: ref.id, revision: ref.revision } }) @@ -1768,7 +1792,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: {}, }) } - const page = historyPage(ctx, state.events, beforeSeq, maxMessages) + // `ctx.get`, not `ctx.agents`: this is the COLD path, and a caller may + // serve history from storage with no agent registry composed at all. + // An absent registry means no live agent, which is the same answer a + // present one gives here — presenters fall back to the global layer. + const page = historyPage(ctx, state.events, beforeSeq, maxMessages, ctx.get('agents')?.get(sessionId)) return ok(request, { events: page.events, hasMore: page.hasMore, @@ -2071,7 +2099,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: { childSessionId }, }) } - const page = historyPage(ctx, snapshot.events, beforeSeq, maxMessages) + const page = historyPage(ctx, snapshot.events, beforeSeq, maxMessages, ctx.agents.get(childSessionId)) const projections = beforeSeq === undefined ? detachedProjectionsFor(ctx, snapshot.events) : undefined @@ -2440,10 +2468,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, async clear(request) { - const goals = goalService() - if ('error' in goals) return err(request, goals.error) const found = await agentFor(request.payload.sessionId) if ('error' in found) return err(request, found.error) + const goals = goalServiceFor(found.agent) + if ('error' in goals) return err(request, goals.error) try { goals.clear(found.agent, request.payload.ref) return ok(request, { cleared: true as const }) @@ -2473,14 +2501,22 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} }) } const cwd = session.header.cwd - // Same stance as the commands domain: a missing service means the - // deployment omitted dsh-skill from its composition, not an empty - // catalog. ctx.get also keeps this handler independent of the gateway - // plugin's inject list (an undeclared `ctx.skills` property read - // fails the reflect proxy). - const skillRegistry = ctx.get('skills') + // The registry is per session when a preset mounts one — a preset + // ships its own skill directory, so the catalog IS the session's — and + // that instance sits behind an `isolate` realm no host context + // resolves. Address it through the live agent; `agents.get` keeps the + // no-side-effect stance above (a cold session creates nothing and + // falls through to whatever the host composes). + const live = ctx.agents.get(sessionId) + const presets = ctx.get('agentPresets') + const scoped = live === undefined ? undefined : presets?.serviceFor(live, 'skills') + // Same stance as the commands domain: a missing service means no + // composition mounts dsh-skill, not an empty catalog. `ctx.get` also + // keeps this handler independent of the gateway plugin's inject list + // (an undeclared `ctx.skills` property read fails the reflect proxy). + const skillRegistry = scoped ?? ctx.get('skills') if (skillRegistry === undefined) { - return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} }) + return err(request, { code: 'internal', message: 'skill registry is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-skill', details: {} }) } try { const skills = (await skillRegistry.list({ cwd })) @@ -2711,8 +2747,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } else if (event.type === 'turn/end') { openCalls.delete(session.id) } - const view = viewFor(ctx, event, callId => - openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId)) + const view = viewFor( + ctx, event, + callId => openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId), + ctx.agents.get(session.id), + ) queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } })) }), ctx.on('session/created', (session: Session) => { diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index 293ab3015e..014b7847ca 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -15,6 +15,7 @@ import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import { RpcId, type RpcRequest } from '../src/api/rpc.ts' import { UnknownPresetError } from '@deepseek-ai/dsh-agent-presets' +import { GoalId } from '@deepseek-ai/dsh-goal' import { createApiProxy } from '../src/api-proxy.ts' import { describe, expect, it } from 'vitest' @@ -44,9 +45,19 @@ function roster(ids: readonly string[]): unknown { }, mount: (_ctx: Context, id?: string) => Promise.resolve({ id: id ?? ids[0], trust: 'system', path: '/presets/x.yml' }), + // What a real mount leaves behind: a service instance only the agent that + // mounted it can be used to address. The doubles are per agent so a test + // can tell "this session's" from "some session's". + serviceFor: (agent: { id: unknown }, name: string) => { + const perAgent = services.get(String(agent.id)) + return perAgent?.[name] + }, } } +/** Per-agent service instances a mounted preset would own, keyed by session id. */ +const services = new Map>() + async function harness(presets?: readonly string[]) { const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-preset-'))) const ctx = new Context() @@ -143,3 +154,62 @@ describe('session.create with an agent preset', () => { expect(ctx.sessions.get(SessionId('s6'))?.header.agentPreset).toBeUndefined() }) }) + +/** + * A capability a preset mounts is reachable from nowhere the host normally + * looks: an `isolate` realm is what makes it per session. The gateway serves + * requests that are ABOUT a session from OUTSIDE it, so it addresses the + * instance through the agent instead of reading a root-realm singleton. + */ +describe('a capability the session\'s preset mounts', () => { + it('serves the goal RPC from the session\'s own goal service', async () => { + const { api } = await harness(['standard']) + await api.sessions.create(request({ sessionId: SessionId('g1'), agentPreset: 'standard' })) + const ref = { id: GoalId('goal-1'), revision: 1 } + const paused: unknown[] = [] + services.set('g1', { + goals: { pause: (agent: { id: unknown }, r: unknown) => { paused.push([String(agent.id), r]); return ref } }, + }) + + const response = await api.goals.pause(request({ sessionId: SessionId('g1'), ref })) + + expect(response.result).toMatchObject({ ok: true, value: { ref } }) + // Reached the instance this session mounted, and was handed its own agent. + expect(paused).toEqual([['g1', ref]]) + services.delete('g1') + }) + + it('serves the skill catalog from the session\'s own registry', async () => { + const { api } = await harness(['standard']) + await api.sessions.create(request({ sessionId: SessionId('k1'), agentPreset: 'standard' })) + services.set('k1', { + skills: { + list: () => Promise.resolve([{ + name: 'preset-owned', + description: 'ships inside the preset directory', + invocation: { modelInvocable: true, userInvocable: true }, + }]), + }, + }) + + const response = await api.skills.list(request({ sessionId: SessionId('k1') })) + + // A preset ships its own skill directory, so the catalog IS the + // session's; reading a host singleton would answer for the wrong one. + expect(response.result).toMatchObject({ ok: true, value: { skills: [{ name: 'preset-owned' }] } }) + services.delete('k1') + }) + + it('says so when no composition mounts the capability at all', async () => { + const { api } = await harness(['standard']) + await api.sessions.create(request({ sessionId: SessionId('n1'), agentPreset: 'standard' })) + + const response = await api.skills.list(request({ sessionId: SessionId('n1') })) + + // Absent means absent — not "this session has none", which is what a + // root-realm read used to report for every presetd session. + expect(response.result.ok).toBe(false) + const failure = response.result as { ok: false; error: { message: string } } + expect(failure.error.message).toContain('neither this session') + }) +}) diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index faa1c39e9f..f9e317bf5a 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -112,7 +112,12 @@ describe('subagent gateway', () => { .toMatchObject({ ok: true, value: { entries: [{ activity: 'running' }] } }) }) - it('reads a healthy direct child without looking up or activating any Agent', async () => { + it('reads a healthy direct child without acquiring an Agent owner', async () => { + // `bench()` leaves the child with no live Agent at all, so the response + // below is produced cold — which is the invariant: the read never creates + // or resumes one. It may still CONSULT the live registry, because tool + // presenters live with the per-agent definitions and rendering this + // child's own cards needs its layer. const { api, getAgent, readSession } = bench() const response = await api.subagents.history(request({ parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', maxMessages: 10, @@ -122,7 +127,7 @@ describe('subagent gateway', () => { value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] }, }) expect(readSession).toHaveBeenCalledWith(CHILD) - expect(getAgent).not.toHaveBeenCalled() + expect(getAgent).not.toHaveBeenCalledWith(PARENT) }) it('reads one-shot history and rejects an address with the wrong mode', async () => { diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index ff0f0fe116..69e59d1fd0 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -13,11 +13,13 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import { discoverPresets } from './discovery.ts' -import { mountPreset } from './mount.ts' +import { mountPreset, serviceForAgent } from './mount.ts' import { UnknownPresetError, type AgentPreset, type Config } from './types.ts' export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts' -export { inactiveRows, leakedServices, livePresetMounts, mountPreset, type PresetMount } from './mount.ts' +export { + inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, type PresetMount, +} from './mount.ts' export { PresetMountError, UnknownPresetError } from './types.ts' export type { AgentPreset, Config, PresetRoot, PresetTrust } from './types.ts' @@ -95,6 +97,25 @@ export class AgentPresets extends Service { await mountPreset(agentCtx, preset) return preset } + + /** + * One agent's instance of a service its preset mounted. + * + * A preset publishes services behind `isolate` realms, which are invisible + * outside the group that declares them — including to the host. This is how a + * caller holding the agent reads one anyway: a request that is ABOUT a + * session but arrives from outside it, which is every browser RPC. + * + * Read addressing only. A host row that `inject`s a service cannot use this, + * because injection resolves before any session exists and has no agent to + * key by; such a service belongs on the host plane instead. + * @param agent - the agent whose composition to look inside. + * @param name - the service name as the preset's rows resolve it. + * @returns the agent's instance, or undefined when its preset mounts none. + */ + serviceFor(agent: { ctx: Context }, name: K): Context[K] | undefined { + return serviceForAgent(this.ctx, agent, name) + } } export default AgentPresets diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index 88d3936013..e307601a49 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -155,6 +155,47 @@ export function leakedServices(ctx: Context, mount: Fiber): string[] { return leaked.sort((left, right) => left.localeCompare(right)) } +/** + * One agent's instance of a service its preset mounted. + * + * A preset publishes a service behind an `isolate` realm so two sessions + * cannot collide, and an entry-local realm is invisible to everything outside + * the group — including the agent's own scope context and the host. That is + * right for the rows inside the group and wrong for one caller: a request that + * is ABOUT a session but arrives from outside it, which is every browser RPC + * the api-proxy serves. + * + * Ownership is the same relation {@link leakedServices} reads, inverted: there + * it names implementations a subtree published into the ROOT realm, here it + * names the one this subtree published anywhere. Fiber membership is object + * identity for the reason stated on {@link withinFiber}. + * + * This is READ addressing for a caller that already holds the agent. It is not + * a general host handle on a session's internals: a host row that `inject`s a + * service cannot use it, because injection resolves before any session exists + * and has no agent to key by — such a service belongs on the host plane. + * @param ctx - any context of the runtime whose service store is inspected. + * @param agent - the agent whose mounted composition to look inside. + * @param name - the service name as the preset's rows resolve it. + * @returns the agent's instance, or undefined when its preset mounts none. + */ +export function serviceForAgent( + ctx: Context, + agent: { ctx: Context }, + name: K, +): Context[K] | undefined { + const root = agent.ctx.fiber + const store = ctx.reflect.store + for (const key of Object.getOwnPropertySymbols(store)) { + const impl = store[key] + /* v8 ignore next -- cordis deletes a store slot on disposal rather than clearing it */ + if (impl === undefined) continue + if (impl.name !== name) continue + if (withinFiber(impl.fiber, root)) return impl.value as Context[K] + } + return undefined +} + /** * Rows that did not reach a usable state, each rendered as one diagnostic line. * diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 36810c61d1..3ece3db20e 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -12,6 +12,13 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { beforeEach, describe, expect, it } from 'vitest' import AgentPresets, { leakedServices, livePresetMounts } from '@deepseek-ai/dsh-agent-presets' +declare module 'cordis' { + interface Context { + /** Published by the `isolated` fixture preset behind an entry-local realm. */ + fixtureIsolatedSvc: { label: string } + } +} + const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') const ROOTS = [ { path: join(FIXTURES, 'system'), trust: 'system' as const }, @@ -155,6 +162,29 @@ describe('rejecting a composition that cannot be used', () => { expect(rootResolves(ctx, 'fixtureIsolatedSvc')).toBe(false) }) + it('addresses one agent\'s instance of a realm-private service', async () => { + const first = await agentOn(ctx, 'sess-reach-a', 'isolated') + const second = await agentOn(ctx, 'sess-reach-b', 'isolated') + + // The realm keeps the service out of every host context — that is what + // makes it per session — so a caller holding the agent is the only way a + // request from OUTSIDE the session can read the instance it is about. + expect(rootResolves(ctx, 'fixtureIsolatedSvc')).toBe(false) + const mine = ctx.agentPresets.serviceFor(first, 'fixtureIsolatedSvc') + const theirs = ctx.agentPresets.serviceFor(second, 'fixtureIsolatedSvc') + expect(mine).toBeDefined() + expect(theirs).toBeDefined() + // Each agent gets ITS own: the addressing is per subtree, not a lookup + // that happens to find the first match. + expect(mine).not.toBe(theirs) + }) + + it('answers undefined for a service the agent\'s preset does not mount', async () => { + const agent = await agentOn(ctx, 'sess-reach-none', 'standard') + + expect(ctx.agentPresets.serviceFor(agent, 'fixtureIsolatedSvc')).toBeUndefined() + }) + it('reports the known ids when a preset is unknown', async () => { await expect(ctx.agentPresets.resolve('nope')) .rejects.toThrow(/preset "nope" not found \(available: .*standard/)