@morlay/dsh-session-mode 0.0.4 → 0.0.6

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/scope.ts CHANGED
@@ -18,6 +18,9 @@
18
18
  import { type Context } from "@deepseek-ai/cordis";
19
19
  import type { Agent, PreStepDecision } from "@deepseek-ai/dsh-agent";
20
20
  import type { UserMessage } from "@deepseek-ai/dsh-llm";
21
+ // 技能目录那条消息的 `source` 形状由官方 `tool-skill` 行声明(`MessageSourceMap` 的合并扩展):这里只借它的类型,
22
+ // 认它仍是按 `kind` 字符串读——运行期不依赖那一行在场。
23
+ import type {} from "@deepseek-ai/dsh-tool-skill";
21
24
  // 通道的服务声明(`ctx.contextAssembler`)住在 `@morlay/dsh-context-assembler` 的包根上;那是可选搭档,
22
25
  // 所以只借它的类型,取服务仍按"可能没有"处理。
23
26
  import type {} from "@morlay/dsh-context-assembler";
@@ -30,6 +33,10 @@ export interface SessionScopeDefinition {
30
33
  readonly allowTools: readonly string[];
31
34
  // 这个会话明确排除的工具。与 `allowTools` 同时命中一个名字时**黑名单优先**(拒,且文案说黑名单)。
32
35
  readonly denyTools: readonly string[];
36
+ // 这个会话能用的技能;其余既不进技能目录,`skill` 工具加载它也被拒。**留空表示不设白名单**——起点是全部技能。
37
+ readonly allowSkills: readonly string[];
38
+ // 这个会话明确排除的技能。与 `allowSkills` 同时命中一个名字时**黑名单优先**(拒,且文案说黑名单)。
39
+ readonly denySkills: readonly string[];
33
40
  // 这个会话要不要 instruction 类注入(`false` → 丢掉官方 `agent-instructions` 的注入,并关通道的降级注入)。
34
41
  readonly instructions: boolean;
35
42
  // 这个会话要不要技能目录(`false` → 丢掉官方 `skill-catalog` 的注入)。
@@ -53,28 +60,45 @@ function refusalOf(toolName: string, modeName: string, denial: Denial): string {
53
60
  : OUT_OF_SCOPE(toolName, modeName);
54
61
  }
55
62
 
63
+ // 技能被拒的两类原因:同一套白名单 / 黑名单语义,文案换到技能面(拒绝加载某一件技能,而不是某个工具)。
64
+ const SKILL_OUT_OF_SCOPE = (skillName: string, modeName: string): string =>
65
+ `${skillName} 不在「${modeName}」的技能白名单里,加载它不会有结果;按当前模式可用的技能完成任务,或让用户切到别的模式。`;
66
+
67
+ const SKILL_DENYLISTED = (skillName: string, modeName: string): string =>
68
+ `${skillName} 被「${modeName}」的技能黑名单排除,加载它不会有结果;按当前模式可用的技能完成任务,或让用户切到别的模式。`;
69
+
70
+ function skillRefusalOf(skillName: string, modeName: string, denial: Denial): string {
71
+ return denial === "denylisted"
72
+ ? SKILL_DENYLISTED(skillName, modeName)
73
+ : SKILL_OUT_OF_SCOPE(skillName, modeName);
74
+ }
75
+
56
76
  // 单个工具的说明 section 名前缀:`tool:<工具名>`(`tools:` 那类是聚合块,不归收口管)。
57
77
  const TOOL_SECTION_PREFIX = "tool:";
58
78
 
59
- // 一次收口**合成**出来的工具面:最终可用 = (白名单留空 ? 全部 : 白名单) − 黑名单。
60
- // `undefined` 表示不过滤(两条名单都空)——装配投影、section 过滤与执行 guard 三处都读同一个合成结果。
61
- type ToolGate = {
62
- // undefined = 不设白名单(全部工具都是起点)。
79
+ // 加载技能的那个官方工具名:官方 `tool-skill` 行的注册名,本包不 import 那一行(依赖面只到服务),所以按字符串读
80
+ // ——与 `kindOf` 读官方注入面同一做法。
81
+ const SKILL_TOOL_NAME = "skill";
82
+
83
+ // 一次收口**合成**出来的一份名单面:最终可用 = (白名单留空 ? 全部 : 白名单) − 黑名单。工具面与技能面同形:
84
+ // 两处(装配投影 / 执行 guard)都读同一个合成结果。
85
+ type NameGate = {
86
+ // undefined = 不设白名单(全部都是起点)。
63
87
  readonly allow: ReadonlySet<string> | undefined;
64
88
  readonly deny: ReadonlySet<string>;
65
89
  };
66
90
 
67
- function gateOf(definition: SessionScopeDefinition): ToolGate | undefined {
68
- const deny = new Set(definition.denyTools);
69
- const allow = definition.allowTools.length === 0 ? undefined : new Set(definition.allowTools);
70
- if (allow === undefined && deny.size === 0) return undefined;
71
- return { allow, deny };
91
+ function gateOfNames(allow: readonly string[], deny: readonly string[]): NameGate | undefined {
92
+ const denied = new Set(deny);
93
+ const allowed = allow.length === 0 ? undefined : new Set(allow);
94
+ if (allowed === undefined && denied.size === 0) return undefined;
95
+ return { allow: allowed, deny: denied };
72
96
  }
73
97
 
74
- // 判定一个工具名:`undefined` = 放行,否则是被拒的原因(黑名单先判,于是 deny 优先)。
75
- function denialOf(gate: ToolGate, toolName: string): Denial | undefined {
76
- if (gate.deny.has(toolName)) return "denylisted";
77
- if (gate.allow !== undefined && !gate.allow.has(toolName)) return "not-allowlisted";
98
+ // 判定一个名字:`undefined` = 放行,否则是被拒的原因(黑名单先判,于是 deny 优先)。
99
+ function denialOf(gate: NameGate, name: string): Denial | undefined {
100
+ if (gate.deny.has(name)) return "denylisted";
101
+ if (gate.allow !== undefined && !gate.allow.has(name)) return "not-allowlisted";
78
102
  return undefined;
79
103
  }
80
104
 
@@ -85,10 +109,68 @@ function kindOf(message: UserMessage): string | undefined {
85
109
  return typeof kind === "string" ? kind : undefined;
86
110
  }
87
111
 
112
+ // 技能目录正文里的条目行:`- \`<技能名>\`: <说明>`(官方 `tool-skill` 的渲染形状)。只认行首这一种,
113
+ // 认不出的行一律原样留着——宁可多列,不可把别的东西删掉。
114
+ const CATALOG_LINE = /^- `([^`]+)`: /u;
115
+
116
+ // 按技能名单收窄一条 `skill-catalog` 消息:正文删掉被拒技能那几行,`source.entries` 同步剔除。
117
+ // `undefined` = 这条消息不用动(名单没排除它任何一项,或者形状认不出)。
118
+ function narrowCatalog(
119
+ message: UserMessage,
120
+ gate: NameGate,
121
+ warn: (line: string) => void,
122
+ ): UserMessage | undefined {
123
+ const source = message.source;
124
+ if (source.kind !== "skill-catalog") return undefined;
125
+ const entries = source.entries;
126
+ const denied = new Set(
127
+ entries.map((entry) => entry.name).filter((name) => denialOf(gate, name) !== undefined),
128
+ );
129
+ if (denied.size === 0) return undefined;
130
+
131
+ const [block, ...rest] = message.content;
132
+ if (block?.type !== "text" || rest.length > 0) {
133
+ warn(
134
+ `skill catalog message is not one text block; denied skills stay listed: ${[...denied].join(", ")}`,
135
+ );
136
+ return undefined;
137
+ }
138
+ const removed = new Set<string>();
139
+ const lines = block.text.split("\n").filter((line) => {
140
+ const match = CATALOG_LINE.exec(line);
141
+ if (match === null) return true;
142
+ const name = match[1] ?? "";
143
+ if (!denied.has(name)) return true;
144
+ removed.add(name);
145
+ return false;
146
+ });
147
+ // 上游渲染形状漂移:正文里没定位到这些名字。这一条**整条不动**(正文与结构化名单保持一致:宁可多列,
148
+ // 不可出现"正文里有、名单里没有"的分叉),但要点出来——这条名单面就靠这行告警才不算静默失灵。
149
+ const missing = [...denied].filter((name) => !removed.has(name));
150
+ if (missing.length > 0) {
151
+ warn(`skill catalog lines not recognized; denied skills stay listed: ${missing.join(", ")}`);
152
+ return undefined;
153
+ }
154
+ return {
155
+ ...message,
156
+ content: [{ ...block, text: lines.join("\n") }],
157
+ source: { ...source, entries: entries.filter((entry) => !denied.has(entry.name)) },
158
+ };
159
+ }
160
+
161
+ // `skill` 工具调用的技能名:参数形状是官方那一行的 `{ name: string }`,认不出就不判(那是别人的工具)。
162
+ function skillNameOf(arguments_: unknown): string | undefined {
163
+ if (typeof arguments_ !== "object" || arguments_ === null) return undefined;
164
+ const name = (arguments_ as { readonly name?: unknown }).name;
165
+ return typeof name === "string" && name.length > 0 ? name : undefined;
166
+ }
167
+
88
168
  // 一个 agent 当前装着的那一份:`apply` 时解析好的开关(抑制面读它)。
89
169
  interface Applied {
90
170
  // 两条名单都空 = 不过滤(装配期与执行层都放行)。
91
- readonly gate: ToolGate | undefined;
171
+ readonly gate: NameGate | undefined;
172
+ // 技能面的名单:与工具面同形,`undefined` = 不设收窄(目录照旧、`skill` 工具不按名判)。
173
+ readonly skillGate: NameGate | undefined;
92
174
  readonly instructions: boolean;
93
175
  readonly skills: boolean;
94
176
  // 收回这一份在 `agent.ctx` 上的注册(抑制器与执行层 guard)。
@@ -122,7 +204,7 @@ export class SessionScope {
122
204
  });
123
205
 
124
206
  // 两个抑制面:官方 `agent-instructions`(工作区指令)与官方 `skill-catalog`(技能目录)都在装配投影之外自己
125
- // 往这条瀑布里 append,所以只有在这里丢掉它们的条目。
207
+ // 往这条瀑布里 append,所以只有在这里丢掉它们的条目(技能名单则在这里把目录按名收窄)。
126
208
  //
127
209
  // `prepend` 是必需的:官方那两行比本行早注册(会话挂着的 preset 先于 host 平面),瀑布里先注册的是**外层**,
128
210
  // 站在它们后面就看不到、也丢不掉它们 append 的条目——只有抢在最外层(`await next()` 之后再收)才拿得到最终
@@ -135,15 +217,36 @@ export class SessionScope {
135
217
  const applied = this.applied.get(payload.agent);
136
218
  // 没收过口的会话一律不动。
137
219
  if (applied === undefined) return decision;
138
- const kept = decision.messages.filter((message) => {
220
+ let changed = false;
221
+ const kept: UserMessage[] = [];
222
+ for (const message of decision.messages) {
139
223
  const kind = kindOf(message);
140
- if (kind === "agent-instructions") return applied.instructions;
141
- if (kind === "skill-catalog") return applied.skills;
142
- return true;
143
- });
144
- return kept.length === decision.messages.length
145
- ? decision
146
- : { ...decision, messages: kept };
224
+ if (kind === "agent-instructions") {
225
+ if (applied.instructions) kept.push(message);
226
+ else changed = true;
227
+ continue;
228
+ }
229
+ if (kind === "skill-catalog") {
230
+ if (!applied.skills) {
231
+ changed = true;
232
+ continue;
233
+ }
234
+ const narrowed =
235
+ applied.skillGate === undefined
236
+ ? undefined
237
+ : narrowCatalog(message, applied.skillGate, (line) =>
238
+ this.ctx.logger.warn(`session-mode: ${line}`),
239
+ );
240
+ if (narrowed === undefined) kept.push(message);
241
+ else {
242
+ changed = true;
243
+ kept.push(narrowed);
244
+ }
245
+ continue;
246
+ }
247
+ kept.push(message);
248
+ }
249
+ return changed ? { ...decision, messages: kept } : decision;
147
250
  },
148
251
  { prepend: true },
149
252
  );
@@ -153,8 +256,9 @@ export class SessionScope {
153
256
  apply(agent: Agent, definition: SessionScopeDefinition): void {
154
257
  const previous = this.applied.get(agent);
155
258
  if (previous !== undefined) previous.dispose();
156
- // 两条名单都空 = 不过滤:没有守卫要装,装配期也一路放行。
157
- const gate = gateOf(definition);
259
+ // 两份名单都空 = 不过滤:没有守卫要装,装配期也一路放行。
260
+ const gate = gateOfNames(definition.allowTools, definition.denyTools);
261
+ const skillGate = gateOfNames(definition.allowSkills, definition.denySkills);
158
262
 
159
263
  // 落在 `agent.ctx` 上的那两件用一个 effect 装:动态快照抑制是 scope 层的一次注册;执行层 guard 要等
160
264
  // `tools` 激活才装得上(`inject` 的回调),它挂在那个 inject fiber 下,所以收回时连 fiber 一起收。
@@ -166,10 +270,19 @@ export class SessionScope {
166
270
  }
167
271
  // 必须落在 `agent.ctx` 上才是这个会话的作用域。
168
272
  const fiber = agent.ctx.inject(["tools"], (scope) => {
169
- if (gate === undefined) return;
273
+ if (gate === undefined && skillGate === undefined) return;
170
274
  scope.tools.guard((exec) => {
171
- const denial = denialOf(gate, exec.name);
172
- return denial === undefined ? undefined : refusalOf(exec.name, definition.name, denial);
275
+ // 工具面:按名判这个工具本身能不能用。
276
+ const denial = gate === undefined ? undefined : denialOf(gate, exec.name);
277
+ if (denial !== undefined) return refusalOf(exec.name, definition.name, denial);
278
+ // 技能面:`skill` 工具本身照旧可用,被收窄的是它这次要加载的那一件技能。
279
+ if (skillGate === undefined || exec.name !== SKILL_TOOL_NAME) return undefined;
280
+ const skillName = skillNameOf(exec.arguments);
281
+ if (skillName === undefined) return undefined;
282
+ const skillDenial = denialOf(skillGate, skillName);
283
+ return skillDenial === undefined
284
+ ? undefined
285
+ : skillRefusalOf(skillName, definition.name, skillDenial);
173
286
  });
174
287
  });
175
288
  return [...stoppers, () => fiber.dispose()];
@@ -180,6 +293,7 @@ export class SessionScope {
180
293
 
181
294
  this.applied.set(agent, {
182
295
  gate,
296
+ skillGate,
183
297
  instructions: definition.instructions,
184
298
  skills: definition.skills,
185
299
  dispose,