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:
Yichen Jiang
2026-08-10 22:36:06 +08:00
parent cd226191a8
commit 1cfbafab6a
49 changed files with 398 additions and 114 deletions
+34 -1
View File
@@ -24,7 +24,9 @@
import { stat } from 'node:fs/promises'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope'
import { bindScopeParent, createScope, scopeChainOf, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope'
// Type-only: resolves the `agent/created` lifecycle event this service watches.
import type {} from '@deepseek-ai/dsh-agent'
import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings'
import { discoverPresets } from './discovery.ts'
import { copyComposition, deleteComposition, readComposition } from './authoring.ts'
@@ -130,6 +132,31 @@ export class AgentPresets extends Service {
this.settingsService = undefined
}, 'agentPresets.settings()')
})
// An agent joins a preset by having its scope key parented to a standing
// mount, and `mount`/`composeFrom` are the only things in the runtime that
// install that link. An agent that never joined keeps a chain of length
// one, so its `tools`, `system-prompt`, and `skill` views resolve against
// the EMPTY global layer and the model simply has nothing — no error, no
// empty catalog to notice, just an agent that cannot act.
//
// Advisory rather than fatal, and deliberately not the same observation the
// invariant companion makes. A synchronous `agent/created` listener that
// throws VETOES publication, and this service must not: composing an agent
// outside the roster is legal (`recompose` documents the bare agent it then
// binds, and entry points that predate presets still create one), so
// vetoing would turn a capability gap into an outage. The companion fails
// loud instead, at the later point where the empty world reaches a model.
ctx.on('agent/created', ({ agent }) => {
if (this.config.roots.length === 0) return
const key = scopeOf(agent.ctx)
if (key !== undefined && scopeChainOf(key).length > 1) return
ctx.logger.warn(
`agent "${agent.id}" was published without joining an agent preset; `
+ 'its tools, prompt sections, and skill catalog resolve against the empty global layer '
+ '(join through AgentPresets.mount() or composeFrom() in the agent factory setup)',
)
})
}
/**
@@ -440,6 +467,12 @@ export class AgentPresets extends Service {
// disappearing, and failing the session over a stat would not.
const current = await compositionStamp(preset.path)
if (current === undefined || sameStamp(mounted.stamp, current)) return mounted
// TODO: reclaim the superseded generation once the last agent joined to
// it is gone. The subtree is not inert — `dsh-skill-local` watches its
// roots — and the settings-page authoring flow turns "a composition
// changed" into a per-save event. This needs a joined-agent count on
// StandingMount, incremented in `mount`/`composeFrom`/`recompose` and
// decremented when the agent's scope key dies.
// Guarded delete: a caller that raced this one may have already started
// the next generation, and dropping THAT pointer would fork a third.
if (this.standing.get(preset.id) === pending) this.standing.delete(preset.id)