Merge origin/master into codex/dsh-badge-plugin
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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。
|
||||
|
||||
## 提供方契约
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -122,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('&', '&').replaceAll('"', '"').replaceAll('<', '<')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
||||
}
|
||||
|
||||
/** One catalog observation plus whether discovery completed within a stable catalog revision. */
|
||||
export interface SkillCatalogSnapshot {
|
||||
/** Sorted invocation-neutral summaries collected in this observation. */
|
||||
|
||||
@@ -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 <bundle>')
|
||||
})
|
||||
|
||||
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 <hub>".')
|
||||
})
|
||||
|
||||
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"&<y">')
|
||||
expect(text).toContain('Keep </skill_content> and <tags> as-is.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user