dsh-plugin-prompt-tool 2.1.0 → 3.0.0

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 (204) hide show
  1. package/README.md +100 -233
  2. package/engine/actions/append-context.mjs +3 -24
  3. package/engine/actions/assembly.mjs +70 -94
  4. package/engine/actions/catalog.mjs +65 -28
  5. package/engine/actions/content.mjs +8 -152
  6. package/engine/actions/examples.mjs +8 -4
  7. package/engine/actions/inbox-prepend.mjs +4 -5
  8. package/engine/actions/index.mjs +17 -9
  9. package/engine/actions/inject-text.mjs +24 -4
  10. package/engine/actions/pre-step-filter.mjs +3 -4
  11. package/engine/actions/registration.mjs +20 -28
  12. package/engine/actions/request-params.mjs +21 -1
  13. package/engine/actions/shared.mjs +22 -13
  14. package/engine/actions/stream-output.mjs +26 -0
  15. package/engine/actions/tool-gate.mjs +31 -0
  16. package/engine/actions/tool-result.mjs +36 -0
  17. package/engine/actions/tool-visibility.mjs +248 -0
  18. package/engine/agent-capability.mjs +67 -0
  19. package/engine/anchor-match.mjs +14 -21
  20. package/engine/audience.d.mts +2 -0
  21. package/engine/audience.mjs +102 -0
  22. package/engine/branch.d.mts +4 -0
  23. package/engine/branch.mjs +61 -18
  24. package/engine/compaction-epoch.d.mts +25 -0
  25. package/engine/compaction-epoch.mjs +27 -4
  26. package/engine/compositions/source/local/filesystem-editor.yml +3 -3
  27. package/engine/compositions/source/local/instruction-hint.yml +2 -4
  28. package/engine/compositions/source/local/module-file-tools.yml +2 -0
  29. package/engine/compositions/source/local/module-import-tools.yml +4 -0
  30. package/engine/compositions/source/local/module-manifest-tools.yml +2 -0
  31. package/engine/compositions/source/local/module-rule-tools.yml +5 -0
  32. package/engine/compositions/source/local/module-variable-tools.yml +5 -0
  33. package/engine/compositions/source/local/present-as.yml +26 -0
  34. package/engine/compositions/source/local/tool-config-engine.yml +2 -3
  35. package/engine/compositions/source/local/tool-search.yml +11 -0
  36. package/engine/condition-facts.mjs +84 -0
  37. package/engine/conditions/anchor.mjs +63 -23
  38. package/engine/conditions/availability.mjs +1 -1
  39. package/engine/conditions/composite.mjs +3 -3
  40. package/engine/conditions/count.mjs +42 -11
  41. package/engine/conditions/evaluation.mjs +74 -19
  42. package/engine/conditions/examples.mjs +2 -1
  43. package/engine/conditions/index.d.mts +15 -0
  44. package/engine/conditions/index.mjs +23 -9
  45. package/engine/conditions/operators.mjs +4 -0
  46. package/engine/conditions/phase.mjs +9 -3
  47. package/engine/conditions/preset.mjs +1 -1
  48. package/engine/conditions/session.mjs +8 -2
  49. package/engine/conditions/subject.mjs +144 -65
  50. package/engine/conditions/text.mjs +26 -8
  51. package/engine/conditions/variable.mjs +83 -0
  52. package/engine/context-activation.d.mts +11 -0
  53. package/engine/{st-world-book.mjs → context-activation.mjs} +104 -64
  54. package/engine/delivery.d.mts +2 -0
  55. package/engine/delivery.mjs +93 -0
  56. package/engine/discovery.d.mts +38 -0
  57. package/engine/discovery.mjs +146 -0
  58. package/engine/env-safety.mjs +14 -0
  59. package/engine/executor.d.mts +28 -0
  60. package/engine/executor.mjs +290 -205
  61. package/engine/fillers.mjs +39 -8
  62. package/engine/fixed-registration.mjs +12 -0
  63. package/engine/history.mjs +71 -0
  64. package/engine/instruction-blocks.d.mts +13 -0
  65. package/engine/instruction-blocks.mjs +21 -0
  66. package/engine/instruction-files.d.mts +5 -0
  67. package/engine/instruction-files.mjs +97 -0
  68. package/engine/instruction-hint.mjs +69 -103
  69. package/engine/interpolate.d.mts +7 -0
  70. package/engine/interpolate.mjs +44 -13
  71. package/engine/layers.mjs +163 -227
  72. package/engine/machine-paths.mjs +40 -0
  73. package/engine/module-file-tools.mjs +8 -0
  74. package/engine/module-import-tools.mjs +10 -0
  75. package/engine/module-manifest-tools.mjs +8 -0
  76. package/engine/module-rule-tools.mjs +10 -0
  77. package/engine/module-variable-tools.mjs +10 -0
  78. package/engine/order.d.mts +14 -0
  79. package/engine/order.mjs +0 -3
  80. package/engine/path-safety.d.mts +2 -0
  81. package/engine/path-safety.mjs +58 -0
  82. package/engine/pre-step-source.d.mts +69 -0
  83. package/engine/pre-step-source.mjs +103 -0
  84. package/engine/present-as.mjs +77 -0
  85. package/engine/promotion-pool.d.mts +9 -0
  86. package/engine/promotion-pool.mjs +69 -0
  87. package/engine/record.mjs +2 -0
  88. package/engine/rule-engine.mjs +3 -2
  89. package/engine/rule-facts.d.mts +11 -0
  90. package/engine/rule-facts.mjs +60 -0
  91. package/engine/rule-runtime.d.mts +46 -0
  92. package/engine/rule-runtime.mjs +98 -13
  93. package/engine/rule-spec.d.mts +106 -0
  94. package/engine/rule-spec.mjs +301 -27
  95. package/engine/run-code-env.mjs +73 -19
  96. package/engine/schema.mjs +165 -243
  97. package/engine/sdk-strip.mjs +2 -2
  98. package/engine/service-names.d.mts +10 -0
  99. package/engine/service-names.mjs +10 -0
  100. package/engine/shared.d.mts +21 -0
  101. package/engine/shared.mjs +46 -13
  102. package/engine/skill-search.mjs +125 -53
  103. package/engine/source-kind.d.mts +9 -0
  104. package/engine/source-kind.mjs +16 -0
  105. package/engine/strategy-ids.mjs +5 -0
  106. package/engine/template-evaluation.d.mts +16 -0
  107. package/engine/template-evaluation.mjs +178 -0
  108. package/engine/template-frame.d.mts +16 -0
  109. package/engine/template-frame.mjs +69 -0
  110. package/engine/tool-config-engine.mjs +38 -34
  111. package/engine/tool-definition.d.mts +4 -0
  112. package/engine/tool-definition.mjs +5 -1
  113. package/engine/tool-git-bash.mjs +5 -1
  114. package/engine/tool-scope.mjs +22 -0
  115. package/engine/{dev-tool-search.mjs → tool-search.mjs} +63 -65
  116. package/engine/trigger.mjs +15 -270
  117. package/engine/vendor/yaml/dist/compose/resolve-flow-scalar.js +19 -18
  118. package/engine/vendor/yaml/dist/nodes/Alias.js +25 -23
  119. package/engine/vendor/yaml/package.json +1 -1
  120. package/lib/client.js +8312 -6724
  121. package/lib/client.js.map +1 -1
  122. package/lib/index.d.mts +16 -2120
  123. package/lib/index.mjs +26914 -5816
  124. package/modules/ponytail/module.yml +83 -203
  125. package/modules/ptc-mode/module.yml +45 -0
  126. package/modules/soul/assets/core-hard.md +6 -0
  127. package/modules/soul/assets/criteria.md +12 -0
  128. package/modules/soul/assets/echo.md +12 -0
  129. package/modules/soul/assets/layers.md +11 -0
  130. package/modules/soul/assets/ledger.md +9 -0
  131. package/modules/soul/assets/manifest.yml +20 -0
  132. package/modules/soul/assets/reference/00-layers.md +27 -0
  133. package/modules/soul/assets/reference/01-criteria.md +32 -0
  134. package/modules/soul/assets/reference/02-echo.md +158 -0
  135. package/modules/soul/assets/reference/03-vital.md +183 -0
  136. package/modules/soul/assets/reference/04-manifest.json +106 -0
  137. package/modules/soul/assets/reference/05-doctor.md +67 -0
  138. package/modules/soul/assets/reference/06-reference.md +189 -0
  139. package/modules/soul/assets/reference/07-bundle.json +431 -0
  140. package/modules/soul/assets/reference/08-/350/270/251/345/235/221/345/256/236/345/275/225.md +59 -0
  141. package/modules/soul/assets/reference/09-dev-workflow.md +109 -0
  142. package/modules/soul/assets/reference/10-echo-anatomy.md +100 -0
  143. package/modules/soul/assets/reference/11-self.md +80 -0
  144. package/modules/soul/assets/reference/12-ledger.md +159 -0
  145. package/modules/soul/assets/reference/README.md +10 -0
  146. package/modules/soul/assets/templates/agents-template.md +6 -0
  147. package/modules/soul/assets/vital.md +9 -0
  148. package/modules/soul/module.yml +311 -0
  149. package/modules/superpowers/module.yml +122 -0
  150. package/modules/tool-surface/module.yml +51 -47
  151. package/package.json +37 -40
  152. package/skills/dsh-module/SKILL.md +67 -147
  153. package/skills/dsh-module/reference.md +113 -86
  154. package/templates/10-pre-step.yml +2 -3
  155. package/templates/14-first-turn-anchor.yml +10 -14
  156. package/templates/15-guide-auto.yml +32 -28
  157. package/templates/16-custom-fallback.yml +33 -21
  158. package/templates/20-system-section.yml +1 -4
  159. package/templates/50-llm-stream.yml +4 -10
  160. package/templates/60-tool-pipeline.yml +12 -10
  161. package/templates/63-append-context.yml +4 -6
  162. package/templates/65-turn-stop.yml +2 -2
  163. package/templates/67-inbox-prepend.yml +2 -2
  164. package/templates/70-subagent-maintenance.yml +9 -10
  165. package/templates/75-subagent-end.yml +2 -2
  166. package/templates/80-tool-surface.yml +41 -57
  167. package/templates/tools/40-delegate-module-import.yml +22 -0
  168. package/templates/tools/50-delegate-module-rules.yml +14 -0
  169. package/templates/tools/60-delegate-module-variables.yml +17 -0
  170. package/templates/tools/70-delegate-module-rules-edit.yml +31 -0
  171. package/engine/actions/decision.mjs +0 -54
  172. package/engine/actions/guard.mjs +0 -80
  173. package/engine/actions/sdk-strip.mjs +0 -30
  174. package/engine/character-tools.mjs +0 -8
  175. package/engine/classify-task.mjs +0 -53
  176. package/engine/compositions/source/local/character-tools.yml +0 -5
  177. package/engine/compositions/source/local/dev-tool-search.yml +0 -11
  178. package/engine/compositions/source/local/session-var-tools.yml +0 -5
  179. package/engine/compositions/source/local/subagent-tool-policy.yml +0 -21
  180. package/engine/compositions/source/local/world-book-tools.yml +0 -5
  181. package/engine/condition.mjs +0 -96
  182. package/engine/declared-triggers.mjs +0 -93
  183. package/engine/session-var-tools.mjs +0 -8
  184. package/engine/session-vars.mjs +0 -54
  185. package/engine/st-macros.d.mts +0 -22
  186. package/engine/st-macros.mjs +0 -208
  187. package/engine/st-render.mjs +0 -109
  188. package/engine/st-world-book.d.mts +0 -10
  189. package/engine/subagent-tool-policy-core.mjs +0 -400
  190. package/engine/subagent-tool-policy.mjs +0 -352
  191. package/engine/trigger-spec.mjs +0 -173
  192. package/engine/world-book-tools.mjs +0 -8
  193. package/lib/migrate-rules.d.mts +0 -1
  194. package/lib/migrate-rules.mjs +0 -610
  195. package/lib/module-package-DqJZaE71.mjs +0 -2
  196. package/lib/module-package-q2gDRu9H.mjs +0 -2869
  197. package/lib/rule-builder-DMrJten6.mjs +0 -14767
  198. package/templates/61-deny-tools-presentation.yml +0 -27
  199. package/templates/62-guard-deny-tools.yml +0 -20
  200. package/templates/policies/legacy-defaults.yml +0 -34
  201. package/templates/tools/40-delegate-character.yml +0 -15
  202. package/templates/tools/50-delegate-worldbook.yml +0 -15
  203. package/templates/tools/60-delegate-session-var.yml +0 -15
  204. package/templates/tools/70-delegate-worldbook-upsert.yml +0 -36
