fix(preset): keep the token meter host-plane and name unjoined agents
Moving the agent plane behind presets left two readers on the wrong side of the host/agent line. `dsh-token-meter` was disabled on the host and mounted inside each preset's `compaction` realm, but its three projection units register into the process-wide `sessionProjections` table. A unit registered from one preset answers for every session, so whether a `minimal` session showed a context meter depended on whether some other session had mounted `standard` since boot, and a process that only ever ran `minimal` showed none. The meter takes no configuration, keys every fold by Session, and registers no tool or prompt section, so it returns to the host composition and leaves the presets' `isolate` map; the realm and `compact-basic` stay, because what a preset chooses is whether its agent compacts, not whether its tokens are counted. Nothing named an agent that joined no preset. The join is a scope-parent link, and without it the tools, prompt-section, and skill views resolve the empty global layer: the agent publishes, the turn runs, and the model receives nothing. `AgentPresets` now logs one warning per such agent while a roster is configured, and the invariant companion fails outright — at `system-prompt/assemble` rather than at publication, because an unjoined agent is legal until it addresses a model and `recompose` binds exactly such an agent. The warning stays advisory: a synchronous `agent/created` throw vetoes publication, and the ACP bridge, SDK server, and headless bundle all create an unjoined agent today. Three limits are recorded rather than fixed: projection key presence is not a per-session capability signal, a superseded standing generation is never reclaimed, and a `cordis_mount` temporary plugin belongs to the composition rather than the session that mounted it. Fixes #2203
This commit is contained in:
@@ -84,4 +84,28 @@ describe('agent-presets invariants', () => {
|
||||
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'isolated'),
|
||||
})).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('rejects an agent that addresses a model without joining any preset', async () => {
|
||||
const ctx = await harness()
|
||||
// The delegation shape: an agent composed outside the roster keeps a
|
||||
// scope chain of length one, so every registry view it reads is the empty
|
||||
// global layer. Publication alone stays legal — `recompose` binds exactly
|
||||
// such an agent — so nothing fires until that empty world reaches a prompt.
|
||||
const handle = await ctx.agents.create({ sessionId: SessionId('inv-unjoined') })
|
||||
|
||||
await expect(ctx.systemPrompt.assemble({ scope: handle.agent }))
|
||||
.rejects.toThrow(/without joining any agent preset/)
|
||||
})
|
||||
|
||||
it('admits a joined agent and a host assembly that names no scope', async () => {
|
||||
const ctx = await harness()
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('inv-joined'),
|
||||
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'),
|
||||
})
|
||||
|
||||
await expect(ctx.systemPrompt.assemble({ scope: handle.agent })).resolves.toBeDefined()
|
||||
// A scopeless assembly belongs to no agent, so it cannot be an unjoined one.
|
||||
await expect(ctx.systemPrompt.assemble({})).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -499,6 +499,22 @@ describe('replacing a composition', () => {
|
||||
expect(toolNames(ctx, handle.agent)).toEqual(['alpha'])
|
||||
})
|
||||
|
||||
it('names an agent that was published without joining any preset', async () => {
|
||||
const ctx = await harness()
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
||||
|
||||
await ctx.agents.create({ sessionId: SessionId('sess-unjoined-warn') })
|
||||
// Advisory, not fatal: a synchronous `agent/created` throw would veto
|
||||
// publication, and creating an agent outside the roster stays legal.
|
||||
expect(warnings.filter(line => line.includes('sess-unjoined-warn'))).toHaveLength(1)
|
||||
expect(warnings.at(-1)).toMatch(/without joining an agent preset/)
|
||||
|
||||
warnings.length = 0
|
||||
await agentOn(ctx, 'sess-joined-quiet', 'minimal')
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('composes an agent that had nothing installed', async () => {
|
||||
// An agent created without a preset has no binding to re-link, so the
|
||||
// switch is its first bind — exactly a mount — and once bound only the
|
||||
|
||||
Reference in New Issue
Block a user