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:
@@ -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)
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import { scopeChainOf } from '@deepseek-ai/dsh-scope'
|
||||
// Type-only: resolves the `system-prompt/assemble` waterfall this companion joins.
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
// Imported through the package name, not `./mount.ts`: a module shared between
|
||||
// the two build entry points becomes a third chunk that the published `files`
|
||||
// list does not carry, which `verify-built-package-invariants` rejects.
|
||||
@@ -18,9 +21,10 @@ export const name = 'agent-presets-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* Assert that no installed preset composition reaches the root service realm.
|
||||
* Assert that no installed preset composition reaches the root service realm,
|
||||
* and that a deployment configuring a roster composes every agent from it.
|
||||
*
|
||||
* `mountPreset` proves this once, when the subtree settles. A row that
|
||||
* `mountPreset` proves the first once, when the subtree settles. A row that
|
||||
* publishes later — from a timer, or an asynchronous continuation after its
|
||||
* plugin returned — would escape that one-shot audit, so re-check every live
|
||||
* mount whenever a service registration changes.
|
||||
@@ -37,6 +41,31 @@ const install: InvariantInstaller = (ctx, fail) => {
|
||||
)
|
||||
}
|
||||
}, { global: true })
|
||||
|
||||
// The join is a scope-parent link, and `AgentPresets.mount()` is the only
|
||||
// thing in the runtime that installs one. An agent minted without it keeps a
|
||||
// chain of length one, so its `tools`, `system-prompt`, and `skill` views
|
||||
// fall back to the empty global layer and the model receives nothing.
|
||||
//
|
||||
// Checked at ASSEMBLY, not at publication: an unjoined agent is legal until
|
||||
// it addresses a model — `recompose` binds a bare agent as its first link,
|
||||
// and that agent is unjoined for its whole life up to the switch. Assembling
|
||||
// a prompt is the point where the empty world stops being a state and
|
||||
// becomes what the model sees, and it is the only caller that supplies an
|
||||
// agent scope, so a host assembly (no scope) and a standing mount are both
|
||||
// correctly out of range.
|
||||
ctx.on('system-prompt/assemble', (_assembly, context, next) => {
|
||||
const presets = ctx.get('agentPresets')
|
||||
const scope = context.scope
|
||||
if (presets !== undefined && presets.config.roots.length > 0
|
||||
&& scope !== undefined && scopeChainOf(scope).length === 1) {
|
||||
fail(
|
||||
'an agent addressed a model without joining any agent preset while a roster is composed; '
|
||||
+ 'its tools, prompt sections, and skill catalog resolve against the empty global layer',
|
||||
)
|
||||
}
|
||||
return next()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user