feat(skill): share renderSkillContent and declare the skill-invocation message source
The model-facing <skill_content> rendering moves from dsh-tool-skill to the dsh-skill seam so the skill tool result and the upcoming user-explicit invocation injection share one canonical shape. The seam also declares the skill-invocation MessageSource kind that injection will stamp on its user-role messages.
This commit is contained in:
@@ -26,6 +26,7 @@
|
|||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||||
|
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||||
"cordis": "^4.0.0-rc.7"
|
"cordis": "^4.0.0-rc.7"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||||
|
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||||
"cordis": "^4.0.0-rc.7"
|
"cordis": "^4.0.0-rc.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Context, Service } from 'cordis'
|
import { Context, Service } from 'cordis'
|
||||||
|
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||||
import z from 'schemastery'
|
import z from 'schemastery'
|
||||||
import type Schema from 'schemastery'
|
import type Schema from 'schemastery'
|
||||||
|
|
||||||
@@ -119,6 +120,96 @@ export function isUserInvocable(skill: Pick<SkillSummary, 'invocation'>): boolea
|
|||||||
return skill.invocation.userInvocable
|
return skill.invocation.userInvocable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Durable message source for a user-explicit skill invocation: the host
|
||||||
|
* injects the rendered skill as a user-role message carrying this source, so
|
||||||
|
* transcript consumers present the invocation 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
|
||||||
|
/** Trailing free text the user submitted after the skill token, when present. */
|
||||||
|
readonly args?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
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. */
|
/** One catalog observation plus whether discovery completed within a stable catalog revision. */
|
||||||
export interface SkillCatalogSnapshot {
|
export interface SkillCatalogSnapshot {
|
||||||
/** Sorted invocation-neutral summaries collected in this observation. */
|
/** Sorted invocation-neutral summaries collected in this observation. */
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Context } from 'cordis'
|
|||||||
import SkillService, {
|
import SkillService, {
|
||||||
isModelInvocable,
|
isModelInvocable,
|
||||||
isUserInvocable,
|
isUserInvocable,
|
||||||
|
renderSkillContent,
|
||||||
type SkillCandidate,
|
type SkillCandidate,
|
||||||
type SkillDefinition,
|
type SkillDefinition,
|
||||||
type SkillInvocationPolicy,
|
type SkillInvocationPolicy,
|
||||||
@@ -1013,3 +1014,65 @@ describe('SkillService registry', () => {
|
|||||||
expect(await ctx.skills.get('same-skill')).toBeUndefined()
|
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": "../../../vendor/schemastery"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "../../llm/llm"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "../../support/invariants"
|
"path": "../../support/invariants"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import type { Context } from 'cordis'
|
|||||||
import z from 'schemastery'
|
import z from 'schemastery'
|
||||||
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
|
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
|
||||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
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 type { UserMessage } from '@deepseek-ai/dsh-session'
|
||||||
import {
|
import {
|
||||||
|
escapeText,
|
||||||
isModelInvocable,
|
isModelInvocable,
|
||||||
isSkillName,
|
isSkillName,
|
||||||
type SkillDefinition,
|
renderSkillContent,
|
||||||
type SkillSummary,
|
type SkillSummary,
|
||||||
} from '@deepseek-ai/dsh-skill'
|
} from '@deepseek-ai/dsh-skill'
|
||||||
|
|
||||||
@@ -203,52 +204,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 {
|
function renderCatalogMessage(entries: SkillCatalogSource['entries']): UserMessage {
|
||||||
return createUserMessage({
|
return createUserMessage({
|
||||||
content: [{
|
content: [{
|
||||||
@@ -393,11 +348,3 @@ function assertPositiveInteger(name: string, value: number, minimum = 1): void {
|
|||||||
throw new Error(`tool-skill: ${name} must be an integer greater than or equal to ${minimum}`)
|
throw new Error(`tool-skill: ${name} must be an integer greater than or equal to ${minimum}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeAttr(value: string): string {
|
|
||||||
return value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<')
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeText(value: string): string {
|
|
||||||
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user