Merge pull request #2279 from deepseek-harness/worktree/preset-user-root-in-package
agent-presets owns the writable preset root instead of awaiting an app
This commit is contained in:
+2
-2
@@ -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 .agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md
|
||||
2026-08-11-preset-authoring-agent-validates-its-own-composition.md: 6b9cdf32b70e3ab4adc9f3b0e20bb3d2245486c7
|
||||
2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md: e6e8dabcd886a6331d294744b667552caa01e7b4
|
||||
2026-08-11-preset-authoring-agent-validates-its-own-composition.md: eb21094f0d859a31d5f16d780cada6818a508b36
|
||||
2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md: 02c245348a9c7e9968472044d7ff95e1ff21120c
|
||||
+3
-1
@@ -32,7 +32,9 @@ The agent reaches the roster service the way `cordis_mount` documents: a tempora
|
||||
|
||||
"Whether a row publishes a service" resolves through `cordis_inspect what:"services"`, which names the owning fiber of every live service.
|
||||
|
||||
The guidance keeps `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` as the answer to "where do my presets live" — it is where every `dsh` launcher puts them — while routing the path an agent actually reads or edits through `list()` or `resolve()`. `Config.roots` defaults to `[]` and `apps/cli` patches both roots in, `writableRoot()` takes the first `user` one, and no call reports either path; `authorable` answers only whether a writable root exists, and `list()` cannot reveal a user root that holds nothing yet. Stating the path is therefore right for talking to a person and wrong for feeding a file tool.
|
||||
The guidance keeps `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` as the answer to "where do my presets live" while routing the path an agent actually reads or edits through `list()` or `resolve()`. Stating the path is right for talking to a person and wrong for feeding a file tool: a deployment may configure other roots, and `list()` cannot reveal a user root that holds nothing yet.
|
||||
|
||||
That path is now a property of the package rather than of one launcher. `AgentPresets` derives `<dshHome>/.agent-presets` as a `user` root unless `includeUserRoot` is false, the way [`dsh-skill-local`](../../../../packages/skill/skill-local/README.md) derives `<dshHome>/skills`, and `apps/cli` supplies only the SHIPPED root — the one path an installed app alone can resolve. The asymmetry it replaces cost a bug: with both roots patched in by one launcher, `dsh run` booted a roster with no roots at all and failed resolving `standard` (fixed then by teaching every launcher the patch). The derived root is appended after every configured root, so a shipped id still shadows a home directory claiming it, and `writableRoot()` still prefers an explicitly configured `user` root. It is resolved once at construction: a root set that changed between a `list()` and the `copy()` acting on its answer would author into a directory the caller never saw.
|
||||
|
||||
The prohibition on touching the shipped install is promoted from a paragraph inside the authoring steps to a top `## Off-limits` section, extended to cover editing the host composition as a workaround. The new self-validation calls do not weaken it: `copy()` refuses an id any root supplies, and `remove()` refuses a preset that ships with the deployment.
|
||||
|
||||
|
||||
+3
-1
@@ -32,7 +32,9 @@ agent 按 `cordis_mount` 自身文档所述的方式够到 roster 服务:挂
|
||||
|
||||
「某行是否发布服务」改由 `cordis_inspect what:"services"` 回答,它会给出每个存活服务的持有 fiber。
|
||||
|
||||
指导保留 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` 作为「我的 preset 在哪」的答案——每个 `dsh` 启动器都把它们放在那里——同时把 agent 实际读取或编辑的路径改走 `list()` 或 `resolve()`。`Config.roots` 默认为 `[]`,两个根均由 `apps/cli` 补入,`writableRoot()` 取其中第一个 `user` 根,且没有任何调用会报告任一路径;`authorable` 只回答是否存在可写根,而 `list()` 无法揭示一个尚且为空的用户根。因此写出该路径对人讲是对的,喂给文件工具是错的。
|
||||
指导保留 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` 作为「我的 preset 在哪」的答案,同时把 agent 实际读取或编辑的路径改走 `list()` 或 `resolve()`。写出该路径对人讲是对的,喂给文件工具是错的:部署可以配置其他根目录,而 `list()` 无法揭示一个尚且为空的用户根。
|
||||
|
||||
该路径如今是本包的属性,而非某个启动器的属性。除非 `includeUserRoot` 为 false,`AgentPresets` 自行推导 `<dshHome>/.agent-presets` 作为 `user` 根,正如 [`dsh-skill-local`](../../../../packages/skill/skill-local/README.md) 推导 `<dshHome>/skills`;`apps/cli` 只提供**随附**根——那是唯有已安装 app 才能解析的路径。它取代的那种不对称曾付出过代价:两个根都由单一启动器补入时,`dsh run` 启动的 roster 一个根都没有,解析 `standard` 直接失败(当时的修法是让每个启动器都执行该 patch)。推导出的根追加在全部已配置根之后,因此随附 id 仍会遮蔽占用它的家目录目录,而 `writableRoot()` 仍优先选择显式配置的 `user` 根。它在构造时解析一次:若根目录集合在一次 `list()` 与依据其答案执行的 `copy()` 之间发生变化,写入的将是调用方从未见过的目录。
|
||||
|
||||
禁止改动随发布安装的约束,从创作步骤中的一段提升为顶部的 `## Off-limits` 一节,并扩展到禁止改宿主组装绕行。新增的自校验调用不削弱它:`copy()` 拒绝任何根已提供的 id,`remove()` 拒绝随部署发布的 preset。
|
||||
|
||||
|
||||
@@ -25,13 +25,13 @@ Two planes, and the choice is not about how "agent-related" something feels —
|
||||
|
||||
A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name.
|
||||
|
||||
Locally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. Both roots are configuration rather than fixed locations, though, and no call reports them — `authorable` says only whether a writable one exists — so take the path you actually read or edit from `list()` or `resolve()`, which is also where `copy()` reports what it just created.
|
||||
Locally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. A deployment can configure other roots, so the path you read or edit comes from `list()` or `resolve()` — which is also where `copy()` reports what it just created.
|
||||
|
||||
## The roster service
|
||||
|
||||
`ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step.
|
||||
|
||||
Read `cordis_inspect what:"api" name:"agentPresets"` for the current signatures before writing the code. The four calls this skill relies on:
|
||||
Read `cordis_inspect what:"api" name:"agentPresets"` for the current signatures before writing the code. What this skill relies on:
|
||||
|
||||
- `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent.
|
||||
- `read(id)` — one preset's composition text, without a file tool or a path.
|
||||
|
||||
@@ -29,13 +29,11 @@ import {
|
||||
watchUserPatches,
|
||||
type Profile,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
|
||||
/** Shipped agent-preset root: beside this app's own config, in both source and built layouts. */
|
||||
const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url))
|
||||
|
||||
/** Harness-home directory holding locally authored agent presets. */
|
||||
const USER_PRESET_DIR = '.agent-presets'
|
||||
import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
|
||||
import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
|
||||
import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
|
||||
@@ -154,16 +152,16 @@ function composeProfile(
|
||||
if (typeof row.id === 'string') rows.set(row.id, row)
|
||||
}
|
||||
const composedOverlays = [...overlays]
|
||||
// Preset roots belong to every dsh composition that mounts the roster.
|
||||
// The SHIPPED root is the part of the roster only this app can resolve: it
|
||||
// sits beside this app's own config, in both the source and built layouts.
|
||||
// The writable root the roster appends is `dsh-agent-presets`' own, so a
|
||||
// launcher that never reaches this patch still finds a person's presets.
|
||||
if (rows.has('agent-presets')) {
|
||||
composedOverlays.push({
|
||||
id: 'agent-presets',
|
||||
config: {
|
||||
...(rows.get('agent-presets')?.config ?? {}) as Record<string, unknown>,
|
||||
roots: [
|
||||
{ path: SHIPPED_PRESET_ROOT, trust: 'system' },
|
||||
{ path: dshHomePath(USER_PRESET_DIR), trust: 'user' },
|
||||
],
|
||||
roots: [{ path: SHIPPED_PRESET_ROOT, trust: 'system' }],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -96,7 +96,11 @@ async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promis
|
||||
// document overrides.
|
||||
{
|
||||
id: 'agent-presets',
|
||||
config: { default: 'standard', roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }] },
|
||||
config: {
|
||||
default: 'standard',
|
||||
roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }],
|
||||
includeUserRoot: false,
|
||||
},
|
||||
},
|
||||
...extra,
|
||||
]
|
||||
@@ -442,6 +446,7 @@ describe('product subagent rows in user presets', () => {
|
||||
{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' },
|
||||
{ path: userRoot, trust: 'user' },
|
||||
],
|
||||
includeUserRoot: false,
|
||||
},
|
||||
}])
|
||||
}, 120_000)
|
||||
@@ -624,6 +629,66 @@ describe('a delegated child', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('a launcher that configures no writable root', () => {
|
||||
// The claim this default exists for, asserted through the real shipped
|
||||
// bundles rather than a hand-built context: `apps/cli` patches in only the
|
||||
// system root, and a person's own presets are found anyway because the
|
||||
// roster derives `<dshHome>/.agent-presets` itself. `$DSH_HOME` is pointed
|
||||
// at a temp home BEFORE boot — the derived root is resolved when the plugin
|
||||
// is constructed, and an unpinned run would read the developer's own.
|
||||
let derivedCtx: Context
|
||||
let previousHome: string | undefined
|
||||
|
||||
beforeAll(async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-preset-derived-'))
|
||||
previousHome = process.env.DSH_HOME
|
||||
process.env.DSH_HOME = home
|
||||
await mkdir(join(home, '.agent-presets', 'derived-mine'), { recursive: true })
|
||||
await writeFile(
|
||||
join(home, '.agent-presets', 'derived-mine', 'agent.cordis.yml'),
|
||||
'- id: tool-todo\n name: \'@deepseek-ai/dsh-tool-todo\'\n config:\n allowParallelInProgress: true\n',
|
||||
)
|
||||
const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-preset-derived-settings-')), 'settings.yaml')
|
||||
await writeFile(settingsFile, '{}\n')
|
||||
// Only the shipped root, exactly what `composeProfile` supplies; the
|
||||
// writable one is the roster's own default rather than this patch's job.
|
||||
derivedCtx = await bootWeb(settingsFile, [{
|
||||
id: 'agent-presets',
|
||||
config: {
|
||||
default: 'standard',
|
||||
roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }],
|
||||
includeUserRoot: true,
|
||||
},
|
||||
}])
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
if (previousHome === undefined) delete process.env.DSH_HOME
|
||||
else process.env.DSH_HOME = previousHome
|
||||
await derivedCtx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('discovers and mounts a preset the person authored under the harness home', async () => {
|
||||
const listed = await derivedCtx.agentPresets.list()
|
||||
|
||||
const mine = listed.find(preset => preset.id === 'derived-mine')
|
||||
expect(mine).toMatchObject({ trust: 'user' })
|
||||
// Omitted rather than undefined: a healthy row carries no `broken` key.
|
||||
expect(mine?.broken).toBeUndefined()
|
||||
expect(derivedCtx.agentPresets.authorable).toBe(true)
|
||||
|
||||
const handle = await derivedCtx.agents.create({
|
||||
sessionId: SessionId('preset-derived-root'),
|
||||
setup: agentCtx => derivedCtx.agentPresets.mount(agentCtx, 'derived-mine').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
expect(toolNames(derivedCtx, handle.agent)).toContain('todo_write')
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('authoring a preset on the shipped composition', () => {
|
||||
let authorCtx: Context
|
||||
let userRoot: string
|
||||
@@ -642,6 +707,7 @@ describe('authoring a preset on the shipped composition', () => {
|
||||
// nothing is the normal first-run state.
|
||||
{ path: userRoot, trust: 'user' },
|
||||
],
|
||||
includeUserRoot: false,
|
||||
},
|
||||
}])
|
||||
})
|
||||
|
||||
@@ -385,7 +385,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
// able to change a golden.
|
||||
{
|
||||
id: 'agent-presets',
|
||||
config: { default: 'standard', roots: [{ path: SHIPPED_PRESET_DIR, trust: 'system' }] },
|
||||
config: {
|
||||
default: 'standard',
|
||||
roots: [{ path: SHIPPED_PRESET_DIR, trust: 'system' }],
|
||||
includeUserRoot: false,
|
||||
},
|
||||
},
|
||||
{ id: 'session-persistence-jsonl', config: { root: persistenceRoot } },
|
||||
{ id: 'session-query-sqlite', config: { path: ':memory:', openAt: 'first-search' } },
|
||||
@@ -445,7 +449,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
] },
|
||||
...options.agentPresets === undefined
|
||||
? []
|
||||
: [{ id: 'agent-presets', config: options.agentPresets }],
|
||||
// Never the derived harness-home root: a developer's own presets must not
|
||||
// be able to change a golden, whatever roots a scenario asks for.
|
||||
: [{ id: 'agent-presets', config: { ...options.agentPresets, includeUserRoot: false } }],
|
||||
...options.toolsMode === undefined ? [] : [{ id: 'tools', config: { mode: options.toolsMode } }],
|
||||
...options.cordisTools === true
|
||||
? [{ insert: [{ id: 'tool-cordis', name: 'cordis:tool-cordis' }] }]
|
||||
|
||||
@@ -31,6 +31,8 @@ const ONE_SHOT_LABEL = 'event-sourcing reviewer'
|
||||
const NESTED_LABEL = 'example editor'
|
||||
const PARENT_PROMPT = 'Ask a research subagent to explain event sourcing.'
|
||||
const INITIAL_PROMPT = 'Explain event sourcing in one sentence.'
|
||||
/** The grandchild's own first message; its arrival is what says its history finished loading. */
|
||||
const NESTED_PROMPT = 'Give one concrete event sourcing example.'
|
||||
const FOLLOWUP = 'Now give the same explanation to a human reader.'
|
||||
const POST_FORK_FOLLOWUP = 'Continue the original conversation after the fork.'
|
||||
|
||||
@@ -176,7 +178,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
seq: 1,
|
||||
time: authoredAt + 1,
|
||||
data: {
|
||||
content: [{ type: 'text', text: 'Give one concrete event sourcing example.' }],
|
||||
content: [{ type: 'text', text: NESTED_PROMPT }],
|
||||
source: { kind: 'user' },
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
@@ -404,6 +406,11 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
)
|
||||
await nestedRow.click()
|
||||
await page.getByText('The parent session is offline; reopen it to continue sending messages.').waitFor()
|
||||
// The offline banner renders from the descriptor alone, so it says nothing
|
||||
// about the transcript below it. The golden pins that transcript, and
|
||||
// `captureStableAria` calls two identical polls stable — including two of
|
||||
// "Loading history…". Wait for the message the golden asserts.
|
||||
await page.getByText(NESTED_PROMPT).waitFor()
|
||||
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
|
||||
const crumbs = await hierarchy.getByRole('button').allTextContents()
|
||||
expect(crumbs.slice(-2)).toEqual([LABEL, NESTED_LABEL])
|
||||
|
||||
@@ -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 docs/config-catalog.md
|
||||
config-catalog.md: e7ab120218c6de909bbd799c19b38eef524c7555
|
||||
config-catalog.zh.md: 622a0372de2f194e7063312ad7b8343a0bc58f06
|
||||
config-catalog.md: 2bb1f315e02c3f4bab379593227f1c381818a9fa
|
||||
config-catalog.zh.md: 4be51942de65a7e8afdaf71a44573ba1cdd6230d
|
||||
@@ -135,6 +135,11 @@ export interface Config {
|
||||
default: string
|
||||
/** Scanned roots in precedence order; an earlier root wins a duplicate id. */
|
||||
roots: PresetRoot[]
|
||||
/**
|
||||
* Append the harness home's `USER_PRESET_DIR` as a `user` root, after every
|
||||
* configured root. False mounts a roster over `roots` alone.
|
||||
*/
|
||||
includeUserRoot: boolean
|
||||
}
|
||||
|
||||
/** One directory scanned for preset subdirectories. */
|
||||
|
||||
@@ -137,6 +137,11 @@ export interface Config {
|
||||
default: string
|
||||
/** Scanned roots in precedence order; an earlier root wins a duplicate id. */
|
||||
roots: PresetRoot[]
|
||||
/**
|
||||
* Append the harness home's `USER_PRESET_DIR` as a `user` root, after every
|
||||
* configured root. False mounts a roster over `roots` alone.
|
||||
*/
|
||||
includeUserRoot: boolean
|
||||
}
|
||||
|
||||
/** One directory scanned for preset subdirectories. */
|
||||
|
||||
@@ -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 docs/subsystems/core.md
|
||||
core.md: e52a7619085b2956496be6234f711441902fc259
|
||||
core.zh.md: e9cfe19129e33a1c17e87561397b13139a6d7537
|
||||
core.md: 2e89bac4c0468c094814aa7137381f4be569fc29
|
||||
core.zh.md: 2a8a35bb46adf28fd2ba07d0a319cdca0cbb4a2f
|
||||
@@ -546,7 +546,7 @@ async standingKeyFor(id?: string): Promise<ScopeKey>
|
||||
|
||||
Types: [ScopeKey](scope.md)
|
||||
|
||||
Source: [`packages/preset/agent-presets/src/index.ts:81`](../../packages/preset/agent-presets/src/index.ts)
|
||||
Source: [`packages/preset/agent-presets/src/index.ts:82`](../../packages/preset/agent-presets/src/index.ts)
|
||||
|
||||
<a id="ctxagents--agentregistry"></a>
|
||||
|
||||
|
||||
@@ -554,7 +554,7 @@ async standingKeyFor(id?: string): Promise<ScopeKey>
|
||||
|
||||
Types: [ScopeKey](scope.md)
|
||||
|
||||
Source: [`packages/preset/agent-presets/src/index.ts:81`](../../packages/preset/agent-presets/src/index.ts)
|
||||
Source: [`packages/preset/agent-presets/src/index.ts:82`](../../packages/preset/agent-presets/src/index.ts)
|
||||
|
||||
<a id="ctxagents--agentregistry"></a>
|
||||
|
||||
|
||||
@@ -377,12 +377,15 @@
|
||||
disabled: true
|
||||
|
||||
# The preset roster. `config/agent-presets/` ships with the deployment and is
|
||||
# read-only (its entries carry `system` trust);
|
||||
# `$DSH_HOME/.agent-presets` is where a person — or an agent — authors their own, and
|
||||
# carries the same trust as shell access because a preset IS a composition.
|
||||
# `roots` is an assembly fact, not user config: the shipped preset directory
|
||||
# ships beside this file, so AppCLIEntry resolves it and patches it in — the
|
||||
# same treatment `distIndex` gets on the webserver row.
|
||||
# read-only (its entries carry `system` trust); `$DSH_HOME/.agent-presets` is
|
||||
# where a person — or an agent — authors their own, and carries the same trust
|
||||
# as shell access because a preset IS a composition.
|
||||
#
|
||||
# Only the SHIPPED root is an assembly fact: it sits beside the installed app's
|
||||
# own config, so `apps/cli`'s `composeProfile` resolves and patches it in — the
|
||||
# same treatment `distIndex` gets on the webserver row. The writable root is
|
||||
# `dsh-agent-presets`' own default (`includeUserRoot`), so a composition that
|
||||
# never reaches that patch still finds a person's presets.
|
||||
- insert:
|
||||
- id: agent-presets
|
||||
name: '@deepseek-ai/dsh-agent-presets'
|
||||
|
||||
@@ -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: 28b9a31ed41e5fc41e38d6b0349c5bd9cbaeed9d
|
||||
README.zh.md: 1d8d1481c20005b9e7fed5341aa26dca63dcd815
|
||||
README.md: 63bed95d192e6aeff6f484b63bdde711df0f1967
|
||||
README.zh.md: 505cb017a3a2439a11e0f3ed1c7a950893f5e7de
|
||||
@@ -18,7 +18,8 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal
|
||||
- `ctx.agentPresets.composedPreset(agentCtx): string | undefined` The preset one LIVE agent runs on, read from its scope chain rather than from its session — the only answer available for an agent whose durable header is still being built.
|
||||
- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was. Refuses a broken preset like `mount()`.
|
||||
- `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` The standing scope key a host reader with no agent (a cold transcript read) resolves preset registrations in; ensures the mount without starting an agent, session, or turn. Refuses a broken preset like `mount()`.
|
||||
- `ctx.agentPresets.authorable: boolean` Whether any configured root has `user` trust, and therefore whether a preset can be created at all.
|
||||
- `ctx.agentPresets.roots: readonly PresetRoot[]` The roots this roster scans — every configured root in order, then the derived harness-home root. Not `config.roots`: read this to answer whether a roster is composed at all, so one derivation decides it.
|
||||
- `ctx.agentPresets.authorable: boolean` Whether any of those roots has `user` trust, and therefore whether a preset can be created at all.
|
||||
- `ctx.agentPresets.read(id): Promise<string>` One preset's composition text, exactly as stored.
|
||||
- `ctx.agentPresets.copy(from, id, name?): Promise<void>` Create a locally authored preset by copying an existing one's whole directory — the only authoring write. No composition text crosses this seam, so a copy is exactly as loadable as its source; the copied metadata keeps the source's description but never its name or roster order, and `name` (or the id fallback) is what distinguishes the rows.
|
||||
- `ctx.agentPresets.remove(id): Promise<void>` Delete a locally authored preset; joined sessions keep their standing mount. Clears the user default when it named the preset just deleted: storing a default that does not exist yet is deliberate, but one this call removed will never be supplied again and would fail every session created without an explicit pick.
|
||||
@@ -86,9 +87,20 @@ Every read failure degrades to no metadata — absent, malformed, wrongly typed,
|
||||
|---|---|---|
|
||||
| `default` | required | Preset id mounted when a caller names none |
|
||||
| `roots` | `[]` | Scanned directories in precedence order; each supplies `path` (a leading `~` expands) and `trust` (defaults to `user`) |
|
||||
| `includeUserRoot` | `true` | Append `<dshHome>/.agent-presets` as a `user` root, after every configured root |
|
||||
|
||||
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 writable root is this package's, the shipped root is the app's
|
||||
|
||||
`<dshHome>/.agent-presets` is where a person's own presets live, the way `<dshHome>/skills` is where their own skills live ([`dsh-skill-local`](../../skill/skill-local/README.md)), so the roster derives it rather than waiting for a deployment to remember it — a launcher that configures nothing still finds and authors presets. It is appended AFTER every configured root, which keeps an earlier root winning a duplicate id: a shipped `standard` still shadows a home directory that claimed the name, and `copy()` refuses that id rather than landing a preset nothing would resolve.
|
||||
|
||||
The roots are resolved once, when the service is constructed. A root set that changed between a `list()` and the `copy()` acting on its answer would author into a directory the caller never saw.
|
||||
|
||||
`includeUserRoot: false` mounts a roster over `roots` alone. A deployment that confines presets to its own directories needs it, and so does any test pinning an exact roster — otherwise the machine's real `<dshHome>` decides what the roster contains.
|
||||
|
||||
The SHIPPED root stays an assembly fact: it sits beside the installed app's own config, a path only that app can resolve.
|
||||
|
||||
### 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:
|
||||
@@ -132,6 +144,7 @@ Prefix-stable for the life of an agent: a composition is installed once, before
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **A preset outside the writable root is discoverable but not deletable** — `remove()` refuses anything that does not live under the FIRST `user` root, so a deployment that configures its own writable root while leaving `includeUserRoot` on lists the harness-home presets, mounts them, and then answers "it does not live under the writable preset root" for every delete. The roster carries one writable root by design; a deployment that wants only its own sets `includeUserRoot: false`.
|
||||
- **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.
|
||||
- **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`.
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
- `ctx.agentPresets.composedPreset(agentCtx): string | undefined` 某个**活着的** agent 正在运行的 preset,从其 scope 链读取而不是从其会话读取——对于持久化 header 尚在构建中的 agent,这是唯一能拿到的答案。
|
||||
- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。与 `mount()` 一样拒绝损坏的 preset。
|
||||
- `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key;确保挂载而不启动任何 agent、会话或轮次。与 `mount()` 一样拒绝损坏的 preset。
|
||||
- `ctx.agentPresets.authorable: boolean` 是否有任一配置根目录具备 `user` 信任级别,因而 preset 是否可创建。
|
||||
- `ctx.agentPresets.roots: readonly PresetRoot[]` 本 roster 实际扫描的根目录——全部已配置根目录按序在前,随后是推导出的 harness home 根目录。它不是 `config.roots`:判断「是否已组装 roster」应读它,从而由同一处推导决定。
|
||||
- `ctx.agentPresets.authorable: boolean` 上述根目录中是否有任一具备 `user` 信任级别,因而 preset 是否可创建。
|
||||
- `ctx.agentPresets.read(id): Promise<string>` 某个 preset 的组装文本,与存储内容逐字一致。
|
||||
- `ctx.agentPresets.copy(from, id, name?): Promise<void>` 通过整目录复制一个既有 preset 来创建本地创作的 preset——唯一的创作写入。组装文本不经过这道接缝,因此副本与其来源同等可加载;复制出的元数据保留来源的描述、但绝不保留其名称与 roster 排序,`name`(或回退到 id)才是区分两行的依据。
|
||||
- `ctx.agentPresets.remove(id): Promise<void>` 删除一个本地创作的 preset;已加入的会话保留其常驻挂载。若用户默认值恰好指向刚删除的 preset 则一并清除:存一个尚不存在的默认值是刻意的,但本次删除的这个再也不会有人提供,留着会让所有未显式指定的新会话无法启动。
|
||||
@@ -86,9 +87,20 @@ description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agen
|
||||
|---|---|---|
|
||||
| `default` | 必填 | 调用方未指定时挂载的 preset id |
|
||||
| `roots` | `[]` | 按优先级排列的扫描目录;每项提供 `path`(开头的 `~` 会展开)与 `trust`(默认为 `user`) |
|
||||
| `includeUserRoot` | `true` | 在全部已配置根目录之后,追加 `<dshHome>/.agent-presets` 作为 `user` 根目录 |
|
||||
|
||||
根目录不存在时视为不提供任何 preset,而非失败:用户根目录在写出第一个本地 preset 之前并不存在,而指定了没有任何根目录提供的默认值,在解析时本就会明确报错。
|
||||
|
||||
### 可写根目录属于本包,随附根目录属于 app
|
||||
|
||||
`<dshHome>/.agent-presets` 是个人自有 preset 的所在,正如 `<dshHome>/skills` 是其自有 skill 的所在([`dsh-skill-local`](../../skill/skill-local/README.md)),因此 roster 自行推导它,而不等某个部署记得配置——一个什么都没配的启动器同样能发现并创作 preset。它追加在全部已配置根目录**之后**,从而保持靠前的根目录赢得重复 id:随附的 `standard` 仍然遮蔽一个占用该名字的家目录目录,而 `copy()` 会拒绝该 id,不会落下一个无人解析得到的 preset。
|
||||
|
||||
根目录在服务构造时解析一次。若根目录集合在一次 `list()` 与依据其答案执行的 `copy()` 之间发生变化,写入的将是调用方从未见过的目录。
|
||||
|
||||
`includeUserRoot: false` 使 roster 只覆盖 `roots`。把 preset 限制在自有目录内的部署需要它,任何钉住确切 roster 的测试同样需要——否则将由这台机器真实的 `<dshHome>` 决定 roster 的内容。
|
||||
|
||||
随附根目录仍然是装配事实:它位于已安装 app 自身配置的旁边,那个路径只有该 app 能解析。
|
||||
|
||||
### 默认 preset 是一项用户设置
|
||||
|
||||
当组装中存在 settings 提供方时,本插件会注册 `agent-presets` 命名空间,并以 `config.default` 作为其组装 base,因此用户文档会层叠覆盖部署方的工程默认值:
|
||||
@@ -132,6 +144,7 @@ Indirectly, through the plugins a standing composition registers, which own ever
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **位于可写根目录之外的 preset 可被发现却无法删除** —— `remove()` 拒绝任何不在**第一个** `user` 根目录下的 preset,因此一个既配置了自有可写根、又保留 `includeUserRoot` 的部署,会列出并挂载 harness home 下的 preset,却对每次删除回答「它不在可写 preset 根目录之下」。roster 按设计只有一个可写根;只想要自有根的部署应设置 `includeUserRoot: false`。
|
||||
- **会话一旦产出内容便无法更换 preset** —— `recompose` 把**空白**会话的父作用域重链到另一个常驻挂载,且仅限空白会话:切换已运行过的组装会抽走模型已调用的工具。更改默认值只影响此后创建的会话。
|
||||
- **代际只以组装文件为键** —— stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。
|
||||
- **被替代的代际永不回收** —— 已加入的会话保持其运行所在的代际,而名单没有加入计数可以判断最后一个何时离开,因此整棵子树一直挂到进程结束。代价按代际计而非按会话计,但并非为零:`dsh-skill-local` 默认监听自己的根目录,因此每一轮「编辑后建会话」都会新增一套活的 watcher。上限取决于组装被编辑的频率——而设置页的编写流程把这件事从「每次部署」变成了「每次保存」。要回收就需要给常驻挂载加上已加入 agent 的计数;见 `ensureStanding` 处的 `TODO`。
|
||||
|
||||
@@ -25,6 +25,21 @@ import { PRESET_ID, type AgentPreset, type PresetRoot } from './preset.ts'
|
||||
/** The composition file that makes a directory a preset. */
|
||||
export const COMPOSITION_FILE = 'agent.cordis.yml'
|
||||
|
||||
/**
|
||||
* Harness-home directory holding locally authored presets.
|
||||
*
|
||||
* This package owns the writable root the way `dsh-skill-local` owns
|
||||
* `<dshHome>/skills`. An app must assemble the SHIPPED root, whose path only
|
||||
* the installed app can resolve; where a person's own presets go is the same
|
||||
* place in every deployment that does not say otherwise, so a launcher that
|
||||
* forgets to configure one still finds them.
|
||||
*
|
||||
* Package-internal on purpose: no consumer outside this package addresses the
|
||||
* directory by name, and a test that imported it could not catch this value
|
||||
* being wrong — the expected segment is spelled out where it is asserted.
|
||||
*/
|
||||
export const USER_PRESET_DIR = '.agent-presets'
|
||||
|
||||
/**
|
||||
* Why `rows` cannot be an entry list, or undefined when it can.
|
||||
*
|
||||
|
||||
@@ -28,11 +28,12 @@ import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type
|
||||
// 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 { dshHomePath } from '@deepseek-ai/dsh-paths'
|
||||
import { discoverPresets, USER_PRESET_DIR } from './discovery.ts'
|
||||
import { copyComposition, deleteComposition, readComposition } from './authoring.ts'
|
||||
import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts'
|
||||
import { PresetExistsError } from './authoring.ts'
|
||||
import { PresetMountError, UnknownPresetError, type AgentPreset, type Config } from './preset.ts'
|
||||
import { PresetMountError, UnknownPresetError, type AgentPreset, type Config, type PresetRoot } from './preset.ts'
|
||||
import type {} from './types.ts'
|
||||
|
||||
/** Settings namespace carrying the user's chosen default preset. */
|
||||
@@ -88,8 +89,21 @@ export class AgentPresets extends Service {
|
||||
path: z.string().required(),
|
||||
trust: z.union(['system', 'user'] as const).default('user'),
|
||||
})).default([]),
|
||||
includeUserRoot: z.boolean().default(true),
|
||||
}) as z<Config>
|
||||
|
||||
/**
|
||||
* The roots discovery and authoring actually scan: every configured root in
|
||||
* order, then the harness-home user root unless `includeUserRoot` is false.
|
||||
*
|
||||
* Derived once, because a root set that changed between `list()` and the
|
||||
* `copy()` acting on its answer would author into a directory the caller
|
||||
* never saw. Appending rather than prepending keeps an earlier configured
|
||||
* root winning a duplicate id, so a shipped preset still shadows a
|
||||
* locally authored directory that claimed its name.
|
||||
*/
|
||||
private readonly resolvedRoots: readonly PresetRoot[]
|
||||
|
||||
/**
|
||||
* The user layer over `config.default`, present only while a settings
|
||||
* provider is composed. Held rather than snapshotted so a hot-reloaded
|
||||
@@ -116,6 +130,9 @@ export class AgentPresets extends Service {
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
super(ctx, 'agentPresets')
|
||||
this.selfCtx = ctx
|
||||
this.resolvedRoots = config.includeUserRoot
|
||||
? [...config.roots, { path: dshHomePath(USER_PRESET_DIR), trust: 'user' }]
|
||||
: [...config.roots]
|
||||
// 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
|
||||
@@ -147,7 +164,7 @@ export class AgentPresets extends Service {
|
||||
// does that today — the Web surface mounts in `setup` and children join
|
||||
// through `composeFrom` before publication.
|
||||
ctx.on('agent/created', ({ agent }) => {
|
||||
if (this.config.roots.length === 0) return
|
||||
if (this.resolvedRoots.length === 0) return
|
||||
if (this.composedPreset(agent.ctx) !== undefined) return
|
||||
ctx.logger.warn(
|
||||
`agent "${agent.id}" was published without joining an agent preset; `
|
||||
@@ -180,7 +197,7 @@ export class AgentPresets extends Service {
|
||||
* @returns the presets, first-root-wins per id.
|
||||
*/
|
||||
async list(): Promise<AgentPreset[]> {
|
||||
return await discoverPresets(this.config.roots)
|
||||
return await discoverPresets(this.resolvedRoots)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -320,9 +337,19 @@ export class AgentPresets extends Service {
|
||||
return standingMountFor(agentCtx)?.presetId
|
||||
}
|
||||
|
||||
/** Whether this deployment configures a root locally authored presets go to. */
|
||||
/**
|
||||
* The roots this roster scans, which is not `config.roots`: it is every
|
||||
* configured root in order, then the harness-home user root unless
|
||||
* `includeUserRoot` is false. Read this — not the config field — to answer
|
||||
* whether a roster is composed at all, so one derivation decides it.
|
||||
*/
|
||||
get roots(): readonly PresetRoot[] {
|
||||
return this.resolvedRoots
|
||||
}
|
||||
|
||||
/** Whether this deployment has a root locally authored presets go to. */
|
||||
get authorable(): boolean {
|
||||
return this.config.roots.some(root => root.trust === 'user')
|
||||
return this.resolvedRoots.some(root => root.trust === 'user')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -358,7 +385,7 @@ export class AgentPresets extends Service {
|
||||
if ((await this.list()).some(preset => preset.id === id)) {
|
||||
throw new PresetExistsError(id)
|
||||
}
|
||||
await copyComposition(this.config.roots, source, id, name)
|
||||
await copyComposition(this.resolvedRoots, source, id, name)
|
||||
// A settled mount under this id can only be stale (its preset was deleted
|
||||
// from disk outside `remove`); the new preset must not inherit it. Every
|
||||
// session already joined keeps the generation it runs on regardless.
|
||||
@@ -371,7 +398,7 @@ export class AgentPresets extends Service {
|
||||
* @throws when the preset is unknown or ships with the deployment.
|
||||
*/
|
||||
async remove(id: string): Promise<void> {
|
||||
await deleteComposition(this.config.roots, await this.resolve(id))
|
||||
await deleteComposition(this.resolvedRoots, await this.resolve(id))
|
||||
// Sessions on the deleted preset keep their standing mount; only new
|
||||
// sessions see the roster without it.
|
||||
this.standing.delete(id)
|
||||
|
||||
@@ -60,7 +60,7 @@ const install: InvariantInstaller = (ctx, fail) => {
|
||||
ctx.on('system-prompt/assemble', (_assembly, context, next) => {
|
||||
const presets = ctx.get('agentPresets')
|
||||
const agent = context.agent
|
||||
if (presets !== undefined && presets.config.roots.length > 0
|
||||
if (presets !== undefined && presets.roots.length > 0
|
||||
&& agent !== undefined && presets.composedPreset(agent.ctx) === undefined) {
|
||||
fail(
|
||||
`agent "${agent.id}" addressed a model without joining any agent preset while a roster is `
|
||||
|
||||
@@ -54,6 +54,11 @@ export interface Config {
|
||||
default: string
|
||||
/** Scanned roots in precedence order; an earlier root wins a duplicate id. */
|
||||
roots: PresetRoot[]
|
||||
/**
|
||||
* Append the harness home's `USER_PRESET_DIR` as a `user` root, after every
|
||||
* configured root. False mounts a roster over `roots` alone.
|
||||
*/
|
||||
includeUserRoot: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -52,6 +52,10 @@ beforeEach(async () => {
|
||||
{ path: join(FIXTURES, 'system'), trust: 'system' as const },
|
||||
{ path: userRoot, trust: 'user' as const },
|
||||
],
|
||||
// Every roster in this file pins its own roots: the derived harness-home
|
||||
// root would add the developer's real presets to what these assertions
|
||||
// count, and `copy` would write into it.
|
||||
includeUserRoot: false,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -199,6 +203,7 @@ describe('a deployment with more than one user root', () => {
|
||||
{ path: userRoot, trust: 'user' as const },
|
||||
{ path: second, trust: 'user' as const },
|
||||
],
|
||||
includeUserRoot: false,
|
||||
})
|
||||
|
||||
// Writes go to the first user root, so a preset discovered from a later
|
||||
@@ -219,6 +224,7 @@ describe('a deployment with no writable root', () => {
|
||||
await readOnly.plugin(AgentPresets, {
|
||||
default: 'standard',
|
||||
roots: [{ path: join(FIXTURES, 'system'), trust: 'system' as const }],
|
||||
includeUserRoot: false,
|
||||
})
|
||||
|
||||
expect(readOnly.agentPresets.authorable).toBe(false)
|
||||
@@ -240,6 +246,7 @@ describe('a user root that does not exist yet', () => {
|
||||
{ path: join(FIXTURES, 'system'), trust: 'system' as const },
|
||||
{ path: absent, trust: 'user' as const },
|
||||
],
|
||||
includeUserRoot: false,
|
||||
})
|
||||
|
||||
await fresh.agentPresets.copy('standard', 'mine')
|
||||
|
||||
@@ -11,7 +11,7 @@ import AgentRegistry, { assembleContextFor } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import AgentPresets, { livePresetMounts } from '@deepseek-ai/dsh-agent-presets'
|
||||
import AgentPresets, { livePresetMounts, type Config } from '@deepseek-ai/dsh-agent-presets'
|
||||
import * as AgentPresetsInvariant from '@deepseek-ai/dsh-agent-presets/invariant'
|
||||
|
||||
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
|
||||
@@ -20,7 +20,7 @@ const ROOTS = [
|
||||
{ path: join(FIXTURES, 'user'), trust: 'user' as const },
|
||||
]
|
||||
|
||||
async function harness(): Promise<Context> {
|
||||
async function harness(roster: Partial<Config> = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
@@ -31,7 +31,7 @@ async function harness(): Promise<Context> {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS })
|
||||
await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS, includeUserRoot: false, ...roster })
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(AgentPresetsInvariant)
|
||||
return ctx
|
||||
@@ -97,6 +97,28 @@ describe('agent-presets invariants', () => {
|
||||
.rejects.toThrow(/without joining any agent preset/)
|
||||
})
|
||||
|
||||
it('rejects one just the same when the derived home root is the whole roster', async () => {
|
||||
// The shape this plugin defaults to: an app configures nothing and the
|
||||
// roster is the harness home alone. A roster is a roster however its roots
|
||||
// were resolved, so the fail-loud half must not go quiet here — it read
|
||||
// `config.roots` once, which is empty in exactly this case.
|
||||
const ctx = await harness({ roots: [], includeUserRoot: true })
|
||||
const handle = await ctx.agents.create({ sessionId: SessionId('inv-derived-only') })
|
||||
|
||||
await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent)))
|
||||
.rejects.toThrow(/without joining any agent preset/)
|
||||
})
|
||||
|
||||
it('stays silent for a composition that opted out of every root', async () => {
|
||||
// `includeUserRoot: false` with no configured roots is a deployment that
|
||||
// mounts the roster but keeps its agents on the host plane; there is no
|
||||
// roster to join, so an unjoined agent is not a violation.
|
||||
const ctx = await harness({ roots: [], includeUserRoot: false })
|
||||
const handle = await ctx.agents.create({ sessionId: SessionId('inv-no-roster') })
|
||||
|
||||
await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent))).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('admits a joined agent, a scopeless read, and a standing-key read', async () => {
|
||||
const ctx = await harness()
|
||||
const handle = await ctx.agents.create({
|
||||
|
||||
@@ -38,7 +38,7 @@ const ROOTS = [
|
||||
* @param roster - roster config, defaulting to the fixture roots.
|
||||
* @returns the booted context.
|
||||
*/
|
||||
async function harness(roster: Config = { default: 'standard', roots: ROOTS }): Promise<Context> {
|
||||
async function harness(roster: Config = { default: 'standard', roots: ROOTS, includeUserRoot: false }): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
@@ -94,7 +94,7 @@ describe('composing an agent from a preset', () => {
|
||||
join(presetDir, COMPOSITION_FILE),
|
||||
`- id: only\n name: ${plugin}\n config:\n tool: absolute\n`,
|
||||
)
|
||||
const scoped = await harness({ default: 'absolute', roots: [{ path: root, trust: 'user' }] })
|
||||
const scoped = await harness({ default: 'absolute', roots: [{ path: root, trust: 'user' }], includeUserRoot: false })
|
||||
const imported = vi.spyOn(scoped.loader.internal!, 'import')
|
||||
|
||||
await agentOn(scoped, 'sess-absolute-plugin')
|
||||
@@ -347,7 +347,7 @@ describe('composing from a broken preset', () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-broken-'))
|
||||
await mkdir(join(root, 'damaged'))
|
||||
await writeFile(join(root, 'damaged', COMPOSITION_FILE), composition)
|
||||
return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }] })
|
||||
return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false })
|
||||
}
|
||||
|
||||
it('refuses the mount up front with the discovery-reported reason', async () => {
|
||||
@@ -380,7 +380,7 @@ describe('a roster with nothing in it', () => {
|
||||
it('says so instead of naming an empty list of candidates', async () => {
|
||||
const bare = new Context()
|
||||
await bare.plugin(Loader)
|
||||
await bare.plugin(AgentPresets, { default: 'standard', roots: [] })
|
||||
await bare.plugin(AgentPresets, { default: 'standard', roots: [], includeUserRoot: false })
|
||||
|
||||
await expect(bare.agentPresets.resolve())
|
||||
.rejects.toThrow(/preset "standard" not found \(available: none\)/)
|
||||
@@ -418,7 +418,7 @@ describe('the preset file is an input, never a persistence target', () => {
|
||||
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.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false })
|
||||
|
||||
await scoped.agents.create({
|
||||
sessionId: SessionId('sess-self-dispose'),
|
||||
@@ -528,11 +528,13 @@ describe('replacing a composition', () => {
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('says nothing when the deployment configures no roster at all', async () => {
|
||||
it('says nothing when the composition opts out of every root', async () => {
|
||||
// Presets are optional: every surface except the Web bundle keeps its
|
||||
// model-facing rows in the host plane, so an agent with a chain of one is
|
||||
// exactly right there and the diagnostic must stay silent.
|
||||
const rosterless = await harness({ default: 'standard', roots: [] })
|
||||
// exactly right there and the diagnostic must stay silent. Opting out is
|
||||
// what makes this rosterless — empty `roots` alone would still derive the
|
||||
// harness-home root, which is a roster like any other.
|
||||
const rosterless = await harness({ default: 'standard', roots: [], includeUserRoot: false })
|
||||
const warnings: string[] = []
|
||||
rosterless.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof rosterless.logger.warn
|
||||
|
||||
@@ -581,7 +583,7 @@ describe('replacing a composition', () => {
|
||||
await scoped.plugin(ToolRegistry)
|
||||
await scoped.plugin(AgentRegistry)
|
||||
await scoped.plugin(AgentLoop, { agents: [] })
|
||||
await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }] })
|
||||
await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false })
|
||||
const handle = await scoped.agents.create({
|
||||
sessionId: SessionId('sess-restore-gone'),
|
||||
setup: async (agentCtx: Context) => void await scoped.agentPresets.mount(agentCtx, 'first'),
|
||||
@@ -621,7 +623,7 @@ describe('editing a composition file', () => {
|
||||
await mkdir(join(root, id))
|
||||
const path = join(root, id, COMPOSITION_FILE)
|
||||
await writeFile(path, rowFor('before'))
|
||||
const scoped = await harness({ default: id, roots: [{ path: root, trust: 'user' as const }] })
|
||||
const scoped = await harness({ default: id, roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false })
|
||||
return { scoped, path }
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ async function harness(
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
const settingsFiber = ctx.plugin(SettingsLocal, { path: settingsFile, watch: false })
|
||||
await settingsFiber
|
||||
await ctx.plugin(AgentPresets, { default: 'standard', roots: [...ROOTS, ...extraRoots] })
|
||||
await ctx.plugin(AgentPresets, { default: 'standard', roots: [...ROOTS, ...extraRoots], includeUserRoot: false })
|
||||
return { ctx, settingsFile, settingsFiber }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* The writable root is this package's own, not an assembly fact each app must
|
||||
* remember: a roster configured with only a `system` root still discovers and
|
||||
* authors into `<dshHome>/.agent-presets`, the way `dsh-skill-local` owns
|
||||
* `<dshHome>/skills`. `includeUserRoot: false` is how a deployment — or a test
|
||||
* pinning an exact roster — opts out.
|
||||
*
|
||||
* `$DSH_HOME` is repointed per test because the derived root is resolved in the
|
||||
* constructor: the plugin must be mounted while the environment names the
|
||||
* temporary home, or it would reach the developer's real one.
|
||||
*/
|
||||
|
||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import AgentPresets, { COMPOSITION_FILE, type Config } from '@deepseek-ai/dsh-agent-presets'
|
||||
|
||||
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
|
||||
const SYSTEM_ROOT = join(FIXTURES, 'system')
|
||||
/** Spelled out rather than imported: the convention is what these tests assert. */
|
||||
const USER_ROOT_SEGMENT = '.agent-presets'
|
||||
const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\n tool: alpha\n'
|
||||
|
||||
let home: string
|
||||
let previousHome: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await mkdtemp(join(tmpdir(), 'dsh-preset-home-'))
|
||||
previousHome = process.env.DSH_HOME
|
||||
process.env.DSH_HOME = home
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (previousHome === undefined) delete process.env.DSH_HOME
|
||||
else process.env.DSH_HOME = previousHome
|
||||
})
|
||||
|
||||
/** Boot a roster over the fixture system root, with the derived root left to the plugin. */
|
||||
async function roster(config: Partial<Config> = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
await ctx.plugin(AgentPresets, {
|
||||
default: 'standard',
|
||||
roots: [{ path: SYSTEM_ROOT, trust: 'system' as const }],
|
||||
includeUserRoot: true,
|
||||
...config,
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Hand-place a preset directory under the harness home's preset root. */
|
||||
async function seedHomePreset(id: string): Promise<void> {
|
||||
await mkdir(join(home, USER_ROOT_SEGMENT, id), { recursive: true })
|
||||
await writeFile(join(home, USER_ROOT_SEGMENT, id, COMPOSITION_FILE), VALID)
|
||||
}
|
||||
|
||||
describe('the harness-home preset root', () => {
|
||||
it('is what a roster gets when config names no roots at all', () => {
|
||||
// The schema default is the contract an app relies on by saying nothing;
|
||||
// every other case here passes the field explicitly. The cast stands for
|
||||
// the untyped document the Loader hands the schema, which is where a
|
||||
// composition that omits the key actually comes from.
|
||||
const parsed = AgentPresets.Config({ default: 'standard' } as unknown as Config)
|
||||
|
||||
expect(parsed).toMatchObject({ includeUserRoot: true, roots: [] })
|
||||
})
|
||||
|
||||
it('is discovered without any app configuring it', async () => {
|
||||
await seedHomePreset('mine')
|
||||
const ctx = await roster()
|
||||
|
||||
const listed = await ctx.agentPresets.list()
|
||||
|
||||
expect(listed.find(preset => preset.id === 'mine')).toMatchObject({ trust: 'user' })
|
||||
expect((await ctx.agentPresets.resolve('mine')).path)
|
||||
.toBe(join(home, USER_ROOT_SEGMENT, 'mine', COMPOSITION_FILE))
|
||||
})
|
||||
|
||||
it('makes a roster with only a system root authorable, and receives the copy', async () => {
|
||||
const ctx = await roster()
|
||||
|
||||
expect(ctx.agentPresets.authorable).toBe(true)
|
||||
await ctx.agentPresets.copy('standard', 'copied')
|
||||
|
||||
expect(existsSync(join(home, USER_ROOT_SEGMENT, 'copied', COMPOSITION_FILE))).toBe(true)
|
||||
})
|
||||
|
||||
it('sorts after every configured root, so a shipped id still shadows a home directory', async () => {
|
||||
// `standard` exists in the fixture system root; claiming the name at home
|
||||
// must not take it over, because `copy` refuses an id any root supplies
|
||||
// and a session resolving `standard` must reach the shipped composition.
|
||||
await seedHomePreset('standard')
|
||||
const ctx = await roster()
|
||||
|
||||
expect((await ctx.agentPresets.resolve('standard')).trust).toBe('system')
|
||||
await expect(ctx.agentPresets.copy('standard', 'standard')).rejects.toThrow(/already exists/)
|
||||
})
|
||||
|
||||
it('is absent under includeUserRoot: false, which leaves the roster unauthorable', async () => {
|
||||
await seedHomePreset('mine')
|
||||
const ctx = await roster({ includeUserRoot: false })
|
||||
|
||||
expect((await ctx.agentPresets.list()).map(preset => preset.id)).not.toContain('mine')
|
||||
expect(ctx.agentPresets.authorable).toBe(false)
|
||||
await expect(ctx.agentPresets.copy('standard', 'mine'))
|
||||
.rejects.toThrow(/no user-writable preset root/)
|
||||
})
|
||||
|
||||
it('yields to a configured user root for authoring, which writableRoot takes first', async () => {
|
||||
const explicit = await mkdtemp(join(tmpdir(), 'dsh-preset-explicit-'))
|
||||
const ctx = await roster({
|
||||
roots: [
|
||||
{ path: SYSTEM_ROOT, trust: 'system' as const },
|
||||
{ path: explicit, trust: 'user' as const },
|
||||
],
|
||||
})
|
||||
|
||||
await ctx.agentPresets.copy('standard', 'copied')
|
||||
|
||||
expect(existsSync(join(explicit, 'copied', COMPOSITION_FILE))).toBe(true)
|
||||
expect(existsSync(join(home, USER_ROOT_SEGMENT, 'copied'))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -40,7 +40,7 @@ async function setupPresetHost(): Promise<{ ctx: Context; adapter: MockAdapter;
|
||||
ctx.loader.builtins.include = Include
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS })
|
||||
await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS, includeUserRoot: false })
|
||||
const adapter = new MockAdapter([textResponse('parent idle'), textResponse('child done')])
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const handle = await ctx.agents.create({
|
||||
|
||||
Reference in New Issue
Block a user