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:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md
|
||||
README.md: 632fc7828313a932512cb59a924050005067a008
|
||||
README.zh.md: 41dfab1af81149a221cb333a5613ab0a2d899899
|
||||
README.md: 2c9a16081e93895e40a60378376d0db06e271913
|
||||
README.zh.md: 2598b35f5494f6c56dfc9f1ba08a6784a5087cb6
|
||||
|
||||
@@ -133,7 +133,8 @@ Prefix-stable for the life of an agent: a composition is installed once, before
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **A preset cannot be changed once a session has produced anything** — `recompose` re-links a BLANK session's parent scope to another standing mount, and only a blank one: switching a composition that already ran would strand tools the model has called. Changing the default affects only sessions created afterwards.
|
||||
- **A generation is keyed on the composition file alone** — the stamp check notices `agent.cordis.yml` changing, not an edit to a skill file or asset beside it; those reach new sessions only once the composition file itself moves or the process restarts. Sessions already joined keep their generation, and nothing reclaims a superseded one while the process lives (bounded by how often compositions are edited, not by sessions).
|
||||
- **A generation is keyed on the composition file alone** — the stamp check notices `agent.cordis.yml` changing, not an edit to a skill file or asset beside it; those reach new sessions only once the composition file itself moves or the process restarts.
|
||||
- **A superseded generation is never reclaimed** — sessions already joined keep the generation they run on, and the roster holds no join count that could tell when the last one left, so the whole subtree stays mounted until the process ends. The cost is per generation rather than per session, but it is not free: `dsh-skill-local` watches its roots by default, so each edit-then-create cycle adds a live watcher set. Bounded by how often compositions are edited — which the settings-page authoring flow makes a per-save event rather than a per-deploy one. Reclaiming one needs a joined-agent count on the standing mount; see the `TODO` at `ensureStanding`.
|
||||
- **A copy is never mounted to validate** — it is byte-identical to its source, so a source broken on disk yields a copy exactly as broken as the source; discovery's health check marks both rows on the next roster read rather than deferring the failure to a session start.
|
||||
- **Health is a shape check, not a mount** — discovery proves the composition parses in the loader dialect and holds named rows, not that every row's module resolves or activates; a row naming an absent package still fails at the first session, which rolls the creation back.
|
||||
- **A copy is a snapshot that drifts** — upgrading the deployment does not update copies of shipped presets, and there is no patch semantics at this layer to express "standard plus one change" (that is the bundle layer's `cordis.patch.yml`); the shipped set itself accepts the same cost — `cordis` and `code` are full copies of `standard` — so the whole assembly stays readable in one file.
|
||||
|
||||
@@ -133,7 +133,8 @@ Indirectly, through the plugins a standing composition registers, which own ever
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **会话一旦产出内容便无法更换 preset** —— `recompose` 把**空白**会话的父作用域重链到另一个常驻挂载,且仅限空白会话:切换已运行过的组装会抽走模型已调用的工具。更改默认值只影响此后创建的会话。
|
||||
- **代际只以组装文件为键** —— stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。已加入的会话保持其代际,进程存活期间不回收被替代的代际(上限取决于组装被编辑的频率,而非会话数)。
|
||||
- **代际只以组装文件为键** —— stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。
|
||||
- **被替代的代际永不回收** —— 已加入的会话保持其运行所在的代际,而名单没有加入计数可以判断最后一个何时离开,因此整棵子树一直挂到进程结束。代价按代际计而非按会话计,但并非为零:`dsh-skill-local` 默认监听自己的根目录,因此每一轮「编辑后建会话」都会新增一套活的 watcher。上限取决于组装被编辑的频率——而设置页的编写流程把这件事从「每次部署」变成了「每次保存」。要回收就需要给常驻挂载加上已加入 agent 的计数;见 `ensureStanding` 处的 `TODO`。
|
||||
- **副本从不被实际挂载以校验** —— 它与来源逐字节相同,因此磁盘上已坏的来源会产出与来源同样损坏的副本;发现过程的健康检查会在下一次读取名单时把两行都标出来,而不是把失败推迟到会话启动。
|
||||
- **健康是形状检查,不是挂载** —— 发现过程只证明组装能以加载器方言解析、由具名行组成,不证明每一行的模块都能解析并激活;引用不存在的包的行仍在第一个会话处失败,并回滚该会话的创建。
|
||||
- **副本是会漂移的快照** —— 升级部署不会更新随附 preset 的副本,本层也没有表达「standard 加一处改动」的 patch 语义(那是 bundle 层 `cordis.patch.yml` 的能力);随附集合自己也接受同样的代价——`cordis` 与 `code` 就是 `standard` 的完整副本——换来整份组装在一个文件里可读。
|
||||
|
||||
@@ -27,12 +27,14 @@
|
||||
"peerDependencies": {
|
||||
"@cordisjs/plugin-include": "^1.0.4",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-atomic-write": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-settings": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,12 +18,18 @@
|
||||
{
|
||||
"path": "../../../vendor/include"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user