dsh-plugin-prompt-tool 0.7.2 → 0.8.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 (169) hide show
  1. package/README.md +185 -46
  2. package/engine/THIRD_PARTY_LICENSES +25 -0
  3. package/engine/actions.mjs +931 -0
  4. package/engine/anchor-match.mjs +6 -3
  5. package/engine/character-tools.mjs +11 -0
  6. package/engine/classify-task.mjs +18 -0
  7. package/engine/compaction-epoch.mjs +41 -20
  8. package/engine/compositions/library/agent-instructions.yml +9 -0
  9. package/engine/compositions/library/command-goal.yml +2 -6
  10. package/engine/compositions/library/compaction.yml +2 -32
  11. package/engine/compositions/library/delegation-ptc.yml +58 -0
  12. package/engine/compositions/library/delegation.yml +5 -38
  13. package/engine/compositions/library/persistent-shell.yml +4 -13
  14. package/engine/compositions/library/planning.yml +8 -23
  15. package/engine/compositions/library/skill-filesystem-cordis.yml +10 -0
  16. package/engine/compositions/library/skill-filesystem.yml +2 -8
  17. package/engine/compositions/library/tool-ask-user.yml +2 -3
  18. package/engine/compositions/library/tool-bash.yml +4 -12
  19. package/engine/compositions/library/tool-cordis.yml +7 -0
  20. package/engine/compositions/library/tool-fs-search.yml +2 -11
  21. package/engine/compositions/library/tool-fs.yml +2 -5
  22. package/engine/compositions/library/tool-goal.yml +2 -6
  23. package/engine/compositions/library/tool-jobs.yml +2 -18
  24. package/engine/compositions/library/tool-plugin-manager-disabled.yml +8 -0
  25. package/engine/compositions/library/tool-plugin-manager.yml +8 -0
  26. package/engine/compositions/library/tool-present.yml +7 -0
  27. package/engine/compositions/library/tool-presentation.yml +9 -0
  28. package/engine/compositions/library/tool-pwsh.yml +2 -6
  29. package/engine/compositions/library/tool-skill.yml +7 -0
  30. package/engine/compositions/library/tool-todo.yml +2 -4
  31. package/engine/compositions/library/tool-web.yml +2 -1
  32. package/engine/compositions/source/local/character-tools.yml +5 -0
  33. package/engine/compositions/source/local/declared-triggers.yml +12 -0
  34. package/engine/compositions/source/local/filesystem-editor.yml +30 -0
  35. package/engine/compositions/source/local/instruction-hint.yml +15 -0
  36. package/engine/compositions/{library/official-persistent-shell.yml → source/local/persistent-shell-posix.yml} +10 -8
  37. package/engine/compositions/source/local/prompt-config-engine.yml +3 -9
  38. package/engine/compositions/source/local/run-code-env.yml +4 -7
  39. package/engine/compositions/source/local/session-var-tools.yml +5 -0
  40. package/engine/compositions/source/local/skill-search.yml +3 -9
  41. package/engine/compositions/source/local/subagent-tool-policy.yml +21 -0
  42. package/engine/compositions/source/local/tool-bash-disabled.yml +9 -0
  43. package/engine/compositions/source/local/tool-config-engine.yml +8 -0
  44. package/engine/compositions/source/local/tool-git-bash.yml +15 -0
  45. package/engine/compositions/source/local/world-book-tools.yml +5 -0
  46. package/engine/condition.mjs +83 -0
  47. package/engine/declared-triggers.mjs +93 -0
  48. package/engine/executor.mjs +364 -185
  49. package/engine/fields.mjs +219 -0
  50. package/engine/fillers.mjs +12 -141
  51. package/engine/host-package.mjs +51 -0
  52. package/engine/instruction-hint.mjs +285 -0
  53. package/engine/interpolate.mjs +157 -29
  54. package/engine/layers.mjs +495 -75
  55. package/engine/predicates.mjs +753 -0
  56. package/engine/prompt-config-engine.mjs +63 -8
  57. package/engine/run-code-env.mjs +48 -31
  58. package/engine/schema.mjs +444 -103
  59. package/engine/sdk-strip.mjs +365 -0
  60. package/engine/session-var-tools.mjs +11 -0
  61. package/engine/session-vars.mjs +8 -6
  62. package/engine/shared.mjs +137 -0
  63. package/engine/skill-search.mjs +34 -21
  64. package/engine/st-macros.d.mts +22 -0
  65. package/engine/st-macros.mjs +208 -0
  66. package/engine/st-render.mjs +105 -0
  67. package/engine/st-world-book.d.mts +10 -0
  68. package/engine/st-world-book.mjs +259 -0
  69. package/engine/strategies.mjs +31 -20
  70. package/engine/subagent-tool-policy-core.mjs +400 -0
  71. package/engine/subagent-tool-policy.mjs +327 -0
  72. package/engine/tool-config-engine.mjs +207 -145
  73. package/engine/tool-definition.mjs +143 -0
  74. package/engine/{custom-bash.mjs → tool-git-bash.mjs} +38 -13
  75. package/engine/trigger-editor-meta.mjs +48 -0
  76. package/engine/trigger-spec.mjs +257 -0
  77. package/engine/trigger.mjs +304 -0
  78. package/engine/world-book-tools.mjs +11 -0
  79. package/lib/client.js +16495 -5456
  80. package/lib/client.js.map +1 -1
  81. package/lib/index.d.mts +628 -382
  82. package/lib/index.mjs +5391 -2942
  83. package/lib/paths-CY5psBqE.d.mts +1233 -0
  84. package/lib/preset-package-BnJqrU4R.mjs +16792 -0
  85. package/lib/preset-transfer.d.mts +2 -0
  86. package/lib/preset-transfer.mjs +2 -0
  87. package/package.json +91 -49
  88. package/preset/pt-cordis/preset.yml +43 -0
  89. package/preset/pt-cordis/skills/cordis-composition-reference/SKILL.md +29 -0
  90. package/preset/pt-cordis/skills/cordis-composition-reference/references/packages.md +501 -0
  91. package/preset/pt-cordis/skills/cordis-plugin-development/SKILL.md +38 -0
  92. package/preset/pt-cordis/skills/cordis-plugin-development/references/host-plugin.md +60 -0
  93. package/preset/pt-cordis/skills/cordis-plugin-development/references/mcp-bundle.md +5 -0
  94. package/preset/pt-cordis/skills/cordis-plugin-development/references/ui-plugin.md +13 -0
  95. package/preset/pt-cordis/skills/cordis-plugin-development/references/verification.md +5 -0
  96. package/preset/pt-cordis/skills/cordis-plugin-development/templates/decoration/client.js +22 -0
  97. package/preset/pt-cordis/skills/cordis-plugin-development/templates/decoration/cordis.patch.yml +3 -0
  98. package/preset/pt-cordis/skills/cordis-plugin-development/templates/decoration/index.js +2 -0
  99. package/preset/pt-cordis/skills/cordis-plugin-development/templates/decoration/package.json +15 -0
  100. package/preset/pt-cordis/skills/cordis-plugin-development/templates/mcp/cordis.patch.yml +8 -0
  101. package/preset/pt-cordis/skills/cordis-plugin-development/templates/mcp/package.json +7 -0
  102. package/preset/pt-cordis/skills/editing-cordis-compositions/SKILL.md +84 -0
  103. package/preset/pt-custom/preset.yml +15 -0
  104. package/preset/pt-minimal/preset.yml +27 -0
  105. package/preset/pt-ptc/preset.yml +45 -0
  106. package/preset/pt-standard/preset.yml +43 -0
  107. package/templates/14-first-turn-anchor.yml +4 -4
  108. package/templates/15-guide-auto.yml +3 -3
  109. package/templates/16-custom-fallback.yml +1 -0
  110. package/templates/18-placeholder-env-facts.yml +3 -3
  111. package/templates/20-system-section.yml +3 -2
  112. package/templates/65-turn-stop.yml +18 -0
  113. package/templates/66-subagent-start.yml +14 -0
  114. package/templates/75-subagent-end.yml +15 -0
  115. package/templates/tools/10-shell-command.yml +1 -1
  116. package/templates/tools/40-delegate-character.yml +1 -1
  117. package/templates/tools/50-delegate-worldbook.yml +1 -1
  118. package/templates/tools/60-delegate-session-var.yml +1 -1
  119. package/templates/tools/70-delegate-worldbook-upsert.yml +1 -1
  120. package/engine/anchor-turn.mjs +0 -68
  121. package/engine/code-presentation.mjs +0 -103
  122. package/engine/compositions/library/anchor-turn.yml +0 -10
  123. package/engine/compositions/library/bootstrap-filesystem.yml +0 -19
  124. package/engine/compositions/library/code-presentation.yml +0 -8
  125. package/engine/compositions/library/context-gate.yml +0 -39
  126. package/engine/compositions/library/cot-drip.yml +0 -11
  127. package/engine/compositions/library/custom-bash.yml +0 -14
  128. package/engine/compositions/library/deliberation-gate.yml +0 -11
  129. package/engine/compositions/library/official-agent-instructions.yml +0 -17
  130. package/engine/compositions/library/official-skill-filesystem-cordis.yml +0 -10
  131. package/engine/compositions/library/official-tool-bash.yml +0 -6
  132. package/engine/compositions/library/official-tool-cordis.yml +0 -13
  133. package/engine/compositions/library/official-tool-presentation.yml +0 -7
  134. package/engine/compositions/library/official-tool-skill.yml +0 -14
  135. package/engine/compositions/library/persona.yml +0 -13
  136. package/engine/compositions/library/prompt-config-engine.yml +0 -16
  137. package/engine/compositions/library/run-code-env.yml +0 -15
  138. package/engine/compositions/library/skill-search.yml +0 -14
  139. package/engine/compositions/library/str-replace-editor.yml +0 -10
  140. package/engine/compositions/library/tool-bootstrap.yml +0 -14
  141. package/engine/compositions/library/tool-config-engine.yml +0 -9
  142. package/engine/compositions/library/tool-filter.yml +0 -8
  143. package/engine/compositions/source/local/code-presentation.yml +0 -8
  144. package/engine/compositions/source/local/context-gate.yml +0 -36
  145. package/engine/compositions/source/local/custom-bash.yml +0 -11
  146. package/engine/compositions/source/local/str-replace-editor.yml +0 -10
  147. package/engine/compositions/source/local/tool-bootstrap.yml +0 -11
  148. package/engine/compositions/source/local/tool-filter.yml +0 -8
  149. package/engine/context-gate.mjs +0 -305
  150. package/engine/cot-drip.mjs +0 -137
  151. package/engine/deliberation-gate.mjs +0 -127
  152. package/engine/tool-bootstrap.mjs +0 -476
  153. package/engine/tool-filter.mjs +0 -80
  154. package/lib/preset-core.d.mts +0 -10
  155. package/lib/preset-core.mjs +0 -77
  156. package/lib/prompt-configs-BnQKRHVb.mjs +0 -884
  157. package/lib/prompt-configs-CN0sJTUL.d.mts +0 -193
  158. package/preset/anchored/preset.yml +0 -296
  159. package/preset/creative/preset.yml +0 -67
  160. package/preset/creative/skills/cordis-plugin-development/SKILL.md +0 -420
  161. package/preset/creative/skills/editing-cordis-compositions/SKILL.md +0 -165
  162. package/preset/custom/preset.yml +0 -12
  163. package/preset/liangshen/preset.yml +0 -75
  164. package/preset/minimal/preset.yml +0 -45
  165. package/preset/ptc/preset.yml +0 -58
  166. package/preset/standard/preset.yml +0 -57
  167. package/skills/manifest.json +0 -7
  168. package/skills/sandboxmod/SKILL.md +0 -49
  169. package/skills/web ui/SKILL.md +0 -42
