feat: add canonical typed tool outputs
This commit is contained in:
@@ -16,7 +16,7 @@ The plugin contributes one user-role `<system-reminder>` catalog through `agent/
|
||||
|---|---|---|
|
||||
| `name` | string (required) | Exact kebab-case skill name from the available skills listing. |
|
||||
|
||||
Execution uses the calling agent's `session.header.cwd` so workspace-sensitive providers resolve the winning skill. A successful call returns one text result containing `<skill_content name="...">`, `<skill_resources>`, and `<skill_instructions>`.
|
||||
Execution uses the calling agent's `session.header.cwd` so workspace-sensitive providers resolve the winning skill. A successful call returns canonical `{ name, provider, resourceBase?, content }`, excluding catalog ranking and provider-internal machinery; its Native renderer produces one text result containing `<skill_content name="...">`, `<skill_resources>`, and `<skill_instructions>`.
|
||||
|
||||
Resource guidance resolves only paths or URLs explicitly referenced by the instructions against `resourceBase`; scripts, references, and assets load on demand, and the result does not enumerate a skill directory. Local providers may supply a directory, while remote or embedded providers may supply a URL or opaque loading guidance.
|
||||
|
||||
|
||||
@@ -42,6 +42,46 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
parameters: {
|
||||
name: { type: 'string', required: true, description: 'The exact skill name from the available skills list.' },
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
name: { type: 'string', required: true },
|
||||
provider: { type: 'string', required: true },
|
||||
resourceBase: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
kind: { type: 'string', required: true, const: 'directory' },
|
||||
path: { type: 'string', required: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
kind: { type: 'string', required: true, const: 'url' },
|
||||
url: { type: 'string', required: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
kind: { type: 'string', required: true, const: 'opaque' },
|
||||
description: { type: 'string', required: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
content: { type: 'string', required: true },
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{ type: 'text', text: renderSkillContent(value) }],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
if (!isSkillName(args.name)) {
|
||||
throw new Error(`invalid skill name "${args.name}"`)
|
||||
@@ -53,7 +93,14 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
if (skill.disableModelInvocation === true) {
|
||||
throw new Error(`skill "${args.name}" is not available for model invocation`)
|
||||
}
|
||||
return [{ type: 'text', text: renderSkillContent(skill) }]
|
||||
return {
|
||||
name: skill.name,
|
||||
provider: skill.provider,
|
||||
...skill.resourceBase !== undefined ? {
|
||||
resourceBase: { ...skill.resourceBase },
|
||||
} : {},
|
||||
content: skill.content,
|
||||
}
|
||||
},
|
||||
presentCall(args) {
|
||||
return { card: 'generic', title: `Load skill ${args.name}`, kind: 'read', rawInput: args.name }
|
||||
@@ -77,7 +124,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
})
|
||||
}
|
||||
|
||||
function renderSkillContent(skill: SkillDefinition): string {
|
||||
function renderSkillContent(skill: Pick<SkillDefinition, 'name' | 'provider' | 'resourceBase' | 'content'>): string {
|
||||
const resourceHint = renderResourceHint(skill)
|
||||
return [
|
||||
`<skill_content name="${escapeAttr(skill.name)}">`,
|
||||
@@ -92,7 +139,7 @@ function renderSkillContent(skill: SkillDefinition): string {
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function renderResourceHint(skill: SkillDefinition): string[] {
|
||||
function renderResourceHint(skill: Pick<SkillDefinition, 'provider' | 'resourceBase'>): string[] {
|
||||
const base = skill.resourceBase
|
||||
if (base === undefined) {
|
||||
return [
|
||||
@@ -116,8 +163,10 @@ function renderResourceHint(skill: SkillDefinition): string[] {
|
||||
`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 */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Context } from 'cordis'
|
||||
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
|
||||
import { createScope, type Scope } from '@deepseek-ai/dsh-scope'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
|
||||
@@ -185,7 +185,7 @@ describe('dsh-tool-skill', () => {
|
||||
const ctx = await setup(home)
|
||||
ctx.skills.register({ name: 'listed-skill', description: 'Listed', source: 'runtime', content: 'body' })
|
||||
const { agent, scope } = await mintAgentScope(ctx, '/workspace')
|
||||
scope.ctx.tools.register(defineTool({
|
||||
scope.ctx.tools.register(defineContentToolFixture({
|
||||
name: 'skill',
|
||||
description: 'A scoped tool with unrelated semantics.',
|
||||
parameters: {},
|
||||
@@ -226,6 +226,13 @@ describe('dsh-tool-skill', () => {
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected skill success')
|
||||
expect(result.value).toEqual({
|
||||
name: 'project-skill',
|
||||
provider: 'local',
|
||||
resourceBase: { kind: 'directory', path: join(project, '.dsh/skills/project-skill') },
|
||||
content: 'Project instructions.',
|
||||
})
|
||||
const block = result.content[0]
|
||||
expect(block?.type).toBe('text')
|
||||
if (block?.type !== 'text') throw new Error('expected text skill result')
|
||||
@@ -283,7 +290,7 @@ describe('dsh-tool-skill', () => {
|
||||
expect(provider.content[0].text).toContain('<skill_resources>\nResources for this skill are managed by provider "runtime".\nLoad referenced resources only as needed.\n</skill_resources>')
|
||||
})
|
||||
|
||||
it('fails loud on an unknown resource base kind', async () => {
|
||||
it('rejects an unknown resource-base kind at the canonical output boundary', async () => {
|
||||
const home = await tempDir('tool-resource-assert-never')
|
||||
const ctx = await setup(home)
|
||||
ctx.skills.register({
|
||||
@@ -298,9 +305,10 @@ describe('dsh-tool-skill', () => {
|
||||
const result = await ctx.tools.execute({ callId: CallId('c5'), name: 'skill', arguments: { name: 'rogue-resource-skill' } })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error?.info?.code).toBe('INVALID_TOOL_OUTPUT')
|
||||
const block = result.content[0]
|
||||
if (block?.type !== 'text') throw new Error('expected text tool result')
|
||||
expect(block.text).toContain('unreachable variant')
|
||||
expect(block.text).toContain('value.resourceBase')
|
||||
})
|
||||
|
||||
it('returns isError for unknown, invalid, and model-disabled skills', async () => {
|
||||
|
||||
Reference in New Issue
Block a user