feat(skill): hot-refresh skill catalogs

This commit is contained in:
Yichen Jiang
2026-07-27 16:50:56 +08:00
parent 79eb3a9035
commit b76659aa57
46 changed files with 2372 additions and 153 deletions

View File

@@ -55,6 +55,7 @@
"@cordisjs/plugin-timer": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-bash-sandbox": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",

View File

@@ -8,8 +8,10 @@ import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import * as agentCore from '../src/index.ts'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { CallId, LlmAdapter, LlmError, type GenerateOptions, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import * as sessionInvariant from '@deepseek-ai/dsh-session/invariant'
@@ -399,6 +401,147 @@ describe('dsh-agent-spine-demo bundle', () => {
await ctx.fiber.dispose()
})
it('snapshots a created project skill through catalog refresh and progressive loading', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-skill-refresh-'))
const home = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-skill-refresh-home-'))
try {
await mkdir(join(root, '.git'), { recursive: true })
const skillPath = '.agents/skills/hot-skill/SKILL.md'
const skillSource = '---\nname: hot-skill\ndescription: Hot-added skill\n---\n\nUse the freshly loaded body.\n'
const adapter = new MockAdapter([
toolCallResponse('mkdir-skill', 'bash', {
command: 'mkdir -p .agents/skills/hot-skill',
description: 'Create the project skill directory',
}),
toolCallResponse('write-skill', 'write', {
file_path: skillPath,
content: skillSource,
}),
toolCallResponse('load-skill', 'skill', { name: 'hot-skill' }),
textResponse('SKILL_REFRESH_OK'),
])
const ctx = await mount({
workspaceContext: false,
skills: {
local: {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watchStabilityThresholdMs: 20,
watchPollIntervalMs: 10,
},
},
})
await ctx.plugin(LocalBashExecutor, {})
await ctx.plugin(LocalFileSystem, { cwd: root })
await ctx.plugin(ToolFs)
ctx.llm.registerAdapter(['mock'], adapter)
const handle = await ctx.agents.create({
sessionId: SessionId('skill-refresh-session'),
meta: { cwd: root },
agentOptions: { provider: 'mock', model: 'mock' },
})
handle.agent.followup([{ type: 'text', text: 'Create and load the project skill.' }])
await waitForIdle(ctx, handle.agent)
expect(adapter.requests).toHaveLength(4)
expect(adapter.requests.slice(0, 2).map(request => request.messages.map(messageText).join('\n')))
.toEqual([
expect.not.stringContaining('hot-skill'),
expect.not.stringContaining('hot-skill'),
])
const catalogRequest = adapter.requests[2]?.messages.map(messageText).join('\n')
expect(catalogRequest).toContain('The available skill catalog changed.')
expect(catalogRequest).toContain('- `hot-skill`: Hot-added skill')
const loadedRequest = JSON.stringify(adapter.requests[3]?.messages)
expect(loadedRequest).toContain('<skill_instructions>')
expect(loadedRequest).toContain('Use the freshly loaded body.')
const transcript = handle.agent.session.events.flatMap<Record<string, unknown>>((event) => {
if (event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'tool-skill') {
return [{
type: event.type,
source: event.data.source,
meta: {
kind: (event.data.meta as { kind?: unknown } | undefined)?.kind,
version: (event.data.meta as { version?: unknown } | undefined)?.version,
digest: typeof (event.data.meta as { digest?: unknown } | undefined)?.digest,
},
text: event.data.content.map(block => block.type === 'text' ? block.text : '').join('\n'),
}]
}
if (event.type === 'tool/result' && ['write-skill', 'load-skill'].includes(event.data.callId)) {
return [{
type: event.type,
callId: event.data.callId,
isError: event.data.isError,
text: event.data.content.map(block => block.type === 'text' ? block.text : '').join('\n')
.replaceAll(root, '{{cwd}}'),
}]
}
return []
})
expect(transcript).toMatchInlineSnapshot(`
[
{
"callId": "write-skill",
"isError": false,
"text": "<path>{{cwd}}/.agents/skills/hot-skill/SKILL.md</path>
<type>file</type>
<content>
Created file
</content>",
"type": "tool/result",
},
{
"meta": {
"digest": "string",
"kind": "skill-catalog",
"version": 1,
},
"source": {
"kind": "plugin",
"plugin": "tool-skill",
},
"text": "<system-reminder>
The available skill catalog changed. This complete catalog replaces every earlier available-skills list in this session:
<available_skills>
- \`hot-skill\`: Hot-added skill
</available_skills>
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.
</system-reminder>",
"type": "user/message",
},
{
"callId": "load-skill",
"isError": false,
"text": "<skill_content name="hot-skill">
<skill_resources>
Base directory for this skill: {{cwd}}/.agents/skills/hot-skill
Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.
</skill_resources>
<skill_instructions>
Use the freshly loaded body.
</skill_instructions>
</skill_content>",
"type": "tool/result",
},
]
`)
await handle.dispose()
await ctx.fiber.dispose()
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('shares top-level dshHome between local skills and the managed bash environment', async () => {
const home = await mkdtemp(join(tmpdir(), 'dsh-agent-core-shared-home-'))
const agentsHome = await mkdtemp(join(tmpdir(), 'dsh-agent-core-shared-agents-'))