@morlay/dsh-context-assembler 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,129 +0,0 @@
1
- import type { Context } from "@deepseek-ai/cordis";
2
- import z from "@deepseek-ai/schemastery";
3
- import type { AssembledSection } from "@deepseek-ai/dsh-system-prompt";
4
- import { ContextAssembler, type PromptEntry } from "./channel.ts";
5
- import { DEFAULT_KEEP, DEFAULT_REPLACE, DEFAULT_SUPPRESS } from "./defaults.ts";
6
- import { RULES_SECTION, latestReminderText, reminderMessage } from "./reminder.ts";
7
- import { RULES_TEXT } from "./rules.ts";
8
-
9
- export { DEFAULT_KEEP, DEFAULT_REPLACE, DEFAULT_SUPPRESS } from "./defaults.ts";
10
- export type {
11
- InjectionMode,
12
- PromptEntry,
13
- PromptRuleDeclaration,
14
- PromptSkillDeclaration,
15
- } from "./channel.ts";
16
- export { ContextAssembler } from "./channel.ts";
17
- export { renderVirtualSkill } from "./reminder.ts";
18
-
19
- export const name = "context-assembler";
20
-
21
- /** 规则声明的位置:紧跟部署 persona(order 0),在任何降级内容之前。 */
22
- const RULES_ORDER = 1;
23
-
24
- export const inject = ["systemPrompt", "skills"];
25
-
26
- export interface Config {
27
- /** 留在系统提示词里的 section 名;其余非空 section 降级为 reminder。 */
28
- keep?: string[];
29
- /** 不进提示词的 section 名(部署级噪音)。 */
30
- suppress?: string[];
31
- /** 装配结果上改写的 section 文本(section 名 → 中文文案)。 */
32
- replace?: Record<string, string>;
33
- }
34
-
35
- export const Config: z<Config> = z.object({
36
- keep: z.array(z.string()).default([...DEFAULT_KEEP]),
37
- suppress: z.array(z.string()).default([...DEFAULT_SUPPRESS]),
38
- replace: z.dict(z.string()).default({ ...DEFAULT_REPLACE }),
39
- });
40
-
41
- /**
42
- * 提示词注入的唯一通道:system prompt 里只留 `keep`,其余内容按声明的方式到达模型——
43
- * 降级为紧随用户消息的 reminder、或直接丢弃(写进按需 skill 正文是调用方自己的事)。
44
- */
45
- export function apply(ctx: Context, config: Config): void {
46
- const channel = new ContextAssembler(ctx);
47
- const keep = new Set(config.keep ?? DEFAULT_KEEP);
48
- for (const name of config.suppress ?? DEFAULT_SUPPRESS) channel.suppressSection(name);
49
- for (const [name, text] of Object.entries(config.replace ?? DEFAULT_REPLACE)) {
50
- channel.replaceSection(name, () => text);
51
- }
52
-
53
- ctx.systemPrompt.section({ name: RULES_SECTION, order: RULES_ORDER, text: RULES_TEXT });
54
-
55
- ctx.on("system-prompt/assemble", async (_assembly, context, next) => {
56
- const result = await next();
57
- const agent = context.agent;
58
- const demoted = new Map<string, string>();
59
- const sections: AssembledSection[] = [];
60
- let changed = false;
61
-
62
- for (const section of result.sections) {
63
- if (channel.isSuppressed(section.name)) {
64
- changed = true;
65
- continue;
66
- }
67
- const text = channel.replacement(section.name, agent) ?? section.text;
68
- if (text !== section.text) changed = true;
69
- if (keep.has(section.name) || text.length === 0) {
70
- sections.push(text === section.text ? section : { ...section, text });
71
- continue;
72
- }
73
- changed = true;
74
- demoted.set(sectionId(section.name), text);
75
- }
76
-
77
- if (agent !== undefined) channel.sync(agent, { sections: demoted });
78
- return changed ? { ...result, sections } : result;
79
- });
80
-
81
- ctx.on("agent/pre-step", async ({ agent, messages }, next) => {
82
- const decision = await next();
83
-
84
- if (decision.kind === "reject" || decision.messages.length === 0) return decision;
85
- const pending = await pendingEntries(agent, channel);
86
- if (pending.length === 0) return decision;
87
-
88
- const claimedEnd = decision.messages.findLastIndex((message) => messages.includes(message));
89
- return {
90
- ...decision,
91
- messages: decision.messages.toSpliced(
92
- claimedEnd + 1,
93
- 0,
94
- ...pending.map(([key, entry]) => reminderMessage(key, entry.text, entry.source)),
95
- ),
96
- };
97
- });
98
-
99
- ctx.on(
100
- "agent/request-error",
101
- async ({ agent, signal }, next) => {
102
- const action = await next();
103
- if (action?.kind !== "retry" || signal.aborted) return action;
104
- for (const [key, entry] of await pendingEntries(agent, channel)) {
105
- agent.session.append("user/message", reminderMessage(key, entry.text, entry.source), {
106
- surfaceOp: "append",
107
- });
108
- }
109
- return action;
110
- },
111
- { prepend: true },
112
- );
113
- }
114
-
115
- /** 降级 section 的条目 id:一条 section 一个 id,于是同一次变化只重发那一条。 */
116
- function sectionId(name: string): string {
117
- return `section:${name}`;
118
- }
119
-
120
- /** 本步要注入的条目:键相同且文本未变就不注入,按键字典序(顺序不随注册顺序抖动)。 */
121
- async function pendingEntries(
122
- agent: Parameters<typeof latestReminderText>[0],
123
- channel: ContextAssembler,
124
- ): Promise<[string, PromptEntry][]> {
125
- const entries = await channel.collect(agent);
126
- return [...entries]
127
- .filter(([key, entry]) => latestReminderText(agent, key) !== entry.text)
128
- .toSorted(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
129
- }
@@ -1,101 +0,0 @@
1
- import type { Agent } from "@deepseek-ai/dsh-agent";
2
- import { createUserMessage, type MessageSource } from "@deepseek-ai/dsh-llm";
3
- import type { UserMessage } from "@deepseek-ai/dsh-session";
4
-
5
- const REMINDER_OPEN = "<system-reminder";
6
- const REMINDER_CLOSE = "</system-reminder>";
7
-
8
- /**
9
- * 覆盖规则在系统提示词里声明一次(见 [`rules.ts`](./rules.ts)),所以每条 reminder 只带 id 与正文,
10
- * 不再重复解释"最新一条覆盖更早的"。
11
- */
12
- export const RULES_SECTION = "assembler:rules";
13
-
14
- /** 通道自己的条目形态(注入方没声明 source 时的默认):规则块,幂等键是 id。 */
15
- export interface PromptReminderSource {
16
- kind: "context-assembler";
17
- /**
18
- * 条目形态:`instructions` 是规则块;`catalog` 是"带清单的目录"(客户端按 `entries` 列条目,
19
- * 而不是读正文)。
20
- */
21
- form: "instructions" | "catalog";
22
- /**
23
- * `catalog` 形态发布的那份清单。**只有我们自己发布的目录**用它:上游 `tool-skill` 的账本扫法
24
- * 只认它自己的 `kind`,所以这里的 kind 必须是我们自己的(见 [`skill-catalog`](../skill-catalog/index.ts))。
25
- */
26
- entries?: readonly { readonly name: string; readonly description: string }[];
27
- /** 条目 id:同 id 的最新一条取代更早的同 id 条目。本插件早先落库的消息没有它。 */
28
- id?: string;
29
- }
30
-
31
- declare module "@deepseek-ai/dsh-llm" {
32
- interface MessageSourceMap {
33
- "context-assembler": PromptReminderSource;
34
- }
35
- }
36
-
37
- /**
38
- * 这条消息是不是通道注入的条目——判据是 source 里的幂等键,而不是 kind:接管上游那两面的条目
39
- * 接管工作区指令那一面的条目用上游 kind(`agent-instructions`),幂等必须照样认。
40
- */
41
- export function promptEntryIdOf(source: MessageSource): string | undefined {
42
- const id = (source as { readonly id?: unknown }).id;
43
- return typeof id === "string" ? id : undefined;
44
- }
45
-
46
- function escapeFrameBody(body: string): string {
47
- return body.replaceAll(REMINDER_CLOSE, "<\\/system-reminder>");
48
- }
49
-
50
- function escapeAttr(value: string): string {
51
- return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;");
52
- }
53
-
54
- export function renderReminder(id: string, body: string): string {
55
- return `${REMINDER_OPEN} id="${escapeAttr(id)}">\n${escapeFrameBody(body)}\n${REMINDER_CLOSE}`;
56
- }
57
-
58
- /**
59
- * 虚拟 skill(运行时注册、没有资源目录)的正文形态:只有 `<skill_instructions>`,
60
- * 不渲染上游 `renderSkillContent` 的 `<skill_resources>`(那段对虚拟 skill 是噪音)。
61
- */
62
- export function renderVirtualSkill(name: string, content: string): string {
63
- return [
64
- `<skill_content name="${escapeAttr(name)}">`,
65
- "<skill_instructions>",
66
- content,
67
- "</skill_instructions>",
68
- "</skill_content>",
69
- ].join("\n");
70
- }
71
-
72
- /** 这条消息是不是通道注入的条目(判据是 source 里的幂等键,见 `promptEntryIdOf`)。 */
73
- export function isPromptReminder(message: UserMessage): boolean {
74
- return promptEntryIdOf(message.source) !== undefined;
75
- }
76
-
77
- /** surface 上最近一条该 id 的 reminder 原文(含信封);没有则 undefined。 */
78
- export function latestReminderText(agent: Agent, id: string): string | undefined {
79
- for (const seq of agent.session.surface.nodes.toReversed()) {
80
- const event = agent.session.eventAt(seq);
81
- if (event?.type !== "user/message") continue;
82
- if (promptEntryIdOf(event.data.source) !== id) continue;
83
- const [block] = event.data.content;
84
- return event.data.content.length === 1 && block?.type === "text" ? block.text : "";
85
- }
86
- return undefined;
87
- }
88
-
89
- /**
90
- * 注入一条提醒:`text` 是已渲染好的完整正文(规则块或内容块),`key` 是它的幂等键。
91
- * `source` 声明这条消息对外的身份——工作区指令那一面用上游 kind,我们自己的条目留默认。
92
- */
93
- export function reminderMessage(key: string, text: string, source?: MessageSource): UserMessage {
94
- return createUserMessage({
95
- content: [{ type: "text", text }],
96
- source: {
97
- ...(source ?? { kind: "context-assembler", form: "instructions" }),
98
- id: key,
99
- } as MessageSource,
100
- });
101
- }
@@ -1,14 +0,0 @@
1
- import { RULES_SECTION } from "./reminder.ts";
2
-
3
- /**
4
- * 覆盖规则的声明文本:写进**系统提示词**(不在 reminder 里重复),所以每条 reminder 只带 id 与正文。
5
- *
6
- * 为什么放系统提示词而不是首条 reminder:它是固定文本(不随会话变,不付缓存代价),而且放在这里
7
- * 才是系统级规则该在的位置——首条 reminder 自身会被压缩、被覆盖,权威性更低。
8
- */
9
- export const RULES_TEXT = [
10
- "带 id 的 system-reminder 块是本系统提示词的一部分,持续有效:同 id 只有最新一条生效并取代更早的,不同 id 互不影响。",
11
- "skill_content 是要遵循的技能说明(随提示送来的与你后来加载的一样),file_content 是本步引用的文件内容。",
12
- ].join("");
13
-
14
- export { RULES_SECTION };
@@ -1,150 +0,0 @@
1
- /**
2
- * 按**会话**收口:工具白名单、instruction 总开关、动态快照。
3
- *
4
- * 这是本包的 `scope` 出口(行 id `context-assembler-scope`)。它不再吃 config,也不认识"模式"这个概念——
5
- * 收口要什么由消费方在会话建立时**推**进来:`ctx.sessionToolScope.apply(agent, mode)`(
6
- * [`@morlay/dsh-session-mode`](../../../profile/dsh-session-mode/README.md) 的模式定义里正好是这几项)。
7
- * 方向因此是单向的:装配层读这个服务,这个包不认识装配层。
8
- *
9
- * `apply` 落到该 agent 身上的三件事,各走一条与"谁注册了这个工具"无关的路:
10
- *
11
- * 1. **装配期投影**(每次装配按已注册的那份):模型目录与工具说明的 `tool:<工具名>` section 同源过滤;
12
- * 2. **执行层 guard**:落在 `agent.ctx` 上,调用被拒时给出该模式的文案;
13
- * 3. **通道开关**:`instructions` 关掉该会话的规则块(工作区指令、技能目录、用法正文)、
14
- * `runtimeContext` 抑制该会话的动态快照(沙箱与审批策略那两条)。
15
- *
16
- * 为什么不用 `tools.restrict()`:它会改动可见工具集,从而触发 `tools/change`;上游 `tool-subagent` 正是用
17
- * 该事件做 composition reconcile——两边互相触发会变成装配风暴。这里用投影过滤加执行层 guard:既不碰注册
18
- * 表、也不发事件。
19
- *
20
- * 为什么按 `agent.ctx` 注册而不是先建一棵 preset 子树:会话的 persona / 工具收口本来就是"这个 agent 的
21
- * 事",`agent.ctx` 的 scope 已经够了(上游给子 agent 装 persona 用的也是这条路);一棵子树要连带上游的
22
- * `agent-presets` 注册表、每 revision 一份 Loader 与 `isolate` realm,模式本身用不上。
23
- *
24
- * 模式在空白窗口里可以切换,所以状态是**可替换**的:同一个 agent 再 `apply` 一次就是换一份——旧的
25
- * disposer 全部收回,再按新的装一遍。
26
- */
27
-
28
- import { Service, type Context } from "@deepseek-ai/cordis";
29
- import type { Agent } from "@deepseek-ai/dsh-agent";
30
- import z from "@deepseek-ai/schemastery";
31
- import type {} from "../assembler/index.ts";
32
-
33
- export const name = "context-assembler-scope";
34
-
35
- /** 收口要的服务;`sessionToolScope` 由本行自己发布,`tools` 的 guard 挂在每个 agent 的 ctx 上。 */
36
- export const inject = ["tools", "systemPrompt"];
37
-
38
- /** 本行不吃 config:要收成什么样由消费方在 `apply` 时给。 */
39
- export const Config = z.object({});
40
-
41
- /** 一次收口的全部输入:模式定义里本出口用到的那几项。 */
42
- export interface SessionToolScopeMode {
43
- /** 拒绝对话时用的模式名。 */
44
- readonly name: string;
45
- /** 这个会话能用的工具;其余既不进目录,调用也被拒。 */
46
- readonly allowTools: readonly string[];
47
- /** 缺省为要;`false` 表示这个会话不要任何 instruction 类注入。 */
48
- readonly instructions?: boolean | undefined;
49
- /** 缺省为要;`false` 表示这个会话不要动态快照。 */
50
- readonly runtimeContext?: boolean | undefined;
51
- }
52
-
53
- declare module "@deepseek-ai/cordis" {
54
- interface Context {
55
- sessionToolScope: SessionToolScope;
56
- }
57
- }
58
-
59
- const OUT_OF_SCOPE = (toolName: string, modeName: string): string =>
60
- `${toolName} 不在「${modeName}」的工具范围内,用它不会有结果;按当前模式提供的工具完成任务,或让用户切到别的模式。`;
61
-
62
- /** 单个工具的说明 section 名前缀:`tool:<工具名>`(`tools:` 那类是聚合块,不归白名单管)。 */
63
- const TOOL_SECTION_PREFIX = "tool:";
64
-
65
- /** 一个 agent 当前装着的那一份。 */
66
- interface Applied {
67
- readonly mode: SessionToolScopeMode;
68
- /** 收回这一份在 `agent.ctx` 上的注册(抑制器与执行层 guard)。 */
69
- readonly dispose: () => void;
70
- }
71
-
72
- /** 通道是可选的搭档:没有它就没有 instruction 可关、也没人收窄技能目录,工具目录与执行 guard 照常生效。 */
73
- function channelOf(ctx: Context):
74
- | {
75
- setInstructions(agent: Agent, on: boolean): void;
76
- restrictTools(agent: Agent, allowed: (tool: string) => boolean): void;
77
- }
78
- | undefined {
79
- try {
80
- return ctx.get("contextAssembler");
81
- } catch {
82
- return undefined;
83
- }
84
- }
85
-
86
- /** 按会话收口工具与注入的服务。 */
87
- export class SessionToolScope extends Service {
88
- private readonly applied = new WeakMap<Agent, Applied>();
89
-
90
- constructor(ctx: Context) {
91
- super(ctx, "sessionToolScope");
92
- // 装配期过滤:这里只认已经 `apply` 过的会话;没登记过的(没装 session-mode 的部署)一律放行。
93
- ctx.on("system-prompt/assemble", async (_assembly, context, next) => {
94
- const agent = context.agent;
95
- const applied = agent === undefined ? undefined : this.applied.get(agent);
96
- if (applied === undefined) return next();
97
- const allow = new Set(applied.mode.allowTools);
98
- const result = await next();
99
- return {
100
- ...result,
101
- // 工具自己的说明 section 与工具目录同源:目录里没有的工具,它的说明也不该留在提示词里。
102
- sections: result.sections.filter((section) =>
103
- section.name.startsWith(TOOL_SECTION_PREFIX)
104
- ? allow.has(section.name.slice(TOOL_SECTION_PREFIX.length))
105
- : true,
106
- ),
107
- tools: result.tools.filter((tool) => allow.has(tool.name)),
108
- };
109
- });
110
- }
111
-
112
- /**
113
- * 把某个会话收口到这份定义上(同一个 agent 再调就是换一份)。
114
- * @param agent - 目标会话的 agent。
115
- * @param mode - 收口的全部输入。
116
- */
117
- apply(agent: Agent, mode: SessionToolScopeMode): void {
118
- const previous = this.applied.get(agent);
119
- if (previous !== undefined) previous.dispose();
120
- const allow = new Set(mode.allowTools);
121
-
122
- // 落在 `agent.ctx` 上的那两件用一个 effect 装:抑制器是 scope 层的一次注册;执行层 guard 要等
123
- // `tools` 激活才装得上(`inject` 的回调),它挂在那个 inject fiber 下,所以收回时连 fiber 一起收。
124
- const dispose = agent.ctx.effect(() => {
125
- const stoppers: (() => void)[] = [];
126
- if (mode.runtimeContext === false) {
127
- // service 在 `agent.ctx` 上是 shadow:抑制落在该 agent 的 scope 层,只覆盖这个会话。
128
- stoppers.push(agent.ctx.systemPrompt.suppressRuntimeContext());
129
- }
130
- // 必须落在 `agent.ctx` 上才是这个会话的作用域。
131
- const fiber = agent.ctx.inject(["tools"], (scope) => {
132
- scope.tools.guard((exec) =>
133
- allow.has(exec.name) ? undefined : OUT_OF_SCOPE(exec.name, mode.name),
134
- );
135
- });
136
- return [...stoppers, () => fiber.dispose()];
137
- }, "sessionToolScope: per-session gate");
138
-
139
- // 与"谁注册了这个工具"无关的那两件:通道按 agent 覆盖,不需要收回。
140
- const channel = channelOf(this.ctx);
141
- channel?.setInstructions(agent, mode.instructions !== false);
142
- channel?.restrictTools(agent, (tool) => allow.has(tool));
143
-
144
- this.applied.set(agent, { mode, dispose });
145
- }
146
- }
147
-
148
- export function apply(ctx: Context): void {
149
- new SessionToolScope(ctx);
150
- }
@@ -1,203 +0,0 @@
1
- import type { Context } from "@deepseek-ai/cordis";
2
- import type { Agent } from "@deepseek-ai/dsh-agent";
3
- import { isModelInvocable, isSkillName } from "@deepseek-ai/dsh-skill";
4
- import type {} from "@deepseek-ai/dsh-tool-skill";
5
- import { defineTool } from "@deepseek-ai/dsh-tools";
6
- import type {} from "../assembler/index.ts";
7
- import { renderVirtualSkill } from "../assembler/index.ts";
8
-
9
- export const name = "context-skill-catalog";
10
-
11
- export const inject = ["agents", "skills", "tools", "contextAssembler"];
12
-
13
- export const CATALOG_ID = "skill-catalog";
14
-
15
- /** 目录行里的描述长度上限(上游默认同值)。 */
16
- const DESCRIPTION_MAX_LENGTH = 500;
17
-
18
- /**
19
- * skill 目录 + `skill` 工具:目录是规则块(一行名字 + 摘要),正文按需加载。
20
- *
21
- * 目录只列模型可调用的 skill;`auto` 的 skill 标 `modelInvocable: false`,所以它不出现在目录里
22
- * (它的正文已经随提示送达)。工具的渲染用通道的虚拟 skill 形态,不带 `<skill_resources>`。
23
- *
24
- * **这一面归通道**(工作区指令那一面相反,是让位给 preset):官方 preset 自己装了上游 `tool-skill`,那份
25
- * 注册在 preset 的 scope 层;我们把 `skill` 工具按会话注册进 **agent 自己那一层**——它最靠里,同名注册遮蔽
26
- * 继承来的那一份([`core/tools/src/index.ts:1185-1207`](../../../vendor/deepseek-harness/packages/core/tools/src/index.ts)),
27
- * 于是模型看到的是我们的工具(中文描述 + 按会话修剪的正文)。上游的目录发布判据是"它自己注册的那个工具是
28
- * 本会话可见的那个"([`skill/tool-skill/src/index.ts:213-236`](../../../vendor/deepseek-harness/packages/skill/tool-skill/src/index.ts)),
29
- * 被遮蔽之后它闭嘴,目录由我们发布;`minimal`(`chat`)那种没有上游行的会话里也只有我们这一份。
30
- */
31
- export function apply(ctx: Context): void {
32
- /**
33
- * 本会话上次算出的目录条目:`source` 是同步的、正文是异步的,两者共用这一次计算的结果
34
- * (`collect` 里先算正文再取 source)。
35
- */
36
- const catalogEntries = new WeakMap<Agent, readonly { name: string; description: string }[]>();
37
-
38
- // 同名工具只能有一个所有者:host 平面已经有人注册了 `skill`(装上游 `tool-skill` 的部署)就让给它,
39
- // 我们只发布目录。我们的部署里 host 平面那一行是禁用的(preset 才装),所以走的是下面这条。
40
- const skillTool = ctx.tools.get("skill") === undefined ? defineSkillTool(ctx) : undefined;
41
- if (skillTool === undefined) {
42
- ctx.logger.warn(
43
- "context-skill-catalog: a `skill` tool is already registered; keeping the existing one and only publishing the catalog",
44
- );
45
- } else {
46
- // 按会话注册:`register` 落到**调用它的那个 ctx** 的 scope 层(agent 自己那一层),随 agent 收回。
47
- const install = (agent: Agent): void => {
48
- // 本行重装(HMR / 设置面)时上次那份注册还在 agent 自己那一层(它随 agent 而不是随本行收回):
49
- // 同一份定义就不要再注册一次——同一层重复注册会抛(`dsh-scope` 的 `NamedEntries.insert`)。
50
- if (ctx.tools.get("skill", agent) === skillTool) return;
51
- try {
52
- agent.ctx.tools.register(skillTool);
53
- } catch (error) {
54
- // agent 自己那一层被更具体的注册占了:让开(它自己会发目录),我们这一份注册不上。
55
- ctx.logger.warn(`context-skill-catalog: ${String(error)}`);
56
- }
57
- };
58
- ctx.on("agent/created", ({ agent }) => {
59
- install(agent);
60
- });
61
- for (const agent of ctx.agents.list()) install(agent);
62
- }
63
-
64
- ctx.contextAssembler.registerRule({
65
- id: CATALOG_ID,
66
- // 对外身份**用我们自己的 kind**(不是上游那个 `skill-catalog`):目录的正文与条目都由我们发布,而上游
67
- // `tool-skill` 的目录监听器把任何 `kind: 'skill-catalog'` 且条目可读的消息都当成**它自己的**账本
68
- // (`catalogMessage` / `catalogHistory`),于是它会删掉我们这一条(首步:`!history.published &&
69
- // skills.length === 0` 那条分支)或者补一条"没有可用 skill"的空目录把它顶掉(它有可见目录之后)。
70
- // 形态仍是它认得的 `catalog`(`entries` 是客户端列条目的那份清单),只是 kind 归我们——
71
- // 它的两个扫法都只看 kind,于是本会话里它彻底闭嘴。
72
- source: (agent) => ({
73
- kind: "context-assembler",
74
- form: "catalog",
75
- entries: catalogEntries.get(agent) ?? [],
76
- }),
77
- text: async (agent) => {
78
- // 目录没了(工具不在、快照不全、一个 skill 都不可见)就把上次的条目也清掉:`source` 与正文同源。
79
- const noCatalog = (): string => {
80
- catalogEntries.delete(agent);
81
- return "";
82
- };
83
- // "谁能用"要连模式的白名单一起算(注册表里有不等于这个会话能用),否则被收窄的工具还会留下技能目录。
84
- const visible = ctx.contextAssembler.visibleTools(
85
- agent,
86
- (tool) => ctx.tools.get(tool, agent) !== undefined,
87
- );
88
- // 依赖关系:没有 `skill` 工具(被白名单挡掉或被别的 composition 拿掉)时,目录没有意义——
89
- // 模型拿到了名字也加载不了。是否注入跟着工具走,而不是靠每个模式去列"不要哪些"。
90
- if (!visible("skill")) return noCatalog();
91
- // 必须带上会话的 cwd 与作用域:本地 skill 发现(`~/.agents/skills`、`{cwd}/.agents/skills`、
92
- // 项目根)由 preset 层的 `skill-filesystem` 行提供,host 层的同名行在 web 组合里是禁用的——
93
- // 不传作用域只看得见全局层(本仓库注册的运行时 skill),不传 cwd 连项目根都不扫。
94
- const snapshot = await ctx.skills.snapshot({
95
- cwd: agent.session.header.cwd,
96
- scope: agent,
97
- });
98
- if (!snapshot.complete) return noCatalog();
99
- // 技能也跟着工具走:依赖的工具一个都不可见的 skill 不进目录(模型看到名字也用不上)。
100
- const hidden = ctx.contextAssembler.hiddenSkills(visible);
101
- const skills = snapshot.skills
102
- .filter(isModelInvocable)
103
- .filter((skill) => !hidden.has(skill.name));
104
- if (skills.length === 0) return noCatalog();
105
- const entries = skills.map((skill) => ({
106
- name: skill.name,
107
- description: clamp(skill.description, DESCRIPTION_MAX_LENGTH),
108
- }));
109
- catalogEntries.set(agent, entries);
110
- return [
111
- "<available_skills>",
112
- ...entries.map((entry) => `- ${entry.name}: ${entry.description}`),
113
- "</available_skills>",
114
- "",
115
- "任务与某个 skill 说明匹配时,先加载它再行事。",
116
- ].join("\n");
117
- },
118
- });
119
- }
120
-
121
- /** 一份工具定义,按会话注册进每个 agent 的自己那一层(同一个定义对象,判据里的同一性靠它)。 */
122
- function defineSkillTool(ctx: Context): ReturnType<typeof defineTool> {
123
- return defineTool({
124
- name: "skill",
125
- description: "按需加载 skill 的完整说明。",
126
- parameters: {
127
- name: { type: "string", required: true, description: "技能目录里的 skill 名。" },
128
- },
129
- output: {
130
- // 与上游 `tool-skill` 的输出**同形**(`additionalProperties: false`,少一个字段就是校验不过):
131
- // 客户端按这个结构渲染 skill 结果,缺 `provider` 会让那次调用直接失败。
132
- schema: {
133
- type: "object",
134
- additionalProperties: false,
135
- properties: {
136
- name: { type: "string", required: true },
137
- provider: { type: "string", required: true },
138
- resourceBase: {
139
- oneOf: [
140
- {
141
- type: "object",
142
- additionalProperties: false,
143
- properties: {
144
- kind: { type: "string", required: true, const: "directory" },
145
- path: { type: "string", required: true },
146
- },
147
- },
148
- {
149
- type: "object",
150
- additionalProperties: false,
151
- properties: {
152
- kind: { type: "string", required: true, const: "url" },
153
- url: { type: "string", required: true },
154
- },
155
- },
156
- {
157
- type: "object",
158
- additionalProperties: false,
159
- properties: {
160
- kind: { type: "string", required: true, const: "opaque" },
161
- description: { type: "string", required: true },
162
- },
163
- },
164
- ],
165
- },
166
- content: { type: "string", required: true },
167
- },
168
- },
169
- render: (_args, value) => [
170
- { type: "text", text: renderVirtualSkill(value.name, value.content) },
171
- ],
172
- },
173
- async execute(args, exec) {
174
- if (!isSkillName(args.name)) throw new Error(`invalid skill name "${args.name}"`);
175
- const lookup = {
176
- cwd: exec.agent?.session.header.cwd,
177
- signal: exec.signal,
178
- scope: exec.agent,
179
- };
180
- const skill = await ctx.skills.get(args.name, lookup);
181
- if (skill === undefined)
182
- throw new Error(`skill "${args.name}" is unknown or no longer available`);
183
- if (!isModelInvocable(skill))
184
- throw new Error(`skill "${args.name}" is not available for model invocation`);
185
- // 注册表里那份是不过滤的全量(技能注册是装配期一次),按需加载时按这个会话重算一份:
186
- // 组正文不该讲这个会话没装的工具。
187
- const content = ctx.contextAssembler.contentFor(skill.name, exec.agent) ?? skill.content;
188
- return { name: skill.name, provider: skill.provider, content };
189
- },
190
- presentCall(args) {
191
- return {
192
- card: "generic",
193
- title: `Load skill ${args.name}`,
194
- kind: "read",
195
- rawInput: args.name,
196
- };
197
- },
198
- });
199
- }
200
-
201
- function clamp(text: string, max: number): string {
202
- return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
203
- }