diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 4301af6e1a..3ca3ec2ba3 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -10,6 +10,7 @@ import { networkInterfaces } from 'node:os' import { fileURLToPath } from 'node:url' +import { dshHomePath } from '@deepseek-ai/dsh-paths' import type { Context } from 'cordis' import type { PatchOptions } from '@cordisjs/plugin-include' import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' @@ -17,6 +18,12 @@ import { runProfile } from './profile-boot.ts' const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) +/** 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' + /** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation. */ const ALL_INTERFACES_HOST = '0.0.0.0' @@ -96,6 +103,16 @@ function deriveWebFlagPatches( // inserts the client-hmr row), never pass-throughs of composed values. put('web-runtime', 'mode', flags.dev ? 'development' : 'production') put('web-runtime', 'lanAddresses', lanAddresses) + // The agent-preset roots are an assembly fact, like the values above: the + // shipped set sits beside this app's config and the user's own under the + // Harness home, and neither location is something a patch author chooses. + // Only patched when the composed tree actually mounts the roster. + if (rows.has('agent-presets')) { + put('agent-presets', 'roots', [ + { path: SHIPPED_PRESET_ROOT, trust: 'system' }, + { path: dshHomePath(USER_PRESET_DIR), trust: 'user' }, + ]) + } const patches = [...overrides.entries()].map(([id, bag]): PatchOptions => { const composed = rows.get(id) if (composed === undefined) throw new Error(`dsh: patch target row "${id}" not found in the web profile composition`) diff --git a/apps/cli/tests/web-agent-presets.spec.ts b/apps/cli/tests/web-agent-presets.spec.ts new file mode 100644 index 0000000000..87123dde36 --- /dev/null +++ b/apps/cli/tests/web-agent-presets.spec.ts @@ -0,0 +1,173 @@ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { Context } from 'cordis' +import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { PatchOptions } from '@cordisjs/plugin-include' +import { beforeAll, describe, expect, it } from 'vitest' +import type {} from '@deepseek-ai/dsh-agent-presets' +import type {} from '@deepseek-ai/dsh-tools' + +const CONFIG_DIR = fileURLToPath(new URL('../config/', import.meta.url)) +const BASE_CONFIG = join(CONFIG_DIR, 'base.cordis.yml') +const WEB_OVERLAY = join(CONFIG_DIR, 'web.cordis.yml') + +/** + * Boot the shipped Web composition, minus the rows that would bind a port, + * touch the network, or write outside the test. Everything that decides an + * agent's capabilities is the real thing, including both shipped presets. + */ +async function bootWeb(): Promise { + const patches: PatchOptions[] = [ + ...loadOverlayPatches('dsh-test', WEB_OVERLAY), + // Host rows with side effects outside this process. + { id: 'webserver', disabled: true }, + { id: 'telemetry-otel', disabled: true }, + { id: 'modules', disabled: true }, + { id: 'connection', disabled: true }, + { id: 'api-gateway', disabled: true }, + { id: 'directory-picker', disabled: true }, + // The roster AppCLIEntry would patch in; only the shipped root, so a + // developer's own `~/.dsh/.preset` cannot change this test's outcome. + { + id: 'agent-presets', + config: { default: 'standard', roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }] }, + }, + ] + return await boot('dsh-test', BASE_CONFIG, patches) +} + +const toolNames = (ctx: Context, agent?: Agent): string[] => + ctx.tools.schemas(agent).map(schema => schema.name).sort() + +let ctx: Context +beforeAll(async () => { + ctx = await bootWeb() +}, 120_000) + +describe('the shipped Web composition', () => { + it('leaves only the host UI tool in the global layer', () => { + // `ask_user_question` is the host's own interaction surface, not an agent + // capability, so it stays global. Every other tool now belongs to a + // preset; a regression here means an agent-plane row came back to base. + expect(toolNames(ctx)).toEqual(['ask_user_question']) + }) + + it('supplies both shipped presets, and only those, from the system root', async () => { + const listed = await ctx.agentPresets.list() + + expect(listed.map(preset => preset.id).sort()).toEqual(['core-web', 'standard']) + expect(listed.every(preset => preset.trust === 'system')).toBe(true) + expect(ctx.agentPresets.defaultId).toBe('standard') + }) + + it('composes the full agent from `standard`', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-standard'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + try { + // The EXACT catalog, not a spot-check: an omission is this design's + // quietest failure mode, because a row that registers into the wrong + // layer mounts cleanly and simply contributes nothing. `glob`/`grep` are + // excluded for the reason the TUI composition e2e excludes them — they + // depend on ripgrep being present on the machine. + expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([ + 'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode', + 'get_goal', 'list_agents', 'ralph', 'read', 'send_message', 'skill', + 'str_replace_editor', 'subagent', 'subagent_fork', 'task_kill', + 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_search', + 'workflow', 'write', + ]) + } finally { + await handle.dispose() + } + }) + + it('composes exactly two tools from `core-web`', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-core-web'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'core-web').then(() => undefined), + }) + try { + expect(toolNames(ctx, handle.agent)).toEqual(['ask_user_question', 'bash', 'str_replace_editor']) + } finally { + await handle.dispose() + } + }) + + it('keeps two differently composed sessions independent', async () => { + const full = await ctx.agents.create({ + sessionId: SessionId('preset-both-full'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + const minimal = await ctx.agents.create({ + sessionId: SessionId('preset-both-minimal'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'core-web').then(() => undefined), + }) + try { + expect(toolNames(ctx, minimal.agent)).toEqual(['ask_user_question', 'bash', 'str_replace_editor']) + expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10) + + await minimal.dispose() + + // Tearing the minimal session down leaves the full one whole. + expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10) + expect(toolNames(ctx)).toEqual(['ask_user_question']) + } finally { + await full.dispose() + } + }) + + it('never rewrites the preset file it composed from', async () => { + // The Loader persists a tree whose plugin self-disposed, and tearing an + // agent down disposes its whole subtree. Inherited, that rewrote the + // shipped composition — truncating it to `[]` the first time a session + // ended — so `PresetTree` refuses to write at all. + const path = join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml') + const before = await readFile(path, 'utf8') + + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-readonly'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + await handle.dispose() + await new Promise(resolve => setTimeout(resolve, 50)) + + expect(await readFile(path, 'utf8')).toBe(before) + }) + + it('gives each session its own persona', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-persona'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'core-web').then(() => undefined), + }) + try { + const assembly = await ctx.systemPrompt.assemble({ scope: handle.agent }) + expect(assembly.sections.find(section => section.name === 'deployment:persona')?.text) + .toContain('You are a coding agent powered by') + } finally { + await handle.dispose() + } + }) +}) + +describe('a session keeps the preset it was created with', () => { + it('refuses to adopt a live session under a different preset', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-locked'), + meta: { agentPreset: 'core-web' }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'core-web').then(() => undefined), + }) + try { + // The api-proxy guard reads exactly this: the header records what the + // session runs, so naming anything else is a caller error rather than a + // switch. Its history was produced under `core-web`'s two tools. + expect(handle.agent.session.header.agentPreset).toBe('core-web') + } finally { + await handle.dispose() + } + }) +}) diff --git a/apps/web/package.json b/apps/web/package.json index 10c2dc4702..c58e5b9682 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,7 @@ "react-dom": "^18.2.0" }, "devDependencies": { + "@cordisjs/plugin-group": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index dc68cbf67d..acc2116531 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -32,6 +32,7 @@ import { expect } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include, { type PatchOptions } from '@cordisjs/plugin-include' +import Group from '@cordisjs/plugin-group' import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot' import { assertEntriesLoaded, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' import { dshHomePath } from '@deepseek-ai/dsh-paths' @@ -76,6 +77,8 @@ const BASE_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml') const WEB_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml') /** The installation anchor whose dependency surface the profile module fallback mirrors. */ const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json') +/** The deployment's own agent-preset root, shipped beside the app's config. */ +const SHIPPED_PRESET_DIR = join(REPO_ROOT, 'apps/cli/config/agent-presets') // Replay publishes the provider catalog the gateway routes to (providers // mode, never catch-all: with llm-deepseek disabled no adapter exists, so a @@ -256,6 +259,18 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise = z.discriminatedUnion('code', z.object({ code: z.literal('directory-exists'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('directory-create-failed'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }), + z.object({ code: z.literal('agent-preset-conflict'), message: z.string(), details: z.object({ sessionId: z.string(), requestedPreset: z.string(), existingPreset: z.string().optional() }) }), z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }), z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 1a35048b32..9bfb30bd11 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -44,6 +44,7 @@ export interface RpcErrorDetailsMap { 'directory-exists': { path: string } 'directory-create-failed': { path: string } 'directory-picker-unavailable': { capability: string } + 'agent-preset-conflict': { sessionId: SessionId; requestedPreset: string; existingPreset?: string } 'agent-preset-not-found': { agentPreset: string; available: string[] } 'agent-preset-invalid': { agentPreset: string; reason: string } 'agent-busy': { reason: string } diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts new file mode 100644 index 0000000000..293ab3015e --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -0,0 +1,145 @@ +/** + * A session's agent preset is fixed at creation. The gateway records the + * resolved id on the header and refuses to adopt the identity under a different + * one, because the session's history was produced under that preset's tools: + * rebuilding it differently would replay tool calls the new agent cannot make. + */ + +import { mkdtempSync, realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import AgentRegistry, { type AgentFactory } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import { RpcId, type RpcRequest } from '../src/api/rpc.ts' +import { UnknownPresetError } from '@deepseek-ai/dsh-agent-presets' +import { createApiProxy } from '../src/api-proxy.ts' +import { describe, expect, it } from 'vitest' + +let nextRpc = 0 +function request

(payload: P): RpcRequest

{ + return { rpcId: RpcId(`preset-${String(nextRpc++)}`), payload } +} + +/** Minimal live agent; the gateway only needs identity and its session. */ +function stubAgent(session: Session): Agent { + return { id: session.id, session, status: 'idle' } as unknown as Agent +} + +/** + * A roster whose `mount` is a no-op: this spec is about the gateway's identity + * rules, and the composition itself is covered by the real-composition test in + * `apps/cli`. + */ +function roster(ids: readonly string[]): unknown { + return { + defaultId: ids[0], + list: () => Promise.resolve(ids.map(id => ({ id, trust: 'system', path: `/presets/${id}.yml` }))), + resolve: (id?: string) => { + const wanted = id ?? ids[0] ?? '' + if (!ids.includes(wanted)) return Promise.reject(new UnknownPresetError(wanted, ids)) + return Promise.resolve({ id: wanted, trust: 'system', path: `/presets/${wanted}.yml` }) + }, + mount: (_ctx: Context, id?: string) => + Promise.resolve({ id: id ?? ids[0], trust: 'system', path: '/presets/x.yml' }), + } +} + +async function harness(presets?: readonly string[]) { + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-preset-'))) + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never) + if (presets !== undefined) ctx.provide('agentPresets', roster(presets) as never) + + const factory: AgentFactory = { + async createAgent(_ownerCtx, options) { + const session = ctx.sessions.create( + options.sessionId, + options.meta === undefined ? {} : { meta: options.meta }, + ) + const agent = stubAgent(session) + // Setup runs before publication against a context that carries the + // agent, and the agent reaches back through `agent.ctx` — the pair the + // gateway's own `installTarget` relies on. + const agentCtx = ctx.extend({ agent }) + ;(agent as { ctx?: Context }).ctx = agentCtx + await options.setup?.(agentCtx) + const unregister = ctx.agents.register(agent) + return { agent, dispose: () => { unregister(); return Promise.resolve() } } + }, + async resume() { + throw new Error('test harness has no persisted sessions') + }, + } + ctx.agents.setFactory(factory) + const api = createApiProxy(ctx, { provider: 'test', model: 'test-model', cwd, workspaceRoot: cwd }) + return { api, ctx, cwd } +} + +describe('session.create with an agent preset', () => { + it('records the resolved preset on the session header', async () => { + const { api, ctx } = await harness(['standard', 'core-web']) + + const created = await api.sessions.create(request({ sessionId: SessionId('s1'), agentPreset: 'core-web' })) + + expect(created.result.ok).toBe(true) + expect(ctx.sessions.get(SessionId('s1'))?.header.agentPreset).toBe('core-web') + }) + + it('records the default when the caller names none', async () => { + const { api, ctx } = await harness(['standard', 'core-web']) + + await api.sessions.create(request({ sessionId: SessionId('s2') })) + + expect(ctx.sessions.get(SessionId('s2'))?.header.agentPreset).toBe('standard') + }) + + it('rejects an unknown preset and names the ones that exist', async () => { + const { api } = await harness(['standard']) + + const response = await api.sessions.create(request({ sessionId: SessionId('s3'), agentPreset: 'nope' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-not-found') + }) + + it('refuses to adopt a live session under a different preset', async () => { + const { api } = await harness(['standard', 'core-web']) + await api.sessions.create(request({ sessionId: SessionId('s4'), agentPreset: 'core-web' })) + + const response = await api.sessions.create(request({ sessionId: SessionId('s4'), agentPreset: 'standard' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-conflict') + expect(response.result.error.details).toEqual({ + sessionId: 's4', + requestedPreset: 'standard', + existingPreset: 'core-web', + }) + }) + + it('adopts a live session unchanged when the caller names no preset', async () => { + const { api } = await harness(['standard', 'core-web']) + await api.sessions.create(request({ sessionId: SessionId('s5'), agentPreset: 'core-web' })) + + // Reconnecting and retrying a create must stay ordinary operations. + const response = await api.sessions.create(request({ sessionId: SessionId('s5') })) + + expect(response.result.ok).toBe(true) + }) + + it('leaves the header preset-less when no roster is composed', async () => { + const { api, ctx } = await harness() + + await api.sessions.create(request({ sessionId: SessionId('s6') })) + + expect(ctx.sessions.get(SessionId('s6'))?.header.agentPreset).toBeUndefined() + }) +}) diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index 2563b623f3..88d3936013 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -41,12 +41,29 @@ interface MountedTree { */ const mounted = new WeakMap() -/** Include subclass whose only addition is publishing its tree and fiber for the audit. */ +/** + * Include subclass that publishes its tree and fiber for the audit, and never + * writes to the file it read. + */ class PresetTree extends Include { constructor(ctx: Context, config: Include.Config) { super(ctx, config) mounted.set(config, { tree: this, fiber: ctx.fiber }) } + + /** + * A preset is an input, never a persistence target. + * + * The Loader writes a tree back through this method whenever it decides the + * config changed — a plugin self-disposing is enough, and tearing an agent + * down disposes its whole subtree. Inherited, that rewrites the preset file + * with whatever the dying tree held, which in practice means truncating a + * shipped composition to `[]` the first time a session ends. Persisting a + * preset is also meaningless: nothing here is user state, and the same file + * backs every session that names it. + */ + override write(): void { + } } /** One preset composition currently installed under some agent. */ diff --git a/packages/skill/tool-skill/README.i18n.yaml b/packages/skill/tool-skill/README.i18n.yaml index b57689d742..a4bdf1ad3c 100644 --- a/packages/skill/tool-skill/README.i18n.yaml +++ b/packages/skill/tool-skill/README.i18n.yaml @@ -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/skill/tool-skill/README.md -README.md: 8e0bff5d1c4853092d412b8f7f9528d4b00d9626 -README.zh.md: c6b815bef59eb1f14be0892078694f129366d004 +README.md: be5b5b98865328ccd5f8a4666bff04acc8fd5927 +README.zh.md: dd84a7a24835c7f55b2b9b694197abd1fa30f77a diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index 8e0bff5d1c..be5b5b9886 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -12,7 +12,7 @@ At every eligible `agent/pre-step`, the plugin calls `ctx.skills.snapshot()` for Every catalog message carries the `skill-catalog` source: a `catalog`-form context whose `entries` record exactly the `name` and `description` pairs it published, plus `update` on a replacement. The digest covers those durable entries, not the rendered prose, so the surrounding `` framing cannot decide whether a republish is needed and consumers never re-parse the `` block. The plugin scans durable session events backwards without copying them and derives the comparison baseline from the newest visible `skill-catalog` message it can read; unreadable and foreign records are skipped. When the digest changes, the downstream `enter` decision receives a durable user-role message containing the complete replacement catalog; an empty replacement explicitly retires earlier names. If no catalog remains visible but a recognizable historical catalog exists, compaction hid it and the next complete observation re-establishes the current catalog. An incomplete provider snapshot emits nothing and preserves the last-good model view for retry at the next pre-step. If no prior catalog exists and the current view is empty, no tombstone is necessary. -The catalog is omitted when no model-invocable skills are initially available, and also when that agent's tool view restricts away the shipped `skill` tool or resolves a same-name scoped shadow instead. Visibility changes participate in the digest, keeping prompt guidance, model-visible schema, and executable dispatch aligned. +The catalog is omitted when no model-invocable skills are initially available, and also when that agent's tool view restricts away the shipped `skill` tool or resolves a same-name scoped shadow instead. Identity is compared against the definition this plugin registered rather than a lookup of its own name, so the plugin works mounted globally or inside one agent's composition, where `register()` files into that agent's layer alone. Visibility changes participate in the digest, keeping prompt guidance, model-visible schema, and executable dispatch aligned. `catalogDescriptionMaxLength` controls normalized catalog descriptions; rendering XML-escapes them. Its default is `500` and values must be integers of at least `3`, which reserves room for a truncation ellipsis. The [skill catalog hot-refresh Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) owns the durable initial catalog and replacement lifecycle. diff --git a/packages/skill/tool-skill/README.zh.md b/packages/skill/tool-skill/README.zh.md index c6b815bef5..dd84a7a248 100644 --- a/packages/skill/tool-skill/README.zh.md +++ b/packages/skill/tool-skill/README.zh.md @@ -12,7 +12,7 @@ 每条目录消息都携带 `skill-catalog` 来源,也就是 `catalog` 形态的上下文。它的 `entries` 精确记录本次发布的 `name` 与 `description` 对,替换目录另带 `update`。digest 覆盖这些持久条目,而不是渲染后的正文,因此 `` 包装不会影响是否需要重新发布,消费方也不需要重新解析 `` 块。插件从后向前扫描持久会话事件且不复制,并以最新一条仍可见且可读的 `skill-catalog` 消息作为比较基线;不可读和外来的记录都会跳过。digest 变化时,下游 `enter` 决策会收到一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,在下一次 pre-step 重试。若不存在先前目录且当前视图为空,则不需要 tombstone。 -如果最初没有模型可调用 skill,则省略目录;如果该 agent(智能体)的工具视图排除了随附的 `skill` 工具,或解析出同名的作用域内遮蔽项,也会省略目录。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。 +如果最初没有模型可调用 skill,则省略目录;如果该 agent(智能体)的工具视图排除了随附的 `skill` 工具,或解析出同名的作用域内遮蔽项,也会省略目录。身份比对针对本插件所注册的那个定义,而非按自身名字回查,因此本插件既可全局挂载,也可挂在单个 agent 的组装内——在后者中 `register()` 只归档进该 agent 的分层。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。 `catalogDescriptionMaxLength` 控制规范化后的目录描述,渲染时会对其执行 XML 转义。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。[skill 目录热刷新 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) 负责定义持久初始目录和替换目录的生命周期。 diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index ddc45d18e9..5761e3bed1 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -154,14 +154,14 @@ export function apply(ctx: Context, config: Config = {}): void { }, }) ctx.tools.register(skillTool) - const registeredSkillTool = ctx.tools.get(skillTool.name) - /* v8 ignore next 3 -- register() publishes synchronously or throws; this guards future registry drift. */ - if (registeredSkillTool === undefined) { - throw new Error('dsh-tool-skill: registered skill tool is not visible in the global registry') - } // Register after the tool so reverse teardown removes guidance first. Exact definition // identity prevents a scoped shadow merely named `skill` from inheriting this catalog. + // + // The comparison is against the definition this plugin registered, not against + // a lookup of its own name: `register()` files into the CALLING context's + // scope, so a plugin mounted inside an agent preset registers for that agent + // alone and an unscoped lookup correctly finds nothing. ctx.on('agent/pre-step', async ( { agent, signal }, next, @@ -169,7 +169,7 @@ export function apply(ctx: Context, config: Config = {}): void { const decision = await next() if (decision.kind === 'reject') return decision signal.throwIfAborted() - const toolVisible = ctx.tools.get(skillTool.name, agent) === registeredSkillTool + const toolVisible = ctx.tools.get(skillTool.name, agent) === skillTool const snapshot = toolVisible ? await ctx.skills.snapshot({ cwd: agent.session.header.cwd, signal }) : { skills: [], complete: true } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 914238e3d5..42628a86b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -229,6 +229,9 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) devDependencies: + '@cordisjs/plugin-group': + specifier: workspace:^ + version: link:../../vendor/group '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../packages/client/modules