feat(agent-presets): make the default preset a user setting
`config.default` becomes the composition base of an `agent-presets` settings namespace, so the user document layers over the deployment's engineering default and a person can change which preset new sessions get without a restart. The value is read per resolution rather than snapshotted: a hot-reloaded document takes effect on the next session created, and every running session stays on the preset it was composed from — which is the same rule the session-header guard enforces from the other side. `resolve()` read `config.default` directly, which would have made the whole setting inert; it now goes through `defaultId` like every other caller. The write-protection test is rewritten against a temp profile root. It was passing vacuously: the un-overridden Loader REWRITES the composition it read — stamping `disabled: true` onto the self-disposing row — so the committed fixture had been mutated by the very run that proved the bug, and every later run compared against the damaged file and passed. Building the preset in a temp directory makes the assertion immune to its own failure mode, and it now fails with a visible `+ disabled: true` when the override is removed. Review follow-ups on this layer. The exported schema is `AgentPresetSettingsSchema`, symmetric with the `AgentPresetSettings` interface it resolves and self-describing at an import site. The `session.create` JSDoc promised "the deployment's default preset" for an omitted `agentPreset`, which this layer makes false — it now names the effective default. The constructor records why it does not use `installSettingsSection`: that helper re-judges what a consumer DERIVED across attach and detach, and nothing here is derived. The provider-unload test disposes the fiber `ctx.plugin()` handed back instead of reaching into `ctx.reflect.store`, and the write-protection wait says why slack is the right shape for an absence assertion. The real composition covers the layering too. `apps/cli` boots the shipped `cordis.yml`, stores `agent-presets.default`, and asserts an unnamed session composes from it — the package suite proves the layering against a hand-built context, this proves the roster and the settings provider are wired to each other. That test also pins the settings row at a temp file: it defaulted to `$DSH_HOME/settings.yaml`, so a developer's own stored default decided the outcome of a file whose whole point is that only the shipped root does. The Agent Note records the per-resolution read and its correspondence with the session header, and the vacuous-test finding above.
This commit is contained in:
@@ -201,10 +201,11 @@ export interface SessionsApi {
|
||||
* returns `workspace-attach-failed` with the published session id.
|
||||
*
|
||||
* `agentPreset` names the composition the new session's agent is built
|
||||
* from; omitted, the deployment's default preset applies. The resolved id
|
||||
* is stored on the session header, so a later resume rebuilds the same
|
||||
* agent. An unknown id fails with `agent-preset-not-found`, and a preset
|
||||
* whose composition cannot be mounted fails with `agent-preset-invalid`.
|
||||
* from; omitted, the effective default applies — the user's stored choice
|
||||
* where one exists, else the deployment's own. The resolved id is stored on
|
||||
* the session header, so a later resume rebuilds the same agent. An unknown
|
||||
* id fails with `agent-preset-not-found`, and a preset whose composition
|
||||
* cannot be mounted fails with `agent-preset-invalid`.
|
||||
*/
|
||||
create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId; agentPreset?: string }>):
|
||||
Promise<RpcResponse<{ sessionId: SessionId }>>
|
||||
|
||||
@@ -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: 1592fba1163448aaedbd482c56962fa07ebc897e
|
||||
README.zh.md: ef4512ab93783674890bc9a848e590792a820e8c
|
||||
README.md: 5e785b747209d4c0cedaebbd3a90ba1b46dcd1c6
|
||||
README.zh.md: 4c40d7b7bfabb83dba2859251ad189a2646170c6
|
||||
|
||||
@@ -30,6 +30,17 @@ The agent factory's `setup(agentCtx)` hook is the one supported call site. Only
|
||||
|
||||
An absent root supplies no presets rather than failing: the user root does not exist until the first locally authored preset, and naming a default no root supplies already fails loud at resolution.
|
||||
|
||||
### The default preset is a user setting
|
||||
|
||||
When a settings provider is composed, this plugin registers the `agent-presets` namespace with `config.default` as its composition base, so the user document layers over the deployment's engineering default:
|
||||
|
||||
```yaml
|
||||
agent-presets:
|
||||
default: core-web
|
||||
```
|
||||
|
||||
The value is read per resolution rather than snapshotted, so a hot-reloaded document takes effect on the next session created and every running session stays on the preset it was composed from. Clearing the user field re-inherits the composition default. A default naming a preset no root supplies is stored without complaint and fails at the next `resolve()` — the roster is a live directory, so a name absent now may exist by the time a session asks for it.
|
||||
|
||||
## What a mount rejects
|
||||
|
||||
A directly-plugged subtree is absent from `ctx.loader.entries()`, so no boot audit covers it. `mount()` therefore proves the result usable itself, and rejects three things.
|
||||
|
||||
@@ -30,6 +30,17 @@ agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有
|
||||
|
||||
根目录不存在时视为不提供任何 preset,而非失败:用户根目录在写出第一个本地 preset 之前并不存在,而指定了没有任何根目录提供的默认值,在解析时本就会明确报错。
|
||||
|
||||
### 默认 preset 是一项用户设置
|
||||
|
||||
当组装中存在 settings 提供方时,本插件会注册 `agent-presets` 命名空间,并以 `config.default` 作为其组装 base,因此用户文档会层叠覆盖部署方的工程默认值:
|
||||
|
||||
```yaml
|
||||
agent-presets:
|
||||
default: core-web
|
||||
```
|
||||
|
||||
该值在每次解析时读取而非快照,因此热重载的文档对**此后创建**的会话生效,而每个运行中的会话仍停留在它当初据以组装的 preset 上。清空用户字段即重新继承组装默认值。若默认值指向没有任何根目录提供的 preset,写入时不会报错,而在下一次 `resolve()` 时失败——名单是一个活动目录,此刻不存在的名字,等到某个会话真正索取时可能已经存在。
|
||||
|
||||
## 挂载会拒绝什么
|
||||
|
||||
直接挂载的子树不会出现在 `ctx.loader.entries()` 中,因此没有任何启动审计能覆盖它。`mount()` 因此自行校验结果可用,并拒绝三种情况。
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-settings": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -45,6 +46,8 @@
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
|
||||
@@ -12,10 +12,25 @@
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings'
|
||||
import { discoverPresets } from './discovery.ts'
|
||||
import { mountPreset, serviceForAgent } from './mount.ts'
|
||||
import { UnknownPresetError, type AgentPreset, type Config } from './types.ts'
|
||||
|
||||
/** Settings namespace carrying the user's chosen default preset. */
|
||||
export const SETTINGS_NAMESPACE = 'agent-presets'
|
||||
|
||||
/** The user-writable slice of this plugin's config. */
|
||||
export interface AgentPresetSettings {
|
||||
/** Preset mounted when a session names none. */
|
||||
default?: string
|
||||
}
|
||||
|
||||
/** Runtime schema for the user-writable slice. */
|
||||
export const AgentPresetSettingsSchema: z<AgentPresetSettings> = z.object({
|
||||
default: z.string(),
|
||||
})
|
||||
|
||||
export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts'
|
||||
export {
|
||||
inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, type PresetMount,
|
||||
@@ -48,13 +63,39 @@ export class AgentPresets extends Service {
|
||||
})).default([]),
|
||||
}) as z<Config>
|
||||
|
||||
/**
|
||||
* The user layer over `config.default`, present only while a settings
|
||||
* provider is composed. Held rather than snapshotted so a hot-reloaded
|
||||
* document takes effect without a restart.
|
||||
*/
|
||||
private settings: SettingsScope<AgentPresetSettings> | undefined
|
||||
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
super(ctx, 'agentPresets')
|
||||
// Deliberately not `installSettingsSection`: that helper exists to re-judge
|
||||
// what a consumer DERIVED from the source — memoized resolutions,
|
||||
// registration-level facts — across attach, detach, and change. Nothing
|
||||
// here is derived. `defaultId` reads through on every call, so both of its
|
||||
// hooks would be no-ops and the source thunk would restate this field.
|
||||
ctx.inject(['settings'], (settingsCtx) => {
|
||||
this.settings = settingsCtx.settings.register(
|
||||
settingsNamespace(SETTINGS_NAMESPACE),
|
||||
AgentPresetSettingsSchema,
|
||||
{ base: { default: config.default } },
|
||||
)
|
||||
settingsCtx.effect(() => () => { this.settings = undefined }, 'agentPresets.settings()')
|
||||
})
|
||||
}
|
||||
|
||||
/** The preset id mounted when a caller names none. */
|
||||
/**
|
||||
* The preset id mounted when a caller names none.
|
||||
*
|
||||
* Read per call rather than cached: the settings document is hot-reloaded, so
|
||||
* changing the default takes effect on the next session created and leaves
|
||||
* every running session on the preset it was composed from.
|
||||
*/
|
||||
get defaultId(): string {
|
||||
return this.config.default
|
||||
return this.settings?.get().default ?? this.config.default
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,7 +113,7 @@ export class AgentPresets extends Service {
|
||||
* @throws when no configured root supplies that id.
|
||||
*/
|
||||
async resolve(id?: string): Promise<AgentPreset> {
|
||||
const wanted = id ?? this.config.default
|
||||
const wanted = id ?? this.defaultId
|
||||
const presets = await this.list()
|
||||
const found = presets.find(preset => preset.id === wanted)
|
||||
if (found === undefined) {
|
||||
|
||||
9
packages/preset/agent-presets/tests/fixtures/plugins/self-dispose.js
vendored
Normal file
9
packages/preset/agent-presets/tests/fixtures/plugins/self-dispose.js
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
// Disposes itself once active. The Loader treats a self-disposing entry as a
|
||||
// config change and writes the tree back through `EntryTree.write()`, which is
|
||||
// the exact path that once truncated a preset file to `[]`.
|
||||
export const name = 'self-dispose'
|
||||
export function apply(ctx) {
|
||||
globalThis.__SELF_DISPOSED__ = new Promise((resolve) => {
|
||||
setTimeout(() => { ctx.fiber.dispose(); resolve(undefined) }, 0)
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
@@ -10,7 +12,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import AgentPresets, { leakedServices, livePresetMounts } from '@deepseek-ai/dsh-agent-presets'
|
||||
import AgentPresets, { COMPOSITION_FILE, leakedServices, livePresetMounts } from '@deepseek-ai/dsh-agent-presets'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
@@ -232,3 +234,56 @@ describe('attributing a service to a subtree', () => {
|
||||
expect(leakedServices(ctx, mount!.fiber)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('the preset file is an input, never a persistence target', () => {
|
||||
it('survives a row that disposes itself, which makes the Loader persist a tree', async () => {
|
||||
// The preset lives in a temp root, not under `fixtures/`: without the
|
||||
// `write()` override the Loader REWRITES the composition it read, so a
|
||||
// committed fixture would be mutated by the very run that proves the bug
|
||||
// and every later run would compare against the damaged file and pass.
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-write-'))
|
||||
const dir = join(root, 'self-disposing')
|
||||
await mkdir(dir)
|
||||
const path = join(dir, COMPOSITION_FILE)
|
||||
const composition = [
|
||||
'- id: tool-kept',
|
||||
` name: ${join(FIXTURES, 'plugins', 'contribute.js')}`,
|
||||
' config:',
|
||||
' tool: kept',
|
||||
'- id: goes-away',
|
||||
` name: ${join(FIXTURES, 'plugins', 'self-dispose.js')}`,
|
||||
'',
|
||||
].join('\n')
|
||||
await writeFile(path, composition)
|
||||
|
||||
const scoped = new Context()
|
||||
scoped.baseUrl = pathToFileURL(FIXTURES).href + '/'
|
||||
await scoped.plugin(Loader)
|
||||
scoped.loader.builtins.include = Include
|
||||
await scoped.plugin(LlmService)
|
||||
await scoped.plugin(SessionStore)
|
||||
await scoped.plugin(SystemPrompt, { persona: '' })
|
||||
await scoped.plugin(ToolRegistry)
|
||||
await scoped.plugin(AgentRegistry)
|
||||
await scoped.plugin(AgentLoop, { agents: [] })
|
||||
await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }] })
|
||||
|
||||
await scoped.agents.create({
|
||||
sessionId: SessionId('sess-self-dispose'),
|
||||
setup: async (agentCtx: Context) => void await scoped.agentPresets.mount(agentCtx),
|
||||
})
|
||||
await (globalThis as { __SELF_DISPOSED__?: Promise<unknown> }).__SELF_DISPOSED__
|
||||
// Slack past the deterministic signal above, not a race the number has to
|
||||
// win. The write rides the Loader's fiber-unload listener, which stamps
|
||||
// `disabled: true` and calls `write()` in the same synchronous step; once
|
||||
// the self-dispose has settled, a regression has already written. Polling
|
||||
// would not help — the assertion is an ABSENCE, and no amount of waiting
|
||||
// proves one — so the wait only has to clear settlement.
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
// Inherited, `EntryTree.write()` persists the dying tree — stamping
|
||||
// `disabled: true` onto the row and, in the shipped case, truncating the
|
||||
// composition every session shares.
|
||||
expect(await readFile(path, 'utf8')).toBe(composition)
|
||||
})
|
||||
})
|
||||
|
||||
141
packages/preset/agent-presets/tests/settings.spec.ts
Normal file
141
packages/preset/agent-presets/tests/settings.spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* The default preset is a user setting. `config.default` is the deployment's
|
||||
* engineering default; the settings document overrides it and is hot-reloaded,
|
||||
* so a person can change which preset new sessions get without a restart.
|
||||
*/
|
||||
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import SettingsLocal from '@deepseek-ai/dsh-settings-local'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import AgentPresets, { SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets'
|
||||
|
||||
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
|
||||
const ROOTS = [{ path: join(FIXTURES, 'system'), trust: 'system' as const }]
|
||||
const NS = settingsNamespace(SETTINGS_NAMESPACE)
|
||||
|
||||
/**
|
||||
* A composition with a real file-backed settings provider. `settingsFiber` is
|
||||
* the provider's own handle, so a test can take it away the way a reload does.
|
||||
*/
|
||||
async function harness(): Promise<{ ctx: Context; settingsFile: string; settingsFiber: { dispose: () => unknown } }> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-preset-settings-'))
|
||||
const settingsFile = join(home, 'settings.yaml')
|
||||
await writeFile(settingsFile, '{}\n')
|
||||
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
const settingsFiber = ctx.plugin(SettingsLocal, { path: settingsFile, watch: false })
|
||||
await settingsFiber
|
||||
await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS })
|
||||
return { ctx, settingsFile, settingsFiber }
|
||||
}
|
||||
|
||||
const toolNames = (ctx: Context, agent?: unknown): string[] =>
|
||||
ctx.tools.schemas(agent as never).map(schema => schema.name).sort()
|
||||
|
||||
describe('the default preset as a user setting', () => {
|
||||
it('falls back to the composition default while the user set none', async () => {
|
||||
const { ctx } = await harness()
|
||||
|
||||
expect(ctx.agentPresets.defaultId).toBe('standard')
|
||||
})
|
||||
|
||||
it('takes the user default over the composition default', async () => {
|
||||
const { ctx } = await harness()
|
||||
|
||||
await ctx.settings.update(NS, { default: 'minimal' })
|
||||
|
||||
expect(ctx.agentPresets.defaultId).toBe('minimal')
|
||||
})
|
||||
|
||||
it('composes a new session from the user default', async () => {
|
||||
const { ctx } = await harness()
|
||||
await ctx.settings.update(NS, { default: 'minimal' })
|
||||
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('settings-default'),
|
||||
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx),
|
||||
})
|
||||
try {
|
||||
expect(toolNames(ctx, handle.agent)).toEqual(['beta'])
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves a running session on the preset it was composed from', async () => {
|
||||
const { ctx } = await harness()
|
||||
const running = await ctx.agents.create({
|
||||
sessionId: SessionId('settings-running'),
|
||||
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx),
|
||||
})
|
||||
try {
|
||||
expect(toolNames(ctx, running.agent)).toEqual(['alpha'])
|
||||
|
||||
// Changing the default mid-flight must not reach an agent that already
|
||||
// composed: its history was produced under `standard`'s tools.
|
||||
await ctx.settings.update(NS, { default: 'minimal' })
|
||||
|
||||
expect(ctx.agentPresets.defaultId).toBe('minimal')
|
||||
expect(toolNames(ctx, running.agent)).toEqual(['alpha'])
|
||||
} finally {
|
||||
await running.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('re-inherits the composition default when the user setting is cleared', async () => {
|
||||
const { ctx } = await harness()
|
||||
await ctx.settings.update(NS, { default: 'minimal' })
|
||||
expect(ctx.agentPresets.defaultId).toBe('minimal')
|
||||
|
||||
await ctx.settings.replace(NS, {})
|
||||
|
||||
expect(ctx.agentPresets.defaultId).toBe('standard')
|
||||
})
|
||||
|
||||
it('reports an unknown user default only when a session tries to use it', async () => {
|
||||
const { ctx } = await harness()
|
||||
|
||||
// Storing it succeeds — the roster is a live directory, so a name that is
|
||||
// absent now may exist by the time a session asks for it.
|
||||
await ctx.settings.update(NS, { default: 'no-such-preset' })
|
||||
|
||||
await expect(ctx.agentPresets.resolve())
|
||||
.rejects.toThrow(/preset "no-such-preset" not found/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a settings provider that goes away', () => {
|
||||
it('falls back to the composition default when the provider unloads', async () => {
|
||||
const { ctx, settingsFiber } = await harness()
|
||||
await ctx.settings.update(NS, { default: 'minimal' })
|
||||
expect(ctx.agentPresets.defaultId).toBe('minimal')
|
||||
|
||||
// Unloading the provider takes the user layer with it; the roster keeps
|
||||
// working on its composition default rather than holding a stale override.
|
||||
await settingsFiber.dispose()
|
||||
|
||||
expect(ctx.agentPresets.defaultId).toBe('standard')
|
||||
})
|
||||
})
|
||||
@@ -21,6 +21,9 @@
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user