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:
@@ -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/)
|
||||
|
||||
Reference in New Issue
Block a user