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

View File

@@ -5,8 +5,10 @@
* The tool registry itself stays on the host plane — the agent loop's
* scheduler, the API proxy's presenters, and every tool plugin are all its
* consumers, so it cannot move into a preset. What a preset CAN own is the
* presentation: `ctx.tools.presentAs()` declares it for the mounting agent
* alone, so a Code Mode agent runs beside native ones in one process.
* presentation: `ctx.tools.presentAs()` declares it for the mounting SCOPE,
* which is the preset's standing mount, so the declaration covers every agent
* joined to that preset and a Code Mode preset runs beside native ones in one
* process. One row per composition, not one per session.
*
* A code mode needs a TypeScript code runtime, which is a host-plane service
* ([`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)).
@@ -50,8 +52,8 @@ export const Config: z<Config> = z.object({
})
/**
* Declare this agent's tool presentation.
* @param ctx - the mounting agent's scope context.
* Declare the tool presentation for every agent this composition covers.
* @param ctx - the mounting composition's scope context (a preset's standing scope).
* @param config - the selected presentation.
*/
export function apply(ctx: Context, config: Config): void {

View File

@@ -786,7 +786,7 @@ export class ToolRegistry extends Service {
scope => new ToolLayer(scope),
() => { this.ctx.emit('tools/change') },
)
/** Presentation for agents that declare none; {@link presentAs} shadows it per agent. */
/** Presentation for scopes that declare none; {@link presentAs} shadows it per scope. */
private readonly defaultMode: ToolPresentationMode
private readonly maxParallelSubCalls: number
/**
@@ -811,7 +811,7 @@ export class ToolRegistry extends Service {
/**
* The generated-SDK prompt section, registered globally by a code-mode
* deployment and per agent by {@link presentAs}.
* deployment and per scope by {@link presentAs}.
*
* The body regenerates from the CALLING scope, and renders empty for an
* agent presenting natively — an agent that opted out under a code-mode
@@ -880,12 +880,14 @@ export class ToolRegistry extends Service {
}
/**
* Present this agent's tools in `mode` instead of the deployment default.
* Present the calling scope's tools in `mode` instead of the deployment
* default. Nearest scope on the chain wins, so a preset's standing
* declaration covers every agent joined under it.
*
* Scoped only, and one declaration per agent: this is how an agent preset
* composes a Code Mode agent beside native ones in the same process, and a
* Scoped only, and one declaration per scope: this is how an agent preset
* composes Code Mode agents beside native ones in the same process, and a
* process-global override would be the `mode` config field instead.
* @param mode - the presentation this agent's model sees.
* @param mode - the presentation the covered agents' models see.
* @returns the exact disposer that restores the deployment default.
*/
presentAs(mode: ToolPresentationMode): () => void {
@@ -898,14 +900,14 @@ export class ToolRegistry extends Service {
ctx,
(layer) => {
if (layer.mode !== undefined) {
throw new Error(`tools.presentAs("${mode}") conflicts with "${layer.mode}" already declared for this agent; one composition selects one presentation`)
throw new Error(`tools.presentAs("${mode}") conflicts with "${layer.mode}" already declared for this scope; one composition selects one presentation`)
}
layer.mode = mode
return () => { layer.mode = undefined }
},
{ label: 'tools.presentAs()' },
)
// The SDK section is per agent for the same reason the mode is. Under a
// The SDK section is per scope for the same reason the mode is. Under a
// deployment that already defaults to a code mode this shadows the
// global registration with an identical body, which costs nothing and
// keeps one rule instead of a case analysis.