package/engine/layers.mjs CHANGED
@@ -1,25 +1,25 @@
1
1
  /**
2
- * layers — 非 pre-step 的五个官方层级接线。
2
+ * layers — 非 pre-step 的官方层级接线。
3
3
  * 所有注册都只作用于本插件提示词配置;单条失败 warnOnce 后继续。
4
4
  */
5
5
 
6
6
  import {
7
7
  MAX_TRACKED_SESSIONS,
8
- extractText,
9
8
  getService,
10
9
  isDelegated,
11
10
  keepDisposer,
11
+ matchesAudience,
12
12
  matchesModel,
13
13
  newMessageId,
14
- parseToolNames,
15
14
  sessionState,
16
15
  } from './shared.mjs'
17
- import { KNOWN_STRATEGIES } from './schema.mjs'
18
- import { interpolateVariables, stripUnresolvedRefs, RUNTIME_FACTS, runtimeFactValue } from './interpolate.mjs'
19
- import { getSessionVar, sessionVarsSnapshot } from './session-vars.mjs'
20
- import { conditionHit, lastAssistantText, subagentTextOf, toolArgsText } from './condition.mjs'
16
+ import { pluginKind } from './source-kind.mjs'
17
+ import { INJECTION_LAYERS, KNOWN_STRATEGIES, SESSION_VARIABLES_DISABLED } from './schema.mjs'
18
+ import { interpolateVariables, stripUnresolvedRefs, RUNTIME_FACTS, runtimeFactValue, isReservedInterpolationName } from './interpolate.mjs'
19
+ import { renderTemplate } from './template-frame.mjs'
21
20
  import { compareConfigSequence, compareTextPlacement } from './order.mjs'
22
- import { ruleFrame, ruleMatches } from './conditions/evaluation.mjs'
21
+ import { ruleFrame, actionMatches } from './conditions/evaluation.mjs'
22
+ import { textRegistrationName, identityStampOf, actionStamp, injectedMessage, findActionDelivery } from './delivery.mjs'
23
23
 
24
24
  const name = 'prompt-config-engine'
25
25
  // 同一宿主会话共享投递账本,避免预设作用域重挂后重投同一次结束通知。
@@ -33,11 +33,14 @@ const subagentEndDeliveries = new WeakMap()
33
33
  * keep=官方变量通道按 assembly 求值的名字,静态 pass 命中即原样保留引用。 */
