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.
This commit is contained in:
Yichen Jiang
2026-08-07 00:35:30 +08:00
parent fedb8a2702
commit c58cc23d45
6 changed files with 234 additions and 28 deletions
+63 -24
View File
@@ -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) => {
@@ -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')
})
})
@@ -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
View File
@@ -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
@@ -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.
*
@@ -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/)