dsh-plugin-prompt-tool 1.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -29
- package/engine/actions/append-context.mjs +49 -0
- package/engine/actions/assembly.mjs +150 -0
- package/engine/actions/catalog.mjs +92 -0
- package/engine/actions/content.mjs +205 -0
- package/engine/actions/decision.mjs +54 -0
- package/engine/actions/examples.mjs +11 -0
- package/engine/actions/guard.mjs +80 -0
- package/engine/actions/inbox-prepend.mjs +37 -0
- package/engine/actions/index.mjs +100 -0
- package/engine/actions/inject-text.mjs +22 -0
- package/engine/actions/pre-step-filter.mjs +55 -0
- package/engine/actions/registration.mjs +168 -0
- package/engine/actions/request-params.mjs +32 -0
- package/engine/actions/sdk-strip.mjs +30 -0
- package/engine/actions/shared.mjs +71 -0
- package/engine/actions.mjs +2 -931
- package/engine/branch.mjs +44 -0
- package/engine/compaction-epoch.mjs +31 -13
- package/engine/compositions/source/local/dev-tool-search.yml +11 -0
- package/engine/compositions/source/local/instruction-hint.yml +22 -1
- package/engine/compositions/source/local/rule-engine.yml +6 -0
- package/engine/condition.mjs +16 -3
- package/engine/conditions/anchor.mjs +32 -0
- package/engine/conditions/availability.mjs +47 -0
- package/engine/conditions/composite.mjs +83 -0
- package/engine/conditions/count.mjs +140 -0
- package/engine/conditions/evaluation.mjs +41 -0
- package/engine/conditions/examples.mjs +11 -0
- package/engine/conditions/index.mjs +85 -0
- package/engine/conditions/names.mjs +21 -0
- package/engine/conditions/phase.mjs +54 -0
- package/engine/conditions/preset.mjs +94 -0
- package/engine/conditions/scope.mjs +18 -0
- package/engine/conditions/session.mjs +22 -0
- package/engine/conditions/source.mjs +63 -0
- package/engine/conditions/subject.mjs +121 -0
- package/engine/conditions/text.mjs +44 -0
- package/engine/conditions/values.mjs +32 -0
- package/engine/dev-tool-search.mjs +213 -0
- package/engine/executor.mjs +55 -6
- package/engine/fillers.mjs +23 -16
- package/engine/instruction-hint.mjs +34 -22
- package/engine/layers.mjs +135 -49
- package/engine/order.mjs +4 -1
- package/engine/predicates.mjs +2 -753
- package/engine/rule-engine.mjs +40 -0
- package/engine/rule-runtime.mjs +110 -0
- package/engine/rule-spec.mjs +187 -0
- package/engine/schema.mjs +16 -5
- package/engine/shared.mjs +1 -1
- package/engine/strategies.mjs +2 -235
- package/engine/subagent-tool-policy-core.mjs +7 -7
- package/engine/subagent-tool-policy.mjs +20 -10
- package/engine/tool-config-engine.mjs +53 -32
- package/engine/trigger-spec.mjs +49 -133
- package/lib/client.js +11117 -10860
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +82 -139
- package/lib/index.mjs +826 -1087
- package/lib/migrate-rules.d.mts +1 -0
- package/lib/migrate-rules.mjs +39 -0
- package/lib/module-package-CHPFweqK.mjs +2 -0
- package/lib/module-package-Cnd1_mwx.mjs +2872 -0
- package/lib/{paths-BgL42sSM.d.mts → paths-CyP0IkaM.d.mts} +223 -108
- package/lib/preset-transfer.d.mts +2 -2
- package/lib/preset-transfer.mjs +3 -2
- package/lib/{module-package-Cm3fHHlp.mjs → rules-migration-DJ6vASB5.mjs} +13696 -15557
- package/modules/ponytail/module.yml +381 -147
- package/modules/skill-surface/module.yml +74 -0
- package/modules/tool-surface/module.yml +67 -0
- package/package.json +5 -1
- package/templates/10-pre-step.yml +26 -22
- package/templates/14-first-turn-anchor.yml +25 -21
- package/templates/15-guide-auto.yml +34 -19
- package/templates/16-custom-fallback.yml +27 -15
- package/templates/18-placeholder-env-facts.yml +23 -14
- package/templates/20-system-section.yml +16 -10
- package/templates/30-runtime-context.yml +13 -7
- package/templates/40-agent-request.yml +11 -8
- package/templates/50-llm-stream.yml +14 -6
- package/templates/60-tool-pipeline.yml +15 -9
- package/templates/65-turn-stop.yml +19 -12
- package/templates/66-subagent-start.yml +12 -7
- package/templates/70-subagent-maintenance.yml +35 -29
- package/templates/75-subagent-end.yml +10 -5
- package/templates/80-tool-surface.yml +66 -0
- package/templates/policies/legacy-defaults.yml +34 -0
- package/engine/compositions/source/local/prompt-config-engine.yml +0 -7
- package/engine/prompt-config-engine.mjs +0 -104
- package/engine/trigger-editor-meta.mjs +0 -48
- package/modules/ponytail/configs/0000-ponytail-rules.yml +0 -102
- package/modules/ponytail/configs/0010-ponytail-level-full.yml +0 -19
- package/modules/ponytail/configs/0020-ponytail-level-lite.yml +0 -19
- package/modules/ponytail/configs/0030-ponytail-level-ultra.yml +0 -19
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工具面的按需发现与解锁。
|
|
3
|
+
*
|
|
4
|
+
* 为什么:工具面实测 **156 个、工具描述合计 46894 字符**(≈12K token,每个请求都付),
|
|
5
|
+
* 而多数请求只用到其中几个。收窄成常驻核心集之后,本工具是唯一的发现入口。
|
|
6
|
+
*
|
|
7
|
+
* 两个动作合一:
|
|
8
|
+
* - `query` 关键词搜索**完整装配目录**(只回名字 + 一行截断描述,不回 parameters);
|
|
9
|
+
* - `toolNames` 按**精确名**解锁。解锁名写进本次 `tool/call` 的持久参数,
|
|
10
|
+
* `assembly.target.tools.allowFrom` 从那里读回 → 下次请求起生效,且跨压缩保留。
|
|
11
|
+
*
|
|
12
|
+
* **能力摘要来自运行时截获,不手工维护**(2026-10-05 用户指出):空查询时按前缀把
|
|
13
|
+
* `ctx.tools.schemas()` 的可见目录折叠成分组摘要(`mcp__github__* ×46`,实测 433 字符
|
|
14
|
+
* 覆盖全部 156 个工具)。此前的实现写死一份 UNLOCKABLE_INDEX——工具增删就要改代码,
|
|
15
|
+
* 名字写错还会让模型照着错名字解锁(那份实现甚至需要一条专门测试来防错名)。
|
|
16
|
+
* 官方注册表只投影 name/description(无分组字段),所以分组只能按前缀在本地折叠。
|
|
17
|
+
*
|
|
18
|
+
* 与原 `dev-tool-search.mjs` 的两处修正照搬:
|
|
19
|
+
* 1. 匹配是**打分**而非 AND 过滤——长自然语言查询("file edit write replace")在
|
|
20
|
+
* AND 口径下命中零个,等于白搜。现在精确名优先,其次按命中 token 数排序。
|
|
21
|
+
* 2. 描述里显式教解锁路径("搜索为空 ≠ 工具不存在")——实测模型只会搜、不会传
|
|
22
|
+
* toolNames,于是收窄后永远拿不到工具。
|
|
23
|
+
*/
|
|
24
|
+
export const name = 'dev-tool-search'
|
|
25
|
+
|
|
26
|
+
/** 工具注册表必须先于本工具存在(同 skill-search 的 inject 纪律)。 */
|
|
27
|
+
export const inject = ['tools']
|
|
28
|
+
|
|
29
|
+
/** 单次搜索结果上限:再多就该让模型收窄关键词。 */
|
|
30
|
+
export const MAX_RESULTS = 25
|
|
31
|
+
|
|
32
|
+
/** 一行描述在结果里的截断长度。 */
|
|
33
|
+
const DESC_LIMIT = 90
|
|
34
|
+
|
|
35
|
+
/** 空查询摘要里最多列出的分组数,超出则并入「其他」。 */
|
|
36
|
+
const MAX_GROUPS = 24
|
|
37
|
+
|
|
38
|
+
/** 常驻核心集:由工具面收窄模板声明,这里只在描述里如实告知模型。 */
|
|
39
|
+
const RESIDENT = ['pwsh', 'read', 'write', 'edit', 'glob', 'grep', 'todo_write', 'skill_search', 'skill_load']
|
|
40
|
+
|
|
41
|
+
/** 零依赖的最小 JSON schema 编译器(与 skill-search 同构)。 */
|
|
42
|
+
function toJsonSchema(spec) {
|
|
43
|
+
const properties = {}
|
|
44
|
+
const required = []
|
|
45
|
+
for (const [key, meta] of Object.entries(spec || {})) {
|
|
46
|
+
const prop = { type: meta.type }
|
|
47
|
+
if (meta.description) prop.description = meta.description
|
|
48
|
+
if (meta.items) prop.items = meta.items
|
|
49
|
+
properties[key] = prop
|
|
50
|
+
if (meta.required) required.push(key)
|
|
51
|
+
}
|
|
52
|
+
return { type: 'object', properties, required, additionalProperties: false }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 能力索引:常驻核心集(pwsh / read / write / edit / glob / grep / todo_write /
|
|
57
|
+
* skill_search / skill_load)覆盖不到的能力。名字全部取自实测工具面。
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/** 把查询切成小写 token(Unicode 字母数字与下划线连字符,与 skill-search 同口径)。 */
|
|
61
|
+
export function queryTokens(query) {
|
|
62
|
+
return (typeof query === 'string' ? query : '').toLowerCase().split(/[^\p{L}\p{N}_-]+/u).filter(Boolean)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 一个工具名的分组键:MCP 工具折到服务级(`mcp__github__*`),其余折到首个下划线段
|
|
67
|
+
* (`task_board_create` → `task_*`),无下划线的单名自成一组。
|
|
68
|
+
*/
|
|
69
|
+
export function groupKeyOf(name) {
|
|
70
|
+
if (typeof name !== 'string' || name.length === 0) return undefined
|
|
71
|
+
if (name.startsWith('mcp__')) {
|
|
72
|
+
const parts = name.split('__')
|
|
73
|
+
return parts.length >= 2 ? `${parts[0]}__${parts[1]}__*` : `${name}*`
|
|
74
|
+
}
|
|
75
|
+
const cut = name.indexOf('_')
|
|
76
|
+
return cut <= 0 ? name : `${name.slice(0, cut)}_*`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 从**运行时目录**折叠出分组摘要——这是替代手工能力索引的唯一来源。
|
|
81
|
+
* 名字、分组、数量全部来自 `ctx.tools.schemas()`,工具增删不需要改本模块的代码。
|
|
82
|
+
* 超过 MAX_GROUPS 个分组时,把长尾并成一行「其他」而不是无限增长。
|
|
83
|
+
*/
|
|
84
|
+
export function summarizeCatalog(schemas) {
|
|
85
|
+
const counts = new Map()
|
|
86
|
+
for (const schema of Array.isArray(schemas) ? schemas : []) {
|
|
87
|
+
const key = groupKeyOf(schema?.name)
|
|
88
|
+
if (key === undefined) continue
|
|
89
|
+
counts.set(key, (counts.get(key) ?? 0) + 1)
|
|
90
|
+
}
|
|
91
|
+
const sorted = [...counts.entries()].sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0]))
|
|
92
|
+
const head = sorted.slice(0, MAX_GROUPS)
|
|
93
|
+
const tail = sorted.slice(MAX_GROUPS)
|
|
94
|
+
const parts = head.map(([key, count]) => `${key}(${count})`)
|
|
95
|
+
if (tail.length > 0) parts.push(`其他 ${tail.length} 组(${tail.reduce((sum, [, n]) => sum + n, 0)})`)
|
|
96
|
+
return { total: [...counts.values()].reduce((sum, n) => sum + n, 0), text: parts.join(' ') }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 对目录打分排序(纯函数,可单测):
|
|
101
|
+
* 精确名命中排最前,其次按命中 token 数降序,同分按名字字典序。
|
|
102
|
+
* `wanted` 为空时返回空数组——调用方据此区分「没给查询」与「没搜到」。
|
|
103
|
+
*/
|
|
104
|
+
export function scoreTools(schemas, wanted) {
|
|
105
|
+
if (!Array.isArray(schemas) || wanted.length === 0) return []
|
|
106
|
+
return schemas
|
|
107
|
+
.map((schema) => {
|
|
108
|
+
const name = typeof schema?.name === 'string' ? schema.name : ''
|
|
109
|
+
const haystack = `${name} ${schema?.description ?? ''}`.toLowerCase()
|
|
110
|
+
let score = 0
|
|
111
|
+
for (const token of wanted) if (haystack.includes(token)) score += 1
|
|
112
|
+
return { schema, name, score, exact: wanted.includes(name.toLowerCase()) }
|
|
113
|
+
})
|
|
114
|
+
.filter((entry) => entry.name.length > 0 && (entry.exact || entry.score >= 1))
|
|
115
|
+
.sort((a, b) => (b.exact - a.exact) || (b.score - a.score) || a.name.localeCompare(b.name))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 解锁名单归一:只留非空字符串,去重保序。 */
|
|
119
|
+
export function unlockNames(value) {
|
|
120
|
+
if (!Array.isArray(value)) return []
|
|
121
|
+
const seen = new Set()
|
|
122
|
+
for (const item of value) {
|
|
123
|
+
if (typeof item !== 'string') continue
|
|
124
|
+
const name = item.trim()
|
|
125
|
+
if (name.length > 0) seen.add(name)
|
|
126
|
+
}
|
|
127
|
+
return [...seen]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 把目录搜索结果渲染成给模型的文本(带截断提示与解锁引导)。 */
|
|
131
|
+
export function renderMatches(scored, query) {
|
|
132
|
+
const lines = []
|
|
133
|
+
const matches = scored.slice(0, MAX_RESULTS)
|
|
134
|
+
if (matches.length === 0) {
|
|
135
|
+
lines.push(
|
|
136
|
+
`No tools match "${query}". An empty result only means no tool matched your keywords — if you need a specific tool, unlock it directly by exact name, e.g. dev_tool_search({"toolNames":["web_search"]}).`,
|
|
137
|
+
)
|
|
138
|
+
return lines
|
|
139
|
+
}
|
|
140
|
+
lines.push(`Matching tools (${matches.length}${scored.length > MAX_RESULTS ? ` of ${scored.length}` : ''}):`)
|
|
141
|
+
for (const { schema, name, exact } of matches) {
|
|
142
|
+
const desc = String(schema?.description ?? '').split('\n')[0].slice(0, DESC_LIMIT)
|
|
143
|
+
lines.push(`- ${name}${exact ? ' (exact)' : ''}: ${desc}`)
|
|
144
|
+
}
|
|
145
|
+
if (scored.length > MAX_RESULTS) {
|
|
146
|
+
lines.push(`(truncated at ${MAX_RESULTS} — add tokens to narrow the query)`)
|
|
147
|
+
}
|
|
148
|
+
lines.push('Unlock with dev_tool_search({"toolNames": ["<exact name>"]}).')
|
|
149
|
+
return lines
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 注册模型可见的 `dev_tool_search`。 */
|
|
153
|
+
export function apply(ctx) {
|
|
154
|
+
ctx.tools.register({
|
|
155
|
+
name: 'dev_tool_search',
|
|
156
|
+
description: [
|
|
157
|
+
'Discover and unlock tools that are NOT currently available.',
|
|
158
|
+
'',
|
|
159
|
+
`This session starts with a minimal resident set: ${RESIDENT.join(', ')}. Everything else is unlocked on demand through this tool.`,
|
|
160
|
+
'',
|
|
161
|
+
'Usage:',
|
|
162
|
+
'- `query` — search the catalog by keyword (matches tool names AND descriptions, 1-2 short words work best).',
|
|
163
|
+
'- call with NO arguments — list the catalog as group counts (`<prefix>_*(count) ...`), so you can see what exists before searching.',
|
|
164
|
+
'- `toolNames` — unlock exact names. Unlocked tools appear from the NEXT request on and stay unlocked for the session.',
|
|
165
|
+
'',
|
|
166
|
+
'Example: dev_tool_search({"query":"pull request","toolNames":["mcp__github__create_pull_request"]}) — search AND unlock in one call.',
|
|
167
|
+
'IMPORTANT: an empty search result does NOT mean the tool does not exist — it only means no tool matched your keywords. Call with no arguments to see the catalog groups, then unlock by exact name.',
|
|
168
|
+
].join('\n'),
|
|
169
|
+
parameters: toJsonSchema({
|
|
170
|
+
query: { type: 'string', required: false, description: 'search keywords (e.g. "web", "subagent", "github")' },
|
|
171
|
+
toolNames: {
|
|
172
|
+
type: 'array',
|
|
173
|
+
required: false,
|
|
174
|
+
items: { type: 'string' },
|
|
175
|
+
description: 'exact tool names to unlock for the next request',
|
|
176
|
+
},
|
|
177
|
+
}),
|
|
178
|
+
output: {
|
|
179
|
+
schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string' } }, required: ['text'] },
|
|
180
|
+
render: (_args, value) => [{ type: 'text', text: value.text }],
|
|
181
|
+
},
|
|
182
|
+
async execute(args, exec) {
|
|
183
|
+
const query = typeof args?.query === 'string' ? args.query.trim() : ''
|
|
184
|
+
const unlock = unlockNames(args?.toolNames)
|
|
185
|
+
const lines = []
|
|
186
|
+
// 解锁动作本身不需要任何运行时服务:引擎的 allowFrom 只读本工具的持久调用参数,
|
|
187
|
+
// 所以即便目录不可用(无 tools 服务/无 agent),解锁也必须照样回报成功。
|
|
188
|
+
if (unlock.length > 0) lines.push(`Unlocked for the next request: ${unlock.join(', ')}`)
|
|
189
|
+
if (query.length === 0 && unlock.length > 0) return { text: lines.join('\n') }
|
|
190
|
+
let schemas
|
|
191
|
+
try {
|
|
192
|
+
// 执行中的 agent 就是观察作用域:preset 工具注册在 agent scope 层,
|
|
193
|
+
// 不带 scope 的 schemas() 只看全局层,会漏掉它们。
|
|
194
|
+
schemas = ctx.tools.schemas(exec?.agent)
|
|
195
|
+
} catch (error) {
|
|
196
|
+
return { text: [...lines, `catalog search unavailable: ${String(error?.message ?? error)}`].join('\n') }
|
|
197
|
+
}
|
|
198
|
+
// 空查询 = 要目录概览。摘要由运行时目录折叠而来,不需要维护任何索引。
|
|
199
|
+
if (query.length === 0) {
|
|
200
|
+
const summary = summarizeCatalog(schemas)
|
|
201
|
+
lines.push(
|
|
202
|
+
summary.total === 0
|
|
203
|
+
? 'The catalog is empty — nothing to unlock.'
|
|
204
|
+
: `Catalog groups (${summary.total} tools): ${summary.text}`,
|
|
205
|
+
'Search with `query`, or unlock by exact name with `toolNames`.',
|
|
206
|
+
)
|
|
207
|
+
return { text: lines.join('\n') }
|
|
208
|
+
}
|
|
209
|
+
lines.push(...renderMatches(scoreTools(schemas, queryTokens(query)), query))
|
|
210
|
+
return { text: lines.join('\n') }
|
|
211
|
+
},
|
|
212
|
+
})
|
|
213
|
+
}
|
package/engine/executor.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import { wireLayers } from './layers.mjs'
|
|
|
29
29
|
import { sessionVarsSnapshot } from './session-vars.mjs'
|
|
30
30
|
import { selectStWorldBook } from './st-world-book.mjs'
|
|
31
31
|
import { compareConfigSequence } from './order.mjs'
|
|
32
|
+
import { ruleFrame, ruleMatches, actionMatches } from './conditions/evaluation.mjs'
|
|
32
33
|
|
|
33
34
|
const name = 'prompt-config-engine'
|
|
34
35
|
|
|
@@ -66,13 +67,25 @@ function identityOf(config) {
|
|
|
66
67
|
return config.mergeMode === 'merged' ? mergedIdentity(config) : config.identity.value
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
/**
|
|
71
|
+
* 消息来源的插件身份(去重身份)。v4 起 `source.kind` 是生产者名(`plugin:<name>`),
|
|
72
|
+
* `source.plugin` 只作为旧日志字段保留——两条通道都要认,否则去重记账会在格式换代后静默失效。
|
|
73
|
+
*/
|
|
74
|
+
function pluginIdentityOf(source) {
|
|
75
|
+
if (source === null || typeof source !== 'object') return undefined
|
|
76
|
+
if (typeof source.plugin === 'string' && source.plugin.length > 0) return source.plugin
|
|
77
|
+
return typeof source.kind === 'string' && source.kind.startsWith('plugin:')
|
|
78
|
+
? source.kind.slice('plugin:'.length)
|
|
79
|
+
: undefined
|
|
80
|
+
}
|
|
81
|
+
|
|
69
82
|
function hasInjected(config, session) {
|
|
70
83
|
const value = identityOf(config)
|
|
71
84
|
return sessionEvents(session).some((event) => {
|
|
72
85
|
const message = eventMessage(event)
|
|
73
86
|
// 双通道去重:kind(外来/第三方消息,如 context-gate 的 instruction-hint)或
|
|
74
87
|
// plugin(本引擎注入的命名空间,merged 组用 merged:<position>)。
|
|
75
|
-
return message?.source?.kind === config.sourceKind || message?.source
|
|
88
|
+
return message?.source?.kind === config.sourceKind || pluginIdentityOf(message?.source) === value
|
|
76
89
|
})
|
|
77
90
|
}
|
|
78
91
|
|
|
@@ -80,7 +93,7 @@ function hasInjected(config, session) {
|
|
|
80
93
|
function hasInBatch(config, messages) {
|
|
81
94
|
const value = identityOf(config)
|
|
82
95
|
return messages.some((message) =>
|
|
83
|
-
message?.source?.kind === config.sourceKind || message?.source
|
|
96
|
+
message?.source?.kind === config.sourceKind || pluginIdentityOf(message?.source) === value)
|
|
84
97
|
}
|
|
85
98
|
|
|
86
99
|
/**
|
|
@@ -113,6 +126,10 @@ export function confirmDelivered(memo, session, event) {
|
|
|
113
126
|
if (typeof value !== 'string' || value.length === 0) continue
|
|
114
127
|
deliveredSessions(memo, `${field}:${value}`).add(session.id)
|
|
115
128
|
}
|
|
129
|
+
// v4 起生产者身份挂在 kind(`plugin:<name>`),旧日志才有 plugin 字段;
|
|
130
|
+
// 两种形态都补记到 plugin: 账本,alreadyDelivered 的 `confirmed('plugin', …)` 才不会漏。
|
|
131
|
+
const plugin = pluginIdentityOf(source)
|
|
132
|
+
if (plugin !== undefined && source.plugin === undefined) deliveredSessions(memo, `plugin:${plugin}`).add(session.id)
|
|
116
133
|
}
|
|
117
134
|
|
|
118
135
|
/** dedupe=session:本会话是否已有该身份的已确认投递(快路径 + 持久事件真相)。 */
|
|
@@ -151,7 +168,9 @@ function buildMessage(config, resolved, warnOnce) {
|
|
|
151
168
|
const base = resolved.source !== null && typeof resolved.source === 'object'
|
|
152
169
|
? resolved.source
|
|
153
170
|
: {
|
|
154
|
-
kind
|
|
171
|
+
// v4 要求 kind 是**生产者名**:声明了 sourceKind 就用它,否则用与 pluginMessage 同一形状的
|
|
172
|
+
// `plugin:<身份>`。裸 kind(undefined / 'plugin')都会被 codec 拒绝,历史走的是同一个坑。
|
|
173
|
+
kind: typeof config.sourceKind === 'string' && config.sourceKind.length > 0 ? config.sourceKind : `plugin:${sourceValue}`,
|
|
155
174
|
plugin: sourceValue,
|
|
156
175
|
...(typeof config.form === 'string' ? { form: config.form } : {}),
|
|
157
176
|
...(typeof config.summary === 'string' && config.summary.length > 0 ? { summary: config.summary } : {}),
|
|
@@ -178,6 +197,27 @@ function buildMessage(config, resolved, warnOnce) {
|
|
|
178
197
|
* @returns 注入后的 decision;reject、缺 agent/session、全部跳过或异常时原样返回。
|
|
179
198
|
*/
|
|
180
199
|
export async function runPreStepBatch(options) {
|
|
200
|
+
const initialFrame = options.ruleFrame ?? ruleFrame('agent/pre-step', [{ agent: options.agent, messages: options.decision?.messages ?? [] }], options.warnOnce, options.ctx)
|
|
201
|
+
if (!options.ruleActions?.length) return runPromptConfigBatch({ ...options, ruleFrame: initialFrame })
|
|
202
|
+
const entries = [...options.configs.map(config => ({ ...config, config })), ...options.ruleActions].sort(compareConfigSequence)
|
|
203
|
+
let decision = options.decision
|
|
204
|
+
if (decision?.kind === 'reject') return decision
|
|
205
|
+
let pending = []
|
|
206
|
+
const placement = { beforeAll: new Set(), afterUser: new Set() }
|
|
207
|
+
const flush = async () => {
|
|
208
|
+
if (pending.length) decision = await runPromptConfigBatch({ ...options, decision, configs: pending, ruleFrame: initialFrame, placement })
|
|
209
|
+
pending = []
|
|
210
|
+
}
|
|
211
|
+
for (const entry of entries) {
|
|
212
|
+
if (entry.config) { pending.push(entry.config); continue }
|
|
213
|
+
await flush()
|
|
214
|
+
if (ruleMatches(entry.rule, initialFrame)) decision = await entry.handler(options.payload ?? initialFrame.args[0], () => decision, initialFrame)
|
|
215
|
+
}
|
|
216
|
+
await flush()
|
|
217
|
+
return decision
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function runPromptConfigBatch(options) {
|
|
181
221
|
const { ctx, agent, decision, configs, promotion, memo, warnOnce } = options
|
|
182
222
|
if (decision === null || typeof decision !== 'object') return decision
|
|
183
223
|
if (decision.kind === 'reject') return decision
|
|
@@ -204,6 +244,7 @@ export async function runPreStepBatch(options) {
|
|
|
204
244
|
&& (config.promotion !== 'main' || main.status(agent).promoted)
|
|
205
245
|
&& (config.promotion !== 'include-subagents' || withSubagents.status(agent).promoted)
|
|
206
246
|
&& conditionHit(config, { userText })
|
|
247
|
+
&& actionMatches(config, options.ruleFrame)
|
|
207
248
|
const qualifiedConfigs = configs.filter(qualified)
|
|
208
249
|
// 协调器为绑定来源 ctx 会复制 config;renderSt 函数身份在副本间保持不变。
|
|
209
250
|
const eligible = new Set(qualifiedConfigs.map(config => config.renderSt))
|
|
@@ -310,13 +351,18 @@ export async function runPreStepBatch(options) {
|
|
|
310
351
|
const afterUser = planned.filter((item) => item.position !== 'before-all' && item.position !== 'after-all')
|
|
311
352
|
const afterAll = planned.filter((item) => item.position === 'after-all')
|
|
312
353
|
if (beforeAll.length > 0) {
|
|
313
|
-
|
|
354
|
+
// 混合动作把注入切成多批;继续排在此前仍存活的同位置消息之后。
|
|
355
|
+
const index = options.placement === undefined ? 0 : messages.findLastIndex(message => options.placement.beforeAll.has(message)) + 1
|
|
356
|
+
messages.splice(index, 0, ...beforeAll.map((item) => item.message))
|
|
357
|
+
for (const item of beforeAll) options.placement?.beforeAll.add(item.message)
|
|
314
358
|
for (const item of beforeAll) markGroup(item.group)
|
|
315
359
|
changed = true
|
|
316
360
|
}
|
|
317
361
|
const userIndex = messages.findIndex((item) => item?.source?.kind === 'user')
|
|
318
362
|
if (afterUser.length > 0 && userIndex >= 0) {
|
|
319
|
-
|
|
363
|
+
const last = options.placement === undefined ? -1 : messages.findLastIndex(message => options.placement.afterUser.has(message))
|
|
364
|
+
messages.splice(Math.max(userIndex, last) + 1, 0, ...afterUser.map((item) => item.message))
|
|
365
|
+
for (const item of afterUser) options.placement?.afterUser.add(item.message)
|
|
320
366
|
for (const item of afterUser) markGroup(item.group)
|
|
321
367
|
changed = true
|
|
322
368
|
}
|
|
@@ -358,7 +404,7 @@ function effectivePromptConfigs(configs) {
|
|
|
358
404
|
export function applyPromptConfigSources(ctx, sources, options = {}) {
|
|
359
405
|
const selected = sources.map(source => ({ ...source, configs: effectivePromptConfigs(source.configs) }))
|
|
360
406
|
const releases = selected.map(source => applyPromptConfigs(ctx, source.configs, { ...options, ...source, layers: false }))
|
|
361
|
-
const releaseLayers = wireLayers(ctx, selected.flatMap(source => source.configs)
|
|
407
|
+
const releaseLayers = options.layers === false ? () => {} : wireLayers(ctx, selected.flatMap(source => source.configs)
|
|
362
408
|
.filter(config => config.layer !== 'pre-step'), createWarnOnce(ctx, name))
|
|
363
409
|
return () => {
|
|
364
410
|
for (const release of releases) release()
|
|
@@ -388,6 +434,7 @@ export function applyPromptConfigs(ctx, configs, options = {}) {
|
|
|
388
434
|
const promotion = { main, withSubagents }
|
|
389
435
|
const source = {
|
|
390
436
|
configs: effectiveList,
|
|
437
|
+
ruleActions: options.ruleActions ?? [],
|
|
391
438
|
officialInstructions: options.officialInstructions === true,
|
|
392
439
|
}
|
|
393
440
|
/** 已交给协调器的注册(仅管理路径非空)。 */
|
|
@@ -444,9 +491,11 @@ export function applyPromptConfigs(ctx, configs, options = {}) {
|
|
|
444
491
|
if (!active) return decision
|
|
445
492
|
return runPreStepBatch({
|
|
446
493
|
ctx,
|
|
494
|
+
payload,
|
|
447
495
|
agent: payload?.agent,
|
|
448
496
|
decision,
|
|
449
497
|
configs: effectiveList,
|
|
498
|
+
ruleActions: source.ruleActions,
|
|
450
499
|
promotion,
|
|
451
500
|
memo: injectedMemo,
|
|
452
501
|
warnOnce,
|
package/engine/fillers.mjs
CHANGED
|
@@ -8,13 +8,20 @@ import { createInstructionHintResolver } from './instruction-hint.mjs'
|
|
|
8
8
|
|
|
9
9
|
const name = 'prompt-config-engine'
|
|
10
10
|
|
|
11
|
+
function explicitTemplate(config) {
|
|
12
|
+
const texts = Array.isArray(config.texts) ? config.texts : typeof config.text === 'string' ? [config.text] : []
|
|
13
|
+
return texts.length > 0 ? texts.join('\n\n') : typeof config.params?.text === 'string' ? config.params.text : ''
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
/**
|
|
12
17
|
* env-facts:机器事实动态填充器。
|
|
13
18
|
* params.envKeys 逗号分隔环境变量白名单:取值归模板/预设,留空 = 不附加宿主环境
|
|
14
19
|
* 变量(CWD/WORKSPACE 仍由会话事实提供)。
|
|
15
|
-
*
|
|
20
|
+
* 只提供 facts 变量;正文必须由 text/params.text 显式模板声明。
|
|
16
21
|
*/
|
|
17
22
|
function createEnvFactsResolver(config) {
|
|
23
|
+
const template = explicitTemplate(config)
|
|
24
|
+
if (template.trim().length === 0) return () => null
|
|
18
25
|
const keys = parseToolNames(typeof config.params?.envKeys === 'string' ? config.params.envKeys : '')
|
|
19
26
|
return ({ agent }) => {
|
|
20
27
|
const session = agent?.session
|
|
@@ -30,8 +37,8 @@ function createEnvFactsResolver(config) {
|
|
|
30
37
|
}
|
|
31
38
|
facts.WORKSPACE = process.env.DSH_WORKSPACE ?? cwd
|
|
32
39
|
facts.CWD = cwd
|
|
33
|
-
const
|
|
34
|
-
return { text:
|
|
40
|
+
const envFacts = Object.entries(facts).map(([key, value]) => `- ${key}=${value}`).join('\n')
|
|
41
|
+
return { text: template, variables: { ...facts, ENV_FACTS: envFacts } }
|
|
35
42
|
}
|
|
36
43
|
}
|
|
37
44
|
|
|
@@ -42,21 +49,21 @@ function createEnvFactsResolver(config) {
|
|
|
42
49
|
* 跳过本配置并告警一次,绝不伤及会话。
|
|
43
50
|
*/
|
|
44
51
|
function createSkillCatalogResolver(config) {
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
52
|
+
const template = explicitTemplate(config)
|
|
53
|
+
if (template.trim().length === 0) return () => null
|
|
54
|
+
const rawLimit = config.params?.limit
|
|
55
|
+
const limit = rawLimit == null || rawLimit === '' ? undefined : rawLimit
|
|
56
|
+
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 0)) throw new TypeError(`${name}: skill-catalog limit must be a non-negative integer`)
|
|
57
|
+
const fields = parseToolNames(typeof config.params?.fields === 'string' ? config.params.fields : '')
|
|
49
58
|
.filter((field) => ['name', 'description', 'whenToUse'].includes(field))
|
|
59
|
+
const whenToUseLabel = typeof config.params?.whenToUseLabel === 'string' ? config.params.whenToUseLabel : ''
|
|
60
|
+
const unnamedLabel = typeof config.params?.unnamedLabel === 'string' ? config.params.unnamedLabel : ''
|
|
50
61
|
const providers = parseToolNames(config.params?.providers)
|
|
51
62
|
const emptyBehavior = config.params?.emptyBehavior === 'text' ? 'text' : 'skip'
|
|
52
63
|
// 空结果提示文案归模板/预设:留空且 emptyBehavior=text 时按 skip 处理(不注入空消息)。
|
|
53
64
|
const emptyText = typeof config.params?.emptyText === 'string' ? config.params.emptyText : ''
|
|
54
65
|
let warned = false
|
|
55
|
-
//
|
|
56
|
-
// 参数传入;换成 shared 的 createWarnOnce(ctx, name) 必须改工厂签名,而唯一
|
|
57
|
-
// 调用点 engine/strategies.mjs 的 createPlaceholderResolver(config) 不在本支
|
|
58
|
-
// 写区。为替换而扩大改动面违反 PLAN,故此处按原语义手写(一次性 + logger
|
|
59
|
-
// 不可用时不抛)。
|
|
66
|
+
// ctx 直到 resolve 时才可用;每个填充器实例只告警一次,不让日志失败伤及会话。
|
|
60
67
|
const warnOnceLocal = (ctx, message) => {
|
|
61
68
|
if (warned) return
|
|
62
69
|
warned = true
|
|
@@ -93,10 +100,10 @@ function createSkillCatalogResolver(config) {
|
|
|
93
100
|
const value = skill?.name
|
|
94
101
|
if (typeof value === 'string' && value.length > 0) return value
|
|
95
102
|
if (typeof skill?.locator === 'string' && skill.locator.length > 0) return skill.locator
|
|
96
|
-
return String(skill?.id ??
|
|
103
|
+
return String(skill?.id ?? unnamedLabel)
|
|
97
104
|
}
|
|
98
105
|
const firstLine = (value) => typeof value === 'string' ? value.split('\n')[0].trim() : ''
|
|
99
|
-
const rows = (limit === 0 ? scoped : scoped.slice(0, limit)).map((skill) => {
|
|
106
|
+
const rows = (limit === undefined || limit === 0 ? scoped : scoped.slice(0, limit)).map((skill) => {
|
|
100
107
|
const parts = []
|
|
101
108
|
if (fields.includes('name')) parts.push(nameOf(skill))
|
|
102
109
|
if (fields.includes('description')) {
|
|
@@ -105,14 +112,14 @@ function createSkillCatalogResolver(config) {
|
|
|
105
112
|
}
|
|
106
113
|
if (fields.includes('whenToUse')) {
|
|
107
114
|
const whenToUse = firstLine(skill?.whenToUse)
|
|
108
|
-
if (whenToUse.length > 0) parts.push(
|
|
115
|
+
if (whenToUse.length > 0) parts.push(`${whenToUseLabel}${whenToUse}`)
|
|
109
116
|
}
|
|
110
117
|
return parts.length === 0 ? '' : `- ${parts.join(': ')}`
|
|
111
118
|
}).filter((line) => line.length > 0)
|
|
112
119
|
const skillsText = rows.join('\n')
|
|
113
120
|
const skillsNames = scoped.map((skill) => nameOf(skill)).join(', ')
|
|
114
121
|
return {
|
|
115
|
-
text:
|
|
122
|
+
text: template,
|
|
116
123
|
variables: {
|
|
117
124
|
SKILL_COUNT: String(total),
|
|
118
125
|
SKILL_NAMES: skillsNames,
|
|
@@ -14,11 +14,14 @@
|
|
|
14
14
|
* - `enabled`: boolean,缺省 false(组合源须显式写 true)。
|
|
15
15
|
* - `promoteOn`: 'either'(缺省)| 'tool-call' | 'assistant-message'。
|
|
16
16
|
* - `includeSubagents`: boolean,缺省 false(子代理首次请求即视为已晋升)。
|
|
17
|
+
* - `messageTemplate` / `suffixTemplate`: 显式转换模板;正文模板为空时不注册转换。
|
|
18
|
+
* - `projectTemplate` / `globalTemplate`: 显式文件探测提示模板,缺省均为空。
|
|
17
19
|
*/
|
|
18
20
|
|
|
19
21
|
import { randomUUID } from 'node:crypto'
|
|
20
22
|
import { readFileSync } from 'node:fs'
|
|
21
23
|
import { createEpochPromotion } from './compaction-epoch.mjs'
|
|
24
|
+
import { interpolateStatic } from './interpolate.mjs'
|
|
22
25
|
import { booleanOption, createWarnOnce, getService, parsePromoteOn, validateConfig } from './shared.mjs'
|
|
23
26
|
|
|
24
27
|
export const name = 'instruction-hint'
|
|
@@ -29,7 +32,7 @@ export const inject = []
|
|
|
29
32
|
/** 项目目录链中按优先级探测的指令文件名。 */
|
|
30
33
|
export const PROJECT_INSTRUCTION_CANDIDATES = ['AGENTS.md', 'CLAUDE.md', 'AGENTS.local.md', 'CLAUDE.local.md']
|
|
31
34
|
export const USER_GLOBAL_INSTRUCTION_CANDIDATE = 'AGENTS.md'
|
|
32
|
-
const
|
|
35
|
+
const templateOf = (templates, key) => typeof templates?.[key] === 'string' ? templates[key] : ''
|
|
33
36
|
|
|
34
37
|
/** Join one path segment onto a directory (platform-agnostic string join). */
|
|
35
38
|
export function joinPath(dir, segment) {
|
|
@@ -108,22 +111,25 @@ export async function collectInstructionFiles(fs, cwd, signal, home) {
|
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
/**
|
|
111
|
-
*
|
|
114
|
+
* 用显式模板格式化探测结果;无文件或无正文模板时返回空字符串。
|
|
112
115
|
* scope 限定只报告某一来源:all(默认)/ global($DSH_HOME/AGENTS.md)/ project(cwd→项目根链)。
|
|
113
116
|
*/
|
|
114
|
-
export function buildInstructionHintText({ root, projectFiles = [], userGlobalFiles = [], userGlobalHome } = {}, scope = 'all') {
|
|
117
|
+
export function buildInstructionHintText({ root, projectFiles = [], userGlobalFiles = [], userGlobalHome } = {}, scope = 'all', templates = {}) {
|
|
115
118
|
const sections = []
|
|
116
119
|
if (scope !== 'global' && projectFiles.length > 0) {
|
|
117
|
-
|
|
120
|
+
const text = interpolateStatic(templateOf(templates, 'projectTemplate'), { FILES: projectFiles.join(', '), ROOT: root ?? '' })
|
|
121
|
+
if (text.trim().length > 0) sections.push(text)
|
|
118
122
|
}
|
|
119
123
|
if (scope !== 'project' && userGlobalFiles.length > 0) {
|
|
120
124
|
const paths = typeof userGlobalHome === 'string' && userGlobalHome.length > 0
|
|
121
125
|
? userGlobalFiles.map((name) => joinPath(userGlobalHome, name))
|
|
122
126
|
: userGlobalFiles
|
|
123
|
-
|
|
127
|
+
const text = interpolateStatic(templateOf(templates, 'globalTemplate'), { FILES: paths.join(', '), ROOT: root ?? '' })
|
|
128
|
+
if (text.trim().length > 0) sections.push(text)
|
|
124
129
|
}
|
|
125
130
|
if (sections.length === 0) return ''
|
|
126
|
-
|
|
131
|
+
const suffix = templateOf(templates, 'suffixTemplate')
|
|
132
|
+
if (suffix.trim().length > 0) sections.push(suffix)
|
|
127
133
|
return sections.join(' ')
|
|
128
134
|
}
|
|
129
135
|
|
|
@@ -137,13 +143,13 @@ function hintScope(value) {
|
|
|
137
143
|
* prompt-config placeholder fill=instruction-hint 的 resolver。
|
|
138
144
|
* params.text(自定义提示)优先;params.file(文件卡绑定的单个指令文件)次之——
|
|
139
145
|
* 运行时读该文件正文并加 `Instructions from:` 头注入;最后按 params.scope 探测来源
|
|
140
|
-
* (all / global / project
|
|
146
|
+
* (all / global / project)按显式 projectTemplate/globalTemplate 生成提示。
|
|
147
|
+
* 未配置提示模板时返回 null;引擎不提供默认引导文案。
|
|
141
148
|
* 文件缺失、不可读或空内容时返回 null,不注入任何消息。
|
|
142
149
|
*/
|
|
143
150
|
export function createInstructionHintResolver(config = {}) {
|
|
144
|
-
const customText = typeof config?.params?.text === 'string'
|
|
145
|
-
? config.
|
|
146
|
-
: ''
|
|
151
|
+
const customText = typeof config?.params?.text === 'string' ? config.params.text.trim()
|
|
152
|
+
: Array.isArray(config.texts) ? config.texts.join('\n\n').trim() : ''
|
|
147
153
|
const scope = hintScope(config?.params?.scope)
|
|
148
154
|
const file = typeof config?.params?.file === 'string' && config.params.file.trim().length > 0
|
|
149
155
|
? config.params.file.trim()
|
|
@@ -171,11 +177,12 @@ export function createInstructionHintResolver(config = {}) {
|
|
|
171
177
|
? { id, text: `Instructions from: ${fileLabel}\n\n${content}`, source: { kind: 'instruction-file', form: 'instructions' } }
|
|
172
178
|
: null
|
|
173
179
|
}
|
|
180
|
+
if (templateOf(config.params, 'projectTemplate').trim().length === 0 && templateOf(config.params, 'globalTemplate').trim().length === 0) return null
|
|
174
181
|
const fs = ctx.get('fs')
|
|
175
182
|
if (fs === undefined) return null
|
|
176
183
|
const cwd = session.header?.cwd ?? process.cwd()
|
|
177
184
|
const found = await collectInstructionFiles(fs, cwd, agent.signal)
|
|
178
|
-
const text = buildInstructionHintText(found, scope)
|
|
185
|
+
const text = buildInstructionHintText(found, scope, config.params)
|
|
179
186
|
return text.length > 0
|
|
180
187
|
? { id, text, source: { kind: 'instruction-hint', form: 'hint' } }
|
|
181
188
|
: null
|
|
@@ -199,36 +206,41 @@ export function extractInstructionPaths(message) {
|
|
|
199
206
|
}
|
|
200
207
|
|
|
201
208
|
/** 构造一次性非命令式 hint 消息;替换已有消息时保留其 id。 */
|
|
202
|
-
export function buildInstructionHint(original, paths, sourceName = 'instruction-hint') {
|
|
209
|
+
export function buildInstructionHint(original, paths, sourceName = 'instruction-hint', templates = {}) {
|
|
210
|
+
const template = templateOf(templates, 'messageTemplate')
|
|
211
|
+
if (template.trim().length === 0) return original
|
|
212
|
+
const text = interpolateStatic(template, { FILES: paths.join(', '), SUFFIX: templateOf(templates, 'suffixTemplate') })
|
|
213
|
+
if (text.trim().length === 0) return original
|
|
203
214
|
return {
|
|
204
215
|
id: typeof original?.id === 'string' && original.id !== '' ? original.id : `instruction-hint-${randomUUID()}`,
|
|
205
216
|
role: 'user',
|
|
206
217
|
content: [{
|
|
207
218
|
type: 'text',
|
|
208
|
-
text
|
|
209
|
-
+ `Reference documents exist: ${paths.join(', ')}. ${REFERENCE_HINT_SUFFIX}`
|
|
210
|
-
+ '\n</system-reminder>',
|
|
219
|
+
text,
|
|
211
220
|
}],
|
|
212
221
|
source: { kind: 'instruction-hint', form: 'hint', plugin: sourceName },
|
|
213
222
|
}
|
|
214
223
|
}
|
|
215
224
|
|
|
216
225
|
/** 将 agent-instructions 全文替换为一次性 hint,后续全文消息丢弃。 */
|
|
217
|
-
export function instructionHintMessages(messages, state, sourceName = 'instruction-hint') {
|
|
226
|
+
export function instructionHintMessages(messages, state, sourceName = 'instruction-hint', templates = {}) {
|
|
227
|
+
if (templateOf(templates, 'messageTemplate').trim().length === 0) return messages
|
|
218
228
|
const kept = []
|
|
219
229
|
for (const message of messages) {
|
|
220
230
|
if (message?.source?.kind !== 'agent-instructions') {
|
|
221
231
|
kept.push(message)
|
|
222
232
|
continue
|
|
223
233
|
}
|
|
224
|
-
if (state.instructionHinted) continue
|
|
225
234
|
const paths = extractInstructionPaths(message)
|
|
226
235
|
if (paths.length === 0) {
|
|
227
|
-
kept.push(message)
|
|
236
|
+
if (!state.instructionHinted) kept.push(message)
|
|
228
237
|
continue
|
|
229
238
|
}
|
|
239
|
+
const hint = buildInstructionHint(message, paths, sourceName, templates)
|
|
240
|
+
if (hint === message) { kept.push(message); continue }
|
|
241
|
+
if (state.instructionHinted) continue
|
|
230
242
|
state.instructionHinted = true
|
|
231
|
-
kept.push(
|
|
243
|
+
kept.push(hint)
|
|
232
244
|
}
|
|
233
245
|
return kept
|
|
234
246
|
}
|
|
@@ -236,7 +248,7 @@ export function instructionHintMessages(messages, state, sourceName = 'instructi
|
|
|
236
248
|
// ── plugin 形态 ─────────────────────────────────────────────────────────────
|
|
237
249
|
|
|
238
250
|
/** Every config key this plugin accepts — anything else is a typo. */
|
|
239
|
-
const ALLOWED_KEYS = new Set(['enabled', 'promoteOn', 'includeSubagents'])
|
|
251
|
+
const ALLOWED_KEYS = new Set(['enabled', 'promoteOn', 'includeSubagents', 'projectTemplate', 'globalTemplate', 'suffixTemplate', 'messageTemplate'])
|
|
240
252
|
|
|
241
253
|
/** The visible surface, not the append-only log, owns hint lifetime. */
|
|
242
254
|
function hasVisibleInstructionHint(session) {
|
|
@@ -258,7 +270,7 @@ export function apply(ctx, config) {
|
|
|
258
270
|
const includeSubagents = booleanOption(name, source.includeSubagents, 'includeSubagents', false)
|
|
259
271
|
// 开关语义:未声明 = 关闭(组合源为本模块显式写 enabled: true)。
|
|
260
272
|
const enabled = booleanOption(name, source.enabled, 'enabled', false)
|
|
261
|
-
if (!enabled) return
|
|
273
|
+
if (!enabled || templateOf(source, 'messageTemplate').trim().length === 0) return
|
|
262
274
|
|
|
263
275
|
const promotion = createEpochPromotion(promoteEvents, { includeSubagents })
|
|
264
276
|
const warnOnce = createWarnOnce(ctx, name)
|
|
@@ -279,7 +291,7 @@ export function apply(ctx, config) {
|
|
|
279
291
|
// 1 换 1 的转换不能按长度判断(长度相同仍可能已转换),
|
|
280
292
|
// instructionHintMessages 本身保留非目标消息,直接采用结果。
|
|
281
293
|
const hintState = { instructionHinted: hasVisibleInstructionHint(agent.session) }
|
|
282
|
-
return { ...decision, messages: instructionHintMessages(messages, hintState, name) }
|
|
294
|
+
return { ...decision, messages: instructionHintMessages(messages, hintState, name, source) }
|
|
283
295
|
} catch (error) {
|
|
284
296
|
// 转换失败不阻断会话:保留原消息。
|
|
285
297
|
warnOnce(`${name}: instruction hint conversion failed, keeping messages: ${String((error && error.message) || error)}`)
|