package/engine/layers.mjs CHANGED
@@ -4,26 +4,140 @@
4
4
  */
5
5
 
6
6
  import {
7
+ MAX_TRACKED_SESSIONS,
8
+ extractText,
7
9
  getService,
8
10
  isDelegated,
9
11
  keepDisposer,
10
12
  matchesModel,
13
+ newMessageId,
11
14
  parseToolNames,
15
+ sessionState,
12
16
  } from './shared.mjs'
13
- import { interpolateVariables, interpolateStatic } from './interpolate.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'
14
21
 
15
22
  const name = 'prompt-config-engine'
23
+ // 同一宿主会话共享投递账本,避免预设作用域重挂后重投同一次结束通知。
24
+ // B4 保留原状(不迁 `sessionState`):外层确实是「按会话索引」的 WeakMap,但它存的不是
25
+ // 会话条目而是**第二层索引**——内层 `Map` 按 `${runId}:${config.id}` 去重,那是运行级的键,
26
+ // 与统一接口的「按会话索引」不同层;套上去只剩一个改了名字的 WeakMap。内层的
27
+ // `delete(最旧)` 也是与 `clear()` 不同的另一档淘汰策略(见下方 `runs` 的保留理由)。
28
+ const subagentEndDeliveries = new WeakMap()
16
29
 
