fix(scope): harden merged tool and skill boundaries

This commit is contained in:
Tianyi Cui
2026-07-11 23:41:37 +08:00
parent 172005e0e6
commit cf255eebb1
14 changed files with 283 additions and 42 deletions

View File

@@ -6,7 +6,7 @@ Requires `ctx.tools` and `ctx.skills` (`inject: ['tools', 'skills']`).
## Session-prefix catalog
The plugin contributes one user-role `<system-reminder>` catalog through `agent/session-prefix`. It resolves skills for the calling session's cwd, forwards the prefix abort signal to discovery, and lists only sorted `name` and `description` entries; skill bodies, paths, sources, providers, and `whenToUse` hints remain outside the catalog. The catalog is omitted when no model-invocable skills are available.
The plugin contributes one user-role `<system-reminder>` catalog through `agent/session-prefix`. It resolves skills for the calling session's cwd, forwards the prefix abort signal to discovery, and lists only sorted `name` and `description` entries; skill bodies, paths, sources, providers, and `whenToUse` hints remain outside the catalog. The catalog is omitted when no model-invocable skills are available, and also when that agent's tool view restricts away the shipped `skill` tool or resolves a same-name scoped shadow instead. This exact-definition check keeps prompt guidance, the model-visible schema, and executable dispatch aligned.
`catalogDescriptionMaxLength` controls normalized, XML-escaped catalog descriptions. Its default is `500` and values must be integers of at least `3`, which reserves room for a truncation ellipsis. The [session-prefix RFC](../../../docs/rfc/implemented/feature/2026-07-07-session-prefix.md) defines the request-only, header-logged lifecycle of this message.

View File

@@ -34,6 +34,7 @@
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-local": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",

View File

@@ -26,18 +26,16 @@ export const Config: z<Config> = z.object({
catalogDescriptionMaxLength: z.number().default(DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH),
})
/** Register the session-prefix skill catalog and the model-facing skill loader. */
/**
* Register the model-facing skill loader and its visibility-matched
* session-prefix catalog. The catalog is emitted only when the calling agent
* resolves this plugin's exact tool registration; a restriction or scoped
* same-name shadow therefore removes both the schema and its call guidance.
*/
export function apply(ctx: Context, config: Config = {}): void {
const catalogDescriptionMaxLength = config.catalogDescriptionMaxLength ?? DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH
assertPositiveInteger('catalogDescriptionMaxLength', catalogDescriptionMaxLength, 3)
ctx.on('agent/session-prefix', async (agent, _prefix, signal, next): Promise<Message[]> => {
const skills = await ctx.skills.list({ cwd: agent.session.header.cwd, signal })
const rest = await next()
if (skills.length === 0) return rest
return [renderCatalogMessage(skills, catalogDescriptionMaxLength), ...rest]
})
const skillTool = defineTool({
name: 'skill',
description: 'Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.',
@@ -62,6 +60,23 @@ export function apply(ctx: Context, config: Config = {}): void {
},
})
ctx.tools.register(skillTool)
const registeredSkillTool = ctx.tools.get(skillTool.name)
/* v8 ignore next 3 -- register() publishes synchronously or throws; this guards future registry drift. */
if (registeredSkillTool === undefined) {
throw new Error('dsh-tool-skill: registered skill tool is not visible in the global registry')
}
// Register after the tool so reverse-order fiber teardown removes this
// guidance listener before its referenced tool. Exact definition identity is
// the shared truth for restrictions and scoped shadows: another tool merely
// named `skill` must not inherit this plugin's catalog or instructions.
ctx.on('agent/session-prefix', async (agent, _prefix, signal, next): Promise<Message[]> => {
if (ctx.tools.get(skillTool.name, agent) !== registeredSkillTool) return await next()
const skills = await ctx.skills.list({ cwd: agent.session.header.cwd, signal })
const rest = await next()
if (skills.length === 0) return rest
return [renderCatalogMessage(skills, catalogDescriptionMaxLength), ...rest]
})
}
function renderSkillContent(skill: SkillDefinition): string {

View File

@@ -4,8 +4,9 @@ import { join } from 'node:path'
import { tmpdir } from 'node:os'
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 from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineTool } 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'
@@ -36,7 +37,10 @@ function agentForCwd(cwd: string): Agent {
}
async function composePrefix(ctx: Context, cwd: string, signal = new AbortController().signal): Promise<Message[]> {
const agent = agentForCwd(cwd)
return await composePrefixForAgent(ctx, agentForCwd(cwd), signal)
}
async function composePrefixForAgent(ctx: Context, agent: Agent, signal = new AbortController().signal): Promise<Message[]> {
const empty: Message[] = []
return await agentEvents(ctx, agent).waterfall(
'agent/session-prefix', empty, signal,
@@ -44,6 +48,15 @@ async function composePrefix(ctx: Context, cwd: string, signal = new AbortContro
)
}
async function mintAgentScope(ctx: Context, cwd: string): Promise<{ agent: Agent; scope: Scope }> {
const agent = agentForCwd(cwd)
let scope!: Scope
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, {
inject: ['tools'],
}))
return { agent, scope }
}
describe('dsh-tool-skill', () => {
it('registers the skill tool schema and removes it on dispose', async () => {
const ctx = new Context()
@@ -154,6 +167,39 @@ describe('dsh-tool-skill', () => {
expect(await composePrefix(ctx, '/workspace')).toEqual([])
})
it('omits catalog guidance when the calling agent restricts away the shipped skill tool', async () => {
const home = await tempDir('tool-restricted-catalog')
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.restrict({ deny: ['skill'] })
expect(ctx.tools.get('skill', agent)).toBeUndefined()
expect(await composePrefixForAgent(ctx, agent)).toEqual([])
expect(await composePrefix(ctx, '/workspace')).toHaveLength(1)
await scope.dispose()
})
it('does not attach shipped catalog guidance to a scoped same-name tool shadow', async () => {
const home = await tempDir('tool-shadowed-catalog')
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({
name: 'skill',
description: 'A scoped tool with unrelated semantics.',
parameters: {},
execute() {
return Promise.resolve([{ type: 'text', text: 'shadow' }])
},
}))
expect(ctx.tools.get('skill', agent)).not.toBe(ctx.tools.get('skill'))
expect(await composePrefixForAgent(ctx, agent)).toEqual([])
expect(await composePrefix(ctx, '/workspace')).toHaveLength(1)
await scope.dispose()
})
it('validates the catalog description cap', async () => {
const home = await tempDir('tool-invalid-catalog-cap')
const ctx = new Context()

View File

@@ -9,6 +9,7 @@
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../core/scope" },
{ "path": "../../llm/llm" },
{ "path": "../../core/agent" },
{ "path": "../skill" },