@morlay/dsh-tool-guidance 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.
package/src/index.ts ADDED
@@ -0,0 +1,194 @@
1
+ import type { Context } from "@deepseek-ai/cordis";
2
+ import type { Agent } from "@deepseek-ai/dsh-agent";
3
+ import { createUserMessage, type ToolSchema } from "@deepseek-ai/dsh-llm";
4
+ import type {} from "@deepseek-ai/dsh-skill";
5
+ import type {} from "@deepseek-ai/dsh-tools";
6
+ import type {} from "@morlay/dsh-context-assembler";
7
+ import z from "@deepseek-ai/schemastery";
8
+ import {
9
+ BASE_GROUP_KEY,
10
+ SHORT_TOOL_DESCRIPTIONS,
11
+ TOOL_GROUPS,
12
+ groupByKey,
13
+ groupSkillBody,
14
+ skillNameOf,
15
+ } from "./guidance/groups.ts";
16
+
17
+ export const name = "tool-guidance";
18
+
19
+ export const inject = ["tools", "systemPrompt", "skills", "contextAssembler"];
20
+
21
+ export interface Config {
22
+ // 是否做**用法分组**(组 skill + `base` 常驻注入)。部署只想复用**工具预处理**(描述汉化 + 剥掉参数说明)
23
+ // 时把它关掉。这是**部署级**开关(本行在 host 平面,所有会话共用一份 config),表达不了"某个模式不要用法
24
+ // 正文"——chat 那档是模式收窄(`skill` 不进它的工具投影)加上 `skills` 推导成 `false` 丢掉官方目录注入。
25
+ groups?: boolean;
26
+ }
27
+
28
+ export const Config: z<Config> = z.object({ groups: z.boolean().default(true) });
29
+
30
+ // 工具的两件事:**预处理**(所有模式都要——把上游描述换成一行中文、剥掉参数里的说明性字段)与
31
+ // **用法分组**(模式可选,`config.groups`:`base` 的正文由本行常驻注入,其余每组注册成一个官方 skill,
32
+ // 由官方 `tool-skill` 的目录列出、按需加载)。
33
+ export function apply(ctx: Context, config: Config): void {
34
+ const groups = config.groups ?? true;
35
+
36
+ // 这个会话**装配结果里最终可见**的工具名(每次装配刷新一份)。`base` 正文的过滤判据用它,不用工具注册表:
37
+ // 会话收窄(模式的 `allowTools` / `denyTools`)落在装配投影与执行 guard 上,注册表上什么都看不到——挂在
38
+ // 全套工具的 preset 上的 chat 会话,注册表里 read / write / bash 一个不少。
39
+ const visibleTools = new WeakMap<Agent, ReadonlySet<string>>();
40
+
41
+ if (groups) {
42
+ // 被丢弃的上游说明:要点已吸收进组正文,原文不再进提示词。
43
+ for (const group of TOOL_GROUPS) {
44
+ for (const section of group.drops) ctx.contextAssembler.suppressSection(section);
45
+ }
46
+
47
+ for (const group of TOOL_GROUPS) {
48
+ // `base` 不进官方目录:它的正文已经常驻在手上了,列一份出来只会让模型再花一轮加载重复正文。
49
+ if (group.key === BASE_GROUP_KEY) continue;
50
+ // 注册进**官方的 skill 注册表**(`ctx.skills`):目录与按需加载都由官方 `tool-skill` 那一行提供,
51
+ // 本包不再自己发布目录、也不按会话修剪正文——正文里提到的工具是否在这个会话的目录里,由模式收窄决定。
52
+ ctx.effect(() =>
53
+ ctx.skills.register({
54
+ name: skillNameOf(group.key),
55
+ description: group.skillDescription,
56
+ content: groupSkillBody(group),
57
+ source: "runtime",
58
+ invocation: { modelInvocable: true, userInvocable: true },
59
+ }),
60
+ );
61
+ }
62
+
63
+ ctx.on("agent/pre-step", async ({ agent, messages, signal }, next) => {
64
+ const decision = await next();
65
+ if (decision.kind === "reject" || decision.messages.length === 0) return decision;
66
+
67
+ // 幂等:surface 上同键最近一条的正文逐字相等就不送(压缩掉、正文变更时自然补发/重发)。
68
+ signal.throwIfAborted();
69
+ const key = skillNameOf(BASE_GROUP_KEY);
70
+ // 正文按**这个会话装配结果里最终可见的工具目录**过滤:与模型手上的工具目录同源,收窄掉的工具不列。
71
+ const available = visibleTools.get(agent);
72
+ const text = renderSkillContent(
73
+ key,
74
+ groupSkillBody(
75
+ baseGroup,
76
+ available === undefined ? registeredIn(ctx, agent) : (tool) => available.has(tool),
77
+ ),
78
+ );
79
+ if (latestInjectText(agent, key) === text) return decision;
80
+ signal.throwIfAborted();
81
+
82
+ // 落点:本步认领的用户消息**之后**——这个位置不随别的监听者的注册顺序漂移到列表末尾
83
+ // (末尾那个位置归官方 `tool-skill` 的目录、上游的 runtime context)。
84
+ const claimedEnd = decision.messages.findLastIndex((message) => messages.includes(message));
85
+ return {
86
+ ...decision,
87
+ messages: decision.messages.toSpliced(claimedEnd + 1, 0, injectMessage(key, text)),
88
+ };
89
+ });
90
+ }
91
+
92
+ // 本行挂在**最外层**(`prepend`):会话收窄的监听比本行先注册(host 平面的行先于本行的 scope),而瀑布里
93
+ // 先注册的是外层——收窄发生在它的返回路径上,站在内层读 `next()` 拿到的是收窄**之前**的目录。站到最外层,
94
+ // 一次装配走完手里的 `result` 才是这个会话最终可见的那份。工具投影的改写与过滤可交换(只换描述与 schema,
95
+ // 不动名字),所以把改写一并挪到最外层,别人看到的结果不变。
96
+ ctx.on(
97
+ "system-prompt/assemble",
98
+ async (_assembly, context, next) => {
99
+ const result = await next();
100
+ const agent = context.agent;
101
+ // 记名字集合:`result.tools` 是投影**之后**的目录(收窄已生效、描述已被 `shortenTools` 换过),
102
+ // 名字不受描述改写影响。诊断式的无 agent 装配不记(那种装配也不属于任何会话)。
103
+ if (agent !== undefined) {
104
+ visibleTools.set(agent, new Set(result.tools.map((tool) => tool.name)));
105
+ }
106
+ return { ...result, tools: shortenTools(result.tools) };
107
+ },
108
+ { prepend: true },
109
+ );
110
+ }
111
+
112
+ const baseGroup = groupByKey(BASE_GROUP_KEY);
113
+
114
+ // **兜底**判据 = 工具注册表(这一行装没装这个工具):只有当 `visibleTools` 里还没有这个 agent 的记录时才用得上。
115
+ // 为什么需要兜底:理论上 `agent/pre-step` 之前每个 agent 必有一次装配(loop 里先 `assemble` 再走这条瀑布),
116
+ // 但直接调瀑布的路径(诊断、别的插件自造 pre-step)可能没有——退回注册表总比渲染一份全量正文或空正文强。
117
+ function registeredIn(ctx: Context, agent: Agent): (tool: string) => boolean {
118
+ return (tool) => ctx.tools.get(tool, agent) !== undefined;
119
+ }
120
+
121
+ // `base` 正文的信封:与官方 `renderSkillContent` 同形的内容块,但**手写**、不带 `<skill_resources>` 段
122
+ // ——运行时注册的虚拟 skill 没有资源目录,那一段只有 provider 提示的噪音。上游改信封时跟着改。
123
+ function renderSkillContent(name: string, body: string): string {
124
+ return [
125
+ `<skill_content name="${name}">`,
126
+ "<skill_instructions>",
127
+ body,
128
+ "</skill_instructions>",
129
+ "</skill_content>",
130
+ ].join("\n");
131
+ }
132
+
133
+ // 常驻注入的身份用上游已有的 `skill-invocation`(与按需加载同形态),幂等键就是组 skill 名(它不带 id)。
134
+ function injectMessage(name: string, text: string): ReturnType<typeof createUserMessage> {
135
+ return createUserMessage({
136
+ content: [{ type: "text", text }],
137
+ source: { kind: "skill-invocation", name, form: "instructions" },
138
+ });
139
+ }
140
+
141
+ // surface 上最近一条该键注入的正文(含信封);没有则 undefined——压缩掉它、或进程重启后 resume 折叠出它,
142
+ // 都由这一眼分辨。
143
+ function latestInjectText(agent: Agent, name: string): string | undefined {
144
+ for (const seq of agent.session.surface.nodes.toReversed()) {
145
+ const event = agent.session.eventAt(seq);
146
+ if (event?.type !== "user/message") continue;
147
+ const source = event.data.source as { readonly kind?: unknown; readonly name?: unknown };
148
+ if (source.kind !== "skill-invocation" || source.name !== name) continue;
149
+ const [block] = event.data.content;
150
+ return event.data.content.length === 1 && block?.type === "text" ? block.text : "";
151
+ }
152
+ return undefined;
153
+ }
154
+
155
+ // 投影里的参数 schema:只留**约束与散文**之外的一切——`type` / `enum` / `oneOf` / `required` /
156
+ // `default` 这些照旧(它们是校验的一部分),剥掉的是 `description` / `title` / `examples` 这类
157
+ // 说明性字段(语义在组 skill 正文里讲一次)。
158
+ function slimSchema(schema: unknown): unknown {
159
+ if (Array.isArray(schema)) return schema.map(slimSchema);
160
+ if (typeof schema !== "object" || schema === null) return schema;
161
+ const {
162
+ description: _description,
163
+ title: _title,
164
+ examples: _examples,
165
+ ...rest
166
+ } = schema as Record<string, unknown>;
167
+ const properties = rest["properties"];
168
+ if (typeof properties === "object" && properties !== null) {
169
+ rest["properties"] = Object.fromEntries(
170
+ Object.entries(properties as Record<string, unknown>).map(([key, value]) => [
171
+ key,
172
+ slimSchema(value),
173
+ ]),
174
+ );
175
+ }
176
+ for (const key of ["items", "additionalProperties"]) {
177
+ if (key in rest) rest[key] = slimSchema(rest[key]);
178
+ }
179
+ return rest;
180
+ }
181
+
182
+ // 改写模型看到的工具投影:描述换成一行中文,参数只留约束(`type` / `enum` / `oneOf` / `required` / `default`),
183
+ // 说明性字段整段丢弃。改装配结果而不是注册表——注册同名工具会触发 `tools/change`,与上游 `tool-subagent` 的
184
+ // composition reconcile 互相触发会造成装配风暴式重入;投影只影响本次请求,晚注册的工具也照样能覆盖。
185
+ function shortenTools(tools: readonly ToolSchema[]): ToolSchema[] {
186
+ return tools.map((tool) => {
187
+ const description = SHORT_TOOL_DESCRIPTIONS[tool.name];
188
+ return {
189
+ ...tool,
190
+ parameters: slimSchema(tool.parameters),
191
+ ...(description === undefined ? {} : { description }),
192
+ } as ToolSchema;
193
+ });
194
+ }
package/src/rows.ts ADDED
@@ -0,0 +1,21 @@
1
+ // 本包作为能力包发布的**装配数据**:只剩工具说明那一行(`tool-guidance`,host 平面)。
2
+ //
3
+ // 功能行清单(工具 / 命令 / 压缩 / 计划模式 / 委派)**不由本包持有**:它们归会话挂着的 agent preset(官方
4
+ // shipped preset),本部署不再自建一份——取舍见
5
+ // `packages/bundles/session-mode-profile/.agents/adrs/20260929-不再持有行清单.md`。汉化与用法分组的数据在
6
+ // `./guidance/`,运行时在包根 `./index.ts`。
7
+
8
+ // 工具说明那一行(汉化精简 + 用法分组):实现与数据在本包里,行本身也归本包(host 平面——它往
9
+ // 通道这个 host 单例注册被丢弃的 section、往官方 skill 注册表注册按需的组,装进 preset realm 会与 host
10
+ // 那份互相顶掉)。
11
+ export function toolGuidanceRow(config?: Readonly<Record<string, unknown>>): {
12
+ readonly id: string;
13
+ readonly name: string;
14
+ readonly config?: Readonly<Record<string, unknown>>;
15
+ } {
16
+ return {
17
+ id: "tool-guidance",
18
+ name: "@morlay/dsh-tool-guidance",
19
+ ...(config === undefined ? {} : { config }),
20
+ };
21
+ }