17
- /** 单条文本型配置的完整文本:texts 数组按空行拼接(单一文本字段)。 */
18
- function configText(config) {
19
- return config.texts
20
- .map((item) => interpolateStatic(item, config.variables))
30
+ /** 单条文本型配置的完整文本:texts 数组按空行拼接(单一文本字段)。
31
+ * keep=官方变量通道按 assembly 求值的名字,静态 pass 命中即原样保留引用。 */
32
+ function configText(config, registries, warnOnce, context) {
33
+ const registry = registries.get(config)
34
+ if (typeof config.renderSt === 'function') {
35
+ return officialChannelText(config.renderSt(context?.agent, [], warnOnce, context), `${config.layer} ${config.id}`, registry, warnOnce)
36
+ }
37
+ const text = config.texts
38
+ .map((item) => interpolateVariables(item, config.variables, context?.agent?.session, registry?.protect))
21
39
  .filter((item) => item.length > 0)
22
40
  .join('\n\n')
41
+ return officialChannelText(text, `${config.layer} ${config.id}`, registry, warnOnce)
42
+ }
43
+
44
+ /** 官方 prompt 变量名规则(与官方 system-prompt 的 VARIABLE_NAME 一致)。 */
45
+ const OFFICIAL_NAME_RE = /^[a-z][a-z0-9_]*$/
46
+ /** 引用形态(仅名字,无 `::` 参数):事实与内容变量引用都用这一形态。 */
47
+ const NAME_REFERENCE_RE = /\{\{\s*([A-Za-z0-9_.\u4e00-\u9fff-]+)\s*\}\}/g
48
+
49
+ /** 名字哈希(djb2 → base36):非法官方名改写的确定性后缀。 */
50
+ function shortHash(text) {
51
+ let hash = 5381
52
+ for (let index = 0; index < text.length; index += 1) hash = ((hash * 33) ^ text.charCodeAt(index)) >>> 0
53
+ return hash.toString(36)
54
+ }
55
+
56
+ /** 非法官方名的确定性改写:sv_<ascii slug>_<hash>;slug 为空(纯中文名)时只用 hash。 */
57
+ function officialAliasOf(name) {
58
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
59
+ return `sv_${slug.length > 0 ? `${slug}_` : ''}${shortHash(name)}`
60
+ }
61
+
62
+ /**
63
+ * 注册官方 prompt 变量(systemPrompt.variable,按 assembly 求值):
64
+ * - 运行时事实(ST 运行时宏 + 时间类):值随每次 assembly 现算,不再在注册期冻结为空;
65
+ * - 官方通道文本里被引用、且已声明的内容变量:值 = 会话变量覆盖 ?? 声明值。
66
+ * 非法官方名(中文/大写/连字符)改写成确定性别名并改写文本引用;注册失败的名字退回静态解析。
67
+ * @returns 各配置独立的引用白名单与别名;同一作用域内等价绑定只注册一次。
68
+ */
69
+ function registerOfficialVariables(ctx, configs, warnOnce) {
70
+ const registries = new Map(configs.map((config) => [config, { protect: new Set(), alias: new Map(), registered: new Set() }]))
71
+ const systemPrompt = getService(ctx, 'systemPrompt')
72
+ if (systemPrompt === undefined || typeof systemPrompt.variable !== 'function') return registries
73
+ const references = new Map()
74
+ const reserved = new Set()
75
+ const facts = new Set()
76
+ for (const config of configs) {
77
+ if (config.params?.stMacros === true) continue
78
+ const referenced = new Set()
79
+ for (const text of config.texts) {
80
+ for (const match of String(text).matchAll(NAME_REFERENCE_RE)) referenced.add(match[1])
81
+ }
82
+ references.set(config, referenced)
83
+ for (const name of referenced) {
84
+ if (!Object.hasOwn(config.variables, name) && RUNTIME_FACTS.has(name.toLowerCase())) facts.add(name.toLowerCase())
85
+ if (OFFICIAL_NAME_RE.test(name)) reserved.add(name)
86
+ }
87
+ }
88
+ for (const fact of facts) reserved.add(fact)
89
+ const bindings = new Map()
90
+ const usedNames = new Set()
91
+ for (const [config, referenced] of references) {
92
+ const registry = registries.get(config)
93
+ for (const name of referenced) {
94
+ const declared = Object.hasOwn(config.variables, name)
95
+ const isFact = !declared && RUNTIME_FACTS.has(name.toLowerCase())
96
+ if (!declared && !isFact) continue
97
+ let official = isFact ? name.toLowerCase() : OFFICIAL_NAME_RE.test(name) ? name : officialAliasOf(name)
98
+ try {
99
+ // 内容绑定包含局部命名空间:会话覆盖也可以再引用其中的变量。
100
+ // 纯事实不属于任何配置的内容命名空间,所有大小写变体复用规范名。
101
+ const signature = isFact ? `fact:${official}` : JSON.stringify([name, Object.entries(config.variables).sort(([a], [b]) => a.localeCompare(b))])
102
+ let binding = bindings.get(signature)
103
+ if (binding === undefined) {
104
+ if (usedNames.has(official) || (!isFact && facts.has(official)) || (official !== name && !isFact && reserved.has(official))) {
105
+ const base = officialAliasOf(`${name}:${signature}`)
106
+ official = base
107
+ for (let suffix = 1; usedNames.has(official) || reserved.has(official); suffix++) official = `${base}_${suffix}`
108
+ }
109
+ usedNames.add(official)
110
+ const source = isFact ? name.toLowerCase() : name
111
+ const declaredValue = declared ? String(config.variables[name] ?? '') : undefined
112
+ keepDisposer(ctx, systemPrompt.variable(official, (context) => {
113
+ const session = context?.agent?.session
114
+ const override = getSessionVar(session, source)
115
+ const value = override !== undefined ? String(override) : declaredValue ?? runtimeFactValue(source, session) ?? ''
116
+ const expanded = interpolateVariables(value, { ...config.variables, ...sessionVarsSnapshot(session) }, session)
117
+ return officialChannelText(expanded, `variable ${official}`, { alias: new Map(), registered: new Set() }, warnOnce)
118
+ }), `${name}: official prompt variable ${official}`)
119
+ binding = official
120
+ bindings.set(signature, binding)
121
+ }
122
+ registry.registered.add(binding)
123
+ registry.protect.add(name)
124
+ if (binding !== name) registry.alias.set(name, binding)
125
+ } catch (error) {
126
+ warnOnce(`${name}: 官方 prompt 变量 ${official} 注册失败,该引用退回静态解析:${String(error?.message ?? error)}`)
127
+ }
128
+ }
129
+ }
130
+ return registries
131
+ }
132
+
133
+ /** 非法名的引用改写:`{{中文名}}` → `{{sv_中文_<hash>}}`(仅已注册成功的别名)。 */
134
+ function rewriteOfficialAliases(text, alias) {
135
+ if (alias.size === 0) return text
136
+ return text.replace(NAME_REFERENCE_RE, (whole, name) => alias.has(name) ? `{{${alias.get(name)}}}` : whole)
23
137
  }
24
138
 
25
139
  /** agent/request 与 tools/* 层的作用域过滤。 */
26
- function matchesAgentScope(config, agent) {
140
+ export function matchesAgentScope(config, agent) {
27
141
  if (agent === undefined) return true
28
142
  const delegated = isDelegated(agent.session)
29
143
  if (config.audience === 'main' && delegated) return false
@@ -31,6 +145,21 @@ function matchesAgentScope(config, agent) {
31
145
  return matchesModel(config.modelScope, agent.options?.model)
32
146
  }
33
147
 
148
+ /**
149
+ * 官方插值通道(system-section / runtime-context)出口清洗:本项目解析后仍残留的
150
+ * 引用一律剥离,官方严格插值因此看不到未注册引用(既不抛错也不留字面)。
151
+ * 只在本项目的宽容解析之后调用;其他层(pre-step / agent-request / tool-pipeline)
152
+ * 不经官方插值,保持原有宽容语义。
153
+ */
154
+ function officialChannelText(text, label, registry, warnOnce) {
155
+ const result = stripUnresolvedRefs(rewriteOfficialAliases(text, registry.alias), registry.registered)
156
+ if (result.stripped.length > 0) {
157
+ const samples = [...new Set(result.stripped)].slice(0, 3).join(' ')
158
+ warnOnce(`${name}: ${label} 剥离了 ${result.stripped.length} 处无法解析的引用(${samples})——请在模板变量里登记,或改用本项目宏语法 {{roll::1d6}}`)
159
+ }
160
+ return result.text
161
+ }
162
+
34
163
  /** 文本型层分组:merged 模式按位置分组,否则每条独立。 */
35
164
  function textLayerGroups(configs) {
36
165
  const sorted = [...configs].sort((a, b) => a.order - b.order)
@@ -53,51 +182,37 @@ function textLayerGroups(configs) {
53
182
  return groups
54
183
  }
55
184
 
56
- /** persona 段标记(官方 deployment:persona + 旧名 persona)。 */
57
- function isPersonaSection(config) {
58
- const sectionName = config?.params?.sectionName
59
- return sectionName === 'deployment:persona' || sectionName === 'persona'
60
- }
61
-
62
185
  /** system-section:注册静态 system prompt 段(支持官方 {{variable}} 渲染与 merged 拼接)。 */
63
- function wireSystemSections(ctx, configs, warnOnce) {
186
+ function wireSystemSections(ctx, configs, registry, warnOnce) {
187
+ const disposers = []
64
188
  const systemPrompt = getService(ctx, 'systemPrompt')
65
189
  if (systemPrompt === undefined || typeof systemPrompt.section !== 'function') {
66
190
  if (configs.length > 0) warnOnce(`${name}: systemPrompt service unavailable — system-section configs skipped`)
67
- return
68
- }
69
- // 子代理 persona 分支卡(audience=subagent + 人设段标记):不独立注册(同名段冲突),
70
- // 作为主 persona 段的子代理分支——装配时子代理使用子卡文本(替换主会话人设)。
71
- const subPersona = configs
72
- .filter((config) => config.audience === 'subagent' && isPersonaSection(config))
73
- .sort((a, b) => a.order - b.order)[0]
74
- const registerable = configs.filter((config) => !(config.audience === 'subagent' && isPersonaSection(config)))
75
- for (const group of textLayerGroups(registerable)) {
191
+ return disposers
192
+ }
193
+ // 人设段(deployment:persona-prefix/suffix)由官方 @deepseek-ai/dsh-persona 行注册
194
+ // (preset.yml 顶层 persona 段驱动);本层只处理其余 system-section 配置。
195
+ for (const group of textLayerGroups(configs)) {
76
196
  const base = group[0]
77
197
  try {
78
- const groupText = group.map((config) => configText(config)).filter((item) => item.length > 0).join('\n\n')
79
- if (groupText.length === 0) continue
80
- const hasAudience = group.some((config) => config.audience != null)
81
- // persona 主段 + 存在子代理分支:text 按 agent 分支(子代理 = 子卡文本,替换语义;
82
- // 无子卡 = 继承主 persona)。官方 AssembleContext 运行时含 agent(assembleContextFor 注入)。
83
- const isPersonaMain = base.audience == null && isPersonaSection(base)
84
- const text = isPersonaMain && subPersona !== undefined
85
- ? (context) => isDelegated(context?.agent?.session) ? configText(subPersona) : groupText
86
- : hasAudience
87
- ? (context) => group
198
+ const dynamic = group.some((config) => config.audience != null || typeof config.renderSt === 'function'
199
+ || config.texts.some(text => /\{\{\s*(?:random|pick|roll|chance)(?=\s|:|\})/i.test(text)))
200
+ const groupText = dynamic ? '' : group.map((config) => configText(config, registry, warnOnce)).filter((item) => item.length > 0).join('\n\n')
201
+ if (!dynamic && groupText.length === 0) continue
202
+ const text = dynamic
203
+ ? (context) => group
88
204
  .filter((config) => matchesAgentScope(config, context?.agent))
89
- .map((config) => configText(config))
205
+ .map((config) => configText(config, registry, warnOnce, context))
90
206
  .filter((item) => item.length > 0)
91
207
  .join('\n\n')
92
- : groupText
208
+ : groupText
93
209
  keepDisposer(ctx, systemPrompt.section({
94
210
  name: typeof base.params?.sectionName === 'string' && base.params.sectionName.length > 0 ? base.params.sectionName : base.id,
95
211
  order: base.order,
96
212
  text,
97
213
  ...(base.params?.complete === true ? { complete: true } : {}),
98
214
  }), `${name}: section ${base.id}`)
99
- // persona 语义透传:suppressRuntimeContext 抑制该 scope 的动态 runtime-context
100
- // 快照(官方 dsh-persona includeRuntimeContext:false 等价;多段重复调用幂等)。
215
+ // suppressRuntimeContext 抑制该 scope 的动态 runtime-context 快照(多段重复调用幂等)。
101
216
  if (base.params?.suppressRuntimeContext === true) {
102
217
  keepDisposer(ctx, systemPrompt.suppressRuntimeContext(), `${name}: suppressRuntimeContext ${base.id}`)
103
218
  }
@@ -105,21 +220,29 @@ function wireSystemSections(ctx, configs, warnOnce) {
105
220
  warnOnce(`${name}: system-section config ${base.id} failed: ${String(error?.message ?? error)}`)
106
221
  }
107
222
  }
223
+ return disposers
108
224
  }
109
225
 
110
- /** runtime-context:注册动态运行时上下文(晋升后由 context-gate 差分投影;支持 merged 拼接与 placeholder 函数 provider)。 */
111
- function wireRuntimeContexts(ctx, configs, warnOnce) {
226
+ /** runtime-context:注册运行时上下文;静态文本支持 merged,动态策略在官方 assembly waterfall 中填充。 */
227
+ function wireRuntimeContexts(ctx, configs, registry, warnOnce) {
228
+ const disposers = []
112
229
  const systemPrompt = getService(ctx, 'systemPrompt')
113
230
  if (systemPrompt === undefined || typeof systemPrompt.context !== 'function') {
114
231
  if (configs.length > 0) warnOnce(`${name}: systemPrompt service unavailable — runtime-context configs skipped`)
115
- return
232
+ return disposers
116
233
  }
117
- const staticConfigs = configs.filter((config) => config.strategy !== 'placeholder')
234
+ // 模板专属策略(strategyDir 懒加载)与 placeholder 一样由 waterfall 按 assembly 消费
235
+ // resolve——`config.resolve` 只在这两层被调用,按静态注册会让「配了没效果也不报错」。
236
+ const needsResolver = (config) => config.strategy === 'placeholder' || !KNOWN_STRATEGIES.has(config.strategy)
237
+ const staticConfigs = configs.filter((config) => !needsResolver(config))
118
238
  for (const group of textLayerGroups(staticConfigs)) {
119
239
  const base = group[0]
120
240
  try {
121
- const text = group.map((config) => configText(config)).filter((item) => item.length > 0).join('\n\n')
122
- if (text.length === 0) continue
241
+ const dynamic = group.some(config => typeof config.renderSt === 'function'
242
+ || config.texts.some(text => /\{\{\s*(?:random|pick|roll|chance)(?=\s|:|\})/i.test(text)))
243
+ const render = context => group.map(config => configText(config, registry, warnOnce, context)).filter(item => item.length > 0).join('\n\n')
244
+ const text = dynamic ? render : render()
245
+ if (!dynamic && text.length === 0) continue
123
246
  keepDisposer(ctx, systemPrompt.context({
124
247
  name: typeof base.params?.contextName === 'string' && base.params.contextName.length > 0 ? base.params.contextName : base.id,
125
248
  order: base.order,
@@ -129,49 +252,105 @@ function wireRuntimeContexts(ctx, configs, warnOnce) {
129
252
  warnOnce(`${name}: runtime-context config ${base.id} failed: ${String(error?.message ?? error)}`)
130
253
  }
131
254
  }
132
- // placeholder:官方 context 接受函数 provider,在每次 assembly 时动态填充。
133
- const placeholders = configs.filter((config) => config.strategy === 'placeholder')
255
+ // 官方 provider 必须同步:先注册可渲染为空的私有变量占位,再在 waterfall 中填充。
256
+ // 标记随官方排序/遮蔽进入本次 assembly,无会话缓存,也不依赖调用方 context 的对象身份。
257
+ const placeholders = configs.filter(needsResolver)
134
258
  .sort((a, b) => a.order - b.order)
259
+ if (placeholders.length === 0) return disposers
260
+ const slotVariable = `pt_runtime_${newMessageId('context').replace(/[^a-z0-9_]/gi, '_').toLowerCase()}`
261
+ const slotText = `{{${slotVariable}}}`
262
+ const registered = new Map()
263
+ let active = true
264
+ keepDisposer(ctx, () => { active = false }, `${name}: runtime-context lifecycle`)
265
+ keepDisposer(ctx, systemPrompt.variable(slotVariable, () => ''), `${name}: runtime-context placeholder`)
135
266
  for (const config of placeholders) {
136
267
  try {
137
- const resolver = config.resolve
268
+ const contextName = typeof config.params?.contextName === 'string' && config.params.contextName.length > 0 ? config.params.contextName : config.id
138
269
  keepDisposer(ctx, systemPrompt.context({
139
- name: typeof config.params?.contextName === 'string' && config.params.contextName.length > 0 ? config.params.contextName : config.id,
270
+ name: contextName,
140
271
  order: config.order,
141
- text: async (assembly) => {
142
- const agent = assembly?.agent
143
- const session = agent?.session
144
- const resolved = await resolver({ ctx, agent, session, decision: { kind: 'ok', messages: [] }, messages: [] })
145
- if (resolved === null || resolved === undefined) return ''
146
- const variables = { ...config.variables, ...(resolved.variables !== null && typeof resolved.variables === 'object' ? resolved.variables : {}) }
147
- if (config.texts.length > 0) return interpolateVariables(config.texts.join('\n\n'), variables, session)
148
- return typeof resolved.text === 'string' ? interpolateVariables(resolved.text, variables, session) : ''
149
- },
272
+ text: slotText,
150
273
  }), `${name}: context ${config.id}`)
274
+ registered.set(contextName, config)
151
275
  } catch (error) {
152
276
  warnOnce(`${name}: runtime-context placeholder ${config.id} failed: ${String(error?.message ?? error)}`)
153
277
  }
154
278
  }
279
+ disposers.push(ctx.on('system-prompt/assemble', async (assembly, context, next) => {
280
+ const entries = assembly.contexts.filter(entry => registered.has(entry.name) && entry.text === slotText)
281
+ for (const entry of entries) {
282
+ const config = registered.get(entry.name)
283
+ entry.text = ''
284
+ if (!active || context.signal?.aborted) continue
285
+ try {
286
+ const agent = context.agent
287
+ const session = agent?.session
288
+ const resolved = await config.resolve({ ctx, agent, session, signal: context.signal, decision: { kind: 'ok', messages: [] }, messages: [] })
289
+ if (!active || context.signal?.aborted || resolved === null || resolved === undefined) continue
290
+ const variables = { ...config.variables, ...(resolved.variables !== null && typeof resolved.variables === 'object' ? resolved.variables : {}) }
291
+ const rendered = config.texts.length > 0
292
+ ? interpolateVariables(config.texts.join('\n\n'), variables, session)
293
+ : typeof resolved.text === 'string' ? interpolateVariables(resolved.text, variables, session) : ''
294
+ entry.text = officialChannelText(rendered, `runtime-context ${config.id}`, registry.get(config), warnOnce)
295
+ } catch (error) {
296
+ warnOnce(`${name}: runtime-context ${config.id} resolve failed: ${String(error?.message ?? error)}`)
297
+ }
298
+ }
299
+ if (!active || context.signal?.aborted) for (const entry of entries) entry.text = ''
300
+ return next()
301
+ }))
302
+ return disposers
303
+ }
304
+
305
+ /**
306
+ * agent/request 载荷的唯一改写实现(B3 T2 第 (7) 类动作与既有 `agent-request`
307
+ * 层**共用这一份**,不另写一套请求改写)。三种语义:
308
+ * - `patch`:对下游已解析的 LlmCallConfig 做浅合并;
309
+ * - `replace: true`:整体替换为 `patch`(不叠加 base,故 `unset` 无比较对象);
310
+ * - `unset`:**按值条件删键** —— 仅当合并结果里的值**恰好等于**声明值时删除该键
311
+ * (`Object.is` 比较)。这来自原 `tool-bootstrap` 剥离 `bootstrapMaxTokens` 的语义
312
+ * (「仅当解析后的 maxTokens 恰好等于本声明注入的那个值时才删」;声明侧见
313
+ * `test/engine/declarations/tool-bootstrap.yml` 声明 4 的 `do.unset`),不是"存在即删"
314
+ * ——模型或其它插件设置的同名参数不会被删。
315
+ * 浅合并做不到删键,故删键必须走显式通道。
316
+ * @param params 提示词配置的 `params`(或动作的等价声明)。
317
+ * @param base 下游 `next()` 已解析的请求配置。
318
+ * @returns 改写后的请求配置;无 unset 命中时零额外分配。
319
+ */
320
+ export function applyAgentRequestParams(params, base) {
321
+ const patch = params?.patch !== null && typeof params?.patch === 'object' && !Array.isArray(params.patch)
322
+ ? params.patch
323
+ : {}
324
+ if (params?.replace === true) return { ...patch }
325
+ const merged = { ...base, ...patch }
326
+ const unset = params?.unset
327
+ if (unset === null || typeof unset !== 'object' || Array.isArray(unset)) return merged
328
+ let result
329
+ for (const [key, expected] of Object.entries(unset)) {
330
+ // 每个键都对着**合并结果**判定:一旦分配过副本,后续键不得跳过比较。
331
+ if (!Object.is(merged[key], expected)) continue
332
+ if (result === undefined) result = { ...merged }
333
+ delete result[key]
334
+ }
335
+ return result ?? merged
155
336
  }
156
337
 
157
- /** agent-request:对冻结的 LlmCallConfig 做浅合并 / 整体替换。 */
338
+ /** agent-request:对冻结的 LlmCallConfig 做浅合并 / 整体替换 / 按值条件删键。 */
158
339
  function wireAgentRequests(ctx, configs, warnOnce) {
340
+ const disposers = []
159
341
  for (const config of configs) {
160
- ctx.on('agent/request', async (payload, next) => {
342
+ disposers.push(ctx.on('agent/request', async (payload, next) => {
161
343
  const base = await next()
162
344
  try {
163
345
  if (!matchesAgentScope(config, payload?.agent)) return base
164
- const patch = config.params?.patch !== null && typeof config.params?.patch === 'object' && !Array.isArray(config.params.patch)
165
- ? config.params.patch
166
- : {}
167
- if (config.params?.replace === true) return { ...patch }
168
- return { ...base, ...patch }
346
+ return applyAgentRequestParams(config.params, base)
169
347
  } catch (error) {
170
348
  warnOnce(`${name}: agent-request config ${config.id} failed: ${String(error?.message ?? error)}`)
171
349
  return base
172
350
  }
173
- })
351
+ }))
174
352
  }
353
+ return disposers
175
354
  }
176
355
 
177
356
  /** 把流替换为提示词配置文本的最小合法 chunk 序列。 */
@@ -183,8 +362,9 @@ async function* replacedStream(text) {
183
362
 
184
363
  /** llm-stream:pass 透传;replace 用提示词配置文本替代整个模型流。 */
185
364
  function wireLlmStreams(ctx, configs, warnOnce) {
365
+ const disposers = []
186
366
  for (const config of configs) {
187
- ctx.on('llm/stream', (options, next) => {
367
+ disposers.push(ctx.on('llm/stream', (options, next) => {
188
368
  try {
189
369
  const mode = config.params?.mode ?? 'pass'
190
370
  if (mode === 'replace' && config.texts.length > 0 && matchesModel(config.modelScope, options?.model)) {
@@ -195,18 +375,46 @@ function wireLlmStreams(ctx, configs, warnOnce) {
195
375
  warnOnce(`${name}: llm-stream config ${config.id} failed: ${String(error?.message ?? error)}`)
196
376
  return next()
197
377
  }
198
- })
378
+ }))
379
+ }
380
+ return disposers
381
+ }
382
+
383
+ /** 条件判定的匹配器与取文本逻辑由 condition.mjs 承载(pre-step 与其他层共用)。 */
384
+
385
+ /** 插件来源的 user 消息:与 anchor-turn / progress-reminder 同一形状。 */
386
+ export function pluginMessage(prefix, text, summary) {
387
+ return {
388
+ id: newMessageId(prefix),
389
+ role: 'user',
390
+ content: [{ type: 'text', text }],
391
+ source: { kind: 'plugin', plugin: name, form: 'notice', summary },
392
+ }
393
+ }
394
+
395
+ /** 层级文本:按声明变量插值后按空行拼接;不走官方插值通道(非 system-section)。 */
396
+ function layerText(config, agent, warnOnce) {
397
+ try {
398
+ return config.texts
399
+ .map((item) => interpolateVariables(item, config.variables, agent?.session))
400
+ .filter((item) => item.length > 0)
401
+ .join('\n\n')
402
+ } catch (error) {
403
+ warnOnce(`${name}: ${config.layer} ${config.id} text failed: ${String(error?.message ?? error)}`)
404
+ return ''
199
405
  }
200
406
  }
201
407
 
202
408
  /** tool-pipeline:pre-execute 判定、execute 包装、post-execute 结果替换/阻断。 */
203
409
  function wireToolPipelines(ctx, configs, warnOnce) {
410
+ const disposers = []
204
411
  for (const config of configs) {
205
412
  const names = parseToolNames(config.params?.toolNames)
206
413
  const matchesTool = (exec) => names.length === 0 || names.includes(exec?.name)
207
- ctx.on('tools/pre-execute', async (exec, next) => {
414
+ disposers.push(ctx.on('tools/pre-execute', async (exec, next) => {
208
415
  try {
209
416
  if (!matchesTool(exec) || !matchesAgentScope(config, exec?.agent)) return next()
417
+ if (!conditionHit(config, { argsText: toolArgsText(exec?.arguments) })) return next()
210
418
  const decision = config.params?.preDecision ?? 'allow'
211
419
  if (decision === 'allow') return next()
212
420
  if (decision === 'deny') {
@@ -218,10 +426,11 @@ function wireToolPipelines(ctx, configs, warnOnce) {
218
426
  warnOnce(`${name}: tool-pipeline(pre) config ${config.id} failed: ${String(error?.message ?? error)}`)
219
427
  return next()
220
428
  }
221
- })
222
- ctx.on('tools/post-execute', async (exec, result, next) => {
429
+ }))
430
+ disposers.push(ctx.on('tools/post-execute', async (exec, result, next) => {
223
431
  try {
224
432
  if (!matchesTool(exec) || !matchesAgentScope(config, exec?.agent)) return next()
433
+ if (!conditionHit(config, { argsText: toolArgsText(exec?.arguments), resultText: extractText(result) })) return next()
225
434
  const action = config.params?.postAction ?? 'accept'
226
435
  if (action === 'accept') return next()
227
436
  if (action === 'replace' && config.texts.length > 0) {
@@ -235,15 +444,226 @@ function wireToolPipelines(ctx, configs, warnOnce) {
235
444
  warnOnce(`${name}: tool-pipeline(post) config ${config.id} failed: ${String(error?.message ?? error)}`)
236
445
  return next()
237
446
  }
238
- })
447
+ }))
448
+ }
449
+ return disposers
450
+ }
451
+
452
+ /**
453
+ * 轮次停止层的续跑上限:每轮 1 次、每会话 3 次,均为引擎常量。
454
+ * 不暴露为配置——强制续跑失控会把会话卡在停不下来的循环里;官方桥在同一位置
455
+ * 也只留了 TODO(stop-loop-guard) 而未实现上限。
456
+ */
457
+ export const TURN_STOP_MAX_PER_TURN = 1
458
+ export const TURN_STOP_MAX_PER_SESSION = 3
459
+
460
+ /** 会话内保留的轮次计数上限(与 deliberation-gate 同规模)。 */
461
+ export const TURN_STOP_MAX_TRACKED_TURNS = 8
462
+
463
+ /**
464
+ * 续跑预算(唯一实现):B3 T2 第 (4) 类动作的「续跑」与既有 turn-stop 层共用它,
465
+ * 上限是引擎常量而不是配置——强制续跑失控会把会话卡在停不下来的循环里。
466
+ * 三步语义刻意与迁移前逐行一致:
467
+ * entry(...) 取条目(含创建与超限轮次淘汰的副作用),在条件判定**之前**调用;
468
+ * available(...) 只读判定,不落账;
469
+ * claim(...) 落账,必须在 steer **之前**调用(steer 抛错也不允许重试越过预算)。
470
+ */
471
+ export function createTurnStopBudget() {
472
+ /**
473
+ * 会话预算条目(`sessionState`:统一访问接口,策略逐条声明)。
474
+ *
475
+ * 键类型 `session.id`;淘汰策略超限 `clear()` 全清(与迁移前的 `sessionMapGet` 同档:
476
+ * 第 `MAX_TRACKED_SESSIONS` 个会话仍会清空整个容器);**不声明复位**——预算是纯增量、
477
+ * 不可从事件流重建的计数(与 `progress-reminder` 同档),丢失时机必须与迁移前逐字一致,
478
+ * compaction 也不重置它。
479
+ *
480
+ * 本函数**没有 ctx**:`createTurnStopBudget()` 是 B3 交付物的一部分,对外签名与
481
+ * `entry/available/claim` 三个方法逐字不变(调用方见 actions.mjs 的 append-context 与
482
+ * 本文件的 turn-stop 层)。`sessionState` 只在声明了 `reset` 时才读 ctx,因此这里传
483
+ * `undefined` 合法且零开销(详见 shared.mjs 的 JSDoc 与 test/engine/session-state.test.mjs)。
484
+ */
485
+ const state = sessionState(undefined, () => ({ turns: new Map(), total: 0 }))
486
+
487
+ const stateOf = (sessionId, turn) => {
488
+ // 对外仍是 `(sessionId, turn)`:按 id 造一次性键宿主,`keyOf` 取的就是 `.id`,与旧的
489
+ // `sessionMapGet(state, sessionId, …)` 写进同一个槽位、同一个 clear 时机。
490
+ // 唯一差异在**无 id 的会话**(`sessionId === undefined`):`sessionState` 刻意不记账
491
+ // (见 shared.mjs),旧写法把 `undefined` 当一个共享槽位。两个调用点(actions.mjs 的
492
+ // append-context 与下方 turn-stop 层)都在 `entry()` 之前守卫 `session?.id === undefined`,
493
+ // 故该路径不可达;这里兜底一个新条目,避免将来误用时抛 TypeError。
494
+ const entry = state.get({ id: sessionId }) ?? { turns: new Map(), total: 0 }
495
+ if (!Number.isFinite(turn)) return entry
496
+ if (!entry.turns.has(turn) && entry.turns.size >= TURN_STOP_MAX_TRACKED_TURNS) {
497
+ const oldest = [...entry.turns.keys()].sort((a, b) => a - b)
498
+ for (const key of oldest.slice(0, entry.turns.size - TURN_STOP_MAX_TRACKED_TURNS + 1)) entry.turns.delete(key)
499
+ }
500
+ if (!entry.turns.has(turn)) entry.turns.set(turn, 0)
501
+ return entry
502
+ }
503
+
504
+ return {
505
+ entry: (sessionId, turn) => stateOf(sessionId, turn),
506
+ available: (entry, turn) => {
507
+ const turnCount = Number.isFinite(turn) ? (entry.turns.get(turn) ?? 0) : 0
508
+ return turnCount < TURN_STOP_MAX_PER_TURN && entry.total < TURN_STOP_MAX_PER_SESSION
509
+ },
510
+ claim: (entry, turn) => {
511
+ if (Number.isFinite(turn)) entry.turns.set(turn, (entry.turns.get(turn) ?? 0) + 1)
512
+ entry.total += 1
513
+ },
514
+ }
515
+ }
516
+
517
+ /** turn-stop:命中条件时阻止本轮停止并强制续跑一步,上限在引擎内。 */
518
+ function wireTurnStops(ctx, configs, warnOnce) {
519
+ const disposers = []
520
+ if (configs.length === 0) return disposers
521
+ const budget = createTurnStopBudget()
522
+
523
+ for (const config of configs) {
524
+ disposers.push(ctx.on('agent/turn-stopping', ({ agent, turn } = {}) => {
525
+ try {
526
+ const session = agent?.session
527
+ if (session?.id === undefined || typeof agent.steer !== 'function') return
528
+ if (!matchesAgentScope(config, agent)) return
529
+ const entry = budget.entry(session.id, turn)
530
+ if (!budget.available(entry, turn)) return
531
+ if (!conditionHit(config, { assistantText: lastAssistantText(session) })) return
532
+ const text = layerText(config, agent, warnOnce)
533
+ if (text.length === 0) return
534
+ // 计数在 steer 之前落账:steer 抛错也不允许下一步重试越过预算。
535
+ budget.claim(entry, turn)
536
+ agent.steer(pluginMessage(`turn-stop-${config.id}`, text, `turn-stop ${config.id}`))
537
+ } catch (error) {
538
+ warnOnce(`${name}: turn-stop config ${config.id} failed: ${String(error?.message ?? error)}`)
539
+ }
540
+ }))
239
541
  }
542
+ return disposers
240
543
  }
241
544
 
242
- /** 把非 pre-step 提示词配置接入其声明的官方层级通道。 */
545
+ /** 沿官方持久 session 血缘找根会话,不根据当前 UI 会话或预设名称猜测。 */
546
+ function mainSessionForChild(ctx, id) {
547
+ const agents = getService(ctx, 'agents')
548
+ const sessions = getService(ctx, 'sessions')
549
+ const visited = new Set()
550
+ let session = agents?.get?.(id)?.session ?? sessions?.get?.(id)
551
+ if (session?.header?.parentSession === undefined) return undefined
552
+ while (session !== undefined) {
553
+ const sessionId = session.id ?? session.header?.id
554
+ if (typeof sessionId !== 'string' || visited.has(sessionId)) return undefined
555
+ visited.add(sessionId)
556
+ const parentId = session.header?.parentSession
557
+ if (parentId === undefined) return isDelegated(session) ? undefined : sessionId
558
+ session = agents?.get?.(parentId)?.session ?? sessions?.get?.(parentId)
559
+ }
560
+ }
561
+
562
+ /** 子代理生命周期:启动注入子代理;结束默认观察,可显式给所属主会话投递上下文。 */
563
+ function wireSubagentEvents(ctx, configs, warnOnce) {
564
+ const disposers = []
565
+ const startConfigs = configs.filter((config) => config.layer === 'subagent-start')
566
+ const endConfigs = configs.filter((config) => config.layer === 'subagent-end')
567
+ const injectMain = endConfigs.some((config) => config.params?.action === 'inject-main')
568
+ // B4 保留原状(不迁 `sessionState`):键是 `runId`(运行标识)而不是 session,不属于
569
+ // 「按会话索引」的会话态,套统一接口会把语义挪到错误的键上;它的 `delete(最旧)` 也是与
570
+ // `clear()` 不同的另一档淘汰策略,无等价证明前保留。
571
+ const runs = new Map()
572
+ if (injectMain) {
573
+ disposers.push(ctx.on('subagent/start', (info) => {
574
+ if (typeof info?.runId !== 'string' || typeof info?.id !== 'string') return
575
+ const mainId = mainSessionForChild(ctx, info.id)
576
+ if (mainId === undefined) return
577
+ // ponytail: 有界运行记录;极端并发超过上限时,结束事件仍可通过存活会话血缘定位。
578
+ if (runs.size >= MAX_TRACKED_SESSIONS) runs.delete(runs.keys().next().value)
579
+ runs.set(info.runId, { id: info.id, mainId, model: getService(ctx, 'agents')?.get?.(info.id)?.options?.model })
580
+ }))
581
+ ctx.effect?.(() => () => runs.clear())
582
+ }
583
+ if (startConfigs.length > 0) {
584
+ disposers.push(ctx.on('subagent/start', (info) => {
585
+ try {
586
+ const child = getService(ctx, 'agents')?.get?.(info?.id)
587
+ if (child === undefined) return
588
+ const subagentText = subagentTextOf(info)
589
+ for (const config of startConfigs) {
590
+ if (!matchesAgentScope(config, child)) continue
591
+ if (!conditionHit(config, { subagentText })) continue
592
+ if (typeof child.inject !== 'function') continue
593
+ const text = layerText(config, child, warnOnce)
594
+ if (text.length === 0) continue
595
+ child.inject(pluginMessage(`subagent-start-${config.id}`, text, `subagent-start ${config.id}`))
596
+ }
597
+ } catch (error) {
598
+ warnOnce(`${name}: subagent-start failed: ${String(error?.message ?? error)}`)
599
+ }
600
+ }))
601
+ }
602
+ if (endConfigs.length > 0) {
603
+ disposers.push(ctx.on('subagent/end', (info) => {
604
+ try {
605
+ const child = getService(ctx, 'agents')?.get?.(info?.id)
606
+ const recorded = runs.get(info?.runId)
607
+ runs.delete(info?.runId)
608
+ if (recorded !== undefined && recorded.id !== info?.id) return
609
+ const subagentText = subagentTextOf(info)
610
+ for (const config of endConfigs) {
611
+ if (!matchesModel(config.modelScope, child?.options?.model ?? recorded?.model)) continue
612
+ if (!conditionHit(config, { subagentText })) continue
613
+ if (config.params?.action !== 'inject-main') {
614
+ warnOnce(`${name}: subagent-end ${config.id} matched (observe only)`)
615
+ continue
616
+ }
617
+ if (typeof info?.runId !== 'string' || info.runId.length === 0) continue
618
+ const mainId = recorded?.mainId ?? mainSessionForChild(ctx, info?.id)
619
+ const main = mainId === undefined ? undefined : getService(ctx, 'agents')?.get?.(mainId)
620
+ if (main?.session === undefined || typeof main.inject !== 'function') {
621
+ warnOnce(`${name}: subagent-end ${config.id} has no live main session`)
622
+ continue
623
+ }
624
+ const text = layerText(config, main, warnOnce)
625
+ if (text.length === 0) continue
626
+ let delivered = subagentEndDeliveries.get(main.session)
627
+ if (delivered === undefined) { delivered = new Map(); subagentEndDeliveries.set(main.session, delivered) }
628
+ const key = `${info.runId}:${config.id}`
629
+ if (delivered.has(key)) continue
630
+ // ponytail: 会话内保留最近的投递记录;超大历史改用持久事件索引时可取消此上限。
631
+ if (delivered.size >= MAX_TRACKED_SESSIONS) delivered.delete(delivered.keys().next().value)
632
+ delivered.set(key, true)
633
+ main.inject(pluginMessage(`subagent-end-${config.id}`, text, `subagent-end ${config.id}`))
634
+ }
635
+ } catch (error) {
636
+ warnOnce(`${name}: subagent-end failed: ${String(error?.message ?? error)}`)
637
+ }
638
+ }))
639
+ }
640
+ return disposers
641
+ }
642
+
643
+ /**
644
+ * 把非 pre-step 提示词配置接入其声明的官方层级通道。
645
+ * @returns 聚合 disposer:回收本次接线显式创建的 waterfall 监听器;段/上下文/
646
+ * 变量注册走 keepDisposer(随 ctx fiber 释放),不在本函数的回收面内。
647
+ */
243
648
  export function wireLayers(ctx, configs, warnOnce) {
244
- wireSystemSections(ctx, configs.filter((config) => config.layer === 'system-section'), warnOnce)
245
- wireRuntimeContexts(ctx, configs.filter((config) => config.layer === 'runtime-context'), warnOnce)
246
- wireAgentRequests(ctx, configs.filter((config) => config.layer === 'agent-request'), warnOnce)
247
- wireLlmStreams(ctx, configs.filter((config) => config.layer === 'llm-stream'), warnOnce)
248
- wireToolPipelines(ctx, configs.filter((config) => config.layer === 'tool-pipeline'), warnOnce)
649
+ // 官方插值两层共享一份变量注册:运行时事实按 assembly 求值,非法名走别名改写。
650
+ const registry = registerOfficialVariables(ctx, configs.filter((config) => config.layer === 'system-section' || config.layer === 'runtime-context'), warnOnce)
651
+ const registered = [
652
+ wireSystemSections(ctx, configs.filter((config) => config.layer === 'system-section'), registry, warnOnce),
653
+ wireRuntimeContexts(ctx, configs.filter((config) => config.layer === 'runtime-context'), registry, warnOnce),
654
+ wireAgentRequests(ctx, configs.filter((config) => config.layer === 'agent-request'), warnOnce),
655
+ wireLlmStreams(ctx, configs.filter((config) => config.layer === 'llm-stream'), warnOnce),
656
+ wireToolPipelines(ctx, configs.filter((config) => config.layer === 'tool-pipeline'), warnOnce),
657
+ wireTurnStops(ctx, configs.filter((config) => config.layer === 'turn-stop'), warnOnce),
658
+ wireSubagentEvents(ctx, configs.filter((config) => config.layer === 'subagent-start' || config.layer === 'subagent-end'), warnOnce),
659
+ ].flat().filter((disposer) => typeof disposer === 'function')
660
+ return () => {
661
+ for (const dispose of registered.splice(0)) {
662
+ try {
663
+ dispose()
664
+ } catch {
665
+ // 释放失败不得反过来打断卸载流程。
666
+ }
667
+ }
668
+ }
249
669
  }