dsh-plugin-prompt-tool 3.0.1 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -1
- package/engine/actions/catalog.mjs +2 -1
- package/engine/actions/index.mjs +5 -3
- package/engine/actions/shared.mjs +26 -12
- package/engine/actions/tool-visibility.mjs +31 -14
- package/engine/compositions/source/local/context-provenance.yml +9 -0
- package/engine/compositions/source/local/generation-recovery.yml +10 -0
- package/engine/compositions/source/local/retrieval.yml +7 -0
- package/engine/compositions/source/local/soul-ledger.yml +3 -0
- package/engine/compositions/source/local/soul-maintenance.yml +9 -0
- package/engine/conditions/examples.mjs +1 -0
- package/engine/conditions/index.mjs +7 -0
- package/engine/conditions/manifest.mjs +28 -0
- package/engine/context-idle-compaction.mjs +58 -0
- package/engine/context-provenance.d.mts +7 -0
- package/engine/context-provenance.mjs +156 -0
- package/engine/context-pruner.mjs +64 -0
- package/engine/context-routing.mjs +45 -0
- package/engine/context-summary.mjs +80 -0
- package/engine/fillers.mjs +37 -4
- package/engine/generation-recovery-coordinator.mjs +40 -0
- package/engine/generation-recovery-maintenance.mjs +18 -0
- package/engine/generation-recovery.mjs +153 -0
- package/engine/host-package.mjs +13 -0
- package/engine/layers.d.mts +4 -0
- package/engine/layers.mjs +57 -20
- package/engine/present-as.mjs +2 -2
- package/engine/retrieval-reference.d.mts +5 -0
- package/engine/retrieval-reference.mjs +26 -0
- package/engine/retrieval.mjs +17 -0
- package/engine/rule-engine.mjs +1 -0
- package/engine/rule-runtime.d.mts +8 -1
- package/engine/rule-runtime.mjs +18 -10
- package/engine/rule-spec.mjs +4 -2
- package/engine/schema.mjs +1 -1
- package/engine/service-names.d.mts +5 -0
- package/engine/service-names.mjs +5 -0
- package/engine/soul-ledger.mjs +8 -0
- package/engine/soul-maintenance.mjs +20 -0
- package/lib/client.js +16 -6
- package/lib/client.js.map +1 -1
- package/lib/index.mjs +1953 -246
- package/modules/soul/assets/ledger.md +4 -2
- package/modules/soul/assets/manifest.yml +2 -0
- package/modules/soul/assets/reference/00-layers.md +27 -27
- package/modules/soul/assets/reference/02-echo.md +161 -161
- package/modules/soul/assets/reference/05-doctor.md +67 -67
- package/modules/soul/assets/reference/10-echo-anatomy.md +109 -109
- package/modules/soul/assets/reference/ECHO-/346/235/203/345/250/201/345/217/243/345/276/204.md +202 -202
- package/modules/soul/assets/reference/ECHO/345/205/255/345/261/202/344/270/216mnemon/345/215/217/345/220/214/350/257/264/346/230/216/344/271/246.md +229 -229
- package/modules/soul/assets/templates/agents-template.md +3 -0
- package/modules/soul/assets/templates/self-template.md +19 -0
- package/modules/soul/module.yml +71 -13
- package/modules/tool-surface/module.yml +8 -5
- package/package.json +1 -1
- package/skills/dsh-module/reference.md +2 -2
- package/templates/80-tool-surface.yml +8 -7
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { makeSessionReference } from './retrieval-reference.mjs'
|
|
2
|
+
|
|
3
|
+
const PRUNE_MARKER = '\n\n[... tool result middle pruned ...]\n\n'
|
|
4
|
+
|
|
5
|
+
/** 官方裁剪算法不变,只在本次生成marker的Unicode位置附来源。 */
|
|
6
|
+
function referencedContent(content, headChars, reference) {
|
|
7
|
+
const result = [...content]
|
|
8
|
+
let consumed = 0
|
|
9
|
+
for (let index = 0; index < result.length; index++) {
|
|
10
|
+
const block = result[index]
|
|
11
|
+
if (block.type !== 'text') continue
|
|
12
|
+
const points = [...block.text]
|
|
13
|
+
if (consumed + points.length <= headChars) { consumed += points.length; continue }
|
|
14
|
+
const offset = headChars - consumed
|
|
15
|
+
if (points.slice(offset, offset + [...PRUNE_MARKER].length).join('') !== PRUNE_MARKER) return null
|
|
16
|
+
result[index] = { ...block, text: points.slice(0, offset).join('') + PRUNE_MARKER + `[source: ${JSON.stringify(reference)}]\n` + points.slice(offset + [...PRUNE_MARKER].length).join('') }
|
|
17
|
+
return result
|
|
18
|
+
}
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createProvenancePruner(PrunerClass, { sourceModuleId, isActive = () => true, signalOf = () => undefined, makeReference = makeSessionReference } = {}) {
|
|
23
|
+
return class ProvenancePruner extends PrunerClass {
|
|
24
|
+
pruneSession(session, signal = signalOf()) {
|
|
25
|
+
const check = () => {
|
|
26
|
+
signal?.throwIfAborted()
|
|
27
|
+
if (!isActive()) throw new Error('context-provenance: pruner released')
|
|
28
|
+
}
|
|
29
|
+
check()
|
|
30
|
+
const events = session.snapshotEvents()
|
|
31
|
+
const bySeq = new Map(events.map(event => [event.seq, event]))
|
|
32
|
+
const candidates = [...new Set(session.surface.nodes)].map(seq => bySeq.get(seq)).filter(event => event?.type === 'tool/result')
|
|
33
|
+
const pruned = []
|
|
34
|
+
let charsRemoved = 0
|
|
35
|
+
for (const event of candidates) {
|
|
36
|
+
check()
|
|
37
|
+
const seq = event.seq, original = session.deriveEventMessage(event)
|
|
38
|
+
let content = super.pruneContent(original.content)
|
|
39
|
+
if (content === null) continue
|
|
40
|
+
const charsBefore = this.measureContent(original.content)
|
|
41
|
+
try {
|
|
42
|
+
const reference = makeReference(sourceModuleId, session.id, events, [seq])
|
|
43
|
+
const cited = referencedContent(content, this.config.headChars, reference)
|
|
44
|
+
if (!cited || this.measureContent(cited) > this.config.thresholdChars || this.measureContent(cited) >= charsBefore) throw new Error('reference exceeds pruning budget')
|
|
45
|
+
content = cited
|
|
46
|
+
} catch (error) {
|
|
47
|
+
this.ctx.logger?.warn?.(`context-provenance: pruned without reference (${String(error?.message ?? error)})`)
|
|
48
|
+
}
|
|
49
|
+
const charsAfter = this.measureContent(content)
|
|
50
|
+
if (charsAfter > this.config.thresholdChars || charsAfter >= charsBefore) throw new Error('context-provenance: invalid prune result')
|
|
51
|
+
const shadowedTokenCount = this.ctx.tokenMeter.estimateMessage(original)
|
|
52
|
+
check()
|
|
53
|
+
session.append('compaction/prune', { shadowedRange: { start: seq, end: seq }, shadowedSeqs: [seq], shadowedTokenCount })
|
|
54
|
+
check()
|
|
55
|
+
const replacement = session.append('tool/result', { ...event.data, message: { ...original, content } }, {
|
|
56
|
+
surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq }, sourceEventSeqs: [seq],
|
|
57
|
+
})
|
|
58
|
+
pruned.push({ originalSeq: seq, replacementSeq: replacement.seq, callId: original.source.callId, charsBefore, charsAfter })
|
|
59
|
+
charsRemoved += charsBefore - charsAfter
|
|
60
|
+
}
|
|
61
|
+
return { pruned, charsRemoved }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Context } from '@deepseek-ai/cordis'
|
|
2
|
+
|
|
3
|
+
/** 只路由声明Agent,原provider的方法和其它监听器保持原所有权。 */
|
|
4
|
+
export function routeContextRuntime(ctx, agent, runtime) {
|
|
5
|
+
let active = true
|
|
6
|
+
const disposers = []
|
|
7
|
+
for (const service of ['compaction', 'toolResultPruner']) {
|
|
8
|
+
if (!runtime[service]) continue
|
|
9
|
+
const dependency = ctx.inject([service], dependent => {
|
|
10
|
+
// 已发布Fiber.store公开了这次依赖快照的精确provider身份。
|
|
11
|
+
const owner = dependent.fiber.store[service].fiber
|
|
12
|
+
const carriers = new WeakMap()
|
|
13
|
+
const offDispatch = service === 'compaction' ? dependent.on('internal/dispatch', (_mode, event, args, carrier) => {
|
|
14
|
+
if (!active || !['agent/pre-step', 'agent/request-error', 'agent/request'].includes(event)
|
|
15
|
+
|| args[0]?.agent !== agent || !carrier) return
|
|
16
|
+
if (carriers.has(carrier)) return
|
|
17
|
+
const original = carrier[Context.filter]
|
|
18
|
+
const filter = function (candidate) {
|
|
19
|
+
return (!original || original.call(this, candidate)) && (!active || candidate.fiber !== owner)
|
|
20
|
+
}
|
|
21
|
+
carriers.set(carrier, original)
|
|
22
|
+
carrier[Context.filter] = filter
|
|
23
|
+
}) : () => {}
|
|
24
|
+
const offGet = dependent.on('internal/get', (_consumer, key, _error, next) => {
|
|
25
|
+
const original = next()
|
|
26
|
+
if (!active || key !== service || !original) return original
|
|
27
|
+
return new Proxy(original, {
|
|
28
|
+
get(target, property) {
|
|
29
|
+
const method = target[property]
|
|
30
|
+
if (typeof method !== 'function') return method
|
|
31
|
+
return (...args) => {
|
|
32
|
+
const subject = service === 'toolResultPruner' ? args[0] : property === 'compactRegion' ? args[2] : args[0]
|
|
33
|
+
const selected = service === 'toolResultPruner' ? subject === agent.session : subject === agent
|
|
34
|
+
if (active && selected && typeof runtime[service][property] === 'function') return runtime[service][property](...args)
|
|
35
|
+
return method.apply(target, args)
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
}, { prepend: true })
|
|
40
|
+
return () => { offDispatch(); offGet() }
|
|
41
|
+
})
|
|
42
|
+
disposers.push(() => dependency.dispose())
|
|
43
|
+
}
|
|
44
|
+
return () => { active = false; for (const dispose of disposers) void dispose() }
|
|
45
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { makeSessionReference } from './retrieval-reference.mjs'
|
|
2
|
+
|
|
3
|
+
// shortcut: Basic alpha.2 未公开导出frameSummary,升级官方framing时同步此版本化快照。
|
|
4
|
+
const CHECKPOINT_OPEN = 'This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n<compacted-summary>'
|
|
5
|
+
export function frameSummary(summary) {
|
|
6
|
+
return [{ type: 'text', text: CHECKPOINT_OPEN }, ...summary, { type: 'text', text: '</compacted-summary>' }]
|
|
7
|
+
}
|
|
8
|
+
const framed = summary => ({
|
|
9
|
+
id: 'context-provenance-budget', role: 'user', source: { kind: 'compact-checkpoint', compactionId: 'context-provenance-budget' },
|
|
10
|
+
content: frameSummary(summary),
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
function sourceSnapshot(input, session, meter) {
|
|
14
|
+
const events = session.snapshotEvents(), bySeq = new Map(events.map(event => [event.seq, event]))
|
|
15
|
+
const nodes = session.surface.nodes.map(seq => {
|
|
16
|
+
const event = bySeq.get(seq)
|
|
17
|
+
if (!event) throw new Error('source event missing')
|
|
18
|
+
return { seq, message: session.deriveEventMessage(event) }
|
|
19
|
+
})
|
|
20
|
+
let messages = input.messages
|
|
21
|
+
const head = nodes[0]?.message
|
|
22
|
+
// Basic 把system头作为缓存前缀再加一次;真正选中system时后面仍保留它。
|
|
23
|
+
if (head?.role === 'system' && messages[0]?.id === head.id) messages = messages.slice(1)
|
|
24
|
+
if (!messages.length || messages.some(message => typeof message.id !== 'string' || !message.id)) throw new Error('source message identity unavailable')
|
|
25
|
+
const matches = []
|
|
26
|
+
for (let start = 0; start < nodes.length; start++) {
|
|
27
|
+
if (nodes[start].message?.id !== messages[0].id) continue
|
|
28
|
+
let position = 0
|
|
29
|
+
for (let end = start; end < nodes.length; end++) {
|
|
30
|
+
const message = nodes[end].message
|
|
31
|
+
if (message !== null) {
|
|
32
|
+
if (message?.id !== messages[position]?.id) break
|
|
33
|
+
position++
|
|
34
|
+
}
|
|
35
|
+
if (position !== messages.length) continue
|
|
36
|
+
// 空投影可能属于区间也可能不属于;没有真实事务端点时不猜。
|
|
37
|
+
if (nodes[start - 1]?.message === null || nodes[end + 1]?.message === null) throw new Error('source span ambiguous')
|
|
38
|
+
matches.push(nodes.slice(start, end + 1).map(node => node.seq))
|
|
39
|
+
break
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (matches.length !== 1) throw new Error('source span unavailable or ambiguous')
|
|
43
|
+
const seqs = matches[0], measured = meter.measure(session).nodes
|
|
44
|
+
if (measured.length !== nodes.length || measured.some((node, index) => node.seq !== nodes[index].seq)) throw new Error('source token surface changed')
|
|
45
|
+
const selected = new Set(seqs)
|
|
46
|
+
const tokens = measured.filter(node => selected.has(node.seq)).reduce((total, node) => total + node.tokens, 0)
|
|
47
|
+
if (!Number.isFinite(tokens) || tokens <= 0) throw new Error('source token budget unavailable')
|
|
48
|
+
return { events, seqs, tokens }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createProvenanceCompaction(BasicClass, { sourceModuleId, isActive = () => true, signalOf = () => undefined, makeReference = makeSessionReference } = {}) {
|
|
52
|
+
return class ProvenanceCompaction extends BasicClass {
|
|
53
|
+
async summarize(input, agent, signal = signalOf(agent)) {
|
|
54
|
+
const check = () => {
|
|
55
|
+
signal?.throwIfAborted()
|
|
56
|
+
if (!isActive(agent)) throw new DOMException('context-provenance: compaction released', 'AbortError')
|
|
57
|
+
}
|
|
58
|
+
const warn = message => { try { this.ctx.logger?.warn?.('context-provenance: summary without reference (' + message + ')') } catch { /* 诊断不能使原本可用的压缩失败。 */ } }
|
|
59
|
+
check()
|
|
60
|
+
let snapshot
|
|
61
|
+
try { snapshot = sourceSnapshot(input, agent.session, this.ctx.tokenMeter) } catch (error) { warn(String(error?.message ?? error)) }
|
|
62
|
+
const result = await super.summarize(input, agent, signal)
|
|
63
|
+
check()
|
|
64
|
+
if (!snapshot) return result
|
|
65
|
+
try {
|
|
66
|
+
const reference = makeReference(sourceModuleId, agent.session.id, snapshot.events, snapshot.seqs)
|
|
67
|
+
const summary = [{ type: 'text', text: `[source: ${JSON.stringify(reference)}]\n` }, ...result.summary]
|
|
68
|
+
const tokens = this.ctx.tokenMeter.estimateMessage(framed(summary))
|
|
69
|
+
if (!Number.isFinite(tokens) || tokens >= snapshot.tokens) {
|
|
70
|
+
warn('reference exceeds shrink budget')
|
|
71
|
+
return result
|
|
72
|
+
}
|
|
73
|
+
return { ...result, summary }
|
|
74
|
+
} catch (error) {
|
|
75
|
+
warn(String(error?.message ?? error))
|
|
76
|
+
return result
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
package/engine/fillers.mjs
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* fillers — 引擎内置的动态填充器(strategy=placeholder 的数据源)。
|
|
3
|
-
*
|
|
3
|
+
* 按本次求值读取宿主能力;失败由填充器或调用方诊断并跳过该配置。
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { getService, parseToolNames } from './shared.mjs'
|
|
7
|
+
import { toolScopeSnapshot } from './tool-scope.mjs'
|
|
7
8
|
import { machinePaths } from './machine-paths.mjs'
|
|
8
9
|
import { SAFE_ENV_KEYS } from './env-safety.mjs'
|
|
9
10
|
import { createInstructionHintResolver } from './instruction-hint.mjs'
|
|
10
|
-
import { DISCOVERY_SERVICE } from './service-names.mjs'
|
|
11
|
+
import { DISCOVERY_SERVICE, SOUL_SELF_SERVICE } from './service-names.mjs'
|
|
11
12
|
|
|
12
13
|
const name = 'prompt-config-engine'
|
|
13
14
|
|
|
@@ -16,7 +17,22 @@ function explicitTemplate(config) {
|
|
|
16
17
|
return texts.length > 0 ? texts.join('\n\n') : typeof config.params?.text === 'string' ? config.params.text : ''
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* 只读同规则工具面声明;文案、条件和投递仍归普通注入动作。
|
|
22
|
+
* RESIDENT_TOOLS 只列**当前可见**的常驻工具:部署层名单可以登记替换提供方(如 dsh-ops 的
|
|
23
|
+
* ops_bash),但该插件未必安装;向模型宣告一个不存在的常驻工具会把「解锁」变成错误前提。
|
|
24
|
+
* 观测到工具面但一个常驻名都不在时(requireMatch 全缺/放宽场景)跳过提示;
|
|
25
|
+
* 观测不到工具面(服务缺失或读取失败)才回落声明名单——「没看到」不等于「都没有」。
|
|
26
|
+
*/
|
|
27
|
+
function visibleToolNames(ctx, agent) {
|
|
28
|
+
try {
|
|
29
|
+
const tools = getService(ctx, 'tools')
|
|
30
|
+
if (tools === undefined || typeof tools.schemas !== 'function') return undefined
|
|
31
|
+
return new Set(toolScopeSnapshot(tools, agent).visible.map((schema) => schema?.name))
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined
|
|
34
|
+
}
|
|
35
|
+
}
|
|
20
36
|
function createToolCatalogResolver(config) {
|
|
21
37
|
if (config.params?.templateEvaluation === true) throw new TypeError('tool-catalog is read-only and does not support templateEvaluation')
|
|
22
38
|
if (typeof config.params?.action !== 'string' || config.params.action.trim().length === 0) {
|
|
@@ -28,12 +44,20 @@ function createToolCatalogResolver(config) {
|
|
|
28
44
|
const source = config.toolCatalogSource
|
|
29
45
|
if (source === undefined) throw new TypeError('tool-catalog requires a same-rule tool-visibility action')
|
|
30
46
|
const deny = new Set(source.deny ?? [])
|
|
31
|
-
const
|
|
47
|
+
const declared = [...new Set(source.allow)].filter(tool => !deny.has(tool))
|
|
32
48
|
const adapter = source.discovery === undefined ? undefined
|
|
33
49
|
: getService(ctx, DISCOVERY_SERVICE)?.get?.(agent, source.discovery.kind)
|
|
34
50
|
if (source.discovery !== undefined && typeof adapter?.tool !== 'string') {
|
|
35
51
|
throw new Error(`tool-catalog discovery adapter ${source.discovery.kind} is unavailable`)
|
|
36
52
|
}
|
|
53
|
+
let resident = declared
|
|
54
|
+
const visible = visibleToolNames(ctx, agent)
|
|
55
|
+
if (visible !== undefined) {
|
|
56
|
+
const present = declared.filter((name) => visible.has(name))
|
|
57
|
+
// 观测到全缺时不宣告任何常驻工具:与 requireMatch 放宽同源,避免「解锁」建在错误前提上。
|
|
58
|
+
if (present.length === 0) return null
|
|
59
|
+
resident = present
|
|
60
|
+
}
|
|
37
61
|
return { text, variables: { RESIDENT_TOOLS: resident.join(', '), DISCOVERY_TOOL: adapter?.tool ?? '' } }
|
|
38
62
|
}
|
|
39
63
|
}
|
|
@@ -163,12 +187,21 @@ function createSkillCatalogResolver(config) {
|
|
|
163
187
|
}
|
|
164
188
|
}
|
|
165
189
|
|
|
190
|
+
function createSoulSelfResolver(config) {
|
|
191
|
+
return ({ ctx, agent, signal }) => {
|
|
192
|
+
const service = getService(ctx, SOUL_SELF_SERVICE)
|
|
193
|
+
if (typeof service?.render !== 'function') throw new Error('SELF dependency missing')
|
|
194
|
+
return { text: service.render(config.sourceModuleId, agent, signal) }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
166
198
|
/** 动态填充器:strategy=placeholder 时按 fill 键在注入点填充内容。 */
|
|
167
199
|
export const FILLERS = {
|
|
168
200
|
'instruction-hint': (config) => createInstructionHintResolver(config),
|
|
169
201
|
'env-facts': (config) => createEnvFactsResolver(config),
|
|
170
202
|
'skill-catalog': (config) => createSkillCatalogResolver(config),
|
|
171
203
|
'tool-catalog': (config) => createToolCatalogResolver(config),
|
|
204
|
+
'soul-self': (config) => createSoulSelfResolver(config),
|
|
172
205
|
}
|
|
173
206
|
|
|
174
207
|
export function createPlaceholderResolver(config) {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { historyEvents } from './history.mjs'
|
|
2
|
+
|
|
3
|
+
const visible = content => Array.isArray(content) && content.some(block => block.type !== 'reasoning' && (block.type !== 'text' || block.text?.trim()))
|
|
4
|
+
|
|
5
|
+
/** 候选只在 durable turn/end 后发布;流与结算共用官方 turn/step。 */
|
|
6
|
+
export function recoveryCoordinator(ctx, agent, config, options, isActive) {
|
|
7
|
+
let frame
|
|
8
|
+
let emitted
|
|
9
|
+
const offRequest = ctx.on('agent/request', (payload, next) => {
|
|
10
|
+
if (payload.agent !== agent) return next()
|
|
11
|
+
frame = { turn: payload.turn, step: payload.step, signal: payload.signal, visible: false }
|
|
12
|
+
return next()
|
|
13
|
+
})
|
|
14
|
+
const offEvent = ctx.on('session/event', (session, event) => {
|
|
15
|
+
if (!isActive() || session.id !== agent.session.id || !frame) return
|
|
16
|
+
const data = event.data ?? {}
|
|
17
|
+
if (event.type === 'assistant/message' && data.turn === frame.turn) {
|
|
18
|
+
if (data.step !== frame.step) return
|
|
19
|
+
frame.visible = visible(data.message?.content)
|
|
20
|
+
if (data.interrupted) frame.interrupted = true
|
|
21
|
+
}
|
|
22
|
+
if (event.type !== 'turn/end' || data.turn !== frame.turn || frame.signal?.aborted || frame.interrupted) return
|
|
23
|
+
if (data.reason?.kind !== 'completed' && data.reason?.kind !== 'no-visible-output') return
|
|
24
|
+
const execution = `${frame.turn}:${frame.step}`
|
|
25
|
+
if (execution === emitted || (!frame.reason && (!config.recoverEmpty || frame.visible))) return
|
|
26
|
+
emitted = execution
|
|
27
|
+
const candidate = { sessionId: session.id, execution, turn: frame.turn, step: frame.step, reason: frame.reason ?? 'empty-final-step', signal: frame.signal }
|
|
28
|
+
options.onRecoveryCandidate?.(candidate)
|
|
29
|
+
options.recover?.(candidate)
|
|
30
|
+
})
|
|
31
|
+
return {
|
|
32
|
+
mark(reason) { if (frame && !frame.signal?.aborted) frame.reason = reason },
|
|
33
|
+
dispose() { offRequest(); offEvent() },
|
|
34
|
+
// 迟到idle回调不能跨过新的用户执行边界。
|
|
35
|
+
unchanged(candidate) {
|
|
36
|
+
const latest = historyEvents(agent.session).findLast(event => event.type === 'turn/start')
|
|
37
|
+
return !latest || latest.data?.turn === candidate.turn
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** 维护窗口与范围选择归上下文runtime,这里只决定是否允许扩大范围。 */
|
|
2
|
+
export async function recoveryMaintenance(runtime, signal, allowFullFallback, valid, report) {
|
|
3
|
+
if (!runtime) { report('context runtime unavailable; recovery stopped'); return null }
|
|
4
|
+
let result
|
|
5
|
+
try {
|
|
6
|
+
result = await runtime.compactRecovery(signal)
|
|
7
|
+
} catch (error) {
|
|
8
|
+
if (!valid() || error?.name === 'AbortError' || error?.code === 'ABORTED') return null
|
|
9
|
+
if (!allowFullFallback) { report(`regional maintenance failed; recovery stopped (${String(error?.message ?? error)})`); return null }
|
|
10
|
+
result = await runtime.compactNow(signal)
|
|
11
|
+
}
|
|
12
|
+
if (!valid()) return null
|
|
13
|
+
if (result === null || !Array.isArray(result?.shadowedSeqs) || result.shadowedSeqs.length === 0) {
|
|
14
|
+
report('maintenance no-op; recovery stopped')
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
return result
|
|
18
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { bool, defineConfig, enumOf, int, passthrough } from './fields.mjs'
|
|
2
|
+
import { capabilityAudience } from './agent-capability.mjs'
|
|
3
|
+
import { recoveryCoordinator } from './generation-recovery-coordinator.mjs'
|
|
4
|
+
import { pluginMessage } from './layers.mjs'
|
|
5
|
+
import { recoveryMaintenance } from './generation-recovery-maintenance.mjs'
|
|
6
|
+
|
|
7
|
+
export const name = 'generation-recovery'
|
|
8
|
+
export const inject = ['llm']
|
|
9
|
+
|
|
10
|
+
const recoveryLimit = passthrough((value, plugin, field) => {
|
|
11
|
+
if (value === undefined) return 0
|
|
12
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > 1000) throw new TypeError(`${plugin}: ${field} must be an integer from 0 to 1000`)
|
|
13
|
+
return value
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
export const configContract = defineConfig({
|
|
17
|
+
enabled: bool({ default: false }),
|
|
18
|
+
audience: enumOf(['all', 'main', 'subagent'], { default: 'all' }),
|
|
19
|
+
textRepeatChars: int({ min: 3, default: 4096 }),
|
|
20
|
+
reasoningRepeatChars: int({ min: 3, default: 8192 }),
|
|
21
|
+
silentReasoningChars: int({ min: 0, default: 0 }),
|
|
22
|
+
recoverEmpty: bool({ default: false }),
|
|
23
|
+
mainRecoveryLimit: recoveryLimit,
|
|
24
|
+
subagentRecoveryLimit: recoveryLimit,
|
|
25
|
+
maintenanceCompression: bool({ default: false }),
|
|
26
|
+
allowFullFallback: bool({ default: false }),
|
|
27
|
+
maxCycleChars: passthrough((value, plugin, field) => {
|
|
28
|
+
if (value === undefined) return 256
|
|
29
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > 1024) throw new TypeError(`${plugin}: ${field} must be an integer from 1 to 1024`)
|
|
30
|
+
return value
|
|
31
|
+
}),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// 逐 Unicode 字符比较周期,内存只随最长周期增长,不保留整段推理。
|
|
35
|
+
function repeatDetector(threshold, maxCycle) {
|
|
36
|
+
const tail = new Array(maxCycle)
|
|
37
|
+
const matches = new Float64Array(maxCycle + 1)
|
|
38
|
+
let seen = 0
|
|
39
|
+
return text => {
|
|
40
|
+
let offset = 0
|
|
41
|
+
for (const char of text) {
|
|
42
|
+
offset += char.length
|
|
43
|
+
let repeated = false
|
|
44
|
+
for (let period = 1; period <= Math.min(seen, maxCycle); period++) {
|
|
45
|
+
const needed = Math.max(threshold - period, 2 * period)
|
|
46
|
+
matches[period] = tail[(seen - period) % maxCycle] === char ? Math.min(matches[period] + 1, needed) : 0
|
|
47
|
+
if (matches[period] >= needed) repeated = true
|
|
48
|
+
}
|
|
49
|
+
tail[seen % maxCycle] = char
|
|
50
|
+
seen++
|
|
51
|
+
if (repeated) return offset
|
|
52
|
+
}
|
|
53
|
+
return undefined
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function apply(ctx, raw, options = {}) {
|
|
58
|
+
const { agent } = options
|
|
59
|
+
const config = configContract.parse(raw, name)
|
|
60
|
+
if (!config.enabled || typeof agent?.session?.id !== 'string') return
|
|
61
|
+
if (config.audience !== 'all' && config.audience !== capabilityAudience(agent)) return
|
|
62
|
+
let active = true
|
|
63
|
+
const report = message => ctx.logger?.warn?.(`${name}: ${message}`)
|
|
64
|
+
const limit = capabilityAudience(agent) === 'subagent' ? config.subagentRecoveryLimit : config.mainRecoveryLimit
|
|
65
|
+
const coordinator = recoveryCoordinator(ctx, agent, config, { ...options, recover(candidate) {
|
|
66
|
+
if (!limit) return
|
|
67
|
+
if (!options.recoveryState) { report('persistent recovery state unavailable; automatic recovery disabled'); return }
|
|
68
|
+
try {
|
|
69
|
+
const claimed = options.recoveryState.claim(agent.session.id, candidate.execution, limit)
|
|
70
|
+
if (!claimed.claimed) { report(`recovery unavailable (${claimed.used}/${limit})`); return }
|
|
71
|
+
void (async () => {
|
|
72
|
+
try {
|
|
73
|
+
await agent.whenIdle()
|
|
74
|
+
const valid = () => active && !candidate.signal?.aborted && coordinator.unchanged(candidate)
|
|
75
|
+
if (!valid()) return
|
|
76
|
+
let maintenance
|
|
77
|
+
if (config.maintenanceCompression) {
|
|
78
|
+
maintenance = await recoveryMaintenance(options.contextRuntimeOf?.(agent), candidate.signal, config.allowFullFallback, valid, report)
|
|
79
|
+
if (!maintenance) return
|
|
80
|
+
}
|
|
81
|
+
if (!valid()) return
|
|
82
|
+
const text = '上一轮输出因异常重复或仅有推理而未完成。请停止重复,检查已完成的工作,并给出有效结果或明确说明阻塞原因。'
|
|
83
|
+
+ (maintenance ? `\n维护压缩已完成,实际来源事件:${maintenance.shadowedSeqs.join(',')}。` : '')
|
|
84
|
+
agent.followup(pluginMessage('generation-recovery', text, '恢复异常生成'))
|
|
85
|
+
} catch (error) { report(String(error?.message ?? error)) }
|
|
86
|
+
})()
|
|
87
|
+
} catch (error) { report(String(error?.message ?? error)) }
|
|
88
|
+
} }, () => active)
|
|
89
|
+
const stop = ctx.effect(() => () => { active = false })
|
|
90
|
+
const off = ctx.on('llm/stream', (options, next) => {
|
|
91
|
+
if (!active || options.signal?.aborted || options.purpose !== undefined || options.sessionId !== agent.session.id) return next()
|
|
92
|
+
return (async function* () {
|
|
93
|
+
const detectors = {
|
|
94
|
+
text: repeatDetector(config.textRepeatChars, config.maxCycleChars),
|
|
95
|
+
reasoning: repeatDetector(config.reasoningRepeatChars, config.maxCycleChars),
|
|
96
|
+
}
|
|
97
|
+
const open = new Map()
|
|
98
|
+
const closed = new Set()
|
|
99
|
+
const finalized = new Set()
|
|
100
|
+
let hasToolCall = false
|
|
101
|
+
let visibleText = false
|
|
102
|
+
let reasoningChars = 0
|
|
103
|
+
for await (const chunk of next()) {
|
|
104
|
+
if (finalized.has(chunk.index)) continue
|
|
105
|
+
if (!active || options.signal?.aborted) { yield chunk; continue }
|
|
106
|
+
if (chunk.type === 'tool-call-delta' || chunk.blockType === 'tool-call' || chunk.block?.type === 'tool-call') hasToolCall = true
|
|
107
|
+
if (chunk.type === 'block-start') open.set(chunk.index, { type: chunk.blockType, text: '' })
|
|
108
|
+
if (chunk.type === 'block-end') { open.delete(chunk.index); closed.add(chunk.index) }
|
|
109
|
+
const kind = chunk.type === 'text-delta' ? 'text' : chunk.type === 'reasoning-delta' ? 'reasoning' : undefined
|
|
110
|
+
if (kind === undefined || closed.has(chunk.index)) { yield chunk; continue }
|
|
111
|
+
if (!open.has(chunk.index)) open.set(chunk.index, { type: kind, text: '' })
|
|
112
|
+
// 已闭合工具也要保留原始finish语义,不把工具回合伪装成普通stop。
|
|
113
|
+
const safe = !hasToolCall && [...open.values()].every(block => block.type === 'text' || block.type === 'reasoning')
|
|
114
|
+
if (kind === 'text' && chunk.text.trim()) visibleText = true
|
|
115
|
+
let cutoff = safe ? detectors[kind](chunk.text) : undefined
|
|
116
|
+
let cause = 'repeated-' + kind
|
|
117
|
+
if (safe && kind === 'reasoning' && !visibleText && config.silentReasoningChars > 0) {
|
|
118
|
+
const chars = [...chunk.text]
|
|
119
|
+
const remaining = config.silentReasoningChars - reasoningChars
|
|
120
|
+
reasoningChars += chars.length
|
|
121
|
+
if (remaining > 0 && remaining <= chars.length) {
|
|
122
|
+
const silentCutoff = chars.slice(0, remaining).join('').length
|
|
123
|
+
if (cutoff === undefined || silentCutoff < cutoff) { cutoff = silentCutoff; cause = 'silent-reasoning' }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const text = cutoff === undefined ? chunk.text : chunk.text.slice(0, cutoff)
|
|
127
|
+
const block = open.get(chunk.index)
|
|
128
|
+
if (block !== undefined) block.text += text
|
|
129
|
+
yield cutoff === undefined ? chunk : { ...chunk, text }
|
|
130
|
+
if (cutoff === undefined) continue
|
|
131
|
+
if (!active || options.signal?.aborted) {
|
|
132
|
+
const remainder = chunk.text.slice(cutoff)
|
|
133
|
+
if (remainder) yield { ...chunk, text: remainder }
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
for (const [index, value] of open) {
|
|
137
|
+
if (!active || options.signal?.aborted) break
|
|
138
|
+
finalized.add(index)
|
|
139
|
+
yield { type: 'block-end', index, block: value }
|
|
140
|
+
}
|
|
141
|
+
if (!active || options.signal?.aborted) {
|
|
142
|
+
const remainder = chunk.text.slice(cutoff)
|
|
143
|
+
if (!finalized.has(chunk.index) && remainder) yield { ...chunk, text: remainder }
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
coordinator.mark(cause)
|
|
147
|
+
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
})()
|
|
151
|
+
})
|
|
152
|
+
return () => { stop(); off(); coordinator.dispose() }
|
|
153
|
+
}
|
package/engine/host-package.mjs
CHANGED
|
@@ -49,3 +49,16 @@ export function resolveHostPackage(id, entry, self) {
|
|
|
49
49
|
export async function importHostPackage(id, entry, self) {
|
|
50
50
|
return import(pathToFileURL(resolveHostPackage(id, entry, self)).href)
|
|
51
51
|
}
|
|
52
|
+
|
|
53
|
+
/** 允许从宿主解析的官方能力包;版本随宿主(alpha/rc 均可),不在插件内做精确匹配。 */
|
|
54
|
+
export const HOST_CAPABILITY_PACKAGES = Object.freeze([
|
|
55
|
+
'@deepseek-ai/dsh-compaction-basic',
|
|
56
|
+
'@deepseek-ai/dsh-compaction-tool-result-pruner',
|
|
57
|
+
'@deepseek-ai/dsh-compaction',
|
|
58
|
+
'@deepseek-ai/dsh-llm',
|
|
59
|
+
])
|
|
60
|
+
|
|
61
|
+
export async function importHostCapability(id) {
|
|
62
|
+
if (!HOST_CAPABILITY_PACKAGES.includes(id)) throw new Error(`host capability not declared: ${id}`)
|
|
63
|
+
return importHostPackage(id)
|
|
64
|
+
}
|