dsh-plugin-prompt-tool 1.0.0 → 2.0.1

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.
Files changed (95) hide show
  1. package/README.md +38 -29
  2. package/engine/actions/append-context.mjs +49 -0
  3. package/engine/actions/assembly.mjs +150 -0
  4. package/engine/actions/catalog.mjs +92 -0
  5. package/engine/actions/content.mjs +205 -0
  6. package/engine/actions/decision.mjs +54 -0
  7. package/engine/actions/examples.mjs +11 -0
  8. package/engine/actions/guard.mjs +80 -0
  9. package/engine/actions/inbox-prepend.mjs +37 -0
  10. package/engine/actions/index.mjs +100 -0
  11. package/engine/actions/inject-text.mjs +22 -0
  12. package/engine/actions/pre-step-filter.mjs +55 -0
  13. package/engine/actions/registration.mjs +168 -0
  14. package/engine/actions/request-params.mjs +32 -0
  15. package/engine/actions/sdk-strip.mjs +30 -0
  16. package/engine/actions/shared.mjs +71 -0
  17. package/engine/actions.mjs +2 -931
  18. package/engine/branch.mjs +44 -0
  19. package/engine/compaction-epoch.mjs +31 -13
  20. package/engine/compositions/source/local/dev-tool-search.yml +11 -0
  21. package/engine/compositions/source/local/instruction-hint.yml +22 -1
  22. package/engine/compositions/source/local/rule-engine.yml +6 -0
  23. package/engine/condition.mjs +16 -3
  24. package/engine/conditions/anchor.mjs +32 -0
  25. package/engine/conditions/availability.mjs +47 -0
  26. package/engine/conditions/composite.mjs +83 -0
  27. package/engine/conditions/count.mjs +140 -0
  28. package/engine/conditions/evaluation.mjs +41 -0
  29. package/engine/conditions/examples.mjs +11 -0
  30. package/engine/conditions/index.mjs +85 -0
  31. package/engine/conditions/names.mjs +21 -0
  32. package/engine/conditions/phase.mjs +54 -0
  33. package/engine/conditions/preset.mjs +94 -0
  34. package/engine/conditions/scope.mjs +18 -0
  35. package/engine/conditions/session.mjs +22 -0
  36. package/engine/conditions/source.mjs +63 -0
  37. package/engine/conditions/subject.mjs +121 -0
  38. package/engine/conditions/text.mjs +44 -0
  39. package/engine/conditions/values.mjs +32 -0
  40. package/engine/dev-tool-search.mjs +213 -0
  41. package/engine/executor.mjs +55 -6
  42. package/engine/fillers.mjs +23 -16
  43. package/engine/instruction-hint.mjs +34 -22
  44. package/engine/layers.mjs +135 -49
  45. package/engine/order.mjs +4 -1
  46. package/engine/predicates.mjs +2 -753
  47. package/engine/rule-engine.mjs +40 -0
  48. package/engine/rule-runtime.mjs +110 -0
  49. package/engine/rule-spec.mjs +187 -0
  50. package/engine/schema.mjs +16 -5
  51. package/engine/shared.mjs +1 -1
  52. package/engine/strategies.mjs +2 -235
  53. package/engine/subagent-tool-policy-core.mjs +7 -7
  54. package/engine/subagent-tool-policy.mjs +20 -10
  55. package/engine/tool-config-engine.mjs +53 -32
  56. package/engine/trigger-spec.mjs +49 -133
  57. package/lib/client.js +11117 -10860
  58. package/lib/client.js.map +1 -1
  59. package/lib/index.d.mts +82 -139
  60. package/lib/index.mjs +826 -1087
  61. package/lib/migrate-rules.d.mts +1 -0
  62. package/lib/migrate-rules.mjs +39 -0
  63. package/lib/module-package-CHPFweqK.mjs +2 -0
  64. package/lib/module-package-Cnd1_mwx.mjs +2872 -0
  65. package/lib/{paths-BgL42sSM.d.mts → paths-CyP0IkaM.d.mts} +223 -108
  66. package/lib/preset-transfer.d.mts +2 -2
  67. package/lib/preset-transfer.mjs +3 -2
  68. package/lib/{module-package-Cm3fHHlp.mjs → rules-migration-DJ6vASB5.mjs} +13696 -15557
  69. package/modules/ponytail/module.yml +381 -147
  70. package/modules/skill-surface/module.yml +74 -0
  71. package/modules/tool-surface/module.yml +67 -0
  72. package/package.json +5 -1
  73. package/templates/10-pre-step.yml +26 -22
  74. package/templates/14-first-turn-anchor.yml +25 -21
  75. package/templates/15-guide-auto.yml +34 -19
  76. package/templates/16-custom-fallback.yml +27 -15
  77. package/templates/18-placeholder-env-facts.yml +23 -14
  78. package/templates/20-system-section.yml +16 -10
  79. package/templates/30-runtime-context.yml +13 -7
  80. package/templates/40-agent-request.yml +11 -8
  81. package/templates/50-llm-stream.yml +14 -6
  82. package/templates/60-tool-pipeline.yml +15 -9
  83. package/templates/65-turn-stop.yml +19 -12
  84. package/templates/66-subagent-start.yml +12 -7
  85. package/templates/70-subagent-maintenance.yml +35 -29
  86. package/templates/75-subagent-end.yml +10 -5
  87. package/templates/80-tool-surface.yml +66 -0
  88. package/templates/policies/legacy-defaults.yml +34 -0
  89. package/engine/compositions/source/local/prompt-config-engine.yml +0 -7
  90. package/engine/prompt-config-engine.mjs +0 -104
  91. package/engine/trigger-editor-meta.mjs +0 -48
  92. package/modules/ponytail/configs/0000-ponytail-rules.yml +0 -102
  93. package/modules/ponytail/configs/0010-ponytail-level-full.yml +0 -19
  94. package/modules/ponytail/configs/0020-ponytail-level-lite.yml +0 -19
  95. package/modules/ponytail/configs/0030-ponytail-level-ultra.yml +0 -19
@@ -1,931 +1,2 @@
1
- /**
2
- * actions — 动作库(B3 T2):把「执行」收敛为统一的动作接口。
3
- *
4
- * 一个动作 = `{ kind, id?, ...载荷 }`,由触发器运行时(T3)在判断命中后交来;
5
- * `registerAction(ctx, action)` 把它接到**该动作唯一合法的事件通道**上,返回
6
- * 释放函数。七类动作里只有第 (2)(5)(6) 类是新增执行面,其余五类都是既有实现的
7
- * 收敛入口——同一输入下的结果与现状逐条一致:
8
- *
9
- * (1) inject-text 注入文本:复用九层的注册通道(layers.mjs / executor.mjs),不新开通道
10
- * (2) assembly 改装配:`assembly.tools` / `sections` / `contexts` 的增删改
11
- * (3) decision 裁决:pre-execute 的 allow|deny|ask 与 post-execute 的 accept|replace|block
12
- * (4) append-context 追加上下文与续跑:`additionalContexts` / `agent.steer`(共用续跑预算)
13
- * (5) guard 执行层 guard:`ctx.tools.guard()`,不可被后续策略解除的最终拒绝
14
- * (6) sdk-strip 裁 SDK 声明文本:`tools:sdk` 段,TS / Python 双载荷
15
- * (7) request-params 改模型请求参数:与既有 `agent-request` 层共用
16
- * `applyAgentRequestParams`(patch / replace / 按值条件删键)
17
- *
18
- * 第 (5) 类不是可选补充:已发布工具包在 `run_code` 执行时从
19
- * `registry.schemas(exec.agent)` 建绑定(安装包 `dsh-tools/lib/types/ptc.js:576`),
20
- * **不从裁过的 SDK 正文建绑定**——只裁文本挡不住「知道旧工具名」的调用;而
21
- * `scope restrict` 只收**继承面**,注册在 agent 本层与晚到的工具它收不动
22
- * (`view()` 对 own layer 豁免限制,`lib/index.js:2957-2977`)。因此:(a) 同一份名单
23
- * 判据同时驱动呈现过滤(第 (2) 类)与执行 guard(第 (5) 类);(b) `restrict` 只在
24
- * includeSubagents=true 时复用,主会话专属限制、本层/晚到工具交给 guard 裁决;(c) SDK 文本裁剪
25
- * 只是剩余呈现补救,**不得替代执行边界**;(d) 不新增独立策略提供者。
26
- *
27
- * @module engine/actions
28
- */
29
-
30
- import {
31
- MAX_TRACKED_SESSIONS,
32
- createWarnOnce,
33
- keepDisposer,
34
- newMessageId,
35
- parseToolNames,
36
- } from './shared.mjs'
37
- import { stringList } from './fields.mjs'
38
- import { applyPromptConfigs } from './executor.mjs'
39
- import {
40
- applyAgentRequestParams,
41
- createTurnStopBudget,
42
- matchesAgentScope,
43
- pluginMessage,
44
- wireLayers,
45
- } from './layers.mjs'
46
- import { SDK_SECTION_NAME, sdkToolNames, stripSdkDeclarations } from './sdk-strip.mjs'
47
- import { subjectOf } from './predicates.mjs'
48
- import { createPromptConfigs } from './schema.mjs'
49
-
50
- /**
51
- * 标识来源:**由调用方经 `options.plugin` 传入**(与 `trigger.mjs` 的
52
- * `mountTriggers(ctx, declarations, { plugin })` 同一约定),用于挂载期错误消息、
53
- * 降级告警与 disposer 标签。本文件不假装自己是某个具体模块——缺省值只作
54
- * 直接调用(测试 / 隔离复制)时的中性兜底,不表示任何真实插件身份。
55
- */
56
- const DEFAULT_PLUGIN = 'prompt-actions'
57
- /** PTC 呈现传输名:guard 与 restrict 都不得触碰它(碰了就没有 PTC 面可用)。 */
58
- const RUN_CODE = 'run_code'
59
-
60
- /**
61
- * 降级语义四类(PLAN T2 的固定词汇)。每个动作声明其中之一:
62
- * - `keep` 失败/异常时保持下游原值(放行或原样返回),并 warnOnce;
63
- * - `expose-all` 放弃过滤结果,暴露**完整**装配(比 keep 更弱:连部分过滤也不保留);
64
- * - `empty` 失败/无正文时产出空值(清空 contexts / 不注入);
65
- * - `silent` 没有替代值:命中即生效、不命中完全不存在,注册失败只告警,
66
- * 绝不伪造"已生效"。
67
- */
68
- export const ACTION_DEGRADE = Object.freeze({
69
- keep: 'keep',
70
- exposeAll: 'expose-all',
71
- empty: 'empty',
72
- silent: 'silent',
73
- })
74
-
75
- /**
76
- * 七类动作的声明:**合法事件通道** + **降级语义** + 触发时机。
77
- * `events` 是动作允许注册的官方事件(注册到声明外的事件在挂载期 fail loud);
78
- * `services` 是它允许触碰的宿主服务方法(同一纪律的文档面)。
79
- */
80
- export const ACTION_KINDS = Object.freeze({
81
- 'inject-text': {
82
- title: '注入文本',
83
- events: ['agent/pre-step', 'session/event', 'system-prompt/assemble', 'agent/request', 'llm/stream', 'tools/pre-execute', 'tools/post-execute', 'agent/turn-stopping', 'subagent/start', 'subagent/end'],
84
- services: ['systemPrompt.section', 'systemPrompt.context', 'systemPrompt.variable'],
85
- degrade: ACTION_DEGRADE.keep,
86
- timing: '按配置声明的 layer 落到该层官方通道;pre-step 在 agent/pre-step,system-section/runtime-context 在注册期 + 官方 assembly。',
87
- note: '复用九层注册通道,不新开通道;显式空正文 = 不注册(empty 情形)。`options` 原样交给该层的既有接线(pre-step 执行器的 `prepend` = 声明的位置策略);`session/event` 是 pre-step 执行器自带的会话态旁听,不是第二条注入通道。',
88
- },
89
- assembly: {
90
- title: '改装配',
91
- events: ['system-prompt/assemble'],
92
- services: [],
93
- degrade: ACTION_DEGRADE.exposeAll,
94
- timing: 'system-prompt/assemble 的下游结算之后(`await next()` 之后改,装配事实已就绪)。',
95
- note: '失败时返回未改动的装配 = 对 tools 即暴露完整目录;不改 global,只作用于本次 assembly。',
96
- },
97
- decision: {
98
- title: '裁决',
99
- events: ['tools/pre-execute', 'tools/post-execute'],
100
- services: [],
101
- degrade: ACTION_DEGRADE.keep,
102
- timing: 'pre 相在工具执行之前返回 allow|deny|ask;post 相在结果结算之后返回 accept|replace|block。',
103
- note: '异常与条件未命中一律 `next()`(放行/接受),绝不让裁决 bug 卡死调用。',
104
- },
105
- 'append-context': {
106
- title: '追加上下文与续跑',
107
- events: ['tools/post-execute', 'agent/turn-stopping'],
108
- services: [],
109
- degrade: ACTION_DEGRADE.keep,
110
- timing: 'mode=context 在 post-execute 追加 durable user 通知;mode=continue 在 agent/turn-stopping steer 一步。',
111
- note: '续跑共用引擎的续跑预算(每轮 1 次 / 每会话 3 次),无正文时不追加(empty 情形);绝不伪造 assistant 角色。',
112
- },
113
- guard: {
114
- title: '执行层 guard',
115
- events: ['system-prompt/assemble'],
116
- services: ['tools.guard', 'tools.restrict'],
117
- degrade: ACTION_DEGRADE.silent,
118
- timing: '首次 assembly 时按 agent scope 惰性注册一次(预设切换/重绑后按新的 agent.ctx 重注册)。',
119
- note: '最终拒绝层:注册在 agent.ctx 上(工具本层与晚到工具也被裁决),命中返回拒绝原因;不注册全局 guard。guard 自身不吞异常——异常即失败,绝不退化成"放行"。',
120
- },
121
- 'sdk-strip': {
122
- title: '裁 SDK 声明文本',
123
- events: ['system-prompt/assemble'],
124
- services: [],
125
- degrade: ACTION_DEGRADE.keep,
126
- timing: 'system-prompt/assemble 下游结算之后,改写名为 tools:sdk 的段文本。',
127
- note: '只删不增、保守失败:形态异常/自校验失败一律原样返回;空名单零开销。仅是呈现补救,执行边界在 guard。',
128
- },
129
- 'request-params': {
130
- title: '改模型请求参数',
131
- events: ['agent/request'],
132
- services: [],
133
- degrade: ACTION_DEGRADE.keep,
134
- timing: 'agent/request 的下游结算之后(`await next()` 拿到已冻结的 LlmCallConfig 再改写)。',
135
- note: '与既有 promptConfigs 的 agent-request 层共用 applyAgentRequestParams;只改命中 scope/agent 的请求,删键仅删"本声明注入的那个值"。',
136
- },
137
- 'inbox-prepend': {
138
- title: '前置收件箱消息',
139
- events: ['agent/inbox/inserted'],
140
- services: ['agent.inbox.prepend'],
141
- degrade: ACTION_DEGRADE.silent,
142
- timing: 'emit 通道(**无 next**):命中即前置一条消息,不命中什么都不做。',
143
- note: '只前置不追加;插件来源消息(含本动作自己插入的那条)永不再次前置,防自触发插队。'
144
- + '收件箱的持久化归宿主(`agent/inbox/spliced`),本动作不自己落盘。',
145
- },
146
- 'pre-step-filter': {
147
- title: '过滤 pre-step 注入消息',
148
- events: ['agent/pre-step'],
149
- services: [],
150
- degrade: ACTION_DEGRADE.exposeAll,
151
- timing: 'agent/pre-step 的下游结算之后(`await next()` 拿到 decision 再过滤 messages)。',
152
- note: '两种白名单模式**互斥**:`sources` = 严格白名单(只放行这些 `source.kind`,含 claimed 批);'
153
- + '`keepKinds` = 保留 claimed 基线(按对象身份或 id)+ 这些 kind。未声明任一 = 不过滤(零开销)。'
154
- + '`blockPlugins` 是与二者**正交**的另一维度、可共存:仅当来源 kind 为 plugin 时按 `source.plugin` '
155
- + '做大小写不敏感的**精确等值**匹配并剔除命中项,不做子串/正则/glob;空名单 = 关闭'
156
- + '(与白名单「空 = 全拦」不同),未声明 = 不过滤。'
157
- + '**永不吞上下文**:异常一律返回未过滤的 decision(`expose-all`)。',
158
- },
159
- })
160
-
161
- /** 名单字段:非空字符串数组 → Set;缺省返回 undefined。空数组合法(= 一个都不留)。 */
162
- const NAME_LIST = stringList({ as: 'set', dedupe: true, allowEmpty: true })
163
-
164
- /** 动作标签(错误消息与 source.plugin 命名空间)。 */
165
- const labelOf = (action) => (typeof action?.id === 'string' && action.id.length > 0 ? action.id : String(action?.kind ?? 'action'))
166
-
167
- /** 名单判据的唯一实现:presentation 过滤与执行 guard 共用(呈现与执行不得两套口径)。 */
168
- function createMask(source, label, plugin) {
169
- const allow = NAME_LIST.parse(source?.allow, plugin, `${label}.allow`)
170
- const deny = NAME_LIST.parse(source?.deny, plugin, `${label}.deny`)
171
- if (allow !== undefined && deny !== undefined) {
172
- throw new TypeError(`${plugin}: ${label} cannot combine allow with deny`)
173
- }
174
- if (allow === undefined && deny === undefined) {
175
- throw new TypeError(`${plugin}: ${label} needs allow and/or deny — 空名单无法表达「剔哪些工具」`)
176
- }
177
- // `run_code` 是 PTC 呈现**唯一**的可调用入口(见本文件顶部)。把它写进 **deny** 名单会让
178
- // 整个 PTC 面失效,一律挂载期拒绝。写进 **allow** 名单相反是**必需**的:allow 是
179
- // fail-closed(未列出即剔出),不点名它就会被连带剔掉——PTC 预设因此失去唯一入口。
180
- // 2026-09-22 用户拍板:allow 模式允许点名它(旧模块本就能把它写进白名单)。
181
- if (deny?.has(RUN_CODE)) {
182
- throw new TypeError(`${plugin}: ${label}.deny must not name the reserved ${RUN_CODE} transport — it is the only callable entry of the PTC presentation`)
183
- }
184
- return {
185
- allow,
186
- deny,
187
- /**
188
- * fail-open 兜底开关(**可选**,缺省 false):原 `tool-bootstrap` 的语义是
189
- * 「keep 名单里的工具在本次装配目录里一个都不存在 → 放弃裁剪、暴露完整目录」(声明侧见
190
- * `test/engine/declarations/tool-bootstrap.yml` 的 `requireMatch: true`),而原
191
- * `tool-filter` 的同名场景是**照名单裁成空目录**。两种语义都真实存在,所以这里
192
- * **不设默认**——要兜底的声明自己写 `requireMatch: true`。把某一个模块的特有语义当成
193
- * 通用默认,会让另一个声明路径的行为被悄悄改掉(这正是对拍抓出来的)。
194
- */
195
- requireMatch: source?.requireMatch === true,
196
- /** 与 tool-filter 的名单判据 / Layer.admits 同一判据(tool-filter 模块已删除,判据保留)。 */
197
- blocks: (toolName) => {
198
- if (typeof toolName !== 'string' || toolName.length === 0) return false
199
- if (deny !== undefined && deny.has(toolName)) return true
200
- return allow !== undefined && !allow.has(toolName)
201
- },
202
- }
203
- }
204
-
205
- /** 合法通道绑定器:动作注册到声明外的事件在挂载期 fail loud。 */
206
- function channelBinder(ctx, kind, plugin, prepend) {
207
- const legal = new Set(ACTION_KINDS[kind].events)
208
- return (event, handler, options) => {
209
- if (!legal.has(event)) {
210
- throw new TypeError(`${plugin}: action ${kind} must not register on ${JSON.stringify(event)} — legal channel(s): ${[...legal].join(', ')}`)
211
- }
212
- // `prepend` 只表达「落在宿主 waterfall 的最外层」(否决型动作如预算剥离需要它),
213
- // **不承担声明之间的排序**——同通道顺序由注册先后决定(见 trigger.mjs 的 R13 依据)。
214
- return ctx.on(event, handler, prepend === true ? { ...options, prepend: true } : options)
215
- }
216
- }
217
-
218
- /**
219
- * 支持 `when` 的动作:它们经 `on(...)` 注册处理器,判定按动作的固定执行阶段接线。
220
- *
221
- * 另外两类**不走这个入口**,所以传 `when` 会在挂载期 fail loud(而不是静默无效):
222
- * - `inject-text` 注册的是**层**(`applyPromptConfigs` / `wireLayers`),不是处理器;
223
- * - `guard` 注册的是 agent scope 上的**最终拒绝**(`ctx.tools.guard`,assembly 时惰性挂载)。
224
- * 本集合与实现的一致性由 `test/engine/actions.test.mjs` 的「when 支持面」用例钉住。
225
- */
226
- const ON_REGISTERED_KINDS = new Set(['assembly', 'decision', 'append-context', 'sdk-strip', 'request-params', 'inbox-prepend', 'pre-step-filter'])
227
-
228
- /** 编辑目录与注册校验共用支持面;不另给客户端维护一份名单。 */
229
- export const actionSupportsWhen = (kind) => ON_REGISTERED_KINDS.has(kind)
230
-
231
- /**
232
- * **没有 `next` 的通道**(官方 `@mode emit` / `@mode serial`):注册在这些通道上的动作
233
- * 不能靠「`args` 的最后一个参数是 `next`」工作——判定路径与载荷切分都要走无 `next` 分支。
234
- *
235
- * 依据(`@deepseek-ai/dsh-agent/lib/types/runtime-types.d.ts` 的逐事件 `@mode` 注释):
236
- * **只有 `waterfall` 才有 `next`**;`agent/pre-step`/`agent/request` 是 waterfall,
237
- * 而 `agent/inbox/inserted`(B7 新增动作的通道)是 **emit**、
238
- * `agent/turn-stopping`(既有 append-context「续跑」的通道)是 **serial**——
239
- * 两者都没有 `next`。第三项此前是个隐患:`append-context(continue)` 一旦带 `when`,
240
- * 旧实现会把 `turn` 当成 `next` 调掉。
241
- */
242
- const NEXT_FREE_CHANNELS = new Set([
243
- 'session/event',
244
- 'agent/inbox/inserted',
245
- 'agent/turn-stopping',
246
- ])
247
-
248
- /** 动作的固定执行点;声明编译与 when 接线共用,不能用声明字段改变注册器。 */
249
- export function actionExecutionPoint(action) {
250
- if (action.kind === 'decision') {
251
- return { channel: action.phase === 'post' ? 'tools/post-execute' : 'tools/pre-execute', phase: 'before-next' }
252
- }
253
- if (action.kind === 'append-context') {
254
- return action.mode === 'continue'
255
- ? { channel: 'agent/turn-stopping', phase: 'before-next' }
256
- : { channel: 'tools/post-execute', phase: 'after-next' }
257
- }
258
- if (action.kind === 'inject-text') {
259
- const layer = action.config?.layer
260
- const channel = {
261
- 'pre-step': 'agent/pre-step',
262
- 'system-section': 'system-prompt/assemble',
263
- 'runtime-context': 'system-prompt/assemble',
264
- 'agent-request': 'agent/request',
265
- 'llm-stream': 'llm/stream',
266
- 'turn-stop': 'agent/turn-stopping',
267
- 'subagent-start': 'subagent/start',
268
- 'subagent-end': 'subagent/end',
269
- }[layer]
270
- if (channel === undefined) {
271
- throw new TypeError(`inject-text layer ${JSON.stringify(layer)} has no single trigger channel; tool-pipeline uses separate decision actions`)
272
- }
273
- return { channel, phase: ['pre-step', 'agent-request'].includes(layer) ? 'after-next' : 'before-next' }
274
- }
275
- const channel = ACTION_KINDS[action.kind].events[0]
276
- return { channel, phase: NEXT_FREE_CHANNELS.has(channel) ? 'before-next' : 'after-next' }
277
- }
278
-
279
- /**
280
- * 每轮生效预算(动作声明的 `maxPerTurn`)。
281
- *
282
- * **为什么必须是动作侧、不能用 `count` 原语**:原 `deliberation-gate` 的 `gates` 与原
283
- * `progress-reminder` 的 `drips` 数的是**本动作自己生效了几次**,不是 durable 事件数。
284
- * 它们是纯增量状态(重启后从 0 开始、每轮重置),而 `count` 的模型是冷扫 durable 事件流重建
285
- * ——两者在重启/压缩后给出的答案不同,所以这是独立的一类计数。
286
- *
287
- * **轮号**取自 durable 事件的 `data.turn`(与 `predicates.mjs` 的 `trackTurn` 同规则:任何带
288
- * 有限轮号的事件都推进当前轮并取最大值),所以重启后计数虽从 0 开始,轮边界立刻是对的。
289
- *
290
- * **同步消耗**:动作通过目标与有效结果检查后,紧邻实际效果同步 `take()`;
291
- * claim 与效果之间没有 await,并行调用不得越过预算。
292
- *
293
- * @returns {{take: (subject: unknown) => boolean, observe: Function}|undefined} 无 `maxPerTurn` 时为 undefined
294
- */
295
- function createTurnBudget(max) {
296
- if (max === undefined) return undefined
297
- /** sessionId -> { turn, used }(纯增量,与原模块同语义:重启从 0 开始)。 */
298
- const budgets = new Map()
299
- const entryOf = (session) => {
300
- if (session?.id === undefined) return undefined
301
- let entry = budgets.get(session.id)
302
- if (entry === undefined) {
303
- if (budgets.size >= MAX_TRACKED_SESSIONS) budgets.clear()
304
- entry = { turn: undefined, used: 0 }
305
- budgets.set(session.id, entry)
306
- }
307
- return entry
308
- }
309
- return {
310
- /** 同步查/增;`false` = 本轮预算已尽,调用方按「不命中」放行下游。 */
311
- take(subject) {
312
- const session = subject?.session ?? subject?.agent?.session
313
- // 无会话不设限:与原模块「取不到会话条目即放行」的降级一致。
314
- const entry = entryOf(session)
315
- if (entry === undefined) return true
316
- if (entry.used >= max) return false
317
- entry.used += 1
318
- return true
319
- },
320
- /** 轮边界重置(取最大值,容忍乱序/重复事件)。 */
321
- observe(session, event) {
322
- const turn = event?.data?.turn
323
- if (typeof turn !== 'number' || !Number.isFinite(turn)) return
324
- const entry = entryOf(session)
325
- if (entry === undefined) return
326
- if (entry.turn === undefined || turn > entry.turn) {
327
- entry.turn = turn
328
- entry.used = 0
329
- }
330
- },
331
- }
332
- }
333
-
334
- /**
335
- * 按动作实际阶段求值 when。after-next 先结算下游,再读取当前 epoch,
336
- * 并向动作提供已结算结果,保证宿主 next 只调用一次;未命中或异常保持下游结果。
337
- * 每轮预算由各动作在目标匹配且即将生效时同步 claim,不在谓词入口扣减。
338
- *
339
- * **同步谓词走同步路径**:`tools/pre-execute` 这类通道的裁决靠「deny 前无 await」保证
340
- * 并行调用不越过预算(见 `deliberation-gate.mjs` 的注释),无条件 `await` 会破坏它。
341
- * 返回 Promise 的谓词退回异步路径——声明编译出的谓词恒同步,这条兜底是给手写调用方的。
342
- */
343
- function withWhen(on, when, warnOnce, phase) {
344
- if (typeof when !== 'function') return on
345
- // 注意参数个数:`on` 的签名是 `(event, handler, options)`——包装后的处理器必须占据
346
- // **第二个**位置,多传一个参数会把 options 挤到错位(首版就踩了这个,被测试抓出)。
347
- return (event, handler, options) => on(event, (...args) => {
348
- const nextFree = NEXT_FREE_CHANNELS.has(event)
349
- const payload = nextFree ? args : args.slice(0, -1)
350
- const pass = () => (nextFree ? undefined : args[args.length - 1]())
351
- // 谓词契约是**单个载荷对象**(见 predicates.mjs 头部的统一接口),而处理器收到的是
352
- // 事件参数表:按通道归一后再交给谓词——否则除 `names` 外,各原语在真实载荷上都
353
- // 取不到它们要的域(`phase` 曾因此恒为「已晋升」、`count` 恒 0)。
354
- const evaluate = (invoke, fallback) => {
355
- const decide = (decided) => decided === true ? invoke() : fallback()
356
- const failed = (error) => {
357
- warnOnce(`when predicate failed: ${String(error?.message ?? error)}`)
358
- return fallback()
359
- }
360
- let decided
361
- try {
362
- decided = when(subjectOf(event, payload, warnOnce))
363
- } catch (error) {
364
- return failed(error)
365
- }
366
- return decided !== null && typeof decided === 'object' && typeof decided.then === 'function'
367
- ? decided.then(decide, failed)
368
- : decide(decided)
369
- }
370
- if (!nextFree && phase === 'after-next') {
371
- return Promise.resolve(pass()).then((result) => evaluate(
372
- () => handler(...payload, () => result), () => result,
373
- ))
374
- }
375
- return evaluate(() => handler(...args), pass)
376
- }, options)
377
- }
378
-
379
- /** stringList 已保证形状;这里只做「声明即校验」的显式检查。 */
380
- function requireString(value, label, plugin) {
381
- if (typeof value !== 'string') throw new TypeError(`${plugin}: ${label} must be a string`)
382
- return value
383
- }
384
-
385
- /**
386
- * (1) 注入文本:按声明的 layer 落到既有九层通道。
387
- * 不复制任何一层接线——`pre-step` 走 executor 的执行器,其余层走 layers 的接线。
388
- */
389
- function prepareInjectText(action, plugin, promptConfigOptions) {
390
- const source = action.config
391
- if (source === null || typeof source !== 'object' || Array.isArray(source)) {
392
- throw new TypeError(`${plugin}: inject-text requires a config object (与 promptConfigs 同形状的提示词配置)`)
393
- }
394
- if (typeof source.layer !== 'string' || source.layer.length === 0) {
395
- throw new TypeError(`${plugin}: inject-text config.layer is required (九层之一)`)
396
- }
397
- // 纯声明复用提示词配置编译;函数仅来自已有编程接口,不写回可序列化声明。
398
- const config = typeof source.resolve === 'function' ? source : createPromptConfigs([source], promptConfigOptions)[0]
399
- return (ctx, { warnOnce, collect }) => {
400
- if (config.layer === 'pre-step') {
401
- collect(applyPromptConfigs(ctx, [config], action.options ?? {}))
402
- return
403
- }
404
- collect(wireLayers(ctx, [config], warnOnce))
405
- }
406
- }
407
-
408
- /** (2) 改装配:一次 assembly 内的 tools / sections / contexts 增删改。 */
409
- function prepareAssembly(action, plugin) {
410
- const target = action.target
411
- if (target === null || typeof target !== 'object' || Array.isArray(target)) {
412
- throw new TypeError(`${plugin}: assembly requires a target object — { tools } / { sections } / { contexts }`)
413
- }
414
- const tools = target.tools === undefined ? undefined : createMask(target.tools, 'assembly.tools', plugin)
415
- const sectionsAdd = target.sections?.add
416
- const sectionsRemove = NAME_LIST.parse(target.sections?.remove, plugin, 'assembly.sections.remove')
417
- // 白名单(2026-09-22 用户拍板「扩动作库」):`personaSectionsOnly` 一类的语义是
418
- // `filter(白名单)`,而 remove 是黑名单——未列名的第三方/晚到段不会被它删掉。
419
- // 两种语义**互斥**:同时声明即挂载期 fail loud,而不是静默二选一。
420
- const sectionsKeep = NAME_LIST.parse(target.sections?.keep, plugin, 'assembly.sections.keep')
421
- if (sectionsKeep !== undefined && sectionsRemove !== undefined) {
422
- throw new TypeError(`${plugin}: assembly target.sections cannot combine keep with remove — 一种是白名单、一种是黑名单,同时声明语义不明`)
423
- }
424
- const contextsAdd = target.contexts?.add
425
- const contextsRemove = NAME_LIST.parse(target.contexts?.remove, plugin, 'assembly.contexts.remove')
426
- const contextsClear = target.contexts?.clear === true
427
- for (const [key, list] of [['sections.add', sectionsAdd], ['contexts.add', contextsAdd]]) {
428
- if (list === undefined) continue
429
- if (!Array.isArray(list)) throw new TypeError(`${plugin}: assembly.${key} must be an array of { name, text }`)
430
- for (const entry of list) {
431
- if (entry === null || typeof entry !== 'object') throw new TypeError(`${plugin}: assembly.${key} entries must be objects`)
432
- requireString(entry.name, `assembly.${key}[].name`, plugin)
433
- requireString(entry.text, `assembly.${key}[].text`, plugin)
434
- }
435
- }
436
- return (_ctx, { warnOnce, on, collect, take }) => collect(on('system-prompt/assemble', async (assembly, context, next) => {
437
- // Downstream errors propagate untouched; only this action's own logic is guarded.
438
- const assembled = await next()
439
- try {
440
- if (typeof action.match === 'function' && action.match(context, assembled) !== true) return assembled
441
- let result = assembled
442
- if (tools !== undefined && Array.isArray(result.tools)) {
443
- // keep 名单兜底(2026-09-22 用户拍板,**可选**):判据与原 `tool-bootstrap` 逐条对齐——
444
- // 原模块是 `missing = required.filter(t => !available.has(t))`
445
- // 且 `missing.length > 0` 即放弃裁剪,也就是「**任意一个** 名单工具缺失」而不是
446
- // 「一个都不存在」(对拍实测:目录 `[bash,read]` + 名单 `[bash,str_replace_editor]` 时
447
- // 原模块给完整目录,只判"全缺"会裁成 `[bash]`)。
448
- // 声明写 `requireMatch: true` 才启用;**不写就照名单裁**(与 `tool-filter.mjs` 一致)。
449
- const available = new Set(result.tools.map((tool) => tool?.name).filter((name) => typeof name === 'string'))
450
- const missingRequired = tools.requireMatch && tools.allow !== undefined
451
- && [...tools.allow].some((name) => !available.has(name))
452
- if (missingRequired) {
453
- warnOnce(`${plugin}: assembly action ${labelOf(action)}: a requireMatch tool is absent — exposing the full tool set (fail-open)`)
454
- } else {
455
- const kept = result.tools.filter((tool) => !tools.blocks(tool?.name))
456
- if (kept.length !== result.tools.length) result = { ...result, tools: kept }
457
- }
458
- }
459
- if (Array.isArray(result.sections) && (sectionsAdd !== undefined || sectionsRemove !== undefined || sectionsKeep !== undefined)) {
460
- const kept = sectionsKeep !== undefined
461
- ? result.sections.filter((section) => sectionsKeep.has(section?.name))
462
- : sectionsRemove === undefined
463
- ? result.sections
464
- : result.sections.filter((section) => !sectionsRemove.has(section?.name))
465
- if (kept.length !== result.sections.length || sectionsAdd?.length > 0) {
466
- result = { ...result, sections: sectionsAdd === undefined ? kept : [...kept, ...sectionsAdd] }
467
- }
468
- }
469
- if (Array.isArray(result.contexts)) {
470
- if (contextsClear) {
471
- if (result.contexts.length > 0) result = { ...result, contexts: [] }
472
- } else if (contextsAdd !== undefined || contextsRemove !== undefined) {
473
- const kept = contextsRemove === undefined
474
- ? result.contexts
475
- : result.contexts.filter((entry) => !contextsRemove.has(entry?.name))
476
- if (kept.length !== result.contexts.length || contextsAdd?.length > 0) {
477
- result = { ...result, contexts: contextsAdd === undefined ? kept : [...kept, ...contextsAdd] }
478
- }
479
- }
480
- }
481
- return result !== assembled && take(context) ? result : assembled
482
- } catch (error) {
483
- warnOnce(`${plugin}: assembly action ${labelOf(action)} failed, exposing the full assembly: ${String(error?.message ?? error)}`)
484
- return assembled
485
- }
486
- }))
487
- }
488
-
489
- /** 工具名单:与 tool-pipeline 层同语法(逗号串),也接受数组;空 = 命中全部工具。 */
490
- function toolNameSet(value, plugin, field) {
491
- if (value === undefined) return undefined
492
- const names = NAME_LIST.parse(typeof value === 'string' ? parseToolNames(value) : value, plugin, field)
493
- return names !== undefined && names.size > 0 ? names : undefined
494
- }
495
-
496
- /** (3) 裁决:与 wireToolPipelines 同语义,判定由触发器在动作入口求值。 */
497
- function prepareDecision(action, plugin) {
498
- const label = labelOf(action)
499
- const names = toolNameSet(action.toolNames, plugin, `${label}.toolNames`)
500
- const matchesTool = (exec) => names === undefined || names.has(exec?.name)
501
- const hit = (exec, result) => typeof action.match !== 'function' || action.match(exec, result) === true
502
- const phase = action.phase === undefined ? 'pre' : action.phase
503
- if (!['pre', 'post'].includes(phase)) {
504
- throw new TypeError(`${plugin}: ${label}.phase must be one of pre, post`)
505
- }
506
- if (phase === 'pre') {
507
- const decision = action.decision ?? 'allow'
508
- if (!['allow', 'deny', 'ask'].includes(decision)) {
509
- throw new TypeError(`${plugin}: ${label}.decision must be one of allow, deny, ask`)
510
- }
511
- return (_ctx, { warnOnce, on, collect, take }) => collect(on('tools/pre-execute', async (exec, next) => {
512
- try {
513
- if (!matchesTool(exec) || !hit(exec)) return next()
514
- if (decision === 'allow') return next()
515
- const outcome = decision === 'deny'
516
- ? { kind: 'deny', reason: String(action.reason ?? `${label}: denied by action`) }
517
- : { kind: 'ask' }
518
- return take(exec) ? outcome : next()
519
- } catch (error) {
520
- warnOnce(`${plugin}: decision(pre) action ${label} failed: ${String(error?.message ?? error)}`)
521
- return next()
522
- }
523
- }))
524
- }
525
- const postAction = action.action ?? 'accept'
526
- if (!['accept', 'replace', 'block'].includes(postAction)) {
527
- throw new TypeError(`${plugin}: ${label}.action must be one of accept, replace, block`)
528
- }
529
- const text = typeof action.text === 'string' ? action.text : ''
530
- return (_ctx, { warnOnce, on, collect, take }) => collect(on('tools/post-execute', async (exec, result, next) => {
531
- try {
532
- if (!matchesTool(exec) || !hit(exec, result)) return next()
533
- if (postAction === 'accept') return next()
534
- if (postAction === 'replace' && text.length > 0) {
535
- if (!take(exec)) return next()
536
- return { kind: 'accept', content: [{ type: 'text', text }] }
537
- }
538
- if (postAction === 'block') {
539
- if (!take(exec)) return next()
540
- return { kind: 'block', feedback: [{ type: 'text', text: text.length > 0 ? text : `${label}: blocked by action` }] }
541
- }
542
- return next()
543
- } catch (error) {
544
- warnOnce(`${plugin}: decision(post) action ${label} failed: ${String(error?.message ?? error)}`)
545
- return next()
546
- }
547
- }))
548
- }
549
-
550
- /** (4) 追加上下文与续跑:与 progress-reminder / turn-stop 同语义,续跑共用引擎预算。 */
551
- function prepareAppendContext(action, plugin) {
552
- const label = labelOf(action)
553
- const text = requireString(action.text, `${label}.text`, plugin)
554
- if (text.length === 0) return
555
- if (action.mode === 'continue') {
556
- return (_ctx, { warnOnce, on, collect, take }) => {
557
- const budget = createTurnStopBudget()
558
- collect(on('agent/turn-stopping', ({ agent, turn } = {}) => {
559
- try {
560
- if (typeof action.match === 'function' && action.match(agent, turn) !== true) return
561
- const session = agent?.session
562
- if (session?.id === undefined || typeof agent.steer !== 'function') return
563
- const entry = budget.entry(session.id, turn)
564
- if (!budget.available(entry, turn) || !take({ agent })) return
565
- // 计数在 steer 之前落账:steer 抛错也不允许下一步重试越过预算。
566
- budget.claim(entry, turn)
567
- // 身份归本动作:复用 pluginMessage 的形状,但 source.plugin 必须是动作命名空间
568
- // (层模块的 pluginMessage 用 layers 自己的身份,那不是本动作的名字)。
569
- const message = pluginMessage(`action-${label}`, text, `${label} continue`)
570
- agent.steer({ ...message, source: { ...message.source, plugin: label } })
571
- } catch (error) {
572
- warnOnce(`${plugin}: append-context(continue) action ${label} failed: ${String(error?.message ?? error)}`)
573
- }
574
- }))
575
- }
576
- }
577
- // 追加上下文:只回 user 角色的 durable 通知(宿主把 additionalContexts 落成 user 消息)。
578
- return (_ctx, { warnOnce, on, collect, take }) => collect(on('tools/post-execute', async (exec, result, next) => {
579
- const decision = await next()
580
- try {
581
- if (typeof action.match === 'function' && action.match(exec, result, decision) !== true) return decision
582
- if (decision?.kind !== 'accept') return decision
583
- const notice = {
584
- ...pluginMessage(`action-${label}`, text, `${label} context`),
585
- source: { kind: 'plugin', plugin: label, form: 'notice', summary: `${label} context` },
586
- }
587
- const updated = { ...decision, additionalContexts: [...(decision.additionalContexts ?? []), notice] }
588
- return take(exec) ? updated : decision
589
- } catch (error) {
590
- warnOnce(`${plugin}: append-context action ${label} failed, keeping the plain result: ${String(error?.message ?? error)}`)
591
- return decision
592
- }
593
- }))
594
- }
595
-
596
- /**
597
- * (5) 执行层 guard —— 唯一能兜住执行的口子。
598
- * 注册面:`agent.ctx.tools.guard`(agent scope,故只作用于该 agent,且覆盖本层与晚到工具);
599
- * 复用面:受众包含子代理时,同一名单顺带 `restrict`(最佳努力,失败不影响 guard)。
600
- * guard 自身**不包 try/catch**:官方 `guardReason()` 只收集「返回的拒绝原因」,
601
- * 而它在 `prepareExecution` 的 try 内被调用(安装包 `dsh-tools/lib/index.js:3236`),
602
- * 抛错会落进同一 try 的 catch(`:3262-3268`)变成失败的工具结果——所以"异常即失败"
603
- * 是构造上的 fail-closed,绝不退化成放行,也不需要用 catch 去"兜"成允许。
604
- */
605
- function prepareGuard(action, plugin) {
606
- const label = labelOf(action)
607
- const mask = createMask(action.mask ?? action, `${label}.mask`, plugin)
608
- const includeSubagents = action.includeSubagents === true
609
- const reasonFor = (toolName) => String(action.reason ?? `${label}: tool ${JSON.stringify(toolName)} is denied by action`)
610
-
611
- return (_ctx, { warnOnce, on, collect }) => {
612
- /** session -> { ctx, disposers }:同一 agent.ctx 只注册一次,重绑后按新 ctx 重注册。 */
613
- const appliedBySession = new WeakMap()
614
- const states = new Set()
615
- const releaseState = (state) => {
616
- states.delete(state)
617
- for (const dispose of state.disposers.splice(0)) {
618
- try { dispose() } catch { /* 释放失败不得反过来打断卸载 */ }
619
- }
620
- }
621
- const releaseAll = () => { for (const state of [...states]) releaseState(state) }
622
-
623
- const applyTo = (agent) => {
624
- const session = agent?.session
625
- const scoped = agent?.ctx
626
- if (session === undefined || scoped === undefined) return
627
- // 受众不在本动作的裁决面内就不注册(子代理的 guard 由它自己的 assembly 决定;
628
- // 万一父 guard 经 scope 链被走到,guard 体内的同一道受众判定仍然放行)。
629
- const depth = session.header?.delegationDepth ?? 0
630
- if (depth > 0 ? !includeSubagents : action.audience === 'subagent') return
631
- const existing = appliedBySession.get(session)
632
- if (existing !== undefined) {
633
- // 预设切换/重绑会换掉 agent.ctx:旧 scope 的 guard 随旧 scope 失效,必须重注册。
634
- if (existing.ctx === scoped) return
635
- releaseState(existing)
636
- appliedBySession.delete(session)
637
- }
638
- const tools = scoped.tools
639
- if (tools === undefined || typeof tools.guard !== 'function') {
640
- warnOnce(`${plugin}: guard action ${label} found no agent-scoped tools service — execution guard not registered`)
641
- return
642
- }
643
- const state = { ctx: scoped, disposers: [] }
644
- states.add(state)
645
- appliedBySession.set(session, state)
646
- const denials = {
647
- ...(mask.allow !== undefined ? { allow: [...mask.allow] } : {}),
648
- ...(mask.deny !== undefined ? { deny: [...mask.deny] } : {}),
649
- }
650
- // restrict 沿父 scope 链传播且不识别受众;只在子代理也受限时复用。
651
- if (includeSubagents && typeof tools.restrict === 'function') {
652
- try {
653
- const dispose = tools.restrict(denials)
654
- if (typeof dispose === 'function') state.disposers.push(dispose)
655
- } catch (error) {
656
- // restrict 只收继承面:本层/晚到工具不在它的可限制集合里,交给 guard 裁决。
657
- warnOnce(`${plugin}: guard action ${label} restrict skipped (own-layer/late tools stay covered by the guard): ${String(error?.message ?? error)}`)
658
- }
659
- }
660
- state.disposers.push(tools.guard((exec) => {
661
- const toolName = exec?.name
662
- if (toolName === RUN_CODE || typeof toolName !== 'string' || toolName.length === 0) return undefined
663
- const depth = exec?.agent?.session?.header?.delegationDepth ?? 0
664
- // 主子代理隔离:scope 注册之外再按受众判定一次(子代理 scope 可能挂在父链上)。
665
- if (depth > 0 ? !includeSubagents : action.audience === 'subagent') return undefined
666
- return mask.blocks(toolName) ? reasonFor(toolName) : undefined
667
- }))
668
- }
669
-
670
- collect(releaseAll)
671
- collect(on('system-prompt/assemble', async (assembly, context, next) => {
672
- // Downstream errors propagate untouched; only this action's own logic is guarded.
673
- const assembled = await next()
674
- try {
675
- applyTo(context?.agent)
676
- } catch (error) {
677
- warnOnce(`${plugin}: guard action ${label} failed to register: ${String(error?.message ?? error)}`)
678
- }
679
- return assembled
680
- }))
681
- }
682
- }
683
-
684
- /**
685
- * (6) 裁 SDK 声明文本。
686
- * 注:官方 `sdkSection().text(context)` 与一次 assembly 里 `tools:sdk` 段的文本同源
687
- * (前者按 `this.sdkSchemas(context.scope)` 现渲染,后者是同一 provider 在同一次
688
- * assembly 里求值的结果),所以"按更新后的视图重生"并不改变文本——真正需要的是
689
- * 对**已产出正文**做删行;重生不是必需步骤,故这里直接改段文本。
690
- */
691
- function prepareSdkStrip(action, plugin) {
692
- const label = labelOf(action)
693
- const mask = createMask(action.mask ?? action, `${label}.mask`, plugin)
694
- return (_ctx, { warnOnce, on, collect, take }) => collect(on('system-prompt/assemble', async (assembly, context, next) => {
695
- // Downstream errors propagate untouched; only this action's own logic is guarded.
696
- const assembled = await next()
697
- try {
698
- if (!Array.isArray(assembled.sections)) return assembled
699
- let changed = false
700
- const sections = assembled.sections.map((section) => {
701
- if (section?.name !== SDK_SECTION_NAME || typeof section.text !== 'string') return section
702
- const names = mask.allow === undefined
703
- ? mask.deny
704
- : new Set([...sdkToolNames(section.text)].filter((toolName) => mask.blocks(toolName)))
705
- if (names === undefined || names.size === 0) return section
706
- const stripped = stripSdkDeclarations(section.text, names)
707
- if (stripped === section.text) return section
708
- changed = true
709
- return { ...section, text: stripped }
710
- })
711
- return changed && take(context) ? { ...assembled, sections } : assembled
712
- } catch (error) {
713
- warnOnce(`${plugin}: sdk-strip action ${label} failed, keeping the original SDK text: ${String(error?.message ?? error)}`)
714
- return assembled
715
- }
716
- }))
717
- }
718
-
719
- /** (7) 改模型请求参数:与既有 agent-request 层共用同一实现,不另写一套请求改写。 */
720
- function prepareRequestParams(action, plugin) {
721
- const label = labelOf(action)
722
- const patch = action.patch
723
- if (patch !== undefined && (patch === null || typeof patch !== 'object' || Array.isArray(patch))) {
724
- throw new TypeError(`${plugin}: ${label}.patch must be an object`)
725
- }
726
- const unset = action.unset
727
- if (unset !== undefined && (unset === null || typeof unset !== 'object' || Array.isArray(unset))) {
728
- throw new TypeError(`${plugin}: ${label}.unset must be an object of { key: expectedValue }`)
729
- }
730
- if (action.replace === true && unset !== undefined) {
731
- throw new TypeError(`${plugin}: ${label} cannot combine replace with unset — 整体替换没有可比较的下游值`)
732
- }
733
- const params = { ...(patch !== undefined ? { patch } : {}), ...(unset !== undefined ? { unset } : {}), ...(action.replace === true ? { replace: true } : {}) }
734
- return (_ctx, { warnOnce, on, collect, take }) => collect(on('agent/request', async (payload, next) => {
735
- const base = await next()
736
- try {
737
- if (!matchesAgentScope(action, payload?.agent)) return base
738
- const result = applyAgentRequestParams(params, base)
739
- const keys = Object.keys(result)
740
- const changed = base == null || keys.length !== Object.keys(base).length
741
- || keys.some((key) => !Object.hasOwn(base, key) || !Object.is(result[key], base[key]))
742
- return changed && take(payload) ? result : base
743
- } catch (error) {
744
- warnOnce(`${plugin}: request-params action ${label} failed: ${String(error?.message ?? error)}`)
745
- return base
746
- }
747
- }))
748
- }
749
-
750
- /**
751
- * (8) 前置收件箱消息 —— 注册在 **emit** 通道 `agent/inbox/inserted`(无 `next`)。
752
- *
753
- * 语义(`anchor-turn` 的重建目标):真实消息进入收件箱时,把一条合成消息**插到它前面**,
754
- * 使下一条被 claim 的消息是合成的那条。判定(是否全新会话、是否插件来源)由声明的 `when`
755
- * 表达;本动作只负责"插队"这一件事,不判断时机。
756
- *
757
- * **防自触发**:插件来源消息(包括本动作自己插入的那条)永不再次前置——`anchor-turn` 的
758
- * 原实现也守这条,否则一条消息会引出无限插队。
759
- */
760
- function prepareInboxPrepend(action, plugin) {
761
- const label = labelOf(action)
762
- const target = action.target ?? 'next-turn'
763
- if (target !== 'next-turn' && target !== 'next-step') {
764
- throw new TypeError(`${plugin}: ${label}.target must be "next-turn" or "next-step"`)
765
- }
766
- const text = requireString(action.text, `${label}.text`, plugin)
767
- // 空正文 = 没有可插入的内容(empty 情形):不注册,而不是插入一条空消息。
768
- if (text.length === 0) return
769
- return (_ctx, { warnOnce, on, collect, take }) => collect(on('agent/inbox/inserted', ({ agent, message } = {}) => {
770
- try {
771
- // 无 session 的 agent 不锚定(与原 `anchor-turn` 的守卫逐条对齐;声明侧见
772
- // `test/engine/declarations/anchor-turn.yml` 的 session 谓词):锚定是**会话**
773
- // 首轮语义,没有会话就没有「首轮」;缺这条会让匿名/临时 agent 也被插一条合成消息
774
- // (对拍实测:声明路径多插 2 条)。
775
- if (agent?.session === undefined) return
776
- if (typeof agent?.inbox?.prepend !== 'function') return
777
- if (message?.source?.kind === 'plugin') return
778
- if (typeof action.match === 'function' && action.match(agent, message) !== true) return
779
- if (!take({ agent })) return
780
- agent.inbox.prepend(target, {
781
- id: newMessageId(`action-${label}`),
782
- role: 'user',
783
- content: [{ type: 'text', text }],
784
- source: { kind: 'plugin', plugin: label, form: 'notice', summary: `${label} ${target}` },
785
- })
786
- } catch (error) {
787
- warnOnce(`${plugin}: inbox-prepend action ${label} failed: ${String(error?.message ?? error)}`)
788
- }
789
- }))
790
- }
791
-
792
- /**
793
- * (9) 过滤 pre-step 注入消息 —— waterfall(有 `next`):`await next()` 后改写 `decision.messages`。
794
- *
795
- * 语义(`context-gate` 的两个 pre-step 监听都用它):
796
- * - `sources`(严格白名单):只放行 `source.kind` 在名单内的消息,**包括 claimed 批**;
797
- * - `keepKinds`(基线 + 名单):保留本次 step 从收件箱 claim 的 baseline(按**对象身份**或
798
- * `id` 匹配,因为下游可能换了对象引用),再加上 `source.kind` 在名单内的追加消息。
799
- *
800
- * 两者**互斥**:混用会在挂载期报错——它们的"保留集"定义不同,叠在一起没有确定语义。
801
- * 未声明任一 = 不过滤(不注册,零开销):与 `context-gate` 的「allowKinds 未声明 = 官方行为」一致。
802
- *
803
- * **永不吞上下文**:异常一律返回未过滤的 decision。门控 bug 可以少拦,不可以吃掉会话内容。
804
- */
805
- function preparePreStepFilter(action, plugin) {
806
- const label = labelOf(action)
807
- const sources = NAME_LIST.parse(action.sources, plugin, `${label}.sources`)
808
- const keepKinds = NAME_LIST.parse(action.keepKinds, plugin, `${label}.keepKinds`)
809
- // `blockPlugins`(B8 T8,2026-09-22 拍板「大小写不敏感的精确等值」):与上面两种白名单**正交**,
810
- // 因此可共存——白名单决定「留哪些 kind」,本名单决定「再剔掉哪些插件」。未声明或空名单 =
811
- // 不过滤(空集没有可剔对象;这与白名单「空 = 全拦」的语义**相反**,勿沿用邻居语义)。
812
- const parsedBlockPlugins = NAME_LIST.parse(action.blockPlugins, plugin, `${label}.blockPlugins`)
813
- const blockPlugins = parsedBlockPlugins === undefined || parsedBlockPlugins.size === 0
814
- ? undefined
815
- : new Set([...parsedBlockPlugins].map((name) => name.toLowerCase()))
816
- if (sources !== undefined && keepKinds !== undefined) {
817
- throw new TypeError(`${plugin}: ${label} cannot combine sources with keepKinds — 两种过滤语义的保留集定义不同`)
818
- }
819
- if (sources === undefined && keepKinds === undefined && blockPlugins === undefined) return
820
- return (_ctx, { warnOnce, on, collect, take }) => collect(on('agent/pre-step', async ({ agent, messages: claimed } = {}, next) => {
821
- const decision = await next()
822
- try {
823
- if (decision?.kind === 'reject') return decision
824
- if (!Array.isArray(decision?.messages)) return decision
825
- let messages = decision.messages
826
- if (sources !== undefined) {
827
- messages = messages.filter((message) => sources.has(message?.source?.kind))
828
- } else if (keepKinds !== undefined && Array.isArray(claimed)) {
829
- const baseline = new Set(claimed)
830
- const baselineIds = new Set(claimed
831
- .map((message) => message?.id)
832
- .filter((id) => id !== undefined && id !== null))
833
- messages = messages.filter((message) =>
834
- baseline.has(message)
835
- || (message?.id !== undefined && message?.id !== null && baselineIds.has(message.id))
836
- || keepKinds.has(message?.source?.kind))
837
- }
838
- if (blockPlugins !== undefined) {
839
- messages = messages.filter((message) => {
840
- const source = message?.source
841
- if (source?.kind !== 'plugin') return true
842
- const name = source.plugin
843
- // 只认插件自报的字符串身份;非字符串一律不拦(宁可少拦,不可误删上下文)。
844
- return typeof name !== 'string' || !blockPlugins.has(name.toLowerCase())
845
- })
846
- }
847
- return messages.length !== decision.messages.length && take({ agent }) ? { ...decision, messages } : decision
848
- } catch (error) {
849
- warnOnce(`${plugin}: pre-step-filter action ${label} failed, keeping every message: ${String(error?.message ?? error)}`)
850
- return decision
851
- }
852
- }))
853
- }
854
-
855
- const PREPARERS = {
856
- 'inject-text': prepareInjectText,
857
- assembly: prepareAssembly,
858
- decision: prepareDecision,
859
- 'append-context': prepareAppendContext,
860
- guard: prepareGuard,
861
- 'sdk-strip': prepareSdkStrip,
862
- 'request-params': prepareRequestParams,
863
- 'inbox-prepend': prepareInboxPrepend,
864
- 'pre-step-filter': preparePreStepFilter,
865
- }
866
-
867
- /**
868
- * 纯准备阶段:校验动作和注册选项,返回接收真实 ctx 的绑定函数。
869
- * 声明编译只调用准备阶段;运行时注册复用同一入口,不复制校验或模拟宿主。
870
- *
871
- * @param action `{ kind, id?, ... }`,形状见 {@link ACTION_KINDS}。
872
- * @param options.plugin 调用方插件名(错误消息 / 告警 / disposer 标签),与
873
- * `mountTriggers(ctx, declarations, { plugin })` 同一约定;缺省 `prompt-actions`。
874
- * @param options.warnOnce 复用宿主模块的告警器;缺省为本文件自己的 warnOnce。
875
- * @param options.when 可选判定:不命中(非 `true`)即放行下游、不执行本动作。仅对
876
- * {@link ON_REGISTERED_KINDS} 里的动作有效;对 `inject-text` / `guard` 传它会挂载期报错
877
- * (那两类不走 `on(...)`,静默忽略就是"配了没效果")。
878
- * @param options.prepend 让本动作落在宿主 waterfall 的**最外层**(否决型动作需要)。同样只对
879
- * `ON_REGISTERED_KINDS` 有效;它表达的是**位置**,不承担声明之间的排序。
880
- * @param options.promptConfigOptions 宿主提供的 createPromptConfigs 选项(模板基准和策略目录),不从动作数据读取。
881
- * @returns 绑定函数:传入 ctx 后注册监听器,并返回 disposer。
882
- */
883
- export function prepareAction(action, options = {}) {
884
- const kind = action?.kind
885
- const plugin = typeof options.plugin === 'string' && options.plugin.length > 0 ? options.plugin : DEFAULT_PLUGIN
886
- if (!Object.hasOwn(ACTION_KINDS, kind)) {
887
- throw new TypeError(`${plugin}: unknown action kind ${JSON.stringify(kind)} — known kinds: ${Object.keys(ACTION_KINDS).join(', ')}`)
888
- }
889
- if (!actionSupportsWhen(kind) && (options.when !== undefined || options.prepend === true)) {
890
- const feature = options.when !== undefined ? 'a when predicate' : 'prepend'
891
- throw new TypeError(`${plugin}: action ${kind} does not support ${feature} — 它不经 on(...) 注册(inject-text 注册层、guard 注册 agent scope 的最终拒绝)`)
892
- }
893
- // `maxPerTurn` 与 `when` 的支持面相同:都只对经 `on(...)` 注册的动作有效。
894
- if (!actionSupportsWhen(kind) && action?.maxPerTurn !== undefined) {
895
- throw new TypeError(`${plugin}: action ${kind} does not support maxPerTurn — 它不经 on(...) 注册`)
896
- }
897
- const max = action?.maxPerTurn
898
- if (max !== undefined && (!Number.isSafeInteger(max) || max <= 0)) {
899
- throw new TypeError(`${labelOf(action)}.maxPerTurn must be a positive integer`)
900
- }
901
- const bind = PREPARERS[kind](action, plugin, options.promptConfigOptions)
902
- const phase = actionSupportsWhen(kind) ? actionExecutionPoint(action).phase : undefined
903
- return (ctx) => {
904
- const warnOnce = options.warnOnce ?? createWarnOnce(ctx, plugin)
905
- const budget = createTurnBudget(max)
906
- const on = withWhen(channelBinder(ctx, kind, plugin, options.prepend === true), options.when, warnOnce, phase)
907
- const disposers = []
908
- const collect = (disposer) => { if (typeof disposer === 'function') disposers.push(disposer) }
909
- // 每轮预算的轮边界由 durable 事件驱动(与谓词的 observe 同一事件源,但**独立记账**:
910
- // 预算是「本动作生效次数」,不是 durable 事件计数)。
911
- if (budget !== undefined) collect(ctx.on('session/event', (session, event) => budget.observe(session, event)))
912
- const take = (subject) => budget?.take(subject) ?? true
913
- bind?.(ctx, { warnOnce, on, collect, take })
914
- const dispose = () => {
915
- for (const release of disposers.splice(0)) {
916
- try {
917
- release()
918
- } catch {
919
- // 释放失败不得反过来打断卸载流程。
920
- }
921
- }
922
- }
923
- keepDisposer(ctx, dispose, `${plugin}: action ${labelOf(action)}`)
924
- return dispose
925
- }
926
- }
927
-
928
- /** 校验后把动作接到其合法通道,返回释放函数。 */
929
- export function registerAction(ctx, action, options = {}) {
930
- return prepareAction(action, options)(ctx)
931
- }
1
+ /** 既有包内导出路径;真实动作归 actions,各入口复用同一实现。 */
2
+ export * from './actions/index.mjs'