34
34
  function configText(config, registries, warnOnce, context) {
35
35
  const registry = registries.get(config)
36
- if (typeof config.renderSt === 'function') {
37
- return officialChannelText(config.renderSt(context?.agent, [], warnOnce, context), `${config.layer} ${config.id}`, registry, warnOnce)
36
+ if (config.params?.templateEvaluation === true) {
37
+ try { return officialChannelText(renderTemplate(config, context, { enabled: config[SESSION_VARIABLES_DISABLED] !== true, warn: warnOnce }), `${config.layer} ${config.id}`, registry, warnOnce) }
38
+ catch (error) { warnOnce(`${name}: template ${config.id} failed: ${String(error?.message ?? error)}`); return '' }
38
39
  }
40
+ const variables = config[SESSION_VARIABLES_DISABLED] === true ? {} : config.texts.some(text => text.includes('{{'))
41
+ ? config.variableStore?.read(config.sourceModuleId).effective ?? config.variables : config.variables
39
42
  const text = config.texts
40
- .map((item) => interpolateVariables(item, config.variables, context?.agent?.session, registry?.protect))
43
+ .map((item, index) => interpolateVariables(item, variables, context?.agent?.session, registry?.protect, `${config.id}#${index}`))
41
44
  .filter((item) => item.length > 0)
42
45
  .join('\n\n')
43
46
  return officialChannelText(text, `${config.layer} ${config.id}`, registry, warnOnce)
@@ -76,7 +79,8 @@ function registerOfficialVariables(ctx, configs, warnOnce, keep) {
76
79
  const reserved = new Set()
77
80
  const facts = new Set()
78
81
  for (const config of configs) {
79
- if (config.params?.stMacros === true) continue
82
+ // 持久值在各正文 provider 被调用时读取,使后卡看到前卡刚提交的变量。
83
+ if (config.params?.templateEvaluation === true || config.variableStore !== undefined) continue
80
84
  const referenced = new Set()
81
85
  for (const text of config.texts) {
82
86
  for (const match of String(text).matchAll(NAME_REFERENCE_RE)) referenced.add(match[1])
@@ -93,14 +97,14 @@ function registerOfficialVariables(ctx, configs, warnOnce, keep) {
93
97
  for (const [config, referenced] of references) {
94
98
  const registry = registries.get(config)
95
99
  for (const name of referenced) {
96
- const declared = Object.hasOwn(config.variables, name)
100
+ const declared = Object.hasOwn(config.variables, name) || config.variableStore !== undefined && !isReservedInterpolationName(name)
97
101
  const isFact = !declared && RUNTIME_FACTS.has(name.toLowerCase())
98
102
  if (!declared && !isFact) continue
99
103
  let official = isFact ? name.toLowerCase() : OFFICIAL_NAME_RE.test(name) ? name : officialAliasOf(name)
100
104
  try {
101
105
  // 内容绑定包含局部命名空间:会话覆盖也可以再引用其中的变量。
102
106
  // 纯事实不属于任何配置的内容命名空间,所有大小写变体复用规范名。
103
- const signature = isFact ? `fact:${official}` : JSON.stringify([name, Object.entries(config.variables).sort(([a], [b]) => a.localeCompare(b))])
107
+ const signature = isFact ? `fact:${official}` : JSON.stringify([config.sourceModuleId, name, config.variableStore === undefined ? Object.entries(config.variables).sort(([a], [b]) => a.localeCompare(b)) : null])
104
108
  let binding = bindings.get(signature)
105
109
  if (binding === undefined) {
106
110
  if (usedNames.has(official) || (!isFact && facts.has(official)) || (official !== name && !isFact && reserved.has(official))) {
@@ -110,12 +114,13 @@ function registerOfficialVariables(ctx, configs, warnOnce, keep) {
110
114
  }
111
115
  usedNames.add(official)
112
116
  const source = isFact ? name.toLowerCase() : name
113
- const declaredValue = declared ? String(config.variables[name] ?? '') : undefined
117
+ const variablesDisabled = config[SESSION_VARIABLES_DISABLED] === true
114
118
  keep(systemPrompt.variable(official, (context) => {
115
119
  const session = context?.agent?.session
116
- const override = getSessionVar(session, source)
117
- const value = override !== undefined ? String(override) : declaredValue ?? runtimeFactValue(source, session) ?? ''
118
- const expanded = interpolateVariables(value, { ...config.variables, ...sessionVarsSnapshot(session) }, session)
120
+ const variables = variablesDisabled ? {} : config.variableStore?.read(config.sourceModuleId).effective ?? config.variables
121
+ const value = Object.hasOwn(variables, source) ? String(variables[source]) : runtimeFactValue(source, session) ?? ''
122
+ // 变量值内的 {{pick}} 同样要有位置身份(配置 id + 声明名),否则同会话下多个变量的首个 pick 撞值。
123
+ const expanded = interpolateVariables(value, variables, session, undefined, `variable:${config.sourceModuleId}:${config.id}:${name}`)
119
124
  return officialChannelText(expanded, `variable ${official}`, { alias: new Map(), registered: new Set() }, warnOnce)
120
125
  }), `${name}: official prompt variable ${official}`)
121
126
  binding = official
@@ -141,16 +146,14 @@ function rewriteOfficialAliases(text, alias) {
141
146
  /** agent/request 与 tools/* 层的作用域过滤。 */
142
147
  export function matchesAgentScope(config, agent) {
143
148
  if (agent === undefined) return true
144
- const delegated = isDelegated(agent.session)
145
- if (config.audience === 'main' && delegated) return false
146
- if (config.audience === 'subagent' && !delegated) return false
149
+ if (!matchesAudience(config.audience, isDelegated(agent.session) ? 'subagent' : 'main')) return false
147
150
  return matchesModel(config.modelScope, agent.options?.model)
148
151
  }
149
152
 
150
153
  /**
151
154
  * 官方插值通道(system-section / runtime-context)出口清洗:本项目解析后仍残留的
152
155
  * 引用一律剥离,官方严格插值因此看不到未注册引用(既不抛错也不留字面)。
153
- * 只在本项目的宽容解析之后调用;其他层(pre-step / agent-request / tool-pipeline)
156
+ * 只在本项目的宽容解析之后调用;其他层(pre-step / agent-request)
154
157
  * 不经官方插值,保持原有宽容语义。
155
158
  */
156
159
  function officialChannelText(text, label, registry, warnOnce) {
@@ -171,36 +174,27 @@ function textLayerGroups(configs) {
171
174
  const base = previous?.[0]
172
175
  if (config.mergeMode === 'merged' && base?.mergeMode === 'merged'
173
176
  && config.sourceModuleId === base.sourceModuleId
177
+ && (config.params?.complete === true) === (base.params?.complete === true)
178
+ && (config.params?.suppressRuntimeContext === true) === (base.params?.suppressRuntimeContext === true)
174
179
  && config.position === base.position && config.order === base.order) previous.push(config)
175
180
  else groups.push([config])
176
181
  }
177
182
  return groups
178
183
  }
179
184
 
180
- /** 官方同 order 按 name 排序:默认名编码安全整数序号,显式名仍由作者拥有。 */
181
- function textRegistrationName(config, field) {
182
- const explicit = config.params?.[field]
183
- if (typeof explicit === 'string' && explicit.length > 0) return explicit
184
- if (config.sourceModuleId === undefined) return config.id
185
- // 非负安全整数最多 16 位,跨过四位文件前缀后仍保持数值顺序。
186
- const sequence = String(config.sequence).padStart(16, '0')
187
- return `prompt-tool:${sequence}:${config.sourceModuleId}:${config.id}`
188
- }
189
-
190
185
  /** system-section:注册静态 system prompt 段(支持官方 {{variable}} 渲染与 merged 拼接)。 */
191
- function wireSystemSections(ctx, configs, registry, warnOnce, keep) {
192
- const disposers = []
186
+ function wireSystemSections(ctx, configs, registry, warnOnce, keep, ordered) {
193
187
  const systemPrompt = getService(ctx, 'systemPrompt')
194
188
  if (systemPrompt === undefined || typeof systemPrompt.section !== 'function') {
195
189
  if (configs.length > 0) warnOnce(`${name}: systemPrompt service unavailable — system-section configs skipped`)
196
- return disposers
190
+ return
197
191
  }
198
192
  // 人设段(deployment:persona-prefix/suffix)由官方 @deepseek-ai/dsh-persona 行注册
199
193
  // (preset.yml 顶层 persona 段驱动);本层只处理其余 system-section 配置。
200
194
  for (const group of textLayerGroups(configs)) {
201
195
  const base = group[0]
202
196
  try {
203
- const dynamic = group.some((config) => config.audience != null || typeof config.renderSt === 'function'
197
+ const dynamic = group.some((config) => config.audience != null || config.params?.templateEvaluation === true || config.variableStore !== undefined
204
198
  || config.texts.some(text => /\{\{\s*(?:random|pick|roll|chance)(?=\s|:|\})/i.test(text)))
205
199
  const groupText = dynamic ? '' : group.map((config) => configText(config, registry, warnOnce)).filter((item) => item.length > 0).join('\n\n')
206
200
  if (!dynamic && groupText.length === 0) continue
@@ -211,12 +205,14 @@ function wireSystemSections(ctx, configs, registry, warnOnce, keep) {
211
205
  .filter((item) => item.length > 0)
212
206
  .join('\n\n')
213
207
  : groupText
208
+ const entryName = textRegistrationName(base, 'sectionName')
214
209
  keep(systemPrompt.section({
215
- name: textRegistrationName(base, 'sectionName'),
210
+ name: entryName,
216
211
  order: base.order,
217
212
  text,
218
213
  ...(base.params?.complete === true ? { complete: true } : {}),
219
214
  }), `${name}: section ${base.id}`)
215
+ ordered.sections.push(orderedEntry(entryName, base))
220
216
  // suppressRuntimeContext 抑制该 scope 的动态 runtime-context 快照(多段重复调用幂等)。
221
217
  if (base.params?.suppressRuntimeContext === true) {
222
218
  keep(systemPrompt.suppressRuntimeContext(), `${name}: suppressRuntimeContext ${base.id}`)
@@ -225,7 +221,6 @@ function wireSystemSections(ctx, configs, registry, warnOnce, keep) {
225
221
  warnOnce(`${name}: system-section config ${base.id} failed: ${String(error?.message ?? error)}`)
226
222
  }
227
223
  }
228
- return disposers
229
224
  }
230
225
 
231
226
  async function resolvedContextText(ctx, config, context, registry, warnOnce) {
@@ -233,14 +228,19 @@ async function resolvedContextText(ctx, config, context, registry, warnOnce) {
233
228
  const session = agent?.session
234
229
  const resolved = await config.resolve({ ctx, agent, session, signal: context.signal, decision: { kind: 'ok', messages: [] }, messages: [] })
235
230
  if (context.signal?.aborted || resolved == null) return ''
236
- const variables = { ...config.variables, ...(resolved.variables !== null && typeof resolved.variables === 'object' ? resolved.variables : {}) }
237
- const rendered = config.texts.length > 0 ? interpolateVariables(config.texts.join('\n\n'), variables, session)
238
- : typeof resolved.text === 'string' ? interpolateVariables(resolved.text, variables, session) : ''
231
+ const variables = { ...(config[SESSION_VARIABLES_DISABLED] === true ? {} : config.variableStore?.read(config.sourceModuleId).effective ?? config.variables), ...(resolved.variables !== null && typeof resolved.variables === 'object' ? resolved.variables : {}) }
232
+ if (config.params?.templateEvaluation === true) {
233
+ const texts = config.texts.length ? config.texts : [resolved.text ?? '']
234
+ return officialChannelText(renderTemplate({ ...config, texts }, context, { enabled: config[SESSION_VARIABLES_DISABLED] !== true, warn: warnOnce }), `runtime-context ${config.id}`, registry, warnOnce)
235
+ }
236
+ // 模板位置身份 = 配置 id(texts 合并与 resolved.text 是同一位置的两个互斥分支);缺省会退化成 [会话, 出现序号]。
237
+ const rendered = config.texts.length > 0 ? interpolateVariables(config.texts.join('\n\n'), variables, session, undefined, config.id)
238
+ : typeof resolved.text === 'string' ? interpolateVariables(resolved.text, variables, session, undefined, config.id) : ''
239
239
  return officialChannelText(rendered, `runtime-context ${config.id}`, registry, warnOnce)
240
240
  }
241
241
 
242
242
  /** 条件化文本保留官方排序占位;仅在本次真实 assembly 填充,不缓存 context 身份。 */
243
- function wireRuleTextContributions(ctx, configs, registry, warnOnce, keep, on) {
243
+ function wireRuleTextContributions(ctx, configs, registry, warnOnce, keep, on, ordered) {
244
244
  if (!configs.length) return []
245
245
  const systemPrompt = getService(ctx, 'systemPrompt')
246
246
  if (systemPrompt === undefined) { warnOnce(`${name}: systemPrompt unavailable`); return [] }
@@ -254,6 +254,7 @@ function wireRuleTextContributions(ctx, configs, registry, warnOnce, keep, on) {
254
254
  const entryName = textRegistrationName(base, section ? 'sectionName' : 'contextName')
255
255
  keep(systemPrompt[section ? 'section' : 'context']({ name: entryName, order: base.order, text: marker }), `${name}: conditional ${entryName}`)
256
256
  bindings.push({ group, name: entryName, collection: section ? 'sections' : 'contexts' })
257
+ ordered[section ? 'sections' : 'contexts'].push(orderedEntry(entryName, base))
257
258
  }
258
259
  let active = true
259
260
  keep(() => { active = false }, `${name}: conditional text lifecycle`)
@@ -265,7 +266,7 @@ function wireRuleTextContributions(ctx, configs, registry, warnOnce, keep, on) {
265
266
  entry.text = ''
266
267
  const blocks = []
267
268
  for (const config of binding.group) {
268
- if (!active || context.signal?.aborted || !ruleMatches(config.rule, frame) || !matchesAgentScope(config, context.agent)) continue
269
+ if (!active || context.signal?.aborted || !actionMatches(config, frame) || !matchesAgentScope(config, context.agent)) continue
269
270
  try {
270
271
  const text = config.layer === 'runtime-context' && (config.strategy === 'placeholder' || !KNOWN_STRATEGIES.has(config.strategy))
271
272
  ? await resolvedContextText(ctx, config, context, registry.get(config), warnOnce)
@@ -281,7 +282,7 @@ function wireRuleTextContributions(ctx, configs, registry, warnOnce, keep, on) {
281
282
  }
282
283
 
283
284
  /** runtime-context:注册运行时上下文;静态文本支持 merged,动态策略在官方 assembly waterfall 中填充。 */
284
- function wireRuntimeContexts(ctx, configs, registry, warnOnce, keep, on) {
285
+ function wireRuntimeContexts(ctx, configs, registry, warnOnce, keep, on, ordered) {
285
286
  const disposers = []
286
287
  const systemPrompt = getService(ctx, 'systemPrompt')
287
288
  if (systemPrompt === undefined || typeof systemPrompt.context !== 'function') {
@@ -295,17 +296,20 @@ function wireRuntimeContexts(ctx, configs, registry, warnOnce, keep, on) {
295
296
  for (const group of textLayerGroups(staticConfigs)) {
296
297
  const base = group[0]
297
298
  try {
298
- const dynamic = group.some(config => typeof config.renderSt === 'function'
299
+ const dynamic = group.some(config => config.params?.templateEvaluation === true || config.variableStore !== undefined
299
300
  || config.texts.some(text => /\{\{\s*(?:random|pick|roll|chance)(?=\s|:|\})/i.test(text)))
300
301
  const render = context => group.map(config => configText(config, registry, warnOnce, context)).filter(item => item.length > 0).join('\n\n')
301
302
  const text = dynamic ? render : render()
302
303
  if (!dynamic && text.length === 0) continue
304
+ const entryName = textRegistrationName(base, 'contextName')
303
305
  keep(systemPrompt.context({
304
- name: textRegistrationName(base, 'contextName'),
306
+ name: entryName,
305
307
  order: base.order,
306
308
  text,
307
309
  }), `${name}: context ${base.id}`)
310
+ ordered.contexts.push(orderedEntry(entryName, base))
308
311
  } catch (error) {
312
+ if (base.linkedDelivery) throw error
309
313
  warnOnce(`${name}: runtime-context config ${base.id} failed: ${String(error?.message ?? error)}`)
310
314
  }
311
315
  }
@@ -329,6 +333,7 @@ function wireRuntimeContexts(ctx, configs, registry, warnOnce, keep, on) {
329
333
  text: slotText,
330
334
  }), `${name}: context ${config.id}`)
331
335
  registered.set(contextName, config)
336
+ ordered.contexts.push(orderedEntry(contextName, config))
332
337
  } catch (error) {
333
338
  warnOnce(`${name}: runtime-context placeholder ${config.id} failed: ${String(error?.message ?? error)}`)
334
339
  }
@@ -352,90 +357,6 @@ function wireRuntimeContexts(ctx, configs, registry, warnOnce, keep, on) {
352
357
  return disposers
353
358
  }
354
359
 
355
- /**
356
- * agent/request 载荷的唯一改写实现(B3 T2 第 (7) 类动作与既有 `agent-request`
357
- * 层**共用这一份**,不另写一套请求改写)。三种语义:
358
- * - `patch`:对下游已解析的 LlmCallConfig 做浅合并;
359
- * - `replace: true`:整体替换为 `patch`(不叠加 base,故 `unset` 无比较对象);
360
- * - `unset`:**按值条件删键** —— 仅当合并结果里的值**恰好等于**声明值时删除该键
361
- * (`Object.is` 比较)。这来自原 `tool-bootstrap` 剥离 `bootstrapMaxTokens` 的语义
362
- * (「仅当解析后的 maxTokens 恰好等于本声明注入的那个值时才删」),不是"存在即删"
363
- * ——模型或其它插件设置的同名参数不会被删。
364
- * 浅合并做不到删键,故删键必须走显式通道。
365
- * @param params 提示词配置的 `params`(或动作的等价声明)。
366
- * @param base 下游 `next()` 已解析的请求配置。
367
- * @returns 改写后的请求配置;无 unset 命中时零额外分配。
368
- */
369
- export function applyAgentRequestParams(params, base) {
370
- const patch = params?.patch !== null && typeof params?.patch === 'object' && !Array.isArray(params.patch)
371
- ? params.patch
372
- : {}
373
- if (params?.replace === true) return { ...patch }
374
- const merged = { ...base, ...patch }
375
- const unset = params?.unset
376
- if (unset === null || typeof unset !== 'object' || Array.isArray(unset)) return merged
377
- let result
378
- for (const [key, expected] of Object.entries(unset)) {
379
- // 每个键都对着**合并结果**判定:一旦分配过副本,后续键不得跳过比较。
380
- if (!Object.is(merged[key], expected)) continue
381
- if (result === undefined) result = { ...merged }
382
- delete result[key]
383
- }
384
- return result ?? merged
385
- }
386
-
387
- /** agent-request:对冻结的 LlmCallConfig 做浅合并 / 整体替换 / 按值条件删键。 */
388
- function wireAgentRequests(ctx, configs, warnOnce, on) {
389
- if (configs.length === 0) return []
390
- const ordered = configs.some(config => config.sequence !== undefined) ? configs : [...configs].reverse()
391
- return [on('agent/request', async (payload, next, invocation) => {
392
- let result = await next()
393
- const frame = invocation ?? ruleFrame('agent/request', [payload], warnOnce)
394
- // 模块请求在 next 返回后按序合并;无文件来源的独立调用保持原回栈顺序。
395
- for (const config of ordered) {
396
- try {
397
- if (ruleMatches(config.rule, frame) && matchesAgentScope(config, payload?.agent)) result = applyAgentRequestParams(config.params, result)
398
- } catch (error) {
399
- warnOnce(`${name}: agent-request config ${config.id} failed: ${String(error?.message ?? error)}`)
400
- }
401
- }
402
- return result
403
- })]
404
- }
405
-
406
- /** 把流替换为提示词配置文本的最小合法 chunk 序列。 */
407
- async function* replacedStream(text) {
408
- yield { type: 'block-start', index: 0, blockType: 'text' }
409
- yield { type: 'text-delta', index: 0, text }
410
- yield { type: 'block-end', index: 0, block: { type: 'text', text } }
411
- }
412
-
413
- /** llm-stream:pass 透传;replace 用提示词配置文本替代整个模型流。 */
414
- function wireLlmStreams(ctx, configs, warnOnce, on) {
415
- const disposers = []
416
- for (const config of configs) {
417
- disposers.push(on('llm/stream', (options, next, invocation) => {
418
- try {
419
- const frame = invocation ?? ruleFrame('llm/stream', [options], warnOnce)
420
- const agent = options?.sessionId === undefined ? undefined : getService(ctx, 'agents')?.get?.(options.sessionId)
421
- frame.subject.agent = agent
422
- if (!ruleMatches(config.rule, frame)) return next()
423
- const mode = config.params?.mode ?? 'pass'
424
- if (mode === 'replace' && config.texts.length > 0 && matchesModel(config.modelScope, options?.model)) {
425
- return replacedStream(config.texts.join('\n\n'))
426
- }
427
- return next()
428
- } catch (error) {
429
- warnOnce(`${name}: llm-stream config ${config.id} failed: ${String(error?.message ?? error)}`)
430
- return next()
431
- }
432
- }))
433
- }
434
- return disposers
435
- }
436
-
437
- /** 条件判定的匹配器与取文本逻辑由 condition.mjs 承载(pre-step 与其他层共用)。 */
438
-
439
360
  /**
440
361
  * 插件来源的 user 消息:与 anchor-turn / progress-reminder 同一形状。
441
362
  *
@@ -450,15 +371,17 @@ export function pluginMessage(prefix, text, summary) {
450
371
  id: newMessageId(prefix),
451
372
  role: 'user',
452
373
  content: [{ type: 'text', text }],
453
- source: { kind: `plugin:${name}`, plugin: name, form: 'notice', summary },
374
+ source: { kind: pluginKind(name), plugin: name, form: 'notice', summary },
454
375
  }
455
376
  }
456
377
 
457
378
  /** 层级文本:按声明变量插值后按空行拼接;不走官方插值通道(非 system-section)。 */
458
379
  function layerText(config, agent, warnOnce) {
459
380
  try {
381
+ if (config.params?.templateEvaluation === true) return renderTemplate(config, { agent }, { enabled: config[SESSION_VARIABLES_DISABLED] !== true, warn: warnOnce })
382
+ const variables = config[SESSION_VARIABLES_DISABLED] === true ? {} : config.variableStore?.read(config.sourceModuleId).effective ?? config.variables
460
383
  return config.texts
461
- .map((item) => interpolateVariables(item, config.variables, agent?.session))
384
+ .map((item, index) => interpolateVariables(item, variables, agent?.session, undefined, `${config.sourceModuleId}:${config.id}#${index}`))
462
385
  .filter((item) => item.length > 0)
463
386
  .join('\n\n')
464
387
  } catch (error) {
@@ -467,56 +390,10 @@ function layerText(config, agent, warnOnce) {
467
390
  }
468
391
  }
469
392
 
470
- /** tool-pipeline:pre-execute 判定、execute 包装、post-execute 结果替换/阻断。 */
471
- function wireToolPipelines(ctx, configs, warnOnce, on) {
472
- const disposers = []
473
- for (const config of configs) {
474
- const names = parseToolNames(config.params?.toolNames)
475
- const matchesTool = (exec) => names.length === 0 || names.includes(exec?.name)
476
- disposers.push(on('tools/pre-execute', async (exec, next, invocation) => {
477
- try {
478
- if (!ruleMatches(config.rule, invocation ?? ruleFrame('tools/pre-execute', [exec], warnOnce))) return next()
479
- if (!matchesTool(exec) || !matchesAgentScope(config, exec?.agent)) return next()
480
- if (!conditionHit(config, { argsText: toolArgsText(exec?.arguments) })) return next()
481
- const decision = config.params?.preDecision ?? 'allow'
482
- if (decision === 'allow') return next()
483
- if (decision === 'deny') {
484
- return { kind: 'deny', reason: String(config.params?.denyReason ?? `${config.name}: denied by prompt config`) }
485
- }
486
- if (decision === 'ask') return { kind: 'ask' }
487
- return next()
488
- } catch (error) {
489
- warnOnce(`${name}: tool-pipeline(pre) config ${config.id} failed: ${String(error?.message ?? error)}`)
490
- return next()
491
- }
492
- }))
493
- disposers.push(on('tools/post-execute', async (exec, result, next, invocation) => {
494
- try {
495
- if (!ruleMatches(config.rule, invocation ?? ruleFrame('tools/post-execute', [exec, result], warnOnce))) return next()
496
- if (!matchesTool(exec) || !matchesAgentScope(config, exec?.agent)) return next()
497
- if (!conditionHit(config, { argsText: toolArgsText(exec?.arguments), resultText: extractText(result) })) return next()
498
- const action = config.params?.postAction ?? 'accept'
499
- if (action === 'accept') return next()
500
- if (action === 'replace' && config.texts.length > 0) {
501
- return { kind: 'accept', content: [{ type: 'text', text: config.texts.join('\n\n') }] }
502
- }
503
- if (action === 'block') {
504
- return { kind: 'block', feedback: [{ type: 'text', text: config.texts.length > 0 ? config.texts.join('\n\n') : `${config.name}: blocked by prompt config` }] }
505
- }
506
- return next()
507
- } catch (error) {
508
- warnOnce(`${name}: tool-pipeline(post) config ${config.id} failed: ${String(error?.message ?? error)}`)
509
- return next()
510
- }
511
- }))
512
- }
513
- return disposers
514
- }
515
-
516
393
  /**
517
- * 轮次停止层的续跑上限:每轮 1 次、每会话 3 次,均为引擎常量。
518
- * 不暴露为配置——强制续跑失控会把会话卡在停不下来的循环里;官方桥在同一位置
519
- * 也只留了 TODO(stop-loop-guard) 而未实现上限。
394
+ * 轮次停止层(`turn-stop` 与续跑动作)的续跑上限:**每个来源(模块)**每轮 1 次、
395
+ * 每会话 3 次,均为引擎常量。不暴露为配置——强制续跑失控会把会话卡在停不下来的
396
+ * 循环里;官方桥在同一位置也只留了 TODO(stop-loop-guard) 而未实现上限。
520
397
  */
521
398
  export const TURN_STOP_MAX_PER_TURN = 1
522
399
  export const TURN_STOP_MAX_PER_SESSION = 3
@@ -525,8 +402,10 @@ export const TURN_STOP_MAX_PER_SESSION = 3
525
402
  export const TURN_STOP_MAX_TRACKED_TURNS = 8
526
403
 
527
404
  /**
528
- * 续跑预算(唯一实现):B3 T2 第 (4) 类动作的「续跑」与既有 turn-stop 层共用它,
405
+ * 续跑预算(唯一实现):所有 turn-stop 注入共用它,
529
406
  * 上限是引擎常量而不是配置——强制续跑失控会把会话卡在停不下来的循环里。
407
+ * **一份预算覆盖一个来源模块**(规则运行时按模块传入同一份),因此同模块的多条
408
+ * turn-stop 动作合计每轮 1 次、每会话 3 次。
530
409
  * 三步语义刻意与迁移前逐行一致:
531
410
  * entry(...) 取条目(含创建与超限轮次淘汰的副作用),在条件判定**之前**调用;
532
411
  * available(...) 只读判定,不落账;
@@ -541,9 +420,8 @@ export function createTurnStopBudget() {
541
420
  * 不可从事件流重建的计数(与 `progress-reminder` 同档),丢失时机必须与迁移前逐字一致,
542
421
  * compaction 也不重置它。
543
422
  *
544
- * 本函数**没有 ctx**:`createTurnStopBudget()` 是 B3 交付物的一部分,对外签名与
545
- * `entry/available/claim` 三个方法逐字不变(调用方见 actions.mjs 的 append-context 与
546
- * 本文件的 turn-stop 层)。`sessionState` 只在声明了 `reset` 时才读 ctx,因此这里传
423
+ * 本函数没有 ctx,只有 turn-stop 注入消费预算。
424
+ * `sessionState` 只在声明了 `reset` 时才读 ctx,因此这里传
547
425
  * `undefined` 合法且零开销(详见 shared.mjs 的 JSDoc 与 test/engine/session-state.test.mjs)。
548
426
  */
549
427
  const state = sessionState(undefined, () => ({ turns: new Map(), total: 0 }))
@@ -552,8 +430,7 @@ export function createTurnStopBudget() {
552
430
  // 对外仍是 `(sessionId, turn)`:按 id 造一次性键宿主,`keyOf` 取的就是 `.id`,与旧的
553
431
  // 记账写法写进同一个槽位、同一个 clear 时机。
554
432
  // 唯一差异在**无 id 的会话**(`sessionId === undefined`):`sessionState` 刻意不记账
555
- // (见 shared.mjs),旧写法把 `undefined` 当一个共享槽位。两个调用点(actions.mjs 的
556
- // append-context 与下方 turn-stop 层)都在 `entry()` 之前守卫 `session?.id === undefined`,
433
+ // (见 shared.mjs),下方 turn-stop 层在 `entry()` 前守卫 `session?.id === undefined`,
557
434
  // 故该路径不可达;这里兜底一个新条目,避免将来误用时抛 TypeError。
558
435
  const entry = state.get({ id: sessionId }) ?? { turns: new Map(), total: 0 }
559
436
  if (!Number.isFinite(turn)) return entry
@@ -578,11 +455,15 @@ export function createTurnStopBudget() {
578
455
  }
579
456
  }
580
457
 
581
- /** turn-stop:命中条件时阻止本轮停止并强制续跑一步,上限在引擎内。 */
582
- function wireTurnStops(ctx, configs, warnOnce, on) {
458
+ /**
459
+ * turn-stop:命中条件时阻止本轮停止并强制续跑一步,上限在引擎内。
460
+ * options.turnStopBudgets 由调用方传入时按同一份预算记账(规则运行时按模块一份),
461
+ * 缺省仍按 sourceModuleId 各建一份。
462
+ */
463
+ function wireTurnStops(ctx, configs, warnOnce, on, turnStopBudgets) {
583
464
  const disposers = []
584
465
  if (configs.length === 0) return disposers
585
- const budgets = new Map()
466
+ const budgets = turnStopBudgets ?? new Map()
586
467
 
587
468
  for (const config of configs) {
588
469
  const source = config.sourceModuleId ?? ''
@@ -591,18 +472,20 @@ function wireTurnStops(ctx, configs, warnOnce, on) {
591
472
  disposers.push(on('agent/turn-stopping', (payload = {}, _next, invocation) => {
592
473
  const { agent, turn } = payload
593
474
  try {
594
- if (!ruleMatches(config.rule, invocation ?? ruleFrame('agent/turn-stopping', [payload], warnOnce))) return
475
+ if (payload.signal?.aborted) return
476
+ if (!actionMatches(config, invocation ?? ruleFrame('agent/turn-stopping', [payload], warnOnce))) return
595
477
  const session = agent?.session
596
478
  if (session?.id === undefined || typeof agent.steer !== 'function') return
597
479
  if (!matchesAgentScope(config, agent)) return
480
+ if (config.dedupe === 'session' && (config.linkedDelivery ? findActionDelivery(session, config) : injectedMessage(config, session))) return
598
481
  const entry = budget.entry(session.id, turn)
599
482
  if (!budget.available(entry, turn)) return
600
- if (!conditionHit(config, { assistantText: lastAssistantText(session) })) return
601
483
  const text = layerText(config, agent, warnOnce)
602
484
  if (text.length === 0) return
603
485
  // 计数在 steer 之前落账:steer 抛错也不允许下一步重试越过预算。
604
486
  budget.claim(entry, turn)
605
- agent.steer(pluginMessage(`turn-stop-${config.id}`, text, `turn-stop ${config.id}`))
487
+ const message = pluginMessage(`turn-stop-${config.id}`, text, `turn-stop ${config.id}`)
488
+ agent.steer({ ...message, source: { ...message.source, ...identityStampOf(config), ...actionStamp(config, session) } })
606
489
  } catch (error) {
607
490
  warnOnce(`${name}: turn-stop config ${config.id} failed: ${String(error?.message ?? error)}`)
608
491
  }
@@ -644,7 +527,7 @@ function wireSubagentEvents(ctx, configs, warnOnce, on) {
644
527
  const session = child?.session ?? getService(ctx, 'sessions')?.get?.(info.id)
645
528
  if (session === undefined) return
646
529
  const mainId = mainSessionForChild(ctx, info.id)
647
- // ponytail: 有界运行记录;极端并发超过上限时,结束事件仍可通过存活会话血缘定位。
530
+ // shortcut: 有界运行记录;极端并发超过上限时,结束事件仍可通过存活会话血缘定位。
648
531
  if (runs.size >= MAX_TRACKED_SESSIONS) runs.delete(runs.keys().next().value)
649
532
  runs.set(info.runId, { id: info.id, mainId, agent: child, session, model: child?.options?.model })
650
533
  }))
@@ -659,11 +542,9 @@ function wireSubagentEvents(ctx, configs, warnOnce, on) {
659
542
  if (child === undefined) return
660
543
  const frame = invocation ?? ruleFrame('subagent/start', [info], warnOnce)
661
544
  frame.subject.agent = child
662
- const subagentText = subagentTextOf(info)
663
545
  for (const config of startConfigs) {
664
- if (!ruleMatches(config.rule, frame)) continue
546
+ if (!actionMatches(config, frame)) continue
665
547
  if (!matchesAgentScope(config, child)) continue
666
- if (!conditionHit(config, { subagentText })) continue
667
548
  if (typeof child.inject !== 'function') continue
668
549
  const text = layerText(config, child, warnOnce)
669
550
  if (text.length === 0) continue
@@ -681,17 +562,16 @@ function wireSubagentEvents(ctx, configs, warnOnce, on) {
681
562
  const recorded = runs.get(info?.runId)
682
563
  runs.delete(info?.runId)
683
564
  if (recorded !== undefined && recorded.id !== info?.id) return
684
- const subagentText = subagentTextOf(info)
685
565
  const frame = invocation ?? ruleFrame('subagent/end', [info], warnOnce)
686
566
  frame.subject.agent = child ?? recorded?.agent
687
567
  frame.subject.session = child?.session ?? recorded?.session ?? getService(ctx, 'sessions')?.get?.(info?.id)
688
568
  frame.subject.model = child?.options?.model ?? recorded?.model
689
569
  for (const config of endConfigs) {
690
- if (!ruleMatches(config.rule, frame)) continue
570
+ if (!actionMatches(config, frame)) continue
691
571
  if (!matchesModel(config.modelScope, child?.options?.model ?? recorded?.model)) continue
692
- if (!conditionHit(config, { subagentText })) continue
693
572
  if (config.params?.action !== 'inject-main') {
694
- warnOnce(`${name}: subagent-end ${config.id} matched (observe only)`)
573
+ // 模板本意是逐次记录;命中本身已由装配实例的判定账本记 hit,这里不占告警名额。
574
+ ctx.logger?.info?.(`${name}: subagent-end ${config.id} matched (observe only)`)
695
575
  continue
696
576
  }
697
577
  if (typeof info?.runId !== 'string' || info.runId.length === 0) continue
@@ -707,7 +587,7 @@ function wireSubagentEvents(ctx, configs, warnOnce, on) {
707
587
  if (delivered === undefined) { delivered = new Map(); subagentEndDeliveries.set(main.session, delivered) }
708
588
  const key = `${info.runId}:${config.id}`
709
589
  if (delivered.has(key)) continue
710
- // ponytail: 会话内保留最近的投递记录;超大历史改用持久事件索引时可取消此上限。
590
+ // shortcut: 会话内保留最近的投递记录;超大历史改用持久事件索引时可取消此上限。
711
591
  if (delivered.size >= MAX_TRACKED_SESSIONS) delivered.delete(delivered.keys().next().value)
712
592
  delivered.set(key, true)
713
593
  main.inject(pluginMessage(`subagent-end-${config.id}`, text, `subagent-end ${config.id}`))
@@ -720,43 +600,99 @@ function wireSubagentEvents(ctx, configs, warnOnce, on) {
720
600
  return disposers
721
601
  }
722
602
 
603
+ /** 注册名与序号:字段只在注册点算一次,重排器只消费这里的结果。 */
604
+ function orderedEntry(name, config) {
605
+ return { name, sequence: config.sequence ?? config.order ?? 0, order: config.order ?? 0 }
606
+ }
607
+
608
+ /**
609
+ * 实际注册名按 configOrder(sequence)排序;同序号保持注册序,同名只留一次。
610
+ * 名字来自注册点而不是事后从 configs 推导,避免 regular/conditional/placeholder 三条路径漂移。
611
+ */
612
+ function orderedNamesOf(entries) {
613
+ // configOrder 优先;同序号回到官方 order(而不是接线顺序),最后 stable 保留注册顺序。
614
+ return [...new Set([...entries]
615
+ .sort((left, right) => left.sequence - right.sequence || (left.order ?? 0) - (right.order ?? 0))
616
+ .map((entry) => entry.name))]
617
+ }
618
+
619
+ /**
620
+ * 官方先按 order/段名排好投递顺序;这里按 configOrder 把本插件注册的段/上下文
621
+ * 回填到它们原来的槽位。只换本插件段之间的位置:平台段与其他插件段占住的槽位不动。
622
+ */
623
+ function registerDeliveryOrder(ctx, keep, collection, names) {
624
+ if (names.length < 2) return
625
+ const rank = new Map(names.map((entryName, index) => [entryName, index]))
626
+ keep(ctx.on('system-prompt/assemble', async (assembly, context, next) => {
627
+ const result = await next()
628
+ const list = result?.[collection]
629
+ if (!Array.isArray(list)) return result
630
+ const slots = []
631
+ for (let index = 0; index < list.length; index++) {
632
+ if (rank.has(list[index]?.name)) slots.push(index)
633
+ }
634
+ if (slots.length < 2) return result
635
+ const ordered = slots.map((index) => list[index]).sort((left, right) => rank.get(left.name) - rank.get(right.name))
636
+ if (slots.every((slot, index) => list[slot] === ordered[index])) return result
637
+ const nextList = [...list]
638
+ slots.forEach((slot, index) => { nextList[slot] = ordered[index] })
639
+ return { ...result, [collection]: nextList }
640
+ }, { prepend: true }), `${name}: ${collection} order`)
641
+ }
642
+
723
643
  /**
724
644
  * 把非 pre-step 提示词配置接入其声明的官方层级通道。
725
- * @returns 聚合 disposer:回收本次接线显式创建的 waterfall 监听器;段/上下文/
726
- * 变量注册走 keepDisposer(随 ctx fiber 释放),不在本函数的回收面内。
645
+ * @param options.agent 当前 ctx 所属的真实 Agent;固定效果的受众在注册前匹配。
646
+ * @param options.turnStopBudgets 可选的续跑预算映射(键 = 模块 id,来源缺失用空串兜底);
647
+ * 规则运行时按模块传入同一份,使同模块的 turn-stop 动作共享上限。
648
+ * @returns 聚合 disposer:回收监听器及段/上下文/变量注册;后者也绑定 ctx fiber,重复释放幂等。
727
649
  */
728
650
  export function wireLayers(ctx, configs, warnOnce, options = {}) {
651
+ // 固定效果必须在目标 Agent 注册前筛选;空正文不能撤销官方 complete/suppress 声明。
652
+ configs = configs.filter(config => {
653
+ if (config.layer !== 'system-section' || config.audience == null
654
+ || config.params?.complete !== true && config.params?.suppressRuntimeContext !== true) return true
655
+ if (options.agent?.session === undefined) throw new TypeError(`${name}: fixed system-section ${config.id} with audience requires an explicit Agent in options.agent`)
656
+ return matchesAudience(config.audience, isDelegated(options.agent.session) ? 'subagent' : 'main')
657
+ })
729
658
  configs = [...configs].sort(compareConfigSequence)
659
+ // 没有注入通道的层(`tool-pipeline`)在这里没有分派:显式告警一次,别让 config 级入口静默丢弃。
660
+ for (const config of configs) {
661
+ if (!INJECTION_LAYERS.includes(config.layer)) warnOnce(`${name}: ${config.id}: layer ${String(config.layer)} 没有注入通道,已跳过(工具链的裁决请用 decision / append-context)`)
662
+ }
730
663
  const owned = []
731
664
  const keep = (dispose, label) => {
732
665
  if (typeof dispose !== 'function') return
733
666
  let active = true
734
667
  const release = () => { if (active) { active = false; dispose() } }
735
668
  owned.push(release); keepDisposer(ctx, release, label)
669
+ return release
736
670
  }
737
- const on = options.on ?? ((...args) => ctx.on(...args))
738
- // 官方插值两层共享一份变量注册:运行时事实按 assembly 求值,非法名走别名改写。
739
- const registry = registerOfficialVariables(ctx, configs.filter((config) => config.layer === 'system-section' || config.layer === 'runtime-context'), warnOnce, keep)
740
- const conditional = configs.filter(config => typeof config.rule?.when === 'function' && ['system-section', 'runtime-context'].includes(config.layer))
741
- const regular = configs.filter(config => !conditional.includes(config))
742
- const registered = [
743
- wireSystemSections(ctx, regular.filter((config) => config.layer === 'system-section'), registry, warnOnce, keep),
744
- wireRuntimeContexts(ctx, regular.filter((config) => config.layer === 'runtime-context'), registry, warnOnce, keep, on),
745
- wireRuleTextContributions(ctx, conditional, registry, warnOnce, keep, on),
746
- wireAgentRequests(ctx, configs.filter((config) => config.layer === 'agent-request'), warnOnce, on),
747
- wireLlmStreams(ctx, configs.filter((config) => config.layer === 'llm-stream'), warnOnce, on),
748
- wireToolPipelines(ctx, configs.filter((config) => config.layer === 'tool-pipeline'), warnOnce, on),
749
- wireTurnStops(ctx, configs.filter((config) => config.layer === 'turn-stop'), warnOnce, on),
750
- wireSubagentEvents(ctx, configs.filter((config) => config.layer === 'subagent-start' || config.layer === 'subagent-end'), warnOnce, on),
751
- owned,
752
- ].flat().filter((disposer) => typeof disposer === 'function')
753
- return () => {
754
- for (const dispose of registered.splice(0)) {
755
- try {
756
- dispose()
757
- } catch {
758
- // 释放失败不得反过来打断卸载流程。
759
- }
671
+ const on = (...args) => keep((options.on ?? ((...values) => ctx.on(...values)))(...args), `${name}: ${args[0]}`)
672
+ const dispose = () => {
673
+ for (const release of owned.splice(0).reverse()) { try { release() } catch {} }
674
+ }
675
+ try {
676
+ // 注册即纳入 owned,后续注册失败也能释放已取得的变量和监听器。
677
+ /** 实际注册名与序号:重排器只认这里,不从 configs 反推。 */
678
+ const ordered = { sections: [], contexts: [] }
679
+ const registry = registerOfficialVariables(ctx, configs.filter((config) => config.layer === 'system-section' || config.layer === 'runtime-context'), warnOnce, keep)
680
+ const conditional = configs.filter(config => ['system-section', 'runtime-context'].includes(config.layer)
681
+ && (typeof config.rule?.when === 'function' || typeof config.actionWhen === 'function'
682
+ || (config.rule !== undefined || config.params?.templateEvaluation === true) && config.params?.complete !== true && config.params?.suppressRuntimeContext !== true))
683
+ const regular = configs.filter(config => !conditional.includes(config))
684
+ wireSystemSections(ctx, regular.filter((config) => config.layer === 'system-section'), registry, warnOnce, keep, ordered)
685
+ wireRuntimeContexts(ctx, regular.filter((config) => config.layer === 'runtime-context'), registry, warnOnce, keep, on, ordered)
686
+ wireRuleTextContributions(ctx, conditional, registry, warnOnce, keep, on, ordered)
687
+ wireTurnStops(ctx, configs.filter((config) => config.layer === 'turn-stop'), warnOnce, on, options.turnStopBudgets)
688
+ wireSubagentEvents(ctx, configs.filter((config) => config.layer === 'subagent-start' || config.layer === 'subagent-end'), warnOnce, on)
689
+ // 官方 order 只提供初始槽位;configOrder 覆盖段/上下文的先后(平台段与其他插件段不动)。
690
+ for (const collection of ['sections', 'contexts']) {
691
+ registerDeliveryOrder(ctx, keep, collection, orderedNamesOf(ordered[collection]))
760
692
  }
693
+ return dispose
694
+ } catch (error) {
695
+ dispose()
696
+ throw error
761
697
  }
762
698
  }