Files
deepseek-harness/packages/skill/tool-skill/src/index.ts
Yichen Jiang 3d68185480 feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.

`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.

A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.

Two bugs the real-composition test caught, both invisible to unit tests:

`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.

`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.

The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.

Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.

The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.

The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-07 00:35:30 +08:00

404 lines
16 KiB
TypeScript

/**
* Durable session skill catalog and model-facing `skill` loader tool.
*
* @module @deepseek-ai/dsh-tool-skill
*/
import { createHash } from 'node:crypto'
import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { assertNever, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { UserMessage } from '@deepseek-ai/dsh-session'
import {
isModelInvocable,
isSkillName,
type SkillDefinition,
type SkillSummary,
} from '@deepseek-ai/dsh-skill'
export const name = 'tool-skill'
export const inject = ['agents', 'tools', 'skills']
const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500
/**
* Durable provenance for one published session skill catalog. The catalog is a
* `catalog`-form context, so it records the entries it published beside the
* model-facing prose: a consumer presenting the list must not re-parse the
* `<available_skills>` block, whose framing exists for the model.
*/
export interface SkillCatalogSource {
readonly kind: 'skill-catalog'
readonly form: 'catalog'
/** Marks a replacement catalog rather than this session's first publication. */
readonly update?: true
/** Exactly the entries this message published, in catalog order. */
readonly entries: readonly { readonly name: string; readonly description: string }[]
}
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
'skill-catalog': SkillCatalogSource
}
}
/** Durable entry list mirroring the rendered catalog lines, for non-model consumers. */
function catalogSourceEntries(
skills: SkillSummary[],
descriptionMaxLength: number,
): SkillCatalogSource['entries'] {
return skills.map(skill => ({
name: skill.name,
description: catalogDescription(skill.description, descriptionMaxLength),
}))
}
/** Model-facing skill catalog configuration. */
export interface Config {
/** Maximum normalized description length rendered in the session catalog; minimum 3. */
catalogDescriptionMaxLength?: number
}
/** Validate and default the model-facing skill catalog configuration. */
export const Config: z<Config> = z.object({
catalogDescriptionMaxLength: z.number().default(DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH),
})
/**
* Register the model-facing skill loader and its visibility-matched
* durable session 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)
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.',
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}"`)
}
const lookup = { cwd: exec.agent?.session.header.cwd, signal: exec.signal }
const summary = (await ctx.skills.list(lookup)).find(skill => skill.name === args.name)
if (!summary) {
throw new Error(`skill "${args.name}" is unknown or no longer available`)
}
if (!isModelInvocable(summary)) {
throw new Error(`skill "${args.name}" is not available for model invocation`)
}
const skill = await ctx.skills.get(args.name, lookup)
if (!skill) {
throw new Error(`skill "${args.name}" is unknown or no longer available`)
}
if (!isModelInvocable(skill)) {
throw new Error(`skill "${args.name}" is not available for model invocation`)
}
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 }
},
})
ctx.tools.register(skillTool)
// Register after the tool so reverse teardown removes guidance first. Exact definition
// identity prevents a scoped shadow merely named `skill` from inheriting this catalog.
//
// The comparison is against the definition this plugin registered, not against
// a lookup of its own name: `register()` files into the CALLING context's
// scope, so a plugin mounted inside an agent preset registers for that agent
// alone and an unscoped lookup correctly finds nothing.
ctx.on('agent/pre-step', async (
{ agent, signal },
next,
): Promise<PreStepDecision> => {
const decision = await next()
if (decision.kind === 'reject') return decision
signal.throwIfAborted()
const toolVisible = ctx.tools.get(skillTool.name, agent) === skillTool
const snapshot = toolVisible
? await ctx.skills.snapshot({ cwd: agent.session.header.cwd, signal })
: { skills: [], complete: true }
signal.throwIfAborted()
if (!snapshot.complete) return decision
const skills = snapshot.skills.filter(isModelInvocable)
const entries = catalogSourceEntries(skills, catalogDescriptionMaxLength)
const digest = digestCatalogEntries(entries)
const history = catalogHistory(agent)
const existing = catalogMessage(decision.messages)
if (history.visibleDigest === digest) {
return existing === undefined
? decision
: { kind: 'enter', messages: decision.messages.filter(message => message.id !== existing.message.id) }
}
if (existing !== undefined && digestCatalogEntries(existing.entries) === digest) return decision
if (!history.published && skills.length === 0) {
return existing === undefined
? decision
: { kind: 'enter', messages: decision.messages.filter(message => message.id !== existing.message.id) }
}
const catalog = history.published
? renderCatalogUpdate(entries)
: renderCatalogMessage(entries)
return {
kind: 'enter',
messages: existing === undefined
? [...decision.messages, catalog]
: decision.messages.map(message => message.id === existing.message.id ? catalog : message),
}
})
}
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 {
return createUserMessage({
content: [{
type: 'text',
text: [
'<system-reminder>',
'A skill is a reusable set of task-specific instructions. The following skills are available in this session:',
'',
'<available_skills>',
...renderCatalogEntries(entries),
'</available_skills>',
'',
"If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.",
'</system-reminder>',
].join('\n'),
}],
source: {
kind: 'skill-catalog',
form: 'catalog',
entries,
},
})
}
function renderCatalogUpdate(entries: SkillCatalogSource['entries']): UserMessage {
const availability = entries.length === 0
? [
'No skills are currently available through the `skill` tool. Do not use names from earlier skill catalogs.',
]
: [
'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.',
]
return createUserMessage({
content: [{
type: 'text',
text: [
'<system-reminder>',
'The available skill catalog changed. This complete catalog replaces every earlier available-skills list in this session:',
'',
'<available_skills>',
...renderCatalogEntries(entries),
'</available_skills>',
'',
...availability,
'</system-reminder>',
].join('\n'),
}],
source: {
kind: 'skill-catalog',
form: 'catalog',
update: true,
entries,
},
})
}
/**
* Model-facing catalog lines, projected from the same entries the source records.
* The pseudo-XML escaping belongs to this frame, not to the published fact, so it
* is applied here and never stored. Names are `isSkillName`-validated and carry
* no escapable character.
*/
function renderCatalogEntries(entries: SkillCatalogSource['entries']): string[] {
return entries.map(entry => `- \`${entry.name}\`: ${escapeText(entry.description)}`)
}
/**
* Catalog identity over the durable entry list rather than the rendered prose.
* The entries are what changes; the surrounding `<system-reminder>` framing is
* written for the model and must not decide whether a republish is needed.
*/
function digestCatalogEntries(entries: SkillCatalogSource['entries']): string {
// JSON per entry rather than a separator character: every separator is itself
// a legal description character, so only quoting makes the boundary exact.
const canonical = entries.map(entry => JSON.stringify([entry.name, entry.description])).join('\n')
return createHash('sha256')
.update(canonical)
.digest('hex')
}
/**
* Entries of one durable catalog message, or undefined when the record is not a
* usable catalog.
*
* `agent.session.events` may be a resumed, forked, or externally written seed,
* and seed validation only guarantees a source object with a non-empty `kind`;
* no per-kind field is checked there. An unreadable record is therefore treated
* as "not this plugin's catalog" — the posture the replaced content digest had —
* rather than throwing inside the step listener, which would fail every
* subsequent turn of that session.
*/
function readCatalogEntries(source: unknown): SkillCatalogSource['entries'] | undefined {
const entries = (source as { entries?: unknown }).entries
if (!Array.isArray(entries)) return undefined
const readable: { name: string; description: string }[] = []
for (const entry of entries as readonly unknown[]) {
if (typeof entry !== 'object' || entry === null) return undefined
const { name, description } = entry as { name?: unknown; description?: unknown }
if (typeof name !== 'string' || name === '' || typeof description !== 'string') return undefined
readable.push({ name, description })
}
return readable
}
function catalogHistory(agent: Agent): { visibleDigest?: string; published: boolean } {
const visible = new Set(agent.session.surface.nodes)
const events = agent.session.events
let published = false
for (let index = events.length - 1; index >= 0; index -= 1) {
// The loop bounds prove the read-only event view contains this index.
// oxlint-disable-next-line typescript/no-non-null-assertion
const event = events[index]!
if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') continue
const entries = readCatalogEntries(event.data.source)
if (entries === undefined) continue
const digest = digestCatalogEntries(entries)
published = true
if (visible.has(event.seq)) return { visibleDigest: digest, published }
}
return { published }
}
function catalogMessage(
messages: readonly UserMessage[],
): { message: UserMessage; entries: SkillCatalogSource['entries'] } | undefined {
for (const message of messages) {
if (message.source.kind !== 'skill-catalog') continue
const entries = readCatalogEntries(message.source)
if (entries !== undefined) return { message, entries }
}
return undefined
}
/** Normalized, length-bounded description exactly as the catalog publishes it (unescaped). */
function catalogDescription(value: string, maxLength: number): string {
const normalized = value.replaceAll(/\s+/g, ' ').trim()
return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength - 3)}...`
}
function assertPositiveInteger(name: string, value: number, minimum = 1): void {
if (!Number.isInteger(value) || value < 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('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;')
}
function escapeText(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
}