Browse Source

fix(web): address a session's own services from the host

A preset publishes its services behind `isolate` realms, which is what
makes them per session — and what makes them invisible to every host
context. The api-proxy kept reading the root realm, so requests that are
ABOUT a session but arrive from outside it answered for a singleton that
no longer exists: `goal.pause`/`clear` and `skill.list` returned "this
deployment does not mount @deepseek-ai/dsh-goal / dsh-skill" for sessions
whose composition mounts exactly that. Verified against a running host
before and after.

`agentPresets.serviceFor(agent, name)` addresses the instance instead,
reading the same subtree-ownership relation `leakedServices` already
uses, inverted. It is read addressing for a caller holding the agent: a
host row that `inject`s a service cannot use it, because injection
resolves before any session exists — which is why `tools` and
`subagents` stay host-plane and this is not a way around that.

Tool presenters had the same shape and the same cure: `viewFor` looked
definitions up without a scope while the global layer is empty by
design, so every card degraded to the generic renderer. It now takes the
owning agent.

Cold resume through `agentFor()` mounted no preset at all, so every
generic entry point — prompt, models, commands — rebuilt a restarted
session on host tools and the deployment persona. It composes the
recorded preset now, as the other resume path already did.
Yichen Jiang 2 months ago
parent
commit
c58cc23d45

+ 63 - 24
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<ReturnType<typeof ctx.get<'goals'>>> | { 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<ReturnType<typeof ctx.get<'goals'>>> | { 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<ReturnType<typeof ctx.get<'goals'>>>, agent: Agent) => CoreGoalRef,
   ): Promise<RpcResponse<{ ref: GoalRef }>> {
-    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) => {

+ 70 - 0
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<string, Record<string, unknown>>()
+
 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')
+  })
+})

+ 7 - 2
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 () => {

+ 23 - 2
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<K extends string & keyof Context>(agent: { ctx: Context }, name: K): Context[K] | undefined {
+    return serviceForAgent(this.ctx, agent, name)
+  }
 }
 
 export default AgentPresets

+ 41 - 0
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<K extends string & keyof Context>(
+  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.
  *

+ 30 - 0
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/)