dsh-plugin-prompt-tool 3.1.0 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/engine/layers.mjs CHANGED
@@ -71,7 +71,7 @@ function officialAliasOf(name) {
71
71
  * 非法官方名(中文/大写/连字符)改写成确定性别名并改写文本引用;注册失败的名字退回静态解析。
72
72
  * @returns 各配置独立的引用白名单与别名;同一作用域内等价绑定只注册一次。
73
73
  */
74
- function registerOfficialVariables(ctx, configs, warnOnce, keep) {
74
+ function registerOfficialVariables(ctx, configs, warnOnce, keep, shared) {
75
75
  const registries = new Map(configs.map((config) => [config, { protect: new Set(), alias: new Map(), registered: new Set() }]))
76
76
  const systemPrompt = getService(ctx, 'systemPrompt')
77
77
  if (systemPrompt === undefined || typeof systemPrompt.variable !== 'function') return registries
@@ -92,8 +92,9 @@ function registerOfficialVariables(ctx, configs, warnOnce, keep) {
92
92
  }
93
93
  }
94
94
  for (const fact of facts) reserved.add(fact)
95
- const bindings = new Map()
96
- const usedNames = new Set()
95
+ const bindings = shared?.bindings ?? new Map()
96
+ const usedNames = shared?.usedNames ?? new Set()
97
+ const retained = new Set()
97
98
  for (const [config, referenced] of references) {
98
99
  const registry = registries.get(config)
99
100
  for (const name of referenced) {
@@ -115,20 +116,30 @@ function registerOfficialVariables(ctx, configs, warnOnce, keep) {
115
116
  usedNames.add(official)
116
117
  const source = isFact ? name.toLowerCase() : name
117
118
  const variablesDisabled = config[SESSION_VARIABLES_DISABLED] === true
118
- keep(systemPrompt.variable(official, (context) => {
119
+ const release = systemPrompt.variable(official, (context) => {
119
120
  const session = context?.agent?.session
120
121
  const variables = variablesDisabled ? {} : config.variableStore?.read(config.sourceModuleId).effective ?? config.variables
121
122
  const value = Object.hasOwn(variables, source) ? String(variables[source]) : runtimeFactValue(source, session) ?? ''
122
123
  // 变量值内的 {{pick}} 同样要有位置身份(配置 id + 声明名),否则同会话下多个变量的首个 pick 撞值。
123
124
  const expanded = interpolateVariables(value, variables, session, undefined, `variable:${config.sourceModuleId}:${config.id}:${name}`)
124
125
  return officialChannelText(expanded, `variable ${official}`, { alias: new Map(), registered: new Set() }, warnOnce)
125
- }), `${name}: official prompt variable ${official}`)
126
- binding = official
126
+ })
127
+ binding = { name: official, release, refs: 0 }
127
128
  bindings.set(signature, binding)
128
129
  }
129
- registry.registered.add(binding)
130
+ if (!retained.has(signature)) {
131
+ retained.add(signature)
132
+ binding.refs++
133
+ keep(() => {
134
+ if (--binding.refs > 0) return
135
+ bindings.delete(signature)
136
+ usedNames.delete(binding.name)
137
+ binding.release?.()
138
+ }, `${name}: official prompt variable ${binding.name}`)
139
+ }
140
+ registry.registered.add(binding.name)
130
141
  registry.protect.add(name)
131
- if (binding !== name) registry.alias.set(name, binding)
142
+ if (binding.name !== name) registry.alias.set(name, binding.name)
132
143
  } catch (error) {
133
144
  warnOnce(`${name}: 官方 prompt 变量 ${official} 注册失败,该引用退回静态解析:${String(error?.message ?? error)}`)
134
145
  }
@@ -176,6 +187,7 @@ function textLayerGroups(configs) {
176
187
  && config.sourceModuleId === base.sourceModuleId
177
188
  && (config.params?.complete === true) === (base.params?.complete === true)
178
189
  && (config.params?.suppressRuntimeContext === true) === (base.params?.suppressRuntimeContext === true)
190
+ && config.runtimePoint?.waterfallPosition === base.runtimePoint?.waterfallPosition
179
191
  && config.position === base.position && config.order === base.order) previous.push(config)
180
192
  else groups.push([config])
181
193
  }
@@ -658,9 +670,9 @@ function orderedNamesOf(entries) {
658
670
  * 回填到它们原来的槽位。只换本插件段之间的位置:平台段与其他插件段占住的槽位不动。
659
671
  */
660
672
  function registerDeliveryOrder(ctx, keep, collection, names) {
661
- if (names.length < 2) return
662
- const rank = new Map(names.map((entryName, index) => [entryName, index]))
673
+ if (typeof names !== 'function' && names.length < 2) return
663
674
  keep(ctx.on('system-prompt/assemble', async (assembly, context, next) => {
675
+ const rank = new Map((typeof names === 'function' ? names() : names).map((entryName, index) => [entryName, index]))
664
676
  const result = await next()
665
677
  const list = result?.[collection]
666
678
  if (!Array.isArray(list)) return result
@@ -712,8 +724,8 @@ export function wireLayers(ctx, configs, warnOnce, options = {}) {
712
724
  try {
713
725
  // 注册即纳入 owned,后续注册失败也能释放已取得的变量和监听器。
714
726
  /** 实际注册名与序号:重排器只认这里,不从 configs 反推。 */
715
- const ordered = { sections: [], contexts: [] }
716
- const registry = registerOfficialVariables(ctx, configs.filter((config) => config.layer === 'system-section' || config.layer === 'runtime-context'), warnOnce, keep)
727
+ const ordered = options.ordered ?? { sections: [], contexts: [] }
728
+ const registry = registerOfficialVariables(ctx, configs.filter((config) => config.layer === 'system-section' || config.layer === 'runtime-context'), warnOnce, keep, options.sharedVariables)
717
729
  const conditional = configs.filter(config => ['system-section', 'runtime-context'].includes(config.layer)
718
730
  && (typeof config.rule?.when === 'function' || typeof config.actionWhen === 'function'
719
731
  || (config.rule !== undefined || config.params?.templateEvaluation === true) && config.params?.complete !== true && config.params?.suppressRuntimeContext !== true))
@@ -724,7 +736,7 @@ export function wireLayers(ctx, configs, warnOnce, options = {}) {
724
736
  wireTurnStops(ctx, configs.filter((config) => config.layer === 'turn-stop'), warnOnce, on, options.turnStopBudgets)
725
737
  wireSubagentEvents(ctx, configs.filter((config) => config.layer === 'subagent-start' || config.layer === 'subagent-end'), warnOnce, on)
726
738
  // 官方 order 只提供初始槽位;configOrder 覆盖段/上下文的先后(平台段与其他插件段不动)。
727
- for (const collection of ['sections', 'contexts']) {
739
+ for (const collection of options.deliveryOrder === false ? [] : ['sections', 'contexts']) {
728
740
  registerDeliveryOrder(ctx, keep, collection, orderedNamesOf(ordered[collection]))
729
741
  }
730
742
  return dispose
@@ -733,3 +745,86 @@ export function wireLayers(ctx, configs, warnOnce, options = {}) {
733
745
  throw error
734
746
  }
735
747
  }
748
+
749
+ /** 按实际文本组保留注册;共享排序投影变化不撤销未变化组的 provider。 */
750
+ export function createLayerRuntime(ctx, warnOnce, options = {}) {
751
+ let records = []
752
+ let active = true
753
+ const listeners = []
754
+ const sharedVariables = { bindings: new Map(), usedNames: new Set() }
755
+ const keep = release => { listeners.push(release) }
756
+ try {
757
+ for (const collection of ['sections', 'contexts']) {
758
+ registerDeliveryOrder(ctx, keep, collection, () => orderedNamesOf(records.flatMap(record => record.ordered[collection])))
759
+ }
760
+ } catch (error) {
761
+ for (const release of listeners.reverse()) release()
762
+ throw error
763
+ }
764
+ const groupsOf = configs => {
765
+ const groups = []
766
+ for (const layer of ['system-section', 'runtime-context']) {
767
+ const selected = configs.filter(config => config.layer === layer)
768
+ const conditional = selected.filter(config => typeof config.rule?.when === 'function' || typeof config.actionWhen === 'function'
769
+ || (config.rule !== undefined || config.params?.templateEvaluation === true) && config.params?.complete !== true && config.params?.suppressRuntimeContext !== true)
770
+ const conditionalSet = new Set(conditional)
771
+ const regular = selected.filter(config => !conditionalSet.has(config))
772
+ groups.push(...textLayerGroups(conditional))
773
+ if (layer === 'system-section') groups.push(...textLayerGroups(regular))
774
+ else {
775
+ const resolver = regular.filter(config => config.strategy === 'placeholder' || !KNOWN_STRATEGIES.has(config.strategy))
776
+ const resolverSet = new Set(resolver)
777
+ groups.push(...textLayerGroups(regular.filter(config => !resolverSet.has(config))), ...resolver.map(config => [config]))
778
+ }
779
+ }
780
+ const other = configs.filter(config => !['system-section', 'runtime-context'].includes(config.layer))
781
+ return [...groups, ...other.map(config => [config])]
782
+ }
783
+ const mount = configs => {
784
+ const ordered = { sections: [], contexts: [] }
785
+ const handlers = []
786
+ const dispose = wireLayers(ctx, configs, warnOnce, {
787
+ ...options, sharedVariables, ordered, deliveryOrder: false,
788
+ on(channel, handler) { handlers.push({ channel, handler, config: configs[0] }); return () => {} },
789
+ })
790
+ return { configs, ordered, handlers, dispose }
791
+ }
792
+ return {
793
+ replace(configs) {
794
+ if (!active) throw new Error('rule text runtime disposed')
795
+ const groups = groupsOf(configs)
796
+ const byFirst = new Map(records.map(record => [record.configs[0], record]))
797
+ const retained = groups.map(group => {
798
+ const record = byFirst.get(group[0])
799
+ return record?.configs.length === group.length && group.every((config, index) => record.configs[index] === config) ? record : undefined
800
+ })
801
+ const retainedSet = new Set(retained)
802
+ const removed = records.filter(record => !retainedSet.has(record))
803
+ for (const record of removed) record.dispose()
804
+ const created = []
805
+ try {
806
+ records = groups.map((group, index) => {
807
+ if (retained[index] !== undefined) return retained[index]
808
+ const record = mount(group)
809
+ created.push(record)
810
+ return record
811
+ })
812
+ const owners = new Set(configs.map(config => config.sourceModuleId ?? ''))
813
+ for (const owner of options.turnStopBudgets?.keys() ?? []) if (!owners.has(owner)) options.turnStopBudgets.delete(owner)
814
+ } catch (error) {
815
+ for (const record of created.reverse()) record.dispose()
816
+ const old = records
817
+ records = old.filter(record => !removed.includes(record))
818
+ for (const record of removed) records.push(mount(record.configs))
819
+ throw error
820
+ }
821
+ },
822
+ entries: () => records.flatMap(record => record.handlers),
823
+ dispose() {
824
+ if (!active) return
825
+ active = false
826
+ for (const record of records.splice(0).reverse()) record.dispose()
827
+ for (const release of listeners.splice(0).reverse()) release()
828
+ },
829
+ }
830
+ }
@@ -51,3 +51,9 @@ export function mountRuleSources(
51
51
  sources: ReadonlyArray<RuleSourceLike>,
52
52
  options?: MountRuleSourcesOptions,
53
53
  ): () => void
54
+
55
+ /** 同 moduleId 与 rules 引用保持未变来源;候选拒绝时恢复受影响来源的旧贡献。 */
56
+ export function createRuleRuntime(ctx: unknown, options?: MountRuleSourcesOptions): {
57
+ replace(sources: ReadonlyArray<RuleSourceLike>): void
58
+ dispose(): void
59
+ }
@@ -1,7 +1,7 @@
1
1
  import { prepareAction } from './actions/index.mjs'
2
- import { applyPromptConfigSources } from './executor.mjs'
2
+ import { confirmDelivered, runPreStepBatch } from './executor.mjs'
3
3
  import { createPreStepSource, moduleSourceId } from './pre-step-source.mjs'
4
- import { wireLayers } from './layers.mjs'
4
+ import { createLayerRuntime } from './layers.mjs'
5
5
  import { rebindConditions, validateDeliverySources } from './rule-spec.mjs'
6
6
  import { wireTriggerObservers } from './trigger.mjs'
7
7
  import { ruleFrame, actionMatches } from './conditions/evaluation.mjs'
@@ -18,42 +18,67 @@ const compare = (a, b) => a.action.channelOrder - b.action.channelOrder
18
18
 
19
19
  const sourceError = (error, moduleId) => Object.assign(new Error(String(error?.message ?? error), { cause: error }), { sourceModuleId: moduleId })
20
20
 
21
+ const pointKey = point => `${point.channel}:${point.phase}:${point.waterfallPosition}`
22
+ const compareEntries = (a, b) => a.sequence - b.sequence || a.sourceModuleId.localeCompare(b.sourceModuleId)
23
+ || a.ruleIndex - b.ruleIndex || a.ruleActionIndex - b.ruleActionIndex
24
+
25
+ function pointHandler(ctx, point, snapshot, warnOnce, report, isActive) {
26
+ return (...args) => {
27
+ const handlers = snapshot()
28
+ const free = noNext.has(point.channel)
29
+ const payload = free ? args : args.slice(0, -1)
30
+ const next = free ? () => undefined : args.at(-1)
31
+ if (!isActive()) return next()
32
+ let frame
33
+ try { frame = ruleFrame(point.channel, payload, warnOnce, ctx, report) }
34
+ catch (error) {
35
+ warnOnce(`rule engine: frame construction failed on ${point.channel}: ${String(error?.message ?? error)}`)
36
+ return next()
37
+ }
38
+ if (free) {
39
+ let pending
40
+ for (const entry of handlers) {
41
+ const invoke = () => {
42
+ if (!isActive()) return undefined
43
+ if (!actionMatches(entry, frame)) { entry.miss?.(frame); return undefined }
44
+ return entry.handler(...payload, undefined, frame)
45
+ }
46
+ pending = pending?.then ? pending.then(invoke) : invoke()
47
+ }
48
+ return pending
49
+ }
50
+ if (point.phase === 'after-next') return Promise.resolve(next()).then(async initial => {
51
+ let result = initial
52
+ for (const entry of handlers) {
53
+ if (!isActive()) continue
54
+ if (!actionMatches(entry, frame)) { entry.miss?.(frame); continue }
55
+ result = await entry.handler(...payload, () => result, frame)
56
+ }
57
+ return result
58
+ })
59
+ const invoke = index => {
60
+ if (!isActive() || index === handlers.length) return next()
61
+ const entry = handlers[index]
62
+ if (!actionMatches(entry, frame)) { entry.miss?.(frame); return invoke(index + 1) }
63
+ return entry.handler(...payload, () => invoke(index + 1), frame)
64
+ }
65
+ return invoke(0)
66
+ }
67
+ }
68
+
21
69
  /** 所有来源只进入这个装配入口;受众限定固定效果要求 ctx 属于 options.agent 的 scope。 */
22
- export function mountRuleSources(ctx, sources, options = {}) {
70
+ function mountSources(ctx, sources, options = {}) {
23
71
  validateDeliverySources(sources)
24
- // 晋升相位池先建:条件的挂载期绑定要用它(`phase` 的订阅模式从池取实例,见 promotion-pool.mjs)。
25
- // 池自己订阅 `session/event` 并统一喂事件;自建的随本挂载释放,注入的由创建者释放。
26
- const coordinator = (() => {
27
- const service = getService(ctx, PRE_STEP_COORDINATOR_SERVICE)
28
- return service !== null && typeof service === 'object' && typeof service.registerPreset === 'function'
29
- ? service
30
- : undefined
31
- })()
32
- const pool = options.promotionPool ?? coordinator?.promotionPool ?? createPromotionPool(ctx)
33
- const ownsPool = options.promotionPool === undefined && coordinator?.promotionPool === undefined
72
+ const pool = options.promotionPool
34
73
  // 条件绑定**在这里**发生:`compileRules` 编译期没有挂载 scope 的 ctx(preset 条件在那边恒
35
74
  // UNAVAILABLE 且静默)。必须早于事件订阅与动作注册——动作级 `actionWhen.observe` 是订阅通道,
36
75
  // 绑晚了那一类谓词会漏事件。`standingMountFor` 是可选兜底(官方包缺席=没有兜底,不是错误)。
37
76
  const warnOnce = options.warnOnce ?? createWarnOnce(ctx, options.plugin ?? 'rule-engine')
38
- // 非 pre-step 动作仍共享一次 ruleFrame;规则实例没有来源字段(RuleDefinition 不暴露 moduleId),
39
- // 因此保留一份局部 rule→module 映射。它不是 pre-step 来源事实的重建,不参与 pre-step 的 source sink。
40
- const report = options.onOutcome === undefined ? undefined : (() => {
41
- const moduleOfRule = new Map()
42
- for (const source of sources) for (const rule of source.rules) if (!moduleOfRule.has(rule)) moduleOfRule.set(rule, source.moduleId)
43
- return (rule, channel, outcome) => options.onOutcome({ moduleId: moduleOfRule.get(rule), ruleId: rule.id, channel, outcome })
44
- })()
45
77
  const releases = []
46
- // 自建池随本挂载释放;协调器/调用方注入的池由创建者释放(生命周期随归属,不跨界代管)。
47
- if (ownsPool) releases.push(() => pool.dispose())
48
78
  const points = new Map()
49
- // 续跑预算按来源模块一份:同模块的 turn-stop 动作共享上限,
50
- // 不再按动作各建一份。本 mount 可能含多个模块(agent-assembly 一次装载全部启用模块),
51
- // 因此按 moduleId 分档;来源 id 缺失时落空串兜底键(与 wireTurnStops 同源)。
52
- const turnStopBudgets = new Map()
53
79
  const injections = new Map(sources.map(source => [source.moduleId, { moduleId: source.moduleId, sourceId: moduleSourceId(source.moduleId), configs: [], ruleActions: [] }]))
54
80
  // 动作级分支条件(actionWhen)也要喂 session/event:否则 else / 嵌套 if 里的 phase / count
55
81
  // 谓词永远收不到事件,状态停在冷扫那一刻(规则级 when 已由 rules 覆盖,动作级单独补齐)。
56
- let active = true
57
82
  const bind = (item, on) => prepareAction(item.action, { plugin: options.plugin, warnOnce, promptConfigOptions: item.rule.promptConfigOptions, on })(ctx)
58
83
  try {
59
84
  for (const source of sources) {
@@ -84,21 +109,13 @@ export function mountRuleSources(ctx, sources, options = {}) {
84
109
  let textConfigs = []
85
110
  const flushLayers = () => {
86
111
  if (!textConfigs.length) return
87
- releases.push(wireLayers(ctx, textConfigs, warnOnce, {
88
- agent: options.agent,
89
- turnStopBudgets,
90
- sourceError,
91
- on: (channel, handler) => {
92
- if (channel !== point.channel) throw new TypeError(`rule action registered outside ${point.channel}: ${channel}`)
93
- handlers.push({ handler }); return () => {}
94
- },
95
- }))
112
+ options.collectText(point, textConfigs)
96
113
  textConfigs = []
97
114
  }
98
115
  for (const item of point.items) {
99
116
  const { action, rule, moduleId } = item
100
117
  if (action.kind === 'inject-text') {
101
- const config = { ...action.compiledConfig, rule, sourceModuleId: moduleId, variableStore: options.variableStore, group: undefined, exclusive: false, enabled: true, actionWhen: action.actionWhen, bypassRuleWhen: action.bypassRuleWhen }
118
+ const config = { ...action.compiledConfig, rule, sourceModuleId: moduleId, variableStore: options.variableStore, group: undefined, exclusive: false, enabled: true, actionWhen: action.actionWhen, bypassRuleWhen: action.bypassRuleWhen, runtimeOrder: action.channelOrder, runtimeRuleIndex: item.ruleIndex }
102
119
  if (config.layer === 'pre-step') injections.get(moduleId).configs.push(config)
103
120
  else textConfigs.push(config)
104
121
  continue
@@ -107,12 +124,11 @@ export function mountRuleSources(ctx, sources, options = {}) {
107
124
  const created = []
108
125
  const on = (channel, handler) => {
109
126
  if (channel !== point.channel) throw new TypeError(`rule action registered outside ${point.channel}: ${channel}`)
110
- const entry = { rule, handler, sequence: action.channelOrder, sourceModuleId: moduleId, ruleId: rule.id, ruleActionIndex: action.actionIndex, id: action.id, actionWhen: action.actionWhen, bypassRuleWhen: action.bypassRuleWhen }
127
+ const entry = { rule, handler, sequence: action.channelOrder, sourceModuleId: moduleId, ruleId: rule.id, ruleIndex: item.ruleIndex, ruleActionIndex: action.actionIndex, id: action.id, actionWhen: action.actionWhen, bypassRuleWhen: action.bypassRuleWhen }
111
128
  if (channel === 'agent/pre-step' && point.waterfallPosition === 'default') injections.get(moduleId).ruleActions.push(entry)
112
129
  else handlers.push(entry)
113
130
  created.push(entry)
114
- // 显式放弃:handler 的存活期由下面唯一的 `ctx.on(point.channel, …)` 统一撤销,
115
- // 这里再返回一个真 disposer 会与它重复释放同一批 handler。
131
+ // handler 由稳定执行点统一分派;来源 disposer 只释放动作自身的副作用。
116
132
  return () => {}
117
133
  }
118
134
  let dispose
@@ -127,59 +143,10 @@ export function mountRuleSources(ctx, sources, options = {}) {
127
143
  }
128
144
  flushLayers()
129
145
  if (!handlers.length) continue
130
- releases.push(ctx.on(point.channel, (...args) => {
131
- const free = noNext.has(point.channel)
132
- const payload = free ? args : args.slice(0, -1)
133
- const next = free ? () => undefined : args[args.length - 1]
134
- if (!active) return next()
135
- // 帧构造失败(如宿主 snapshotEvents 抛错)只告警并按「无事实」跳过本点动作,
136
- // 不得把异常抛出整条分派。
137
- let frame
138
- try {
139
- frame = ruleFrame(point.channel, payload, warnOnce, ctx, report)
140
- } catch (error) {
141
- warnOnce(`rule engine: frame construction failed on ${point.channel}: ${String(error?.message ?? error)}`)
142
- return next()
143
- }
144
- if (free) {
145
- let pending
146
- for (const entry of handlers) {
147
- const invoke = () => {
148
- if (!active) return undefined
149
- if (!actionMatches(entry, frame)) { entry.miss?.(frame); return undefined }
150
- return entry.handler(...payload, undefined, frame)
151
- }
152
- pending = pending?.then ? pending.then(invoke) : invoke()
153
- }
154
- return pending
155
- }
156
- if (point.phase === 'after-next') return Promise.resolve(next()).then(async initial => {
157
- let result = initial
158
- for (const entry of handlers) {
159
- if (!active) continue
160
- if (!actionMatches(entry, frame)) { entry.miss?.(frame); continue }
161
- result = await entry.handler(...payload, () => result, frame)
162
- }
163
- return result
164
- })
165
- const invoke = index => {
166
- if (!active || index === handlers.length) return next()
167
- const entry = handlers[index]
168
- if (!actionMatches(entry, frame)) {
169
- entry.miss?.(frame)
170
- return invoke(index + 1)
171
- }
172
- return entry.handler(...payload, () => invoke(index + 1), frame)
173
- }
174
- return invoke(0)
175
- }, point.waterfallPosition === 'outermost' ? { prepend: true } : undefined))
176
- }
177
- // 晋升相位实例对:`options.promotion` 注入(托管路径传协调器那两份),否则从池里按配置取
178
- // ——池在挂载开头已建,条件绑定也用它。共用实例后事件由池统一喂,消费者一律不得再 observe。
179
- const promotion = options.promotion ?? coordinator?.promotion ?? {
180
- main: pool.tracker(PROMOTE_EVENTS.either, { includeSubagents: false }),
181
- withSubagents: pool.tracker(PROMOTE_EVENTS.either, { includeSubagents: true }),
146
+ options.collectPoint(point, handlers)
182
147
  }
148
+ // 各来源共用运行时拥有的晋升相位对,事件只由相位池喂入。
149
+ const promotion = options.promotion
183
150
  const preStep = [...injections.values()]
184
151
  .filter(source => source.configs.length || source.ruleActions.length)
185
152
  .map(source => createPreStepSource({
@@ -190,14 +157,185 @@ export function mountRuleSources(ctx, sources, options = {}) {
190
157
  onOutcome: options.onOutcome === undefined ? undefined : report => options.onOutcome({ moduleId: source.moduleId, ...report }),
191
158
  promotion,
192
159
  }))
193
- if (preStep.length) releases.push(applyPromptConfigSources(preStep, { layers: false }))
160
+ if (preStep.length) releases.push(options.registerPreStep(preStep))
194
161
  } catch (error) {
195
- active = false
196
162
  for (const dispose of releases.reverse()) { try { dispose?.() } catch {} }
197
163
  throw error
198
164
  }
199
165
  return () => {
200
- active = false
201
166
  for (const dispose of releases.splice(0).reverse()) { try { dispose?.() } catch {} }
202
167
  }
203
168
  }
169
+
170
+ /** 稳定执行点持有有序快照;来源仅在规则实例改变时重新绑定和注册。 */
171
+ export function createRuleRuntime(ctx, options = {}) {
172
+ const warnOnce = options.warnOnce ?? createWarnOnce(ctx, options.plugin ?? 'rule-engine')
173
+ const coordinatorOf = () => {
174
+ const value = getService(ctx, PRE_STEP_COORDINATOR_SERVICE)
175
+ return typeof value?.registerPreset === 'function' ? value : undefined
176
+ }
177
+ const initialCoordinator = coordinatorOf()
178
+ const pool = options.promotionPool ?? initialCoordinator?.promotionPool ?? createPromotionPool(ctx)
179
+ const ownsPool = options.promotionPool === undefined && initialCoordinator?.promotionPool === undefined
180
+ const promotion = options.promotion ?? initialCoordinator?.promotion ?? {
181
+ main: pool.tracker(PROMOTE_EVENTS.either, { includeSubagents: false }),
182
+ withSubagents: pool.tracker(PROMOTE_EVENTS.either, { includeSubagents: true }),
183
+ }
184
+ const standalone = new Map()
185
+ const registrations = new Map()
186
+ let registeredCoordinator = initialCoordinator
187
+ let active = true
188
+ let records = new Map()
189
+ let points = new Map()
190
+ const listeners = new Map()
191
+ const ruleOwners = new WeakMap()
192
+ const memo = new Map()
193
+ const report = options.onOutcome === undefined ? undefined : (rule, channel, outcome) => options.onOutcome({ moduleId: ruleOwners.get(rule), ruleId: rule.id, channel, outcome })
194
+ let layers
195
+ let releaseMemo, releaseFallback
196
+ const register = (coordinator, source) => {
197
+ if (coordinator.promotion === undefined) throw new TypeError('prompt-tool: pre-step coordinator v2 must expose a promotion owner')
198
+ return coordinator.registerPreset(source.sourceId, { ...source, promotion: coordinator.promotion }) ?? (() => {})
199
+ }
200
+ const ensureFallback = () => {
201
+ if (releaseFallback !== undefined) return
202
+ releaseMemo = ctx.on('session/event', (session, event) => confirmDelivered(memo, session, event))
203
+ try {
204
+ releaseFallback = ctx.on('agent/pre-step', async (payload, next) => {
205
+ // 取得本次完整来源快照后才进入宿主,途中更新不会拼接两个版本的批次。
206
+ const coordinator = coordinatorOf()
207
+ if (coordinator !== registeredCoordinator) {
208
+ for (const release of registrations.values()) release()
209
+ registrations.clear()
210
+ registeredCoordinator = coordinator
211
+ if (coordinator !== undefined) for (const source of standalone.values()) registrations.set(source.sourceId, register(coordinator, source))
212
+ }
213
+ const sources = [...standalone.values()]
214
+ const decision = await next()
215
+ if (!active || coordinator !== undefined || sources.length === 0) return decision
216
+ return runPreStepBatch({ sources, ctx, payload, agent: payload?.agent, decision, memo, warnOnce })
217
+ }, { prepend: true })
218
+ } catch (error) { releaseMemo(); releaseMemo = undefined; throw error }
219
+ }
220
+ const mount = source => {
221
+ const record = { source, points: [], text: [], dispose: undefined }
222
+ for (const rule of source.rules) ruleOwners.set(rule, source.moduleId)
223
+ record.dispose = mountSources(ctx, [source], {
224
+ ...options, warnOnce, promotionPool: pool, promotion,
225
+ registerPreStep(sources) {
226
+ const installed = []
227
+ const dispose = () => {
228
+ for (const item of installed) if (standalone.get(item.sourceId) === item) {
229
+ standalone.delete(item.sourceId)
230
+ registrations.get(item.sourceId)?.()
231
+ registrations.delete(item.sourceId)
232
+ }
233
+ }
234
+ try {
235
+ ensureFallback()
236
+ for (const item of sources) {
237
+ if (standalone.has(item.sourceId)) throw new TypeError(`prompt-tool: duplicate pre-step source ${item.sourceId}`)
238
+ standalone.set(item.sourceId, item)
239
+ installed.push(item)
240
+ const coordinator = coordinatorOf()
241
+ if (coordinator !== undefined) {
242
+ try { registrations.set(item.sourceId, register(coordinator, item)) }
243
+ catch (error) { throw sourceError(error, source.moduleId) }
244
+ }
245
+ }
246
+ return dispose
247
+ } catch (error) { dispose(); throw error }
248
+ },
249
+ collectPoint(point, handlers) { record.points.push({ point, handlers }) },
250
+ collectText(point, configs) {
251
+ for (const config of configs) { config.runtimePoint = point; record.text.push(config) }
252
+ },
253
+ })
254
+ return record
255
+ }
256
+ const publishPoints = () => {
257
+ const next = new Map()
258
+ const add = (point, entry) => {
259
+ const key = pointKey(point)
260
+ const value = next.get(key) ?? { point, handlers: [] }
261
+ value.handlers.push(entry)
262
+ next.set(key, value)
263
+ }
264
+ for (const record of records.values()) for (const { point, handlers } of record.points) for (const entry of handlers) add(point, entry)
265
+ for (const { channel, handler, config } of layers?.entries() ?? []) {
266
+ const point = { ...config.runtimePoint, channel }
267
+ add(point, { handler, sequence: config.runtimeOrder, sourceModuleId: config.sourceModuleId, ruleIndex: config.runtimeRuleIndex, ruleActionIndex: config.ruleActionIndex ?? 0 })
268
+ }
269
+ for (const [key, value] of next) {
270
+ value.handlers.sort(compareEntries)
271
+ if (!listeners.has(key)) listeners.set(key, ctx.on(value.point.channel,
272
+ pointHandler(ctx, value.point, () => points.get(key)?.handlers ?? [], warnOnce, report, () => active),
273
+ value.point.waterfallPosition === 'outermost' ? { prepend: true } : undefined))
274
+ }
275
+ points = next
276
+ }
277
+ return {
278
+ replace(sources) {
279
+ if (!active) throw new Error('rule runtime disposed')
280
+ validateDeliverySources(sources)
281
+ const desired = new Map()
282
+ for (const source of sources) {
283
+ if (desired.has(source.moduleId)) throw sourceError(new TypeError(`duplicate rule source ${source.moduleId}`), source.moduleId)
284
+ desired.set(source.moduleId, source)
285
+ }
286
+ const old = records
287
+ const changed = [...old.values()].filter(record => desired.get(record.source.moduleId)?.rules !== record.source.rules)
288
+ if (changed.length === 0 && sources.length === old.size) return
289
+ for (const record of changed) record.dispose()
290
+ const created = []
291
+ try {
292
+ const next = new Map()
293
+ for (const source of sources) {
294
+ let record = old.get(source.moduleId)
295
+ if (record?.source.rules !== source.rules) {
296
+ record = mount(source)
297
+ created.push(record)
298
+ }
299
+ next.set(source.moduleId, record)
300
+ }
301
+ records = next
302
+ const text = [...records.values()].flatMap(record => record.text)
303
+ if (text.length > 0) layers ??= createLayerRuntime(ctx, warnOnce, { agent: options.agent, turnStopBudgets: new Map(), sourceError })
304
+ layers?.replace(text)
305
+ publishPoints()
306
+ } catch (error) {
307
+ for (const record of created.reverse()) record.dispose()
308
+ records = new Map(old)
309
+ try {
310
+ for (const record of changed) records.set(record.source.moduleId, mount(record.source))
311
+ layers?.replace([...records.values()].flatMap(record => record.text))
312
+ publishPoints()
313
+ } catch (restoreError) {
314
+ throw Object.assign(new AggregateError([error, restoreError], `rule source replacement and recovery failed: ${String(error.message)}; ${String(restoreError.message)}`), { sourceModuleId: error.sourceModuleId })
315
+ }
316
+ throw error
317
+ }
318
+ },
319
+ dispose() {
320
+ if (!active) return
321
+ active = false
322
+ for (const record of records.values()) record.dispose()
323
+ records.clear()
324
+ layers?.dispose()
325
+ for (const release of listeners.values()) release()
326
+ listeners.clear()
327
+ points.clear()
328
+ releaseFallback?.()
329
+ releaseMemo?.()
330
+ if (ownsPool) pool.dispose()
331
+ },
332
+ }
333
+ }
334
+
335
+ /** 兼容一次性挂载;增量调用者持有 createRuleRuntime 的来源集合。 */
336
+ export function mountRuleSources(ctx, sources, options = {}) {
337
+ validateDeliverySources(sources)
338
+ const runtime = createRuleRuntime(ctx, options)
339
+ try { runtime.replace(sources) } catch (error) { runtime.dispose(); throw error }
340
+ return () => runtime.dispose()
341
+ }