Merge branch 'stack/agent-profiles-3-wire' into stack/agent-profiles-5-web-ui
# Conflicts: # apps/cli/tests/web-agent-presets.e2e.ts # packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts
This commit is contained in:
@@ -233,8 +233,14 @@
|
||||
- id: tool-str-replace-editor
|
||||
disabled: true
|
||||
|
||||
- id: skill
|
||||
disabled: true
|
||||
# The `skill` REGISTRY stays in the host plane. It is host+per-scope layered
|
||||
# (the tools-registry shape): deployment-level providers — repository plugins,
|
||||
# a host skill-local row — register into its global layer, while a preset's
|
||||
# `skill-local` registers into that preset's layer, and each agent reads the
|
||||
# merged catalog its scope chain selects. Only the per-agent rows move behind
|
||||
# presets: the base host `skill-local` row is disabled here (presets own local
|
||||
# discovery), and `tool-skill` is what a preset mounts to give its agent the
|
||||
# catalog and loader at all.
|
||||
|
||||
- id: skill-local
|
||||
disabled: true
|
||||
|
||||
@@ -2680,9 +2680,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
},
|
||||
|
||||
skills: {
|
||||
// Skill lookup never touches the Agent registry: the session address
|
||||
// resolves to a canonical cwd from the host-resident session header, so
|
||||
// listing skills cannot create or resume an agent as a side effect.
|
||||
// Skill lookup never creates or resumes an agent: the session address
|
||||
// resolves to a canonical cwd from the host-resident session header, and
|
||||
// the view scope is the live agent or the preset's standing key.
|
||||
async list(request) {
|
||||
const { sessionId } = request.payload
|
||||
const session = ctx.sessions.get(sessionId)
|
||||
@@ -2699,12 +2699,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} })
|
||||
}
|
||||
const cwd = session.header.cwd
|
||||
// The registry is per session when a preset mounts one — a preset
|
||||
// ships its own skill directory, so the catalog IS the session's — and
|
||||
// that instance sits behind an `isolate` realm no host context
|
||||
// resolves. Address it through the live agent; `agents.get` keeps the
|
||||
// no-side-effect stance above (a cold session creates nothing and
|
||||
// falls through to whatever the host composes).
|
||||
// The host registry is layered per scope and serves every session. A
|
||||
// composition may still realm-mount its own registry instead; that
|
||||
// instance is invisible to host contexts, so address it through the
|
||||
// live agent (`agents.get` keeps the no-side-effect stance above).
|
||||
const live = ctx.agents.get(sessionId)
|
||||
const presets = ctx.get('agentPresets')
|
||||
const scoped = live === undefined ? undefined : presets?.serviceFor(live, 'skills')
|
||||
@@ -2716,8 +2714,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
if (skillRegistry === undefined) {
|
||||
return err(request, { code: 'internal', message: 'skill registry is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-skill', details: {} })
|
||||
}
|
||||
// The scope presenters resolve in — the live agent, else the recorded
|
||||
// preset's standing key, else the global layer — so a cold session's
|
||||
// '/' popup lists the catalog its composition actually serves.
|
||||
const scope = await presenterScopeFor(sessionId, session.header)
|
||||
try {
|
||||
const skills = (await skillRegistry.list({ cwd })).filter(isUserInvocable)
|
||||
const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable)
|
||||
return ok(request, {
|
||||
skills: skills.map(skill => ({
|
||||
name: skill.name,
|
||||
|
||||
@@ -384,6 +384,59 @@ describe('agentPreset.select', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('skills over the layered host registry', () => {
|
||||
it('passes the live agent as the view scope to the host registry', async () => {
|
||||
const { api, ctx } = await harness(['standard'])
|
||||
const seen: unknown[] = []
|
||||
ctx.provide('skills', {
|
||||
list: (options: { scope?: unknown }) => {
|
||||
seen.push(options.scope)
|
||||
return Promise.resolve([])
|
||||
},
|
||||
} as never)
|
||||
await api.sessions.create(request({ sessionId: SessionId('h1'), agentPreset: 'standard' }))
|
||||
|
||||
const response = await api.skills.list(request({ sessionId: SessionId('h1') }))
|
||||
|
||||
expect(response.result).toMatchObject({ ok: true, value: { skills: [] } })
|
||||
expect(seen).toEqual([ctx.agents.get(SessionId('h1'))])
|
||||
})
|
||||
|
||||
it('resolves a cold session to its recorded preset standing key', async () => {
|
||||
const { api, ctx } = await harness(['standard', 'core-web'])
|
||||
const seen: unknown[] = []
|
||||
ctx.provide('skills', {
|
||||
list: (options: { scope?: unknown }) => {
|
||||
seen.push(options.scope)
|
||||
return Promise.resolve([])
|
||||
},
|
||||
} as never)
|
||||
ctx.sessions.create(SessionId('h2'), { meta: { cwd: '/workspace/cold', agentPreset: 'core-web' } })
|
||||
|
||||
const response = await api.skills.list(request({ sessionId: SessionId('h2') }))
|
||||
|
||||
expect(response.result).toMatchObject({ ok: true, value: { skills: [] } })
|
||||
expect(seen).toEqual([standingKeys.get('core-web')])
|
||||
})
|
||||
|
||||
it('serves the global view when the roster no longer supplies the recorded preset', async () => {
|
||||
const { api, ctx } = await harness(['standard'])
|
||||
const seen: unknown[] = []
|
||||
ctx.provide('skills', {
|
||||
list: (options: { scope?: unknown }) => {
|
||||
seen.push(options.scope)
|
||||
return Promise.resolve([])
|
||||
},
|
||||
} as never)
|
||||
ctx.sessions.create(SessionId('h3'), { meta: { cwd: '/workspace/cold', agentPreset: 'gone' } })
|
||||
|
||||
const response = await api.skills.list(request({ sessionId: SessionId('h3') }))
|
||||
|
||||
expect(response.result).toMatchObject({ ok: true, value: { skills: [] } })
|
||||
expect(seen).toEqual([undefined])
|
||||
})
|
||||
})
|
||||
|
||||
describe('session.history presenter scope', () => {
|
||||
it('asks the roster for the RECORDED preset\'s standing key on a cold read', async () => {
|
||||
const { api } = await harness(['standard', 'core-web'])
|
||||
|
||||
@@ -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/self-modification/repository-plugin/README.md
|
||||
README.md: 794d176dc69bf45ca61472e081bf2bf623851761
|
||||
README.zh.md: 6d8428e6baa75fe37fefa2d2c55ae73ca450dc1a
|
||||
README.md: 666f00e02b9ab33bff348df6b4ff90e3f3bfecc7
|
||||
README.zh.md: b09f68bc17a4eb08df6ecbb3782e14bf26fb7d7f
|
||||
|
||||
@@ -122,7 +122,6 @@ Stable registrations preserve the owning surface's normal prefix behavior. Loadi
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Skill contributions do not reach preset-composed sessions** — `dsh.skills` mounts a host-plane provider into the host `skills` registry, but a composition whose agent plane lives behind agent presets moves that registry into each preset's private realm: the wrapper then has no host registry to wait on, and a host-registered catalog would not reach any session's model-facing skill surface either. Until the skills registry grows the host+per-scope layering the tools registry has (or repository skills are delivered as directories a preset's provider scans), a preset-composed deployment should not declare `dsh.skills`; MCP and entry contributions are unaffected because the tools registry is host-plane and layered.
|
||||
- **No code sandbox** — `dsh.entry`, npm dependencies, and package lifecycle scripts execute with the DSH host's authority; repository trust is mandatory.
|
||||
- **Entry-only service dependencies are not pre-gated** — the generated wrapper cannot declare an entry module's `inject` before importing it. Any service beyond those implied by Skills or MCP must already exist when the wrapper mounts the entry, or that repository generation rejects.
|
||||
- **No MCP authentication protocol** — static headers may use environment expansion, but OAuth-bearing definitions reject and private-server login flows are not implemented here.
|
||||
|
||||
@@ -122,7 +122,6 @@ Namespace 插件:具名导出 `name`/`inject`/`apply`、准备阶段常量
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **skill 贡献到达不了由 preset 组装的会话**:`dsh.skills` 会把一个宿主面 provider 挂进宿主 `skills` 注册表,而 agent 面移入 agent preset 的组合把该注册表搬进了每个 preset 的私有 realm:此时包装层没有可等待的宿主注册表,即便注册进宿主目录也到不了任何会话面向模型的 skill 面。在 skills 注册表获得 tools 注册表那样的宿主+按作用域分层(或 repository skill 改为以目录交付、由 preset 的 provider 扫描)之前,preset 组装的部署不应声明 `dsh.skills`;MCP 与入口贡献不受影响,因为 tools 注册表在宿主面且分层。
|
||||
- **没有代码沙箱**:`dsh.entry`、NPM 依赖和包生命周期脚本以 DSH 宿主权限执行;必须信任该 repository。
|
||||
- **入口专用服务依赖不会预先门控**:生成的包装层无法在导入入口模块前声明其 `inject`。除 skill 或 MCP 隐含的服务外,其他任何服务在包装层挂载入口时都必须已经存在,否则该 repository generation 会被拒绝。
|
||||
- **没有 MCP 认证协议**:静态 header 可以使用环境变量展开,但带 OAuth 的定义会被拒绝,私有 server 登录流程不在此实现。
|
||||
|
||||
@@ -916,27 +916,27 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
key: 'skills',
|
||||
summary: 'Registry of skill providers.',
|
||||
summary: 'Layered registry of skill providers, the host+per-scope shape the tools registry established.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void',
|
||||
jsDoc: '/**\n * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and\n * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters\n * the provider and invalidates catalog caches.\n * @param create - synchronous factory receiving this registration\'s lifecycle and invalidation control.\n * @returns the exact Cordis effect disposer that unregisters this provider;\n * composite effects may yield it directly to preserve teardown ordering.\n */',
|
||||
jsDoc: '/**\n * Register a borrowed same-process provider synchronously during plugin\n * apply, into the calling context\'s layer: a scoped context (an agent\n * preset\'s standing mount) registers for that scope alone, an unscoped\n * context registers globally. Duplicate names within one layer and reserved\n * names throw; remote initialization belongs in `list()`. Fiber disposal\n * unregisters the provider and invalidates catalog caches.\n * @param create - synchronous factory receiving this registration\'s lifecycle and invalidation control.\n * @returns the exact Cordis effect disposer that unregisters this provider;\n * composite effects may yield it directly to preserve teardown ordering.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'register(skill: SkillRegistration): () => void',
|
||||
jsDoc: '/**\n * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which\n * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and\n * receives a no-op disposer so it cannot remove the winner.\n * @param skill - the skill definition input; omitted invocation and provider fields receive defaults.\n * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.\n */',
|
||||
jsDoc: '/**\n * Register a borrowed readonly runtime skill into the calling context\'s\n * layer. Project entries outrank runtime entries, which outrank user\n * entries, within one layer. Same-name runtime entries in one layer are\n * first-wins; a duplicate logs a warning and receives a no-op disposer so\n * it cannot remove the winner.\n * @param skill - the skill definition input; omitted invocation and provider fields receive defaults.\n * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]>',
|
||||
jsDoc: '/**\n * List invocation-neutral skill summaries for a workspace. Consumers apply\n * model or user invocation policy at their operational boundary. Lookup\n * options and provider candidates are readonly same-process values borrowed\n * throughout discovery.\n * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.\n * @returns all sorted winning summaries.\n */',
|
||||
signature: 'async list(options: SkillViewOptions = {}): Promise<SkillSummary[]>',
|
||||
jsDoc: '/**\n * List invocation-neutral skill summaries for a workspace. Consumers apply\n * model or user invocation policy at their operational boundary. Lookup\n * options and provider candidates are readonly same-process values borrowed\n * throughout discovery.\n * @param options - view options; `scope` selects the viewing agent\'s layers, `cwd` selects project roots, and `signal` cancels discovery.\n * @returns all sorted winning summaries.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async snapshot(options: SkillLookupOptions = {}): Promise<SkillCatalogSnapshot>',
|
||||
jsDoc: '/**\n * Observe the current invocation-neutral catalog and whether discovery completed within a stable revision.\n * Incomplete observations are never cached, allowing consumers to retain last-good state and\n * retry on their next request boundary.\n * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.\n * @returns sorted summaries plus discovery-completeness state.\n */',
|
||||
signature: 'async snapshot(options: SkillViewOptions = {}): Promise<SkillCatalogSnapshot>',
|
||||
jsDoc: '/**\n * Observe the current invocation-neutral catalog and whether discovery completed within a stable revision.\n * Incomplete observations are never cached, allowing consumers to retain last-good state and\n * retry on their next request boundary.\n * @param options - view options; `scope` selects the viewing agent\'s layers, `cwd` selects project roots, and `signal` cancels discovery.\n * @returns sorted summaries plus discovery-completeness state.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined>',
|
||||
jsDoc: '/**\n * Load and validate the winning candidate, passing its opaque discovery locator back to the\n * provider. Cancellation is rechecked after selection, including cache hits, and raced against\n * loading so an uncooperative provider cannot hang the caller.\n * @param name - kebab-case skill name.\n * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.\n * @returns the full skill, including body content, or `undefined`.\n */',
|
||||
signature: 'async get(name: string, options: SkillViewOptions = {}): Promise<SkillDefinition | undefined>',
|
||||
jsDoc: '/**\n * Load and validate the winning candidate, passing its opaque discovery locator back to the\n * provider. Cancellation is rechecked after selection, including cache hits, and raced against\n * loading so an uncooperative provider cannot hang the caller.\n * @param name - kebab-case skill name.\n * @param options - view options; `scope` selects the viewing agent\'s layers,\n * `cwd` selects workspace-sensitive skills, and `signal` cancels work.\n * @returns the full skill, including body content, or `undefined`.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2849,6 +2849,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'SkillSummary',
|
||||
declaration: 'export interface SkillSummary {\n readonly name: string;\n readonly description: string;\n readonly whenToUse?: string;\n readonly invocation: SkillInvocationPolicy;\n readonly source: SkillSource;\n readonly provider: string;\n readonly resourceBase?: SkillResourceBase;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SkillViewOptions',
|
||||
declaration: 'export interface SkillViewOptions extends SkillLookupOptions {\n readonly scope?: ScopeKey | undefined;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SpillLocator',
|
||||
declaration: 'export type SpillLocator = Branded<\'SpillLocator\'>;',
|
||||
|
||||
@@ -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/skill/README.md
|
||||
README.md: 3dc2bcfa5775736717bdebcb92329d5655198234
|
||||
README.zh.md: e57e389f9080cfc763916111cf80ea97980f01e7
|
||||
README.md: 9c27a271f03f33d2b53984a6a5c18082ccc6169a
|
||||
README.zh.md: 085dec3e342c2f42a39d28b995dcb4e2cf38f440
|
||||
|
||||
@@ -6,15 +6,17 @@ Pure agent skill provider registry.
|
||||
|
||||
This package owns the `ctx.skills` interface. It does not know whether skills come from local files, embedded plugin data, HTTP, or another backend; providers register those sources with `ctx.skills.registerProvider(...)`. The shipped local implementation is [`@deepseek-ai/dsh-skill-local`](../skill-local).
|
||||
|
||||
The registry is host+per-scope layered over [`@deepseek-ai/dsh-scope`](../../core/scope), the shape the tools registry established: a registration files into the layer of its calling context's scope — host rows and repository plugins land in the global layer, a plugin mounted by an agent preset's standing composition lands in that preset's layer — and a read merges the global layer with the viewing scope's chain, the nearest layer winning a duplicate name outright while rank decides duplicates only within one layer.
|
||||
|
||||
## Service: `SkillService` (ctx key: `skills`)
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.skills.registerProvider(create): () => void` Calls a synchronous provider factory with `{ signal, invalidate }`, then registers its readonly result by unique `provider.name`. Duplicate names throw, `runtime` is reserved, and failed registration aborts the signal. The exact Cordis disposer unregisters the provider, aborts the signal, and preserves ordered composite teardown.
|
||||
- `ctx.skills.snapshot({ cwd?, signal? })` Returns the invocation-neutral `{ skills, complete }` observation. `complete` is false when any provider rejects or explicitly reports incomplete discovery, or when a second catalog revision races the bounded retry; candidates supplied by that observation remain in this result, which is never cached.
|
||||
- `ctx.skills.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns every winning summary for the current workspace, merged across providers and sorted by name. Consumers apply `isModelInvocable(skill)` or `isUserInvocable(skill)` at their own boundary.
|
||||
- `ctx.skills.get(name, { cwd?, signal? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it regardless of invocation policy.
|
||||
- `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill, adding the all-invocable policy and `provider: "runtime"` when omitted. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown.
|
||||
- `ctx.skills.registerProvider(create): () => void` Calls a synchronous provider factory with `{ signal, invalidate }`, then registers its readonly result by `provider.name`, unique within the calling context's layer. Duplicate names in one layer throw, `runtime` is reserved, and failed registration aborts the signal. The exact Cordis disposer unregisters the provider, aborts the signal, and preserves ordered composite teardown.
|
||||
- `ctx.skills.snapshot({ cwd?, signal?, scope? })` Returns the invocation-neutral `{ skills, complete }` observation for the viewing scope's merged layers. `complete` is false when any provider rejects or explicitly reports incomplete discovery, or when a second catalog revision races the bounded retry; candidates supplied by that observation remain in this result, which is never cached.
|
||||
- `ctx.skills.list({ cwd?, signal?, scope? })` Borrows the readonly view options, then returns every winning summary for the current workspace, merged across the global layer and the viewing scope's chain and sorted by name. Consumers apply `isModelInvocable(skill)` or `isUserInvocable(skill)` at their own boundary.
|
||||
- `ctx.skills.get(name, { cwd?, signal?, scope? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it regardless of invocation policy.
|
||||
- `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill into the calling context's layer, adding the all-invocable policy and `provider: "runtime"` when omitted. Same-name runtime registrations in one layer are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown.
|
||||
|
||||
### Events
|
||||
|
||||
@@ -49,7 +51,7 @@ A provider factory runs synchronously and receives one registration-scoped contr
|
||||
|
||||
The registry validates candidates before caching and definitions before returning them. The winning provider receives the same candidate and opaque `locator` it returned from `list()`, allowing backend-specific file, URL, id, or version handles. Callers and providers must preserve the readonly contract.
|
||||
|
||||
Contract violations fail fast. A rejected provider `list()` is treated as a transient source failure and omitted. An explicit incomplete observation still contributes its candidates for `list()` and `get()`, but makes the aggregate snapshot incomplete and uncacheable. A provider or runtime revision change discards an in-flight result and retries once. If the retry is also superseded, its candidates are returned incomplete and uncached so a continuously invalidating provider cannot monopolize the caller. Duplicate names resolve by rank, provider registration order, then provider-local order. Summaries are sorted by skill name.
|
||||
Contract violations fail fast. A rejected provider `list()` is treated as a transient source failure and omitted. An explicit incomplete observation still contributes its candidates for `list()` and `get()`, but makes the aggregate snapshot incomplete and uncacheable. A provider or runtime revision change discards an in-flight result and retries once. If the retry is also superseded, its candidates are returned incomplete and uncached so a continuously invalidating provider cannot monopolize the caller. Within one layer, duplicate names resolve by rank, provider registration order, then provider-local order; across layers the nearest scope's entry wins the name. Summaries are sorted by skill name.
|
||||
|
||||
Definitions remain progressively loaded. `get()` asks the winning provider for the body on every call rather than caching it in this registry. If the returned definition has a different name from the selected candidate, the stale selection is rejected and the registry internally invalidates that exact provider so the next snapshot rediscovers its catalog.
|
||||
|
||||
@@ -74,4 +76,4 @@ No direct prompt effect. The named consumer owns the durable initial catalog and
|
||||
- **Invalidation is provider-driven** — the registry has no TTL and cannot infer that an arbitrary remote source changed; each mutable provider must retain and call its registration-scoped `invalidate()` capability from its own observation mechanism.
|
||||
- **Providers are queried sequentially** — one slow cooperative provider delays every provider registered after it; cancellation stops the caller's wait but cannot terminate work an uncooperative provider keeps running.
|
||||
- **Incomplete observations are not retained** — rejected providers are omitted and explicitly supplied candidates remain available only to the current lookup; the registry owns neither a last-good catalog nor per-provider diagnostics.
|
||||
- **Duplicate resolution is first-wins** — later lower-priority candidates are logged and hidden; there is no API to inspect all shadowed definitions.
|
||||
- **Duplicate resolution is first-wins** — later lower-priority candidates within a layer are logged and hidden, and a nearer layer shadows a farther one silently; there is no API to inspect all shadowed definitions.
|
||||
|
||||
@@ -6,15 +6,17 @@
|
||||
|
||||
该包负责 `ctx.skills` 接口。它不知道 skill 来自本地文件、嵌入式插件数据、HTTP 还是其他后端;提供方通过 `ctx.skills.registerProvider(...)` 注册这些来源。已发布的本地实现是 [`@deepseek-ai/dsh-skill-local`](../skill-local)。
|
||||
|
||||
注册表基于 [`@deepseek-ai/dsh-scope`](../../core/scope) 采用宿主 + 按 scope 的分层结构,即工具注册表确立的形态:注册落入调用方上下文 scope 对应的层——宿主行与 repository 插件落入全局层,由 agent preset 常驻组合挂载的插件落入该 preset 的层——读取时将全局层与观察 scope 的链合并,最近层直接赢得重名,rank 只在单层内裁决重名。
|
||||
|
||||
## 服务:`SkillService`(ctx 键:`skills`)
|
||||
|
||||
### 公开 API
|
||||
|
||||
- `ctx.skills.registerProvider(create): () => void` 调用同步提供方工厂并向其传入 `{ signal, invalidate }`,随后使用唯一 `provider.name` 注册其只读结果。重复提供方名称会抛错,`runtime` 为保留名称;注册失败会中止信号。精确的 Cordis disposer 会注销提供方、中止信号,并保持有序组合拆卸。
|
||||
- `ctx.skills.snapshot({ cwd?, signal? })` 返回与调用策略无关的 `{ skills, complete }` 观测。任一提供方调用被拒绝或显式报告发现不完整,或有界重试期间又发生目录修订时,`complete` 为 false;该次观测提供的候选项仍保留在此结果中,但该结果绝不缓存。
|
||||
- `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中的全部胜出摘要;这些摘要跨提供方合并,并按名称排序。消费方在自身边界调用 `isModelInvocable(skill)` 或 `isUserInvocable(skill)`。
|
||||
- `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后无论调用策略如何都将其返回。
|
||||
- `ctx.skills.register(skill): () => void` 注册只读运行时嵌入式 skill,省略时添加允许模型和用户调用的策略以及 `provider: "runtime"`。同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer,以供有序组合拆卸。
|
||||
- `ctx.skills.registerProvider(create): () => void` 调用同步提供方工厂并向其传入 `{ signal, invalidate }`,随后以在调用方上下文所在层内唯一的 `provider.name` 注册其只读结果。同层重复提供方名称会抛错,`runtime` 为保留名称;注册失败会中止信号。精确的 Cordis disposer 会注销提供方、中止信号,并保持有序组合拆卸。
|
||||
- `ctx.skills.snapshot({ cwd?, signal?, scope? })` 返回观察 scope 各层合并后、与调用策略无关的 `{ skills, complete }` 观测。任一提供方调用被拒绝或显式报告发现不完整,或有界重试期间又发生目录修订时,`complete` 为 false;该次观测提供的候选项仍保留在此结果中,但该结果绝不缓存。
|
||||
- `ctx.skills.list({ cwd?, signal?, scope? })` 借用只读视图选项,然后返回当前工作区中的全部胜出摘要;这些摘要在全局层与观察 scope 链之间合并,并按名称排序。消费方在自身边界调用 `isModelInvocable(skill)` 或 `isUserInvocable(skill)`。
|
||||
- `ctx.skills.get(name, { cwd?, signal?, scope? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后无论调用策略如何都将其返回。
|
||||
- `ctx.skills.register(skill): () => void` 将只读运行时嵌入式 skill 注册进调用方上下文所在层,省略时添加允许模型和用户调用的策略以及 `provider: "runtime"`。同层同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer,以供有序组合拆卸。
|
||||
|
||||
### 事件
|
||||
|
||||
@@ -49,7 +51,7 @@
|
||||
|
||||
注册表在缓存前验证候选项,在返回前验证定义。胜出提供方会收到同一候选项和不透明 `locator`,两者都是它从 `list()` 返回的内容,从而支持后端专用文件、URL、id 或版本句柄。调用方和提供方必须保持只读约定。
|
||||
|
||||
违反约定时会快速失败。`list()` 返回的 Promise 被拒绝会被视为瞬时来源失败,并省略其结果。显式的不完整观测仍会为 `list()` 和 `get()` 提供其候选项,但会使聚合快照不完整且不可缓存。提供方或运行时修订发生变化时,会丢弃正在进行的结果并重试一次。如果这次重试也被后续修订取代,则返回其候选项,并将结果标为不完整且不予缓存,以免持续触发失效的提供方一直占用调用方。重复名称依次按 rank、提供方注册顺序和提供方本地顺序解决冲突。摘要按 skill 名称排序。
|
||||
违反约定时会快速失败。`list()` 返回的 Promise 被拒绝会被视为瞬时来源失败,并省略其结果。显式的不完整观测仍会为 `list()` 和 `get()` 提供其候选项,但会使聚合快照不完整且不可缓存。提供方或运行时修订发生变化时,会丢弃正在进行的结果并重试一次。如果这次重试也被后续修订取代,则返回其候选项,并将结果标为不完整且不予缓存,以免持续触发失效的提供方一直占用调用方。单层内重复名称依次按 rank、提供方注册顺序和提供方本地顺序解决冲突;跨层则由最近 scope 的条目赢得名称。摘要按 skill 名称排序。
|
||||
|
||||
定义仍采用渐进式加载。`get()` 每次调用都会向胜出提供方请求正文,而不是在此注册表中缓存正文。若返回定义的名称不同于所选候选项,系统会拒绝该陈旧选择,并由注册表在内部使该精确提供方失效,以便下一次快照重新发现其目录。
|
||||
|
||||
@@ -74,4 +76,4 @@
|
||||
- **失效由提供方驱动**:注册表没有 TTL,无法推断任意远程来源是否已发生变化;每个可变提供方都必须保留其注册作用域内的 `invalidate()` 能力,并由自身的观测机制调用它。
|
||||
- **提供方依次查询**:一个响应取消但速度缓慢的提供方会延迟之后注册的所有提供方;取消会停止调用方等待,但无法终止不响应取消的提供方持续运行的工作。
|
||||
- **不保留不完整观测**:被拒绝的提供方会被省略,显式提供的候选项也仅在当前查找中可用;注册表既不负责上一份可用目录,也不负责逐提供方诊断。
|
||||
- **重复解析使用先到先得**:系统会记录并隐藏较晚出现的低优先级候选项;不提供检查全部被遮蔽定义的 API。
|
||||
- **重复解析使用先到先得**:系统会记录并隐藏层内较晚出现的低优先级候选项,较近的层会静默遮蔽较远的层;不提供检查全部被遮蔽定义的 API。
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -35,6 +36,7 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import { NamedEntries, ScopedLayers, scopeChainOf, scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope'
|
||||
import z from 'schemastery'
|
||||
import type Schema from 'schemastery'
|
||||
|
||||
@@ -106,6 +108,17 @@ export interface SkillLookupOptions {
|
||||
readonly signal?: AbortSignal | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry read options: provider lookup context plus the viewing scope.
|
||||
* The registry consumes `scope` to select layers; providers receive the same
|
||||
* borrowed options object and read only their {@link SkillLookupOptions}
|
||||
* contract from it.
|
||||
*/
|
||||
export interface SkillViewOptions extends SkillLookupOptions {
|
||||
/** Viewing scope (the calling agent); omitted reads the global layer alone. */
|
||||
readonly scope?: ScopeKey | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Return whether a skill may be advertised to and loaded by a model.
|
||||
* @param skill - skill metadata carrying resolved invocation controls.
|
||||
@@ -290,17 +303,56 @@ interface IndexedCandidate {
|
||||
provider: SkillProvider
|
||||
providerOrder: number
|
||||
localOrder: number
|
||||
/** Owning layer, so a stale-definition invalidation can verify the exact registration is still live. */
|
||||
layer: SkillLayer
|
||||
}
|
||||
|
||||
interface CollectResult {
|
||||
/** One provider registration retained by its layer. */
|
||||
interface RegisteredProvider {
|
||||
provider: SkillProvider
|
||||
/** Service-wide monotonic registration order, the within-layer rank tiebreak. */
|
||||
order: number
|
||||
}
|
||||
|
||||
interface LayerCollectResult {
|
||||
entries: IndexedCandidate[]
|
||||
cacheable: boolean
|
||||
}
|
||||
|
||||
interface CollectResult {
|
||||
entries: Map<string, IndexedCandidate>
|
||||
cacheable: boolean
|
||||
}
|
||||
|
||||
/** One scope's complete skill-registry contribution. */
|
||||
class SkillLayer implements ScopeLayer {
|
||||
/** Providers registered through contexts carrying this scope, insertion-ordered. */
|
||||
readonly providers: NamedEntries<RegisteredProvider>
|
||||
/** Runtime skills registered through contexts carrying this scope. */
|
||||
readonly runtime = new Map<string, SkillDefinition>()
|
||||
|
||||
constructor(scope: ScopeKey | undefined) {
|
||||
this.providers = new NamedEntries(name => new Error(scope === undefined
|
||||
? `a skill provider named "${name}" is already registered`
|
||||
: `a skill provider named "${name}" is already registered in this scope`))
|
||||
}
|
||||
|
||||
/** Whether every contribution table in this aggregate layer is empty. */
|
||||
isEmpty(): boolean {
|
||||
return this.providers.isEmpty() && this.runtime.size === 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of skill providers. It merges provider catalogs with stable
|
||||
* first-wins duplicate handling, exposes sorted invocation-neutral summaries, and
|
||||
* loads full skill bodies on demand.
|
||||
* Layered registry of skill providers, the host+per-scope shape the tools
|
||||
* registry established. A registration files into the layer of its calling
|
||||
* context's scope ({@link scopeOf}): host rows and repository plugins land in
|
||||
* the global layer, while a plugin mounted by an agent preset's standing
|
||||
* composition lands in that preset's layer. A read merges the global layer
|
||||
* with the viewing scope's chain — the nearest layer's entry wins a duplicate
|
||||
* name outright, and the rank order decides duplicates only within one layer.
|
||||
* It exposes sorted invocation-neutral summaries and loads full skill bodies
|
||||
* on demand.
|
||||
*/
|
||||
export class SkillService extends Service {
|
||||
static Config: Schema<Config> = z.object({
|
||||
@@ -308,12 +360,16 @@ export class SkillService extends Service {
|
||||
})
|
||||
|
||||
private readonly collectCacheMaxEntries: number
|
||||
private readonly providers = new Map<string, { provider: SkillProvider; order: number }>()
|
||||
private readonly runtime = new Map<string, SkillDefinition>()
|
||||
private readonly collectCache = new Map<string, IndexedCandidate[]>()
|
||||
private providerRevision = 0
|
||||
private readonly layers = new ScopedLayers<SkillLayer>(
|
||||
scope => new SkillLayer(scope),
|
||||
() => { this.invalidateCache() },
|
||||
)
|
||||
private readonly collectCache = new Map<string, Map<string, IndexedCandidate>>()
|
||||
private revision = 0
|
||||
private nextProviderOrder = 0
|
||||
private runtimeRevision = 0
|
||||
/** Stable identities for cache keys; scope keys are opaque identity-compared objects. */
|
||||
private readonly scopeIds = new WeakMap<ScopeKey, number>()
|
||||
private nextScopeId = 1
|
||||
|
||||
constructor(ctx: Context, config: Config = {}) {
|
||||
super(ctx, 'skills')
|
||||
@@ -322,21 +378,27 @@ export class SkillService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a borrowed same-process provider synchronously during plugin apply. Duplicate and
|
||||
* reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters
|
||||
* the provider and invalidates catalog caches.
|
||||
* Register a borrowed same-process provider synchronously during plugin
|
||||
* apply, into the calling context's layer: a scoped context (an agent
|
||||
* preset's standing mount) registers for that scope alone, an unscoped
|
||||
* context registers globally. Duplicate names within one layer and reserved
|
||||
* names throw; remote initialization belongs in `list()`. Fiber disposal
|
||||
* unregisters the provider and invalidates catalog caches.
|
||||
* @param create - synchronous factory receiving this registration's lifecycle and invalidation control.
|
||||
* @returns the exact Cordis effect disposer that unregisters this provider;
|
||||
* composite effects may yield it directly to preserve teardown ordering.
|
||||
*/
|
||||
registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void {
|
||||
const lifecycle = new AbortController()
|
||||
let active = false
|
||||
let registration: { layer: SkillLayer; name: string } | undefined
|
||||
let provider: SkillProvider
|
||||
const control: SkillProviderControl = {
|
||||
signal: lifecycle.signal,
|
||||
invalidate: () => {
|
||||
if (active) this.invalidateProvider(provider)
|
||||
const active = registration
|
||||
if (active !== undefined && active.layer.providers.get(active.name)?.provider === provider) {
|
||||
this.invalidateCache()
|
||||
}
|
||||
},
|
||||
}
|
||||
try {
|
||||
@@ -345,26 +407,21 @@ export class SkillService extends Service {
|
||||
if (name === RUNTIME_PROVIDER) {
|
||||
throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`)
|
||||
}
|
||||
if (this.providers.has(name)) {
|
||||
throw new Error(`a skill provider named "${name}" is already registered`)
|
||||
}
|
||||
const providers = this.providers
|
||||
const order = this.nextProviderOrder
|
||||
const invalidateCache = (): void => { this.invalidateCache() }
|
||||
this.nextProviderOrder += 1
|
||||
const dispose = this.ctx.effect(function* () {
|
||||
active = true
|
||||
providers.set(name, { provider, order })
|
||||
invalidateCache()
|
||||
yield () => {
|
||||
active = false
|
||||
providers.delete(name)
|
||||
lifecycle.abort(new Error(`skill provider "${name}" disposed`))
|
||||
invalidateCache()
|
||||
}
|
||||
}, 'skills.registerProvider()')
|
||||
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; preserve exact disposer identity
|
||||
return dispose
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
(layer) => {
|
||||
const undo = layer.providers.insert(name, { provider, order })
|
||||
registration = { layer, name }
|
||||
return () => {
|
||||
registration = undefined
|
||||
undo()
|
||||
lifecycle.abort(new Error(`skill provider "${name}" disposed`))
|
||||
}
|
||||
},
|
||||
{ label: 'skills.registerProvider()' },
|
||||
)
|
||||
} catch (error) {
|
||||
lifecycle.abort(error)
|
||||
throw error
|
||||
@@ -372,16 +429,19 @@ export class SkillService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which
|
||||
* outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and
|
||||
* receives a no-op disposer so it cannot remove the winner.
|
||||
* Register a borrowed readonly runtime skill into the calling context's
|
||||
* layer. Project entries outrank runtime entries, which outrank user
|
||||
* entries, within one layer. Same-name runtime entries in one layer are
|
||||
* first-wins; a duplicate logs a warning and receives a no-op disposer so
|
||||
* it cannot remove the winner.
|
||||
* @param skill - the skill definition input; omitted invocation and provider fields receive defaults.
|
||||
* @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.
|
||||
*/
|
||||
register(skill: SkillRegistration): () => void {
|
||||
validateRuntimeSkill(skill)
|
||||
const existing = this.runtime.get(skill.name)
|
||||
if (existing !== undefined) {
|
||||
const scope = scopeOf(this.ctx)
|
||||
const existingLayer = scope === undefined ? this.layers.global : this.layers.peek(scope)
|
||||
if (existingLayer !== undefined && existingLayer.runtime.has(skill.name)) {
|
||||
this.ctx.logger.warn(`runtime skill "${skill.name}" ignored because it is already registered`)
|
||||
return () => {}
|
||||
}
|
||||
@@ -390,21 +450,14 @@ export class SkillService extends Service {
|
||||
invocation: skill.invocation ?? { modelInvocable: true, userInvocable: true },
|
||||
provider: skill.provider ?? RUNTIME_PROVIDER,
|
||||
}
|
||||
const runtime = this.runtime
|
||||
const updateRevision = (): void => { this.runtimeRevision += 1 }
|
||||
const invalidateCache = (): void => { this.invalidateCache() }
|
||||
const dispose = this.ctx.effect(function* () {
|
||||
runtime.set(definition.name, definition)
|
||||
updateRevision()
|
||||
invalidateCache()
|
||||
yield () => {
|
||||
runtime.delete(definition.name)
|
||||
updateRevision()
|
||||
invalidateCache()
|
||||
}
|
||||
}, 'skills.register()')
|
||||
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
(layer) => {
|
||||
layer.runtime.set(definition.name, definition)
|
||||
return () => { layer.runtime.delete(definition.name) }
|
||||
},
|
||||
{ label: 'skills.register()' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -412,10 +465,10 @@ export class SkillService extends Service {
|
||||
* model or user invocation policy at their operational boundary. Lookup
|
||||
* options and provider candidates are readonly same-process values borrowed
|
||||
* throughout discovery.
|
||||
* @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.
|
||||
* @param options - view options; `scope` selects the viewing agent's layers, `cwd` selects project roots, and `signal` cancels discovery.
|
||||
* @returns all sorted winning summaries.
|
||||
*/
|
||||
async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]> {
|
||||
async list(options: SkillViewOptions = {}): Promise<SkillSummary[]> {
|
||||
return (await this.snapshot(options)).skills
|
||||
}
|
||||
|
||||
@@ -423,15 +476,14 @@ export class SkillService extends Service {
|
||||
* Observe the current invocation-neutral catalog and whether discovery completed within a stable revision.
|
||||
* Incomplete observations are never cached, allowing consumers to retain last-good state and
|
||||
* retry on their next request boundary.
|
||||
* @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.
|
||||
* @param options - view options; `scope` selects the viewing agent's layers, `cwd` selects project roots, and `signal` cancels discovery.
|
||||
* @returns sorted summaries plus discovery-completeness state.
|
||||
*/
|
||||
async snapshot(options: SkillLookupOptions = {}): Promise<SkillCatalogSnapshot> {
|
||||
async snapshot(options: SkillViewOptions = {}): Promise<SkillCatalogSnapshot> {
|
||||
const collected = await this.collect(options)
|
||||
return {
|
||||
skills: collected.entries
|
||||
.map(entry => entry.candidate)
|
||||
.map(toSummary)
|
||||
skills: [...collected.entries.values()]
|
||||
.map(entry => toSummary(entry.candidate))
|
||||
.sort(compareSkillSummary),
|
||||
complete: collected.cacheable,
|
||||
}
|
||||
@@ -442,14 +494,15 @@ export class SkillService extends Service {
|
||||
* provider. Cancellation is rechecked after selection, including cache hits, and raced against
|
||||
* loading so an uncooperative provider cannot hang the caller.
|
||||
* @param name - kebab-case skill name.
|
||||
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
|
||||
* @param options - view options; `scope` selects the viewing agent's layers,
|
||||
* `cwd` selects workspace-sensitive skills, and `signal` cancels work.
|
||||
* @returns the full skill, including body content, or `undefined`.
|
||||
*/
|
||||
async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined> {
|
||||
async get(name: string, options: SkillViewOptions = {}): Promise<SkillDefinition | undefined> {
|
||||
if (!isSkillName(name)) return undefined
|
||||
const collected = await this.collect(options)
|
||||
throwIfAborted(options.signal)
|
||||
const match = collected.entries.find(entry => entry.candidate.name === name)
|
||||
const match = collected.entries.get(name)
|
||||
if (match === undefined) return undefined
|
||||
const definition = await waitWithAbort(
|
||||
match.provider.get(match.candidate, options),
|
||||
@@ -458,25 +511,27 @@ export class SkillService extends Service {
|
||||
if (definition === undefined) return undefined
|
||||
validateDefinition(definition)
|
||||
if (definition.name !== match.candidate.name) {
|
||||
this.invalidateProvider(match.provider)
|
||||
this.invalidateEntry(match)
|
||||
return undefined
|
||||
}
|
||||
return definition
|
||||
}
|
||||
|
||||
private async collect(options: SkillLookupOptions): Promise<CollectResult> {
|
||||
private async collect(options: SkillViewOptions): Promise<CollectResult> {
|
||||
throwIfAborted(options.signal)
|
||||
let attempt = 1
|
||||
while (true) {
|
||||
const providerRevision = this.providerRevision
|
||||
const runtimeRevision = this.runtimeRevision
|
||||
const key = collectCacheKey(options, providerRevision, runtimeRevision)
|
||||
const revision = this.revision
|
||||
// The chain is part of the key rather than assumed stable: a blank-session
|
||||
// recompose re-parents an existing scope without touching this registry,
|
||||
// and only a chain-bearing key makes the next read see the new preset.
|
||||
const key = this.collectCacheKey(options.cwd, scopeChainOf(options.scope), revision)
|
||||
const cached = this.collectCache.get(key)
|
||||
if (cached !== undefined) return { entries: cached, cacheable: true }
|
||||
|
||||
const result = await this.collectFresh(options)
|
||||
throwIfAborted(options.signal)
|
||||
if (providerRevision !== this.providerRevision || runtimeRevision !== this.runtimeRevision) {
|
||||
if (revision !== this.revision) {
|
||||
if (attempt < MAX_COLLECT_ATTEMPTS) {
|
||||
attempt += 1
|
||||
continue
|
||||
@@ -494,8 +549,24 @@ export class SkillService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
private async collectFresh(options: SkillLookupOptions): Promise<CollectResult> {
|
||||
const collected = await this.listAllCandidates(options)
|
||||
private async collectFresh(options: SkillViewOptions): Promise<CollectResult> {
|
||||
// Global first, then existing chain overlays farthest ancestor first and
|
||||
// the exact scope last, so the nearest layer's same-name entry replaces
|
||||
// the farther ones — the tools registry's shadowing rule. Rank decides
|
||||
// duplicates only within one layer.
|
||||
const layers = [this.layers.global, ...this.layers.chainLayers(options.scope)]
|
||||
const merged = new Map<string, IndexedCandidate>()
|
||||
let cacheable = true
|
||||
for (const layer of layers) {
|
||||
const collected = await this.collectLayer(layer, options)
|
||||
if (!collected.cacheable) cacheable = false
|
||||
for (const entry of collected.entries) merged.set(entry.candidate.name, entry)
|
||||
}
|
||||
return { entries: merged, cacheable }
|
||||
}
|
||||
|
||||
private async collectLayer(layer: SkillLayer, options: SkillLookupOptions): Promise<LayerCollectResult> {
|
||||
const collected = await this.listLayerCandidates(layer, options)
|
||||
collected.entries.sort(compareIndexedCandidates)
|
||||
const seen = new Set<string>()
|
||||
const result: IndexedCandidate[] = []
|
||||
@@ -511,21 +582,22 @@ export class SkillService extends Service {
|
||||
return { entries: result, cacheable: collected.cacheable }
|
||||
}
|
||||
|
||||
private async listAllCandidates(options: SkillLookupOptions): Promise<CollectResult> {
|
||||
private async listLayerCandidates(layer: SkillLayer, options: SkillLookupOptions): Promise<LayerCollectResult> {
|
||||
throwIfAborted(options.signal)
|
||||
const candidates: IndexedCandidate[] = []
|
||||
let cacheable = true
|
||||
let runtimeOrder = 0
|
||||
for (const skill of [...this.runtime.values()].sort((a, b) => compareCodePoints(a.name, b.name))) {
|
||||
for (const skill of [...layer.runtime.values()].sort((a, b) => compareCodePoints(a.name, b.name))) {
|
||||
candidates.push({
|
||||
candidate: runtimeCandidate(skill),
|
||||
provider: RUNTIME_SKILL_PROVIDER,
|
||||
providerOrder: -1,
|
||||
localOrder: runtimeOrder,
|
||||
layer,
|
||||
})
|
||||
runtimeOrder += 1
|
||||
}
|
||||
for (const { provider, order } of [...this.providers.values()]) {
|
||||
for (const { provider, order } of [...layer.providers.values()]) {
|
||||
let localOrder = 0
|
||||
let output: unknown
|
||||
try {
|
||||
@@ -540,7 +612,7 @@ export class SkillService extends Service {
|
||||
if (!observation.complete) cacheable = false
|
||||
for (const candidate of observation.candidates) {
|
||||
validateCandidate(candidate, provider.name)
|
||||
candidates.push({ candidate, provider, providerOrder: order, localOrder })
|
||||
candidates.push({ candidate, provider, providerOrder: order, localOrder, layer })
|
||||
localOrder += 1
|
||||
}
|
||||
}
|
||||
@@ -548,14 +620,29 @@ export class SkillService extends Service {
|
||||
}
|
||||
|
||||
private invalidateCache(): void {
|
||||
this.providerRevision += 1
|
||||
this.revision += 1
|
||||
this.collectCache.clear()
|
||||
this.notifyChange()
|
||||
}
|
||||
|
||||
private invalidateProvider(provider: SkillProvider): void {
|
||||
/** Invalidate after a stale definition load, only while the exact registration that produced the entry is still live. */
|
||||
private invalidateEntry(entry: IndexedCandidate): void {
|
||||
/* v8 ignore else -- A definition load can outlive the exact provider registration it selected. */
|
||||
if (this.providers.get(provider.name)?.provider === provider) this.invalidateCache()
|
||||
if (entry.layer.providers.get(entry.provider.name)?.provider === entry.provider) this.invalidateCache()
|
||||
}
|
||||
|
||||
private scopeId(key: ScopeKey): number {
|
||||
let id = this.scopeIds.get(key)
|
||||
if (id === undefined) {
|
||||
id = this.nextScopeId
|
||||
this.nextScopeId += 1
|
||||
this.scopeIds.set(key, id)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
private collectCacheKey(cwd: string | undefined, chain: ScopeKey[], revision: number): string {
|
||||
return JSON.stringify({ cwd, scopes: chain.map(key => this.scopeId(key)), revision })
|
||||
}
|
||||
|
||||
/** Notify catalog observers without making their refresh work load-bearing. */
|
||||
@@ -729,10 +816,6 @@ function assertPositiveInteger(name: string, value: number, minimum = 1): void {
|
||||
}
|
||||
}
|
||||
|
||||
function collectCacheKey(options: SkillLookupOptions, providerRevision: number, runtimeRevision: number): string {
|
||||
return JSON.stringify({ cwd: options.cwd, providerRevision, runtimeRevision })
|
||||
}
|
||||
|
||||
function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
||||
if (signal === undefined) return promise
|
||||
throwIfAborted(signal)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { createScope, scopeOf, setScopeParent } from '@deepseek-ai/dsh-scope'
|
||||
import SkillService, {
|
||||
isModelInvocable,
|
||||
isUserInvocable,
|
||||
@@ -49,6 +50,13 @@ function registerProvider(ctx: Context, provider: SkillProvider): () => void {
|
||||
return ctx.skills.registerProvider(() => provider)
|
||||
}
|
||||
|
||||
/** The skills service as a scoped caller resolves it (scope contexts declare no inject). */
|
||||
function scopedSkills(ctx: Context): SkillService {
|
||||
const skills = ctx.get('skills')
|
||||
if (skills === undefined) throw new Error('skills service missing')
|
||||
return skills
|
||||
}
|
||||
|
||||
describe('SkillService registry', () => {
|
||||
it('registers providers, resolves duplicates first-wins, and disposes providers', async () => {
|
||||
const ctx = new Context()
|
||||
@@ -894,6 +902,26 @@ describe('SkillService registry', () => {
|
||||
await expect(ctx.skills.get('vanished-skill')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('propagates a load failure raced against an armed abort signal', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
registerProvider(ctx, {
|
||||
name: 'failing-loader',
|
||||
list: () => Promise.resolve([{
|
||||
name: 'failing-skill',
|
||||
description: 'Failing',
|
||||
invocation: { modelInvocable: true, userInvocable: true },
|
||||
provider: 'failing-loader',
|
||||
source: 'test',
|
||||
rank: 10,
|
||||
locator: 'failing',
|
||||
}]),
|
||||
get: () => Promise.reject(new Error('load failed')),
|
||||
})
|
||||
const controller = new AbortController()
|
||||
await expect(ctx.skills.get('failing-skill', { signal: controller.signal })).rejects.toThrow('load failed')
|
||||
})
|
||||
|
||||
it('contains a provider rejection whose string coercion throws', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
@@ -1076,3 +1104,167 @@ describe('renderSkillContent', () => {
|
||||
expect(text).toContain('Keep </skill_content> and <tags> as-is.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SkillService scoped layers', () => {
|
||||
it('files a scoped provider into its layer and merges it into that scope view only', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
registerProvider(ctx, new MemoryProvider([memorySkill('global-skill', 'Global', 100)]))
|
||||
const preset = createScope(ctx, { preset: 'a' })
|
||||
const presetProvider: SkillProvider = {
|
||||
name: 'preset-local',
|
||||
async list() {
|
||||
return [{
|
||||
name: 'preset-skill',
|
||||
description: 'Preset',
|
||||
invocation: { modelInvocable: true, userInvocable: true },
|
||||
provider: 'preset-local',
|
||||
source: 'preset',
|
||||
rank: 300,
|
||||
locator: { content: 'Preset body.' },
|
||||
}]
|
||||
},
|
||||
async get(candidate) {
|
||||
return { ...candidate, content: (candidate.locator as { content: string }).content }
|
||||
},
|
||||
}
|
||||
scopedSkills(preset.ctx).registerProvider(() => presetProvider)
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['global-skill'])
|
||||
const scoped = await ctx.skills.list({ scope: scopeOf(preset.ctx) })
|
||||
expect(scoped.map(skill => skill.name)).toEqual(['global-skill', 'preset-skill'])
|
||||
expect((await ctx.skills.get('preset-skill', { scope: scopeOf(preset.ctx) }))?.content).toBe('Preset body.')
|
||||
expect(await ctx.skills.get('preset-skill')).toBeUndefined()
|
||||
await preset.dispose()
|
||||
})
|
||||
|
||||
it('lets the nearest layer win a duplicate name regardless of rank', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
registerProvider(ctx, new MemoryProvider([memorySkill('shared-name', 'Global wins ranks', 10)]))
|
||||
const preset = createScope(ctx, { preset: 'shadow' })
|
||||
scopedSkills(preset.ctx).registerProvider(() => ({
|
||||
name: 'preset-local',
|
||||
async list() {
|
||||
return [{
|
||||
name: 'shared-name',
|
||||
description: 'Preset shadow',
|
||||
invocation: { modelInvocable: true, userInvocable: true },
|
||||
provider: 'preset-local',
|
||||
source: 'preset',
|
||||
rank: 900,
|
||||
locator: { content: 'Preset shadow body.' },
|
||||
}]
|
||||
},
|
||||
async get(candidate: SkillCandidate) {
|
||||
return { ...candidate, content: (candidate.locator as { content: string }).content }
|
||||
},
|
||||
}))
|
||||
|
||||
const scoped = await ctx.skills.list({ scope: scopeOf(preset.ctx) })
|
||||
expect(scoped).toHaveLength(1)
|
||||
expect(scoped[0]?.description).toBe('Preset shadow')
|
||||
expect((await ctx.skills.get('shared-name', { scope: scopeOf(preset.ctx) }))?.content).toBe('Preset shadow body.')
|
||||
expect((await ctx.skills.list())[0]?.description).toBe('Global wins ranks')
|
||||
await preset.dispose()
|
||||
})
|
||||
|
||||
it('resolves the scope chain so an agent key inherits its preset layer and recompose follows the new parent', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const presetA = createScope(ctx, { preset: 'a' })
|
||||
const presetB = createScope(ctx, { preset: 'b' })
|
||||
for (const [scope, label] of [[presetA, 'a'], [presetB, 'b']] as const) {
|
||||
scopedSkills(scope.ctx).register({
|
||||
name: `skill-${label}`,
|
||||
description: `Skill ${label}`,
|
||||
source: 'preset',
|
||||
content: `Body ${label}.`,
|
||||
})
|
||||
}
|
||||
const agentKey = {}
|
||||
setScopeParent(agentKey, scopeOf(presetA.ctx) as object)
|
||||
expect((await ctx.skills.list({ scope: agentKey })).map(skill => skill.name)).toEqual(['skill-a'])
|
||||
// A blank-session recompose re-parents the same key without any registry write.
|
||||
setScopeParent(agentKey, scopeOf(presetB.ctx) as object)
|
||||
expect((await ctx.skills.list({ scope: agentKey })).map(skill => skill.name)).toEqual(['skill-b'])
|
||||
await presetA.dispose()
|
||||
await presetB.dispose()
|
||||
})
|
||||
|
||||
it('scopes provider-name uniqueness per layer and reports scoped duplicates distinctly', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
registerProvider(ctx, new MemoryProvider([]))
|
||||
const presetA = createScope(ctx, { preset: 'a' })
|
||||
const presetB = createScope(ctx, { preset: 'b' })
|
||||
scopedSkills(presetA.ctx).registerProvider(() => new MemoryProvider([memorySkill('a-only', 'A', 100)]))
|
||||
scopedSkills(presetB.ctx).registerProvider(() => new MemoryProvider([memorySkill('b-only', 'B', 100)]))
|
||||
expect(() => scopedSkills(presetA.ctx).registerProvider(() => new MemoryProvider([])))
|
||||
.toThrow('a skill provider named "memory" is already registered in this scope')
|
||||
expect((await ctx.skills.list({ scope: scopeOf(presetA.ctx) })).map(skill => skill.name)).toEqual(['a-only'])
|
||||
expect((await ctx.skills.list({ scope: scopeOf(presetB.ctx) })).map(skill => skill.name)).toEqual(['b-only'])
|
||||
await presetA.dispose()
|
||||
await presetB.dispose()
|
||||
})
|
||||
|
||||
it('keeps runtime duplicate handling per layer and shadows a global runtime name', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const warn = vi.fn()
|
||||
ctx.logger.warn = warn as never
|
||||
ctx.skills.register({ name: 'told-twice', description: 'Global runtime', source: 'runtime', content: 'Global body.' })
|
||||
const preset = createScope(ctx, { preset: 'runtime' })
|
||||
const disposeShadow = scopedSkills(preset.ctx).register({
|
||||
name: 'told-twice',
|
||||
description: 'Preset runtime',
|
||||
source: 'preset',
|
||||
content: 'Preset body.',
|
||||
})
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
scopedSkills(preset.ctx).register({ name: 'told-twice', description: 'Ignored', source: 'preset', content: 'Ignored.' })
|
||||
expect(warn).toHaveBeenCalledWith('runtime skill "told-twice" ignored because it is already registered')
|
||||
expect((await ctx.skills.get('told-twice', { scope: scopeOf(preset.ctx) }))?.content).toBe('Preset body.')
|
||||
expect((await ctx.skills.get('told-twice'))?.content).toBe('Global body.')
|
||||
disposeShadow()
|
||||
expect((await ctx.skills.get('told-twice', { scope: scopeOf(preset.ctx) }))?.content).toBe('Global body.')
|
||||
await preset.dispose()
|
||||
})
|
||||
|
||||
it('drops a disposed scoped registration from its scope view and notifies change', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const changes = vi.fn()
|
||||
ctx.on('skills/change', changes)
|
||||
const preset = createScope(ctx, { preset: 'hmr' })
|
||||
const provider = new MemoryProvider([memorySkill('scoped-skill', 'Scoped', 100)])
|
||||
scopedSkills(preset.ctx).registerProvider(() => provider)
|
||||
expect((await ctx.skills.list({ scope: scopeOf(preset.ctx) })).map(skill => skill.name)).toEqual(['scoped-skill'])
|
||||
const notified = changes.mock.calls.length
|
||||
await preset.dispose()
|
||||
expect(changes.mock.calls.length).toBeGreaterThan(notified)
|
||||
expect(await ctx.skills.list({ scope: scopeOf(preset.ctx) })).toEqual([])
|
||||
})
|
||||
|
||||
it('invalidates through a scoped provider control only while its exact registration is live', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const preset = createScope(ctx, { preset: 'invalidate' })
|
||||
const provider = new MemoryProvider([memorySkill('watched', 'Watched', 100)])
|
||||
let control: { invalidate: () => void } | undefined
|
||||
const dispose = scopedSkills(preset.ctx).registerProvider((given) => {
|
||||
control = given
|
||||
return provider
|
||||
})
|
||||
const scope = scopeOf(preset.ctx)
|
||||
expect((await ctx.skills.list({ scope })).map(skill => skill.name)).toEqual(['watched'])
|
||||
provider.replace([memorySkill('replaced', 'Replaced', 100)])
|
||||
control?.invalidate()
|
||||
expect((await ctx.skills.list({ scope })).map(skill => skill.name)).toEqual(['replaced'])
|
||||
dispose()
|
||||
provider.replace([memorySkill('ignored', 'Ignored', 100)])
|
||||
control?.invalidate()
|
||||
expect(await ctx.skills.list({ scope })).toEqual([])
|
||||
await preset.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
|
||||
@@ -128,7 +128,9 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
if (!isSkillName(args.name)) {
|
||||
throw new Error(`invalid skill name "${args.name}"`)
|
||||
}
|
||||
const lookup = { cwd: exec.agent?.session.header.cwd, signal: exec.signal }
|
||||
// The agent is its own scope key, so the lookup resolves the layered
|
||||
// registry exactly as this agent's composition sees it.
|
||||
const lookup = { cwd: exec.agent?.session.header.cwd, signal: exec.signal, scope: exec.agent }
|
||||
const summary = (await ctx.skills.list(lookup)).find(skill => skill.name === args.name)
|
||||
if (!summary) {
|
||||
throw new Error(`skill "${args.name}" is unknown or no longer available`)
|
||||
@@ -181,7 +183,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
const names = invokedSkillNames(messages)
|
||||
if (names.length === 0) return decision
|
||||
signal.throwIfAborted()
|
||||
const lookup = { cwd: agent.session.header.cwd, signal }
|
||||
const lookup = { cwd: agent.session.header.cwd, signal, scope: agent }
|
||||
const injections: UserMessage[] = []
|
||||
for (const name of names) {
|
||||
const skill = await ctx.skills.get(name, lookup)
|
||||
@@ -217,7 +219,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
signal.throwIfAborted()
|
||||
const toolVisible = ctx.tools.get(skillTool.name, agent) === skillTool
|
||||
const snapshot = toolVisible
|
||||
? await ctx.skills.snapshot({ cwd: agent.session.header.cwd, signal })
|
||||
? await ctx.skills.snapshot({ cwd: agent.session.header.cwd, signal, scope: agent })
|
||||
: { skills: [], complete: true }
|
||||
signal.throwIfAborted()
|
||||
if (!snapshot.complete) return decision
|
||||
|
||||
@@ -640,6 +640,43 @@ describe('dsh-tool-skill', () => {
|
||||
expect(JSON.stringify(result.content)).not.toContain('First body.')
|
||||
})
|
||||
|
||||
it('resolves the layered registry as the calling agent sees it', async () => {
|
||||
const home = await tempDir('tool-scoped-layer')
|
||||
const ctx = await setup(home)
|
||||
const { agent, scope } = await mintAgentScope(ctx, '/workspace/scoped')
|
||||
const scopedSkills = scope.ctx.get('skills')
|
||||
if (scopedSkills === undefined) throw new Error('skills service missing')
|
||||
scopedSkills.register({
|
||||
name: 'preset-only-skill',
|
||||
description: 'Visible to the scoped agent alone',
|
||||
source: 'preset',
|
||||
content: 'Preset-only body.',
|
||||
})
|
||||
|
||||
expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('preset-only-skill')
|
||||
expect(JSON.stringify(await composePrefix(ctx, '/workspace/other'))).not.toContain('preset-only-skill')
|
||||
|
||||
const scoped = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('scoped-load'),
|
||||
name: 'skill',
|
||||
arguments: { name: 'preset-only-skill' },
|
||||
agent,
|
||||
})
|
||||
expect(scoped.isError).toBe(false)
|
||||
expect(JSON.stringify(scoped.content)).toContain('Preset-only body.')
|
||||
|
||||
const foreign = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('foreign-load'),
|
||||
name: 'skill',
|
||||
arguments: { name: 'preset-only-skill' },
|
||||
agent: agentForCwd('/workspace/other'),
|
||||
})
|
||||
expect(foreign.isError).toBe(true)
|
||||
await scope.dispose()
|
||||
})
|
||||
|
||||
it('retains the last-good catalog while any provider discovery is incomplete', async () => {
|
||||
const home = await tempDir('tool-incomplete-catalog')
|
||||
const ctx = await setup(home)
|
||||
|
||||
Reference in New Issue
Block a user