Merge branch 'stack/agent-profiles-1-seam' into stack/agent-profiles-3-wire

This commit is contained in:
Yichen Jiang
2026-08-08 22:47:15 +08:00
910 changed files with 17011 additions and 6270 deletions

View File

@@ -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/README.md
README.md: d10049ac3e741350fddb42f430b70f063da1d12d
README.zh.md: 67f2da6f75edecd180ff861122c6392684ed9bdb
README.md: 533904859ad998de4f371a073fde98b68660097b
README.zh.md: 1fad581cc61a05251f671577dcb7edab37281283

View File

@@ -7,6 +7,7 @@ This family discovers reusable agent instructions and exposes them to the model
| Package | Role | ctx key |
|---|---|---|
| [`skill/`](skill/README.md) | Defines skill provider registration and lookup | `ctx.skills` |
| [`skill-badge/`](skill-badge/README.md) | Contributes the optional bundled dsh badge skill | registers on `ctx.skills` |
| [`skill-local/`](skill-local/README.md) | Discovers skills from local filesystems | registers on `ctx.skills` |
| [`tool-skill/`](tool-skill/README.md) | Publishes the skill catalog and model-facing loader | registers on `ctx.tools` |

View File

@@ -7,6 +7,7 @@
| 包 | 职责 | ctx 键 |
|---|---|---|
| [`skill/`](skill/README.md) | 定义 skill 提供方注册和查找 | `ctx.skills` |
| [`skill-badge/`](skill-badge/README.md) | 贡献可选的内置 dsh 徽章 skill | 注册到 `ctx.skills` |
| [`skill-local/`](skill-local/README.md) | 从本地文件系统发现 skill | 注册到 `ctx.skills` |
| [`tool-skill/`](tool-skill/README.md) | 发布 skill 目录和面向模型的 loader | 注册到 `ctx.tools` |

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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-badge/README.md
README.md: 49b38023a7c110bb52bb351668905c702e251216
README.zh.md: bf7eb0d7d4c0552c07f9cdf5665f8a20df829483

View File

@@ -0,0 +1,22 @@
# @deepseek-ai/dsh-skill-badge
English | [中文](README.zh.md)
Optional bundled skill provider that contributes `dsh-badge` to `ctx.skills`. The skill supplies the official “powered by dsh” Markdown snippets and the packaged PNG for systems that cannot import a remote image reliably.
Mount the plugin to enable the provider. It has no configuration. The shipped CLI composition includes the plugin as `disabled: true`; users must explicitly enable its `skill-badge` row before the skill enters a catalog.
The provider exposes its packaged `assets/` directory as the skill resource base. `dsh-badge.png` is the 726×120 source asset, and consumers render it at 121×20.
## Model Experience
Indirectly, through `@deepseek-ai/dsh-tool-skill`, which renders the catalog entry and selected skill body.
#### KV Cache effect
Disabled by default, the plugin changes no request. When enabled, its catalog entry and any loaded body change the provider KV prefix at their insertion points.
## Known Limitations and Deferred Work
- The provider contributes one fixed skill and has no runtime customization.
- Remote Markdown uses Shields.io; use the packaged PNG when the target cannot fetch remote images reliably.

View File

@@ -0,0 +1,22 @@
# @deepseek-ai/dsh-skill-badge
[English](README.md) | 中文
可选的内置 skill技能提供方`ctx.skills` 贡献 `dsh-badge`。该 skill 提供官方「powered by dsh」Markdown 片段和随包分发的 PNG供无法可靠导入远程图片的系统使用。
挂载该插件即可启用提供方。它没有配置。交付的 CLI命令行界面组合以 `disabled: true` 包含该插件;用户必须显式启用其 `skill-badge` 配置行,该 skill 才会进入目录。
该提供方将随包分发的 `assets/` 目录作为 skill 资源基底公开。`dsh-badge.png` 是尺寸为 726×120 的源图资源,消费方以 121×20 的尺寸渲染。
## 模型体验
通过 `@deepseek-ai/dsh-tool-skill` 间接影响模型;该包会渲染目录条目和所选 skill 的正文。
#### KV Cache 影响
该插件默认禁用,不会改变任何请求。启用后,其目录条目和任何已加载正文都会在各自插入点改变提供方的 KV 前缀。
## 已知限制与暂缓事项
- 该提供方只贡献一个固定 skill不提供运行时自定义。
- 远程 Markdown 使用 Shields.io当目标环境无法可靠获取远程图片时请使用随包分发的 PNG。

View File

@@ -0,0 +1,31 @@
# dsh Badge
Add the official “powered by dsh” badge without recreating or restyling it.
## Assets
- Local PNG: [`dsh-badge.png`](dsh-badge.png), 726×120 source image; render at 121×20
- Shields.io image URL: `https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white`
- Project URL: `https://github.com/deepseek-ai/deepseek-harness-sdk`
## Markdown
Use this linked badge in Markdown:
```markdown
[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk)
```
If attribution should not be linked, use:
```markdown
![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)
```
## Usage rules
- For GitHub or GitLab Markdown, use the Shields.io URL and link it to the project URL unless the user asks for an unlinked image.
- For Feishu and other systems that import remote images unreliably, upload `dsh-badge.png` from this skill directory instead of generating another badge.
- Preserve the badge's 121×20 dimensions and aspect ratio.
- Place the badge at the end of the attributed document or section unless the user specifies another position.
- Do not substitute another color, logo, label, or project URL.

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,37 @@
{
"name": "@deepseek-ai/dsh-skill-badge",
"description": "Bundled dsh badge skill provider for DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"assets",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-skill": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,60 @@
/**
* Bundled `dsh-badge` skill provider.
*
* @module @deepseek-ai/dsh-skill-badge
*/
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import type { Context } from 'cordis'
import {
BUNDLED_SKILL_RANK,
type SkillCandidate,
type SkillDefinition,
type SkillProvider,
} from '@deepseek-ai/dsh-skill'
const PROVIDER_NAME = 'dsh-badge'
const SKILL_BODY_URL = new URL('../assets/dsh-badge.md', import.meta.url)
const RESOURCE_BASE = {
kind: 'directory',
path: fileURLToPath(new URL('../assets/', import.meta.url)),
} as const
const INVOCATION = { modelInvocable: true, userInvocable: true } as const
const DESCRIPTION = 'Add the official “powered by dsh” badge to documents, pull requests, merge requests, and other content produced with DeepSeek Harness. Use whenever creating a pull request or merge request. Also use when the user asks for a dsh badge, powered-by-dsh attribution, or a reusable dsh badge asset or snippet.'
const CANDIDATE: SkillCandidate = {
name: 'dsh-badge',
description: DESCRIPTION,
invocation: INVOCATION,
provider: PROVIDER_NAME,
source: 'bundled',
resourceBase: RESOURCE_BASE,
rank: BUNDLED_SKILL_RANK,
locator: SKILL_BODY_URL,
}
const provider: SkillProvider = {
name: PROVIDER_NAME,
list: () => Promise.resolve([CANDIDATE]),
async get(_candidate): Promise<SkillDefinition> {
return {
name: CANDIDATE.name,
description: CANDIDATE.description,
invocation: CANDIDATE.invocation,
provider: CANDIDATE.provider,
source: CANDIDATE.source,
resourceBase: RESOURCE_BASE,
content: await readFile(SKILL_BODY_URL, 'utf8'),
}
},
}
/** Cordis plugin name. */
export const name = 'skill-badge'
/** Service required by the bundled provider. */
export const inject = ['skills']
/** Register the bundled `dsh-badge` provider on `ctx.skills`. */
export function apply(ctx: Context): void {
ctx.skills.registerProvider(() => provider)
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-skill-badge`.
* @module @deepseek-ai/dsh-skill-badge/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-skill-badge'
/** Cordis companion plugin name. */
export const name = 'skill-badge-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the package owns one immutable provider registration,
* while the skill registry owns registration uniqueness and lifecycle checks.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,40 @@
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import SkillService from '@deepseek-ai/dsh-skill'
import * as SkillBadge from '@deepseek-ai/dsh-skill-badge'
describe('dsh-skill-badge', () => {
it('registers and disposes the bundled badge skill', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const fiber = await ctx.plugin(SkillBadge)
const resourcePath = fileURLToPath(new URL('../assets/', import.meta.url))
expect(await ctx.skills.list()).toEqual([{
name: 'dsh-badge',
description: 'Add the official “powered by dsh” badge to documents, pull requests, merge requests, and other content produced with DeepSeek Harness. Use whenever creating a pull request or merge request. Also use when the user asks for a dsh badge, powered-by-dsh attribution, or a reusable dsh badge asset or snippet.',
invocation: { modelInvocable: true, userInvocable: true },
provider: 'dsh-badge',
source: 'bundled',
resourceBase: { kind: 'directory', path: resourcePath },
}])
const loaded = await ctx.skills.get('dsh-badge')
expect(loaded?.content).toContain('Preserve the badge\'s 121×20 dimensions')
expect(loaded?.resourceBase).toEqual({ kind: 'directory', path: resourcePath })
await fiber.dispose()
expect(await ctx.skills.list()).toEqual([])
})
it('ships the official 726×120 PNG unchanged', async () => {
const image = await readFile(new URL('../assets/dsh-badge.png', import.meta.url))
expect(image.readUInt32BE(16)).toBe(726)
expect(image.readUInt32BE(20)).toBe(120)
expect(createHash('sha256').update(image).digest('hex')).toBe(
'f2c4f5ec9cbe847c0c763545c4d839efa8485bc74203733d0a0e8259f233c653',
)
})
})

View File

@@ -0,0 +1,14 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../skill" },
{ "path": "../../support/invariants" }
]
}

View File

@@ -21,6 +21,7 @@ import { parse as parseYaml } from 'yaml'
import type { FileSystem, FsDirEntry, FsTarget } from '@deepseek-ai/dsh-fs'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import {
BUNDLED_SKILL_RANK,
isSkillName,
type SkillCandidate,
type SkillDefinition,
@@ -40,7 +41,6 @@ const USER_AGENTS_RANK = 500
const DEFAULT_WATCH_STABILITY_THRESHOLD_MS = 200
const DEFAULT_WATCH_POLL_INTERVAL_MS = 100
const DEFAULT_WATCH_MAX_PROJECTS = 128
const BUNDLED_RANK = 600
export const name = 'skill-local'
export const inject = ['skills']
@@ -256,7 +256,7 @@ export class LocalSkillProvider implements SkillProvider {
)
}
if (this.bundledSkillDir !== undefined) {
roots.push({ path: this.bundledSkillDir, source: 'bundled', rank: BUNDLED_RANK, trustedHost: true })
roots.push({ path: this.bundledSkillDir, source: 'bundled', rank: BUNDLED_SKILL_RANK, trustedHost: true })
}
return roots
}

View File

@@ -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: f538ae668ccff291be86348627d5547150f460df
README.zh.md: d61a242d01df1e22270c1cb049b922536654bbd6
README.md: 3dc2bcfa5775736717bdebcb92329d5655198234
README.zh.md: d11f90d5a8356f06df63aa249a1f8b5851f36f5f

View File

@@ -37,6 +37,10 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
| `{ modelInvocable: false, userInvocable: true }` | excluded | included |
| `{ modelInvocable: false, userInvocable: false }` | excluded | excluded |
### Shared model-facing rendering
`renderSkillContent(skill)` renders one loaded skill as the canonical `<skill_content>` block (escaped `name` attribute, resource hints, verbatim body). It is the single truth for both loading paths: `dsh-tool-skill` returns it as the `skill` tool result and injects it at the user-explicit gesture boundary, so the model sees one shape regardless of who initiated the load. `escapeText` is exported beside it for consumers embedding prose in the same markup frame. The package also declares the `skill-invocation` `MessageSource` kind ({ name, form: 'instructions' }) that user-explicit injection stamps on its messages — transcript consumers present the invocation from this metadata instead of re-parsing the body.
`isModelInvocable(skill)` and `isUserInvocable(skill)` read the matching positive field directly. `ctx.skills.get()` remains the trusted, policy-neutral loading primitive, so every user- or model-facing consumer must enforce the predicate that matches its surface before exposing or loading a skill.
## Provider Contract

View File

@@ -37,6 +37,10 @@
| `{ modelInvocable: false, userInvocable: true }` | 排除 | 包含 |
| `{ modelInvocable: false, userInvocable: false }` | 排除 | 排除 |
### 共享的面向模型渲染
`renderSkillContent(skill)` 把一个已加载 skill 渲染为规范的 `<skill_content>` 块(转义后的 `name` 属性、资源提示、原样正文)。它是两条加载路径的唯一真源:`dsh-tool-skill` 将其作为 `skill` 工具结果返回,并在用户显式的手势边界将其注入,因此无论加载由谁发起,模型看到的都是同一种形态。`escapeText` 随之一并导出,供要在同一标记框架中嵌入文案的消费方使用。该包还声明 `skill-invocation` 这个 `MessageSource` kind{ name, form: 'instructions' }用户显式注入会把它打在自己的消息上——transcript文本记录消费方依据这份元数据呈现该次调用而不是重新解析正文。
`isModelInvocable(skill)``isUserInvocable(skill)` 分别直接读取对应的正向字段。`ctx.skills.get()` 仍是受信且与策略无关的加载原语,因此每个面向用户或模型的消费方都必须先执行与自身接口匹配的判定,再暴露或加载 skill。
## 提供方契约

View File

@@ -26,6 +26,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
@@ -33,6 +34,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -10,6 +10,7 @@
*/
import { Context, Service } from 'cordis'
import { assertNever } from '@deepseek-ai/dsh-llm'
import z from 'schemastery'
import type Schema from 'schemastery'
@@ -19,6 +20,9 @@ const MAX_COLLECT_ATTEMPTS = 2
const RUNTIME_PROVIDER = 'runtime'
const RUNTIME_RANK = 250
/** Standard precedence rank for packaged skill providers and local bundled roots. */
export const BUNDLED_SKILL_RANK = 600
/**
* Return whether a string is a valid kebab-case skill name.
* @param name - candidate skill name to validate.
@@ -119,6 +123,97 @@ export function isUserInvocable(skill: Pick<SkillSummary, 'invocation'>): boolea
return skill.invocation.userInvocable
}
/**
* Durable source for the context message a user-explicit skill invocation
* injects: the user's own words ride a plain user message, and the rendered
* skill body follows as injected `instructions`-form context carrying this
* source, so transcript consumers present the injection from metadata
* instead of re-parsing the model-facing text.
*/
export interface SkillInvocationSource {
readonly kind: 'skill-invocation'
/** Invoked skill name, validated user-invocable at the injecting boundary. */
readonly name: string
/** Injected skill bodies are instructions for the model to follow. */
readonly form: 'instructions'
}
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
/** A user-explicit skill invocation injected by the host. */
'skill-invocation': SkillInvocationSource
}
}
/**
* Render one loaded skill for the model. The output is shared verbatim by the
* `skill` tool result and the user-explicit invocation injection, so the model
* sees one canonical `<skill_content>` shape on both paths. The name rides an
* escaped attribute; the body is embedded verbatim (skills are trusted local
* content, and user-supplied invocation text stays outside this wrapper).
* @param skill - name, provider, optional resource base, and body to render.
* @returns the complete model-facing `<skill_content>` block.
*/
export function renderSkillContent(skill: Pick<SkillDefinition, 'name' | 'provider' | 'resourceBase' | 'content'>): string {
const resourceHint = renderResourceHint(skill)
return [
`<skill_content name="${escapeAttr(skill.name)}">`,
'<skill_resources>',
...resourceHint,
'</skill_resources>',
'',
'<skill_instructions>',
skill.content,
'</skill_instructions>',
'</skill_content>',
].join('\n')
}
function renderResourceHint(skill: Pick<SkillDefinition, 'provider' | 'resourceBase'>): string[] {
const base = skill.resourceBase
if (base === undefined) {
return [
`Resources for this skill are managed by provider "${escapeText(skill.provider)}".`,
'Load referenced resources only as needed.',
]
}
switch (base.kind) {
case 'directory':
return [
`Base directory for this skill: ${escapeText(base.path)}`,
'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.',
]
case 'url':
return [
`Base URL for this skill: ${escapeText(base.url)}`,
'Resolve relative URLs mentioned by this skill against the base URL before using them. Load referenced resources only as needed.',
]
case 'opaque':
return [
`Resources for this skill: ${escapeText(base.description)}`,
'Load referenced resources only as needed.',
]
/* v8 ignore start -- SkillResourceBase is a closed union; a future kind must fail compilation here. */
default:
return assertNever(base, 'SkillResourceBase.kind')
/* v8 ignore stop */
}
}
function escapeAttr(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;')
}
/**
* Escape model-facing prose embedded inside skill markup so provider-supplied
* text cannot open or close framing tags.
* @param value - raw prose to embed.
* @returns the escaped text.
*/
export function escapeText(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
}
/** One catalog observation plus whether discovery completed within a stable catalog revision. */
export interface SkillCatalogSnapshot {
/** Sorted invocation-neutral summaries collected in this observation. */

View File

@@ -3,6 +3,7 @@ import { Context } from 'cordis'
import SkillService, {
isModelInvocable,
isUserInvocable,
renderSkillContent,
type SkillCandidate,
type SkillDefinition,
type SkillInvocationPolicy,
@@ -1013,3 +1014,65 @@ describe('SkillService registry', () => {
expect(await ctx.skills.get('same-skill')).toBeUndefined()
})
})
describe('renderSkillContent', () => {
it('renders a directory-based skill with the shared wrapper', () => {
const text = renderSkillContent({
name: 'demo-skill',
provider: 'memory',
resourceBase: { kind: 'directory', path: '/tmp/demo' },
content: 'Do the thing.',
})
expect(text).toBe([
'<skill_content name="demo-skill">',
'<skill_resources>',
'Base directory for this skill: /tmp/demo',
'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.',
'</skill_resources>',
'',
'<skill_instructions>',
'Do the thing.',
'</skill_instructions>',
'</skill_content>',
].join('\n'))
})
it('renders url and opaque resource hints', () => {
const url = renderSkillContent({
name: 'url-skill',
provider: 'memory',
resourceBase: { kind: 'url', url: 'https://example.test/base/' },
content: 'Body.',
})
expect(url).toContain('Base URL for this skill: https://example.test/base/')
expect(url).toContain('Resolve relative URLs mentioned by this skill against the base URL before using them.')
const opaque = renderSkillContent({
name: 'opaque-skill',
provider: 'memory',
resourceBase: { kind: 'opaque', description: 'archive <bundle>' },
content: 'Body.',
})
expect(opaque).toContain('Resources for this skill: archive &lt;bundle&gt;')
})
it('falls back to the provider hint without a resource base', () => {
const text = renderSkillContent({
name: 'provider-skill',
provider: 'remote <hub>',
content: 'Body.',
})
expect(text).toContain('Resources for this skill are managed by provider "remote &lt;hub&gt;".')
})
it('escapes hostile attribute names and keeps the body verbatim', () => {
const text = renderSkillContent({
name: 'x"&<y',
provider: 'memory',
resourceBase: { kind: 'directory', path: '/tmp' },
content: 'Keep </skill_content> and <tags> as-is.',
})
expect(text).toContain('<skill_content name="x&quot;&amp;&lt;y">')
expect(text).toContain('Keep </skill_content> and <tags> as-is.')
})
})

View File

@@ -15,6 +15,9 @@
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../llm/llm"
},
{
"path": "../../support/invariants"
}

View File

@@ -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: be5b5b98865328ccd5f8a4666bff04acc8fd5927
README.zh.md: dd84a7a24835c7f55b2b9b694197abd1fa30f77a
README.md: b7309657d85a3d2a19de78a4ee6173d742519daa
README.zh.md: f430f4027c917c5c9b97a56d1a7d7a617670b25c

View File

@@ -36,7 +36,7 @@ Tool execution does not add a synthetic context message. Its freshly loaded resu
#### What the model sees
If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below as a durable user-role message before the first request, with one data-dependent entry per sorted skill. Later membership, description, or visibility changes append a complete replacement using the same `<available_skills>` envelope; deleting every skill appends an empty envelope with an explicit instruction not to use older names.
If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below as a durable user-role message before the first request, with one data-dependent entry per sorted skill. Later membership, description, or visibility changes append a complete replacement using the same `<available_skills>` envelope; deleting every skill appends an empty envelope with an explicit instruction not to use older names. The template's closing sentence is the seam rule against double-loading: the user-explicit gesture boundary (the pre-step listener below) injects the same `renderSkillContent` output (shared from `@deepseek-ai/dsh-skill`) inline, and the catalog tells the model to follow that block instead of re-loading the skill through the tool; the replacement-catalog template carries the same sentence in both arms, including the emptied catalog.
##### Skill catalog template
@@ -49,6 +49,7 @@ A skill is a reusable set of task-specific instructions. The following skills ar
</available_skills>
If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.
A user may also invoke a skill directly; its <skill_content> block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.
</system-reminder>
```
@@ -144,6 +145,20 @@ Only a failing call adds these retained tokens.
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### User-explicit invocation injection
#### What the model sees
A whitespace-bounded `/name` token anywhere in a claimed user message, naming a user-invocable skill in the workspace catalog, injects that skill's full `<skill_content>` rendering (the exact result-template shape above) as a `user`-role instructions context appended after every other injection of that step — background first, the material to act on last. Only direct user input is scanned, the check runs on the loaded definition, and unknown or user-disabled names stay ordinary prose. This is the sole entry point for `disable-model-invocation` skills, which the catalog and the `skill` tool never expose; the catalog's closing sentence tells the model to follow the injected block instead of re-loading it.
#### Token effect
Each gesture adds one rendered skill body to that turn as injected context — the same size as the tool result for the same skill, paid deterministically at the user's request instead of at the model's discretion. Repeated gestures for one skill within one step inject once.
#### KV Cache effect
Append-only; the injection lands after the reusable request prefix inside the step's message batch and does not invalidate existing KV-cache entries.
## Known Limitations and Deferred Work
- **The catalog omits `whenToUse`, source, and provider metadata** — routing is based only on name and a capped description; `whenToUse` remains provider metadata and is not rendered by the loaded wrapper either.

View File

@@ -36,7 +36,7 @@
#### 模型看到的内容
如果存在模型可调用 skill且可见的正是这个 `skill` 工具agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `<available_skills>` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。
如果存在模型可调用 skill且可见的正是这个 `skill` 工具agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `<available_skills>` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。模板的结尾一句是防止双重加载的 seam 规则:用户显式的手势边界(下文的 pre-step 监听器)会把同一份 `renderSkillContent` 输出(共享自 `@deepseek-ai/dsh-skill`)内联注入,目录则告诉模型遵循该块,而不是再经工具重新加载该 skill替换目录模板的两个臂——包括清空后的目录——都携带同一句话。
##### Skill 目录模板
@@ -49,6 +49,7 @@ A skill is a reusable set of task-specific instructions. The following skills ar
</available_skills>
If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.
A user may also invoke a skill directly; its <skill_content> block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.
</system-reminder>
```
@@ -144,6 +145,20 @@ Load referenced resources only as needed.
仅追加;新可见内容位于可重用请求前缀之后,不会使现有 KV Cache 条目失效。
### 用户显式调用注入
#### 模型看到的内容
已认领用户消息中任意位置、以空白为界、指名工作区目录中某个用户可调用 skill 的 `/name` token会把该 skill 的完整 `<skill_content>` 渲染(与上文结果模板完全相同的形态)作为 `user` 角色的指令上下文注入,追加在该步骤所有其他注入之后——背景在前,模型要着手处理的材料在最后。只扫描直接的用户输入,检查在已加载定义上进行,未知名称和用户不可调用的名称保持为普通行文。这是 `disable-model-invocation` skill 唯一的入口,目录和 `skill` 工具永不暴露这类 skill目录的结尾一句会告诉模型遵循注入块而不是重新加载它。
#### Token 影响
每次手势会把一份渲染后的 skill 正文作为注入上下文加进该轮次——尺寸与同一 skill 的工具结果相同,按用户的请求确定性地支付,而非由模型自行裁量。同一步骤内对同一 skill 的重复手势只注入一次。
#### KV Cache 影响
仅追加;注入落在该步骤的消息批次中、可重用请求前缀之后,不会使现有 KV Cache 条目失效。
## 已知限制与暂缓事项
- **目录省略 `whenToUse`、来源和提供方元数据**:路由只基于名称和有长度上限的描述;`whenToUse` 仍是提供方元数据,加载后的包装层也不渲染它。

View File

@@ -9,12 +9,15 @@ import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { assertNever, createUserMessage } from '@deepseek-ai/dsh-llm'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { UserMessage } from '@deepseek-ai/dsh-session'
import {
escapeText,
isModelInvocable,
isSkillName,
type SkillDefinition,
isUserInvocable,
renderSkillContent,
type SkillInvocationSource,
type SkillSummary,
} from '@deepseek-ai/dsh-skill'
@@ -155,6 +158,49 @@ export function apply(ctx: Context, config: Config = {}): void {
})
ctx.tools.register(skillTool)
// User-explicit skill invocation: a claimed user message whose first line
// starts with `/<name>` naming a user-invocable skill is a deterministic
// load gesture. The rendered body enters this step as injected
// instructions context appended after every other injection — background
// first (workspace rules, runtime policy, the catalog), the material the
// model must act on last, closest to its answer. Registration order makes
// that placement deterministic: this listener registers before the catalog
// listener, so the waterfall hands it the catalog-bearing list to extend.
// Only `source.kind === 'user'` messages are scanned — external text
// cannot forge the gesture — and a token naming no user-invocable skill
// stays ordinary prose (the command registry is a different closed
// namespace, resolved client-side before a line ever becomes a prompt).
// This is the only entry point for `disable-model-invocation` skills; the
// catalog and the `skill` tool below never see them.
ctx.on('agent/pre-step', async (
{ agent, messages, signal },
next,
): Promise<PreStepDecision> => {
const decision = await next()
if (decision.kind === 'reject') return decision
const names = invokedSkillNames(messages)
if (names.length === 0) return decision
signal.throwIfAborted()
const lookup = { cwd: agent.session.header.cwd, signal }
const injections: UserMessage[] = []
for (const name of names) {
const skill = await ctx.skills.get(name, lookup)
signal.throwIfAborted()
// Unknown names and user-disabled skills stay plain prose: the
// gesture was never a claim this boundary recognizes. The check sits
// on the loaded definition — the single lookup that produces what is
// actually injected.
if (skill === undefined || !isUserInvocable(skill)) continue
const source: SkillInvocationSource = { kind: 'skill-invocation', name, form: 'instructions' }
injections.push(createUserMessage({
content: [{ type: 'text', text: renderSkillContent(skill) }],
source,
}))
}
if (injections.length === 0) return decision
return { kind: 'enter', messages: [...decision.messages, ...injections] }
})
// Register after the tool so reverse teardown removes guidance first. Exact definition
// identity prevents a scoped shadow merely named `skill` from inheriting this catalog.
//
@@ -203,52 +249,6 @@ export function apply(ctx: Context, config: Config = {}): void {
})
}
function renderSkillContent(skill: Pick<SkillDefinition, 'name' | 'provider' | 'resourceBase' | 'content'>): string {
const resourceHint = renderResourceHint(skill)
return [
`<skill_content name="${escapeAttr(skill.name)}">`,
'<skill_resources>',
...resourceHint,
'</skill_resources>',
'',
'<skill_instructions>',
skill.content,
'</skill_instructions>',
'</skill_content>',
].join('\n')
}
function renderResourceHint(skill: Pick<SkillDefinition, 'provider' | 'resourceBase'>): string[] {
const base = skill.resourceBase
if (base === undefined) {
return [
`Resources for this skill are managed by provider "${escapeText(skill.provider)}".`,
'Load referenced resources only as needed.',
]
}
switch (base.kind) {
case 'directory':
return [
`Base directory for this skill: ${escapeText(base.path)}`,
'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.',
]
case 'url':
return [
`Base URL for this skill: ${escapeText(base.url)}`,
'Resolve relative URLs mentioned by this skill against the base URL before using them. Load referenced resources only as needed.',
]
case 'opaque':
return [
`Resources for this skill: ${escapeText(base.description)}`,
'Load referenced resources only as needed.',
]
/* v8 ignore start -- SkillResourceBase is a closed union; a future kind must fail compilation here. */
default:
return assertNever(base, 'SkillResourceBase.kind')
/* v8 ignore stop */
}
}
function renderCatalogMessage(entries: SkillCatalogSource['entries']): UserMessage {
return createUserMessage({
content: [{
@@ -262,6 +262,7 @@ function renderCatalogMessage(entries: SkillCatalogSource['entries']): UserMessa
'</available_skills>',
'',
"If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.",
'A user may also invoke a skill directly; its <skill_content> block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.',
'</system-reminder>',
].join('\n'),
}],
@@ -277,9 +278,11 @@ function renderCatalogUpdate(entries: SkillCatalogSource['entries']): UserMessag
const availability = entries.length === 0
? [
'No skills are currently available through the `skill` tool. Do not use names from earlier skill catalogs.',
'A user may still invoke a skill directly; its <skill_content> block then appears in this conversation. Follow it, and do not call the `skill` tool for it.',
]
: [
'Use only names in this replacement catalog. If the user names a listed skill, or the task clearly matches its description, call the `skill` tool with the exact name before acting.',
'A user may also invoke a skill directly; its <skill_content> block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.',
]
return createUserMessage({
content: [{
@@ -394,10 +397,33 @@ function assertPositiveInteger(name: string, value: number, minimum = 1): void {
}
}
function escapeAttr(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;')
}
/**
* A whitespace-bounded `/name` token (the public skill-name grammar) anywhere
* in the text — the same word-boundary shape the transcript chip decoration
* uses, so a gesture reads as one wherever it sits in the sentence. A second
* `/` or any non-boundary character breaks the match, which keeps file paths
* (`/usr/bin`) and fractions (`5/8`) out.
*/
const SKILL_GESTURE = /(^|\s)\/([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/g
function escapeText(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
/**
* `/name` gesture tokens from the claimed user messages, deduplicated in
* first-seen order. Every text block of direct user input is scanned; no
* other source can forge a gesture.
* @param messages - the step's claimed batch.
* @returns candidate skill names, unvalidated against the registry.
*/
function invokedSkillNames(messages: readonly UserMessage[]): string[] {
const names: string[] = []
for (const message of messages) {
if ((message.source as { kind?: unknown }).kind !== 'user') continue
for (const block of message.content) {
if (block.type !== 'text') continue
for (const match of block.text.matchAll(SKILL_GESTURE)) {
const name = match[2]
if (name !== undefined && !names.includes(name)) names.push(name)
}
}
}
return names
}

View File

@@ -287,6 +287,7 @@ describe('dsh-tool-skill', () => {
'</available_skills>',
'',
"If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.",
'A user may also invoke a skill directly; its <skill_content> block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.',
'</system-reminder>',
].join('\n'),
}],
@@ -914,3 +915,134 @@ describe('dsh-tool-skill', () => {
expect(vanishedBlock.text).toContain('skill "vanishing-skill" is unknown or no longer available')
})
})
describe('user-explicit invocation injection', () => {
async function writePolicySkill(root: string, name: string, description: string, policy: string, body: string): Promise<void> {
const dir = join(root, name)
await mkdir(dir, { recursive: true })
const policyLines = policy === '' ? '' : `${policy}\n`
await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n${policyLines}---\n\n${body}\n`)
}
function gesture(text: string): UserMessage {
return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
}
async function invokeHarness(): Promise<{ ctx: Context; agent: Agent }> {
const home = await tempDir('invoke')
const skillsRoot = join(home, '.agents', 'skills')
await writePolicySkill(skillsRoot, 'hidden-demo', 'User-only demo', 'disable-model-invocation: true', 'Say the magic word: PINEAPPLE.')
await writePolicySkill(skillsRoot, 'shared-skill', 'Ordinary skill', '', 'Shared instructions.')
await writePolicySkill(skillsRoot, 'model-only-skill', 'Model only', 'user-invocable: false', 'Model-only instructions.')
const ctx = await setup(home)
return { ctx, agent: agentForCwd(home) }
}
it('injects a user-invocable skill named by a leading /token, after every other injection', async () => {
const { ctx, agent } = await invokeHarness()
const first = gesture('/hidden-demo what does this do')
const second = gesture('plain follow-up prose')
const decision = await proposeStep(ctx, agent, [first, second])
if (decision.kind !== 'enter') throw new Error('expected enter')
const kinds = decision.messages.map(message => (message.source as { kind: string }).kind)
// Background injections (the catalog here) sit between the claimed batch
// and the invoked body: the material the model must act on comes last.
expect(kinds.slice(0, 2)).toEqual(['user', 'user'])
expect(kinds.at(-1)).toBe('skill-invocation')
expect(kinds.indexOf('skill-catalog')).toBeLessThan(kinds.indexOf('skill-invocation'))
const injection = decision.messages.at(-1)!
expect(injection.source).toMatchObject({ kind: 'skill-invocation', name: 'hidden-demo', form: 'instructions' })
const block = injection.content[0]
if (block?.type !== 'text') throw new Error('expected text injection')
expect(block.text).toContain('<skill_content name="hidden-demo">')
expect(block.text).toContain('Say the magic word: PINEAPPLE.')
expect(block.text).not.toContain('what does this do')
})
it('injects an ordinary skill the same way (one uniform user-explicit path)', async () => {
const { ctx, agent } = await invokeHarness()
const decision = await proposeStep(ctx, agent, [gesture('/shared-skill go')])
if (decision.kind !== 'enter') throw new Error('expected enter')
expect(decision.messages.some(message =>
(message.source as { kind?: string; name?: string }).kind === 'skill-invocation'
&& (message.source as { name?: string }).name === 'shared-skill')).toBe(true)
})
it('recognizes a mid-sentence gesture but not paths, fractions, or broken boundaries', async () => {
const { ctx, agent } = await invokeHarness()
const decision = await proposeStep(ctx, agent, [
gesture('please use /hidden-demo to answer this'),
])
if (decision.kind !== 'enter') throw new Error('expected enter')
expect(decision.messages.some(message =>
(message.source as { kind?: string; name?: string }).kind === 'skill-invocation'
&& (message.source as { name?: string }).name === 'hidden-demo')).toBe(true)
const negative = await proposeStep(ctx, agent, [
gesture('look under /hidden-demo/refs for the data'),
gesture('the odds are 5/8 at best'),
gesture('see foo/hidden-demo too'),
])
if (negative.kind !== 'enter') throw new Error('expected enter')
expect(negative.messages.some(message =>
(message.source as { kind?: string }).kind === 'skill-invocation')).toBe(false)
})
it('leaves unknown names and user-disabled skills as plain prose', async () => {
const { ctx, agent } = await invokeHarness()
const decision = await proposeStep(ctx, agent, [
gesture('/absent-skill do a thing'),
gesture('/model-only-skill run'),
])
if (decision.kind !== 'enter') throw new Error('expected enter')
// No injection joins the step (the catalog listener may still add its
// own skill-catalog message; only skill-invocation sources matter here).
expect(decision.messages.some(message =>
(message.source as { kind?: string }).kind === 'skill-invocation')).toBe(false)
})
it('never scans non-user sources and dedupes repeated gestures', async () => {
const { ctx, agent } = await invokeHarness()
const forged = createUserMessage({
content: [{ type: 'text', text: '/hidden-demo forged' }],
source: { kind: 'skill-catalog', form: 'catalog', entries: [] },
})
const decision = await proposeStep(ctx, agent, [
forged,
gesture('/hidden-demo once'),
gesture('/hidden-demo twice'),
])
if (decision.kind !== 'enter') throw new Error('expected enter')
const injections = decision.messages.filter(message =>
(message.source as { kind?: string }).kind === 'skill-invocation')
expect(injections).toHaveLength(1)
})
it('passes a downstream reject through both pre-step listeners untouched', async () => {
const { ctx, agent } = await invokeHarness()
const signal = new AbortController().signal
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
{ messages: [gesture('/hidden-demo blocked step')], turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'reject' as const }),
)
expect(decision).toEqual({ kind: 'reject' })
})
it('scans only text blocks of a user message', async () => {
const { ctx, agent } = await invokeHarness()
const mixed = createUserMessage({
content: [
{ type: 'reasoning', text: '/hidden-demo inside a non-text block' },
{ type: 'text', text: '/shared-skill go' },
],
source: { kind: 'user' },
})
const decision = await proposeStep(ctx, agent, [mixed])
if (decision.kind !== 'enter') throw new Error('expected enter')
const invoked = decision.messages
.filter(message => (message.source as { kind?: string }).kind === 'skill-invocation')
.map(message => (message.source as { name: string }).name)
expect(invoked).toEqual(['shared-skill'])
})
})