dsh-plugin-prompt-tool 0.1.4 → 0.2.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 +75 -171
- package/lib/client.js +118 -11
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +16 -2
- package/lib/index.mjs +399 -101
- package/lib/preset-core.d.mts +12 -1
- package/lib/preset-core.mjs +94 -21
- package/package.json +5 -6
- package/plan.md +292 -265
- package/preset/near-anchor.mjs +117 -0
- package/preset/router-first-turn.mjs +59 -0
- package/{vendor → upstream}/dsh-anchored-standard/NOTICE +5 -0
- package/upstream/dsh-anchored-standard/REVISION +1 -0
- package/{vendor → upstream}/dsh-anchored-standard/preset/agent.cordis.yml +53 -24
- package/upstream/dsh-anchored-standard/preset/context-gate.mjs +202 -0
- package/{vendor → upstream}/dsh-anchored-standard/preset/dev-tool-search.mjs +10 -8
- package/{vendor → upstream}/dsh-anchored-standard/preset/instruction-hint.mjs +66 -13
- package/{vendor → upstream}/dsh-anchored-standard/preset/tool-bootstrap.mjs +41 -78
- package/preset/turn-anchor.mjs +0 -83
- /package/{vendor → upstream}/dsh-anchored-standard/LICENSE +0 -0
- /package/{vendor → upstream}/dsh-anchored-standard/preset/compaction-epoch.mjs +0 -0
- /package/{vendor → upstream}/dsh-anchored-standard/preset/custom-bash.mjs +0 -0
- /package/{vendor → upstream}/dsh-anchored-standard/preset/preset.yml +0 -0
- /package/{vendor → upstream}/dsh-anchored-standard/preset/skill-search.mjs +0 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* near-anchor — prompt-tool 近距离首句锚定(替代独立锚定轮)。
|
|
3
|
+
*
|
|
4
|
+
* 依据 dsh-router-standard P13/P14/P15/P22 与作者勘误:
|
|
5
|
+
* - 行为引导放 system(远距离)会衰减甚至反向;放在真实用户消息之后
|
|
6
|
+
* (近距离)零衰减;
|
|
7
|
+
* - 只要求首句一次:模型自回归续写会维持语域,越锚越牢;
|
|
8
|
+
* - 强制“每个 reasoning 块都以某句开头/回顾开头”是负效果;
|
|
9
|
+
* - 日常任务锚 We(官方后训练压出的高效路径),复杂规划放行 Let
|
|
10
|
+
* (未收敛旧路径携带更深规划能力),不做唯一解。
|
|
11
|
+
*
|
|
12
|
+
* 实现:在首条真实用户消息之后追加一条锚点消息,不拆轮、不挪任务;
|
|
13
|
+
* 幂等性来自持久事件(source.plugin === 'near-anchor')+ 进程内 memo。
|
|
14
|
+
* 自定义开关:config.useCustom 为 true 时固定使用 config.anchorText;
|
|
15
|
+
* false(默认)时忽略 anchorText,按任务与模型自动选择文本。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Cordis 插件名,供 loader 诊断使用。 */
|
|
19
|
+
export const name = 'near-anchor'
|
|
20
|
+
|
|
21
|
+
/** 无服务依赖,只监听 pre-step 消息组装。 */
|
|
22
|
+
export const inject = []
|
|
23
|
+
|
|
24
|
+
/** 开发/生成类任务关键词。 */
|
|
25
|
+
const BUILD_RE = /(开发|创建|写一个|生成|从零|做一个|构建|新项目|搭建|实现|做出|上线|落地|脚本|工具|应用|build|create|develop|generate|implement|make a|new project)/i
|
|
26
|
+
/** 复杂规划/修复类任务关键词:放行 Let 深度路径。 */
|
|
27
|
+
const COMPLEX_RE = /(架构|重构|设计|系统|全面|深度|迁移|兼容|审查|architecture|refactor|comprehensive|design|system|migrate|review)/i
|
|
28
|
+
|
|
29
|
+
/** 开发类:we 直接产出并验证。 */
|
|
30
|
+
const ANCHOR_BUILD = "Start your reasoning with the exact sentence: 'We need to build it directly and verify it.'"
|
|
31
|
+
/** 检查/日常类:we 先看代码。 */
|
|
32
|
+
const ANCHOR_INSPECT = "Start your reasoning with the exact sentence: 'We need to inspect the code first.'"
|
|
33
|
+
/** 复杂规划类:放行 Let 深度规划路径。 */
|
|
34
|
+
const ANCHOR_DEEP = "Start your reasoning with the exact sentence: 'Let me think through the design before changing anything.'"
|
|
35
|
+
|
|
36
|
+
/** Flash 模型附加三锚(回顾已完成 + 信息足够就产出 + 禁止环境检查/穷举)。 */
|
|
37
|
+
const FLASH_ANCHORS = ' Before acting, briefly review what you have already done in this session and continue from where you left off; do not repeat completed steps. When you have gathered enough information, stop exploring and produce the deliverable. Do not run environment checks (echo, whoami, uname, node --version, date) or exhaustive grep/glob scans.'
|
|
38
|
+
|
|
39
|
+
/** 生成消息 id:优先加密随机 id,旧运行时回退到随机串。 */
|
|
40
|
+
function newMessageId() {
|
|
41
|
+
return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
42
|
+
? crypto.randomUUID()
|
|
43
|
+
: `near-anchor-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 从 user/message 的 data 中提取纯文本;兼容 data.message 嵌套形状。 */
|
|
47
|
+
function extractText(data) {
|
|
48
|
+
if (!data) return ''
|
|
49
|
+
const payload = data && typeof data.message === 'object' && data.message !== null ? data.message : data
|
|
50
|
+
const content = Array.isArray(payload.content) ? payload.content : []
|
|
51
|
+
return content.map((block) => (typeof block === 'string' ? block : (block?.text ?? ''))).join(' ').trim()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 按开关与任务选择锚点:useCustom=true 固定用自定义;false 自动选择。 */
|
|
55
|
+
function chooseAnchor(text, modelId, customText, useCustom) {
|
|
56
|
+
if (useCustom === true) {
|
|
57
|
+
return typeof customText === 'string' ? customText.trim() : ''
|
|
58
|
+
}
|
|
59
|
+
let anchor
|
|
60
|
+
if (COMPLEX_RE.test(text)) anchor = ANCHOR_DEEP
|
|
61
|
+
else if (BUILD_RE.test(text)) anchor = ANCHOR_BUILD
|
|
62
|
+
else anchor = ANCHOR_INSPECT
|
|
63
|
+
if (typeof modelId === 'string' && /flash/i.test(modelId)) return anchor + FLASH_ANCHORS
|
|
64
|
+
return anchor
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function apply(ctx, config) {
|
|
68
|
+
const customText = typeof config.anchorText === 'string' ? config.anchorText : ''
|
|
69
|
+
const useCustom = config.useCustom === true
|
|
70
|
+
|
|
71
|
+
/** 本进程已处理过的会话(真相在持久事件流)。 */
|
|
72
|
+
const handled = new Set()
|
|
73
|
+
|
|
74
|
+
/** 持久事件里是否已有本插件的锚点消息。 */
|
|
75
|
+
const seenAnchor = (session) => session.events.some((event) => {
|
|
76
|
+
const payload = event.data && typeof event.data.message === 'object' && event.data.message !== null
|
|
77
|
+
? event.data.message
|
|
78
|
+
: event.data
|
|
79
|
+
return payload?.source?.plugin === 'near-anchor'
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
ctx.on('agent/pre-step', async ({ agent }, next) => {
|
|
83
|
+
const decision = await next()
|
|
84
|
+
if (decision.kind === 'reject') return decision
|
|
85
|
+
if (agent === undefined) return decision
|
|
86
|
+
const session = agent.session
|
|
87
|
+
if (session === undefined || handled.has(session.id) || seenAnchor(session)) return decision
|
|
88
|
+
// 子代理不注入锚点:让 dsh-mnemon 等结构化 worker 按自己的提示词工作。
|
|
89
|
+
if ((session.header?.delegationDepth ?? 0) > 0) return decision
|
|
90
|
+
|
|
91
|
+
const messages = Array.isArray(decision.messages) ? decision.messages : []
|
|
92
|
+
// 只锚真实用户消息;插件消息原样保留。
|
|
93
|
+
const userIndex = messages.findIndex((message) => message?.source?.kind === 'user')
|
|
94
|
+
if (userIndex < 0) return decision
|
|
95
|
+
const taskText = extractText(messages[userIndex])
|
|
96
|
+
if (taskText.length === 0) return decision
|
|
97
|
+
|
|
98
|
+
const anchorText = chooseAnchor(taskText, agent.options?.model, customText, useCustom)
|
|
99
|
+
if (anchorText.length === 0) return decision
|
|
100
|
+
handled.add(session.id)
|
|
101
|
+
|
|
102
|
+
const anchor = {
|
|
103
|
+
id: newMessageId(),
|
|
104
|
+
role: 'user',
|
|
105
|
+
content: [{ type: 'text', text: anchorText }],
|
|
106
|
+
source: {
|
|
107
|
+
kind: 'near-anchor',
|
|
108
|
+
plugin: 'near-anchor',
|
|
109
|
+
form: 'notice',
|
|
110
|
+
summary: 'near-anchor 近距离首句锚点',
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
const nextMessages = [...messages]
|
|
114
|
+
nextMessages.splice(userIndex + 1, 0, anchor)
|
|
115
|
+
return { ...decision, messages: nextMessages }
|
|
116
|
+
})
|
|
117
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* router-first-turn — prompt-tool 本地附加件(最优组合)。
|
|
3
|
+
*
|
|
4
|
+
* 依据 dsh-router-standard 的 router-bootstrap 组装逻辑:
|
|
5
|
+
* - 只替换 persona 段为官方训练原句,保留计划模式段与其他第三方
|
|
6
|
+
* section(applyPersona 语义,不是整段丢弃);
|
|
7
|
+
* - 首轮隐藏 mnemon:* 自动注入段(记忆路由/热记忆上下文)并清空 contexts,
|
|
8
|
+
* 晋升后两者都恢复——与 anchored-standard 的“首轮剥离自动注入”一致;
|
|
9
|
+
* - 子代理(委托深度 > 0)直接放行完整结果,保证 dsh-mnemon 等插件
|
|
10
|
+
* 通过工具白名单要求的 mnemon_* 工具首轮可见。
|
|
11
|
+
*
|
|
12
|
+
* 工具目录裁剪仍由上游 tool-bootstrap 负责(首轮 = 真实 Minimal 工具对;
|
|
13
|
+
* 晋升后 = resident 集),本模块不触碰 tools,避免两层过滤器冲突。
|
|
14
|
+
* 计划模式段必须保留:router-standard 早期实测证明整段替换 section 会
|
|
15
|
+
* 丢失 plan 边界,导致模型离开计划模式后重复探索(“失忆”问题)。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Cordis 插件名,供 loader 诊断使用。 */
|
|
19
|
+
export const name = 'router-first-turn'
|
|
20
|
+
|
|
21
|
+
/** 无服务依赖,只监听 system-prompt 组装。 */
|
|
22
|
+
export const inject = []
|
|
23
|
+
|
|
24
|
+
/** 官方 RL 训练原句(保持与 Minimal 预设逐字节一致)。 */
|
|
25
|
+
const RL_PERSONA = 'You are a helpful software engineer assistant.'
|
|
26
|
+
|
|
27
|
+
/** 是否为 persona 段:旧 persona 段由本模块替换。 */
|
|
28
|
+
function isPersonaSection(section) {
|
|
29
|
+
if (section === null || typeof section !== 'object') return false
|
|
30
|
+
const name = String(section.name ?? '')
|
|
31
|
+
return name === 'persona' || /persona/i.test(name)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 首轮需要隐藏的 mnemon 自动注入段(晋升后恢复)。 */
|
|
35
|
+
function isMnemonSection(section) {
|
|
36
|
+
if (section === null || typeof section !== 'object') return false
|
|
37
|
+
return String(section.name ?? '').startsWith('mnemon:')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function apply(ctx) {
|
|
41
|
+
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
|
|
42
|
+
const assembled = await next()
|
|
43
|
+
const agent = context.agent
|
|
44
|
+
if (agent === undefined) return assembled
|
|
45
|
+
const session = agent.session
|
|
46
|
+
if (session === undefined) return assembled
|
|
47
|
+
|
|
48
|
+
// 子代理不裁剪任何 section/context:它与 dsh-mnemon 的工具白名单协作。
|
|
49
|
+
if ((session.header?.delegationDepth ?? 0) > 0) return assembled
|
|
50
|
+
|
|
51
|
+
const sections = assembled.sections ?? []
|
|
52
|
+
const promoted = session.events.some((event) => event.type === 'tool/call')
|
|
53
|
+
const kept = sections.filter((section) => !isPersonaSection(section) && (promoted || !isMnemonSection(section)))
|
|
54
|
+
const routerSections = [...kept, { name: 'router-persona', text: RL_PERSONA, order: 0 }]
|
|
55
|
+
return promoted
|
|
56
|
+
? { ...assembled, sections: routerSections }
|
|
57
|
+
: { ...assembled, sections: routerSections, contexts: [] }
|
|
58
|
+
})
|
|
59
|
+
}
|
|
@@ -10,5 +10,10 @@ DeepSeek Harness is distributed under the MIT License:
|
|
|
10
10
|
|
|
11
11
|
The full MIT permission notice is included in this repository's LICENSE file.
|
|
12
12
|
|
|
13
|
+
shared/toolchoice-adapter.mjs additionally adapts a minimal, protocol-faithful subset
|
|
14
|
+
of the DeepSeek Harness llm-deepseek adapter pipeline (request serialization,
|
|
15
|
+
SSE parsing, and chunk translation) from the same repository and commit; the
|
|
16
|
+
same MIT attribution above covers it.
|
|
17
|
+
|
|
13
18
|
DeepSeek and DeepSeek Harness are names of their respective owner. This
|
|
14
19
|
community project is not affiliated with or endorsed by DeepSeek.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ba7a27cf92f80ef3a73a16a05b9df46a20ebbb2a
|
|
@@ -18,34 +18,53 @@
|
|
|
18
18
|
# pool instances — `provide()` throws on the second registration under the
|
|
19
19
|
# same realm symbol; labels join REALMS, and are not what this file needs.)
|
|
20
20
|
|
|
21
|
-
# ──
|
|
22
|
-
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
21
|
+
# ── context gate (must stay FIRST) ──────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
# Unified injection control (shared/context-gate.mjs, reusable by any preset):
|
|
24
|
+
# while the session is unpromoted, BOTH unified injection paths are closed.
|
|
25
|
+
# The assembly's dynamic runtime-context contributions are blanked — the whole
|
|
26
|
+
# `SystemPrompt.context()` family (sandbox/approval policy snapshots and any
|
|
27
|
+
# third-party context provider) without enumerating them — and the pre-step
|
|
28
|
+
# waterfall keeps ONLY the claimed message batch plus the `allowKinds` entries
|
|
29
|
+
# (a user skill gesture survives; every automatic injection — skill catalog,
|
|
30
|
+
# AGENTS.md digest, time/tmux context, hooks, unknown third-party plugins — is
|
|
31
|
+
# stripped by default). After the first durable promotion signal (a tool call
|
|
32
|
+
# OR the first assistant message, `promoteOn: either`) the gate opens, and the
|
|
33
|
+
# loop's own snapshot projection diffs exactly ONE fresh runtime-context
|
|
34
|
+
# message into the next request: minimal first round, injections on the second
|
|
35
|
+
# round. A `compaction/end` boundary re-closes the gate the same way.
|
|
29
36
|
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
37
|
+
# This row deliberately sits before every other row: waterfall after-next
|
|
38
|
+
# transforms apply in reverse registration order, so registering first (plus
|
|
39
|
+
# the empty inject list and the pre-step listener's `prepend` flag) makes the
|
|
40
|
+
# gate the outermost transform — nothing registered later re-injects past it
|
|
41
|
+
# (see issue #6).
|
|
42
|
+
#
|
|
43
|
+
# `includeSubagents: true` gates subagents the same way — their first request
|
|
44
|
+
# is clean too, and their own first reply or tool call opens the gate — so a
|
|
45
|
+
# delegation cannot reintroduce an uncontrolled first request (issues #38 and
|
|
46
|
+
# #52). Keep this flag in sync with the tool-bootstrap row below.
|
|
47
|
+
- id: context-gate
|
|
48
|
+
name: ./context-gate.mjs
|
|
49
|
+
config:
|
|
50
|
+
promoteOn: either
|
|
51
|
+
includeSubagents: true
|
|
52
|
+
allowKinds: [skill-invocation]
|
|
53
|
+
|
|
54
|
+
# ── bootstrap ───────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
# Tool catalog control (mode-owned tool-bootstrap.mjs). V4 Pro conditions
|
|
57
|
+
# strongly on the API tool catalog AND the first request output budget:
|
|
58
|
+
# request #1 exposes the OFFICIAL Minimal preset's real tool pair —
|
|
59
|
+
# persistent `bash` + `str_replace_editor` — which anchors at the
|
|
33
60
|
# adapter-default maxTokens (256000) with no output cap needed (issue #11:
|
|
34
61
|
# 5/5 anchored vs 11/11 standard-like for every standard-family schema);
|
|
35
62
|
# after the session records its first durable promotion signal (a tool call OR
|
|
36
63
|
# the first assistant message, default `promoteOn: either`), later steps
|
|
37
64
|
# narrow to the minimal RESIDENT set (see below). `bootstrapMaxTokens` is
|
|
38
65
|
# opt-in for standard-schema bootstraps; unset, the adapter default flows. See
|
|
39
|
-
# tool-bootstrap.mjs for the other triggers.
|
|
40
|
-
#
|
|
41
|
-
# The same phase gate suppresses AUTO-INJECTED context on request #1:
|
|
42
|
-
# `suppressedContextSources` lists the `agent/pre-step` message sources the
|
|
43
|
-
# bootstrap filter strips while the session is unpromoted. The defaults are the
|
|
44
|
-
# two automatic injections Standard adds over Minimal — the available-skills
|
|
45
|
-
# reminder (`skill-catalog`) and the workspace instruction digest
|
|
46
|
-
# (`agent-instructions`). User-initiated skill gestures are not filtered, and
|
|
47
|
-
# both injections return unchanged from request #2 on. Set the list to [] to
|
|
48
|
-
# disable the context filter while keeping the tool bootstrap.
|
|
66
|
+
# tool-bootstrap.mjs for the other triggers. Context stripping is NOT here —
|
|
67
|
+
# the context-gate row above owns it.
|
|
49
68
|
#
|
|
50
69
|
# POST-PROMOTION (local addition): the promoted catalog is NOT the full
|
|
51
70
|
# Standard dump — it stays on the bootstrap pair + the three discovery tools
|
|
@@ -57,12 +76,16 @@
|
|
|
57
76
|
# to the controlled phase — bootstrap pair + `compactionTools` — until a NEW
|
|
58
77
|
# durable promotion signal exists past the boundary (epoch-aware, see
|
|
59
78
|
# compaction-epoch.mjs).
|
|
79
|
+
#
|
|
80
|
+
# `includeSubagents: true` keeps the subagent phase in sync with the
|
|
81
|
+
# context-gate row: a subagent's first request sees the bootstrap pair, then
|
|
82
|
+
# its own first reply or tool call promotes it to the resident set.
|
|
60
83
|
- id: tool-bootstrap
|
|
61
84
|
name: ./tool-bootstrap.mjs
|
|
62
85
|
config:
|
|
63
86
|
bootstrapTools: [bash, str_replace_editor]
|
|
64
87
|
promoteOn: either
|
|
65
|
-
|
|
88
|
+
includeSubagents: true
|
|
66
89
|
# Post-compaction core work set: the model is mid-task and needs to keep
|
|
67
90
|
# working, but faces a small catalog instead of the full Standard set.
|
|
68
91
|
compactionTools: [read, write, edit, glob, grep, todo_write, ask_user_question]
|
|
@@ -85,11 +108,17 @@
|
|
|
85
108
|
# that perturbs the trajectory even after promotion. Instead, after promotion
|
|
86
109
|
# ONE short hint is injected once per session — "these instruction files
|
|
87
110
|
# exist; read them before acting" — and the model reads the files itself via
|
|
88
|
-
# the filesystem tools when relevant.
|
|
111
|
+
# the filesystem tools when relevant. The once-per-session guard is DERIVED
|
|
112
|
+
# FROM DURABLE EVENTS (restart-safe): a process restart must never inject a
|
|
113
|
+
# second hint with the same deterministic message id — the duplicate id
|
|
114
|
+
# breaks history replay. `includeSubagents: true` makes a subagent's own
|
|
115
|
+
# first reply or tool call open its hint, keeping it out of the context
|
|
116
|
+
# gate's stripped first request.
|
|
89
117
|
- id: instruction-hint
|
|
90
118
|
name: ./instruction-hint.mjs
|
|
91
119
|
config:
|
|
92
120
|
promoteOn: either
|
|
121
|
+
includeSubagents: true
|
|
93
122
|
|
|
94
123
|
# On-demand tool discovery (the tool-search pattern, local addition): the
|
|
95
124
|
# promoted catalog keeps a minimal resident set; heavier Standard tools
|
|
@@ -170,7 +199,7 @@
|
|
|
170
199
|
name: ./custom-bash.mjs
|
|
171
200
|
disabled: !!js process.platform !== 'win32'
|
|
172
201
|
config:
|
|
173
|
-
bashPath: 'bash.exe'
|
|
202
|
+
bashPath: 'C:\Program Files\Git\bin\bash.exe'
|
|
174
203
|
|
|
175
204
|
# ── filesystem ──────────────────────────────────────────────────────────────
|
|
176
205
|
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* anchored-context-gate — reusable unified injection control for ANY preset.
|
|
3
|
+
*
|
|
4
|
+
* Mount this one plugin to keep a session's first model request free of
|
|
5
|
+
* auto-injected context, whatever its source, and to have every injection
|
|
6
|
+
* return on the second round. It intercepts the harness's two unified
|
|
7
|
+
* injection paths — not a per-source denylist — so it covers sources that do
|
|
8
|
+
* not exist yet:
|
|
9
|
+
*
|
|
10
|
+
* a. RUNTIME CONTEXT (system-prompt/assemble): while the session is
|
|
11
|
+
* unpromoted, the assembly's `contexts` are blanked. That covers the
|
|
12
|
+
* WHOLE `SystemPrompt.context()` family — the sandbox and approval
|
|
13
|
+
* policy snapshots and any third-party context provider — without
|
|
14
|
+
* enumerating them. The loop's own snapshot projection then emits no
|
|
15
|
+
* message during the gate (no snapshot ever existed), and at the first
|
|
16
|
+
* promoted request it emits exactly ONE fresh snapshot: "minimal first
|
|
17
|
+
* round, inject on the second round" falls out of the projection's
|
|
18
|
+
* diffing, with no reinjection logic here.
|
|
19
|
+
*
|
|
20
|
+
* b. STEP MESSAGES (agent/pre-step): the waterfall payload carries the
|
|
21
|
+
* CLAIMED message batch (the inbox messages this step owns). While
|
|
22
|
+
* unpromoted, the gate keeps exactly the claimed messages plus a small
|
|
23
|
+
* kind allowlist, and strips everything any listener appended — skill
|
|
24
|
+
* catalog, AGENTS.md digest, time/tmux context, hooks, unknown
|
|
25
|
+
* third-party plugins — by DEFAULT, regardless of source identity. The
|
|
26
|
+
* default allowlist is `['skill-invocation']`: a user-initiated skill
|
|
27
|
+
* gesture is not an automatic injection, and stripping it would lose the
|
|
28
|
+
* skill content once the gesture scrolls out of the per-step claim.
|
|
29
|
+
* Durable history (compaction summaries included) never passes through
|
|
30
|
+
* this gate: it enters the request via the session surface, not the
|
|
31
|
+
* pre-step waterfall.
|
|
32
|
+
*
|
|
33
|
+
* The phase is the same epoch-aware promotion machine the anchored presets
|
|
34
|
+
* use (see compaction-epoch.mjs): a durable `tool/call` and/or
|
|
35
|
+
* `assistant/message` (per `promoteOn`, default `either`) promotes, and a
|
|
36
|
+
* `compaction/end` boundary demotes again — the first post-compaction request
|
|
37
|
+
* is a "second first request" and is gated the same way. Derived from durable
|
|
38
|
+
* events, so resume and reload preserve it.
|
|
39
|
+
*
|
|
40
|
+
* SUBAGENTS: by default subagents (delegationDepth > 0) skip the gate (their
|
|
41
|
+
* first request already sees full context). `includeSubagents: true` gates
|
|
42
|
+
* them too — their first request is clean and their own first reply or tool
|
|
43
|
+
* call opens the gate — so a delegation cannot reintroduce an uncontrolled
|
|
44
|
+
* first request. Keep this flag in sync with any companion phase plugin
|
|
45
|
+
* (e.g. the tool-bootstrap row).
|
|
46
|
+
*
|
|
47
|
+
* CONFIG:
|
|
48
|
+
* - `promoteOn`: 'either' (default) | 'tool-call' | 'assistant-message'.
|
|
49
|
+
* - `includeSubagents`: boolean, default false.
|
|
50
|
+
* - `enabled`: boolean, default true. `false` disables both interception
|
|
51
|
+
* paths (A/B testing without touching the row set).
|
|
52
|
+
* - `allowKinds`: message `source.kind` names allowed beyond the claimed
|
|
53
|
+
* batch, default ['skill-invocation']. An explicitly empty array keeps
|
|
54
|
+
* ONLY the claimed batch.
|
|
55
|
+
*
|
|
56
|
+
* ROW ORDER: mount this row FIRST in the composition. Waterfall after-next
|
|
57
|
+
* transforms apply in reverse registration order, so registering first (plus
|
|
58
|
+
* the pre-step listener's `prepend: true`) makes the gate the outermost
|
|
59
|
+
* transform — nothing registered later re-injects past it.
|
|
60
|
+
*
|
|
61
|
+
* Robustness: both filters degrade to "keep everything" on their own
|
|
62
|
+
* failures — a gate bug must never eat the user's context — and invalid
|
|
63
|
+
* config fails at apply time, i.e. at preset mount, where it is visible.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
import { createEpochPromotion } from './compaction-epoch.mjs'
|
|
67
|
+
|
|
68
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
69
|
+
export const name = 'anchored-context-gate'
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Deliberately NO inject list: the listeners only touch services at event
|
|
73
|
+
* time, and applying without an inject lets this row register before the
|
|
74
|
+
* context-injecting plugins (dsh-agent-instructions, dsh-tool-skill, host
|
|
75
|
+
* plane policy projections) when it sits first in the composition.
|
|
76
|
+
*/
|
|
77
|
+
export const inject = []
|
|
78
|
+
|
|
79
|
+
/** Durable session event types that count as a promotion signal per mode. */
|
|
80
|
+
const PROMOTE_EVENTS = {
|
|
81
|
+
'tool-call': ['tool/call'],
|
|
82
|
+
'assistant-message': ['assistant/message'],
|
|
83
|
+
either: ['tool/call', 'assistant/message'],
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Every config key this plugin accepts — anything else is a typo. */
|
|
87
|
+
const ALLOWED_KEYS = new Set(['promoteOn', 'includeSubagents', 'enabled', 'allowKinds'])
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Message kinds allowed through the pre-step gate beyond the claimed batch.
|
|
91
|
+
* A user-initiated skill gesture is the only default entry: it is not an
|
|
92
|
+
* automatic injection (see the header note).
|
|
93
|
+
*/
|
|
94
|
+
const DEFAULT_ALLOW_KINDS = ['skill-invocation']
|
|
95
|
+
|
|
96
|
+
function parsePromoteOn(value) {
|
|
97
|
+
if (value === undefined || value === 'either') return PROMOTE_EVENTS.either
|
|
98
|
+
if (value === 'tool-call' || value === 'assistant-message') return PROMOTE_EVENTS[value]
|
|
99
|
+
throw new TypeError(`${name}: promoteOn must be one of "tool-call", "assistant-message", "either"; got ${JSON.stringify(value)}`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Validate the kind allowlist. An explicitly empty array is meaningful: keep
|
|
104
|
+
* ONLY the claimed batch, stripping even user skill gestures.
|
|
105
|
+
*/
|
|
106
|
+
function allowKindList(value, field) {
|
|
107
|
+
if (value === undefined) return new Set(DEFAULT_ALLOW_KINDS)
|
|
108
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || item.length === 0)) {
|
|
109
|
+
throw new TypeError(`${name}: ${field} must be an array of non-empty strings`)
|
|
110
|
+
}
|
|
111
|
+
return new Set(value)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Validate an optional boolean flag with a default. */
|
|
115
|
+
function booleanOption(value, field, fallback) {
|
|
116
|
+
if (value === undefined) return fallback
|
|
117
|
+
if (typeof value !== 'boolean') {
|
|
118
|
+
throw new TypeError(`${name}: ${field} must be a boolean`)
|
|
119
|
+
}
|
|
120
|
+
return value
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Register the unified context gate. */
|
|
124
|
+
export function apply(ctx, config) {
|
|
125
|
+
const source = config === undefined ? {} : config
|
|
126
|
+
if (typeof source !== 'object' || source === null || Array.isArray(source)) {
|
|
127
|
+
throw new TypeError(`${name}: config must be an object`)
|
|
128
|
+
}
|
|
129
|
+
const unknown = Object.keys(source).filter((key) => !ALLOWED_KEYS.has(key))
|
|
130
|
+
if (unknown.length > 0) {
|
|
131
|
+
throw new TypeError(
|
|
132
|
+
`${name}: unknown config key(s) ${unknown.join(', ')} — allowed keys: ${[...ALLOWED_KEYS].sort().join(', ')}`,
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
const promoteEvents = parsePromoteOn(source.promoteOn)
|
|
136
|
+
const includeSubagents = booleanOption(source.includeSubagents, 'includeSubagents', false)
|
|
137
|
+
const enabled = booleanOption(source.enabled, 'enabled', true)
|
|
138
|
+
const allowKinds = allowKindList(source.allowKinds, 'allowKinds')
|
|
139
|
+
|
|
140
|
+
const promotion = createEpochPromotion(promoteEvents, { includeSubagents })
|
|
141
|
+
ctx.on('session/event', (session, event) => promotion.observe(session, event))
|
|
142
|
+
|
|
143
|
+
let warned = false
|
|
144
|
+
const warnOnce = (message) => {
|
|
145
|
+
if (warned) return
|
|
146
|
+
warned = true
|
|
147
|
+
try {
|
|
148
|
+
ctx.logger.warn(message)
|
|
149
|
+
} catch {
|
|
150
|
+
// Logger unavailable — the guard exists only to avoid spamming.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Path (a): blank the dynamic runtime-context contributions while the
|
|
155
|
+
// session is unpromoted. Covers the whole SystemPrompt.context() family
|
|
156
|
+
// without enumerating it; the loop's snapshot projection then stays silent
|
|
157
|
+
// and diffs exactly ONE fresh snapshot in at the first promoted request.
|
|
158
|
+
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
|
|
159
|
+
// Downstream errors propagate untouched; only this filter's own logic is guarded.
|
|
160
|
+
const assembled = await next()
|
|
161
|
+
if (enabled === false) return assembled
|
|
162
|
+
try {
|
|
163
|
+
if (promotion.status(context.agent).promoted) return assembled
|
|
164
|
+
if (!Array.isArray(assembled.contexts) || assembled.contexts.length === 0) return assembled
|
|
165
|
+
return { ...assembled, contexts: [] }
|
|
166
|
+
} catch (error) {
|
|
167
|
+
// A gate bug must never break assembly: degrade to the assembled value.
|
|
168
|
+
warnOnce(`${name}: runtime-context suppression failed, keeping contexts: ${String((error && error.message) || error)}`)
|
|
169
|
+
return assembled
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
// Path (b): claimed-baseline deny on the pre-step waterfall. The payload's
|
|
174
|
+
// `messages` is the batch this step CLAIMED from the inbox — the baseline
|
|
175
|
+
// every injection appends to. Keep that baseline plus the kind allowlist,
|
|
176
|
+
// strip every appended message regardless of its source identity.
|
|
177
|
+
ctx.on('agent/pre-step', async ({ agent, messages: claimed }, next) => {
|
|
178
|
+
// Downstream errors propagate untouched; only this filter's own logic is guarded.
|
|
179
|
+
const decision = await next()
|
|
180
|
+
if (decision.kind === 'reject') return decision
|
|
181
|
+
if (enabled === false) return decision
|
|
182
|
+
try {
|
|
183
|
+
if (promotion.status(agent).promoted) return decision
|
|
184
|
+
if (!Array.isArray(decision.messages)) return decision
|
|
185
|
+
if (!Array.isArray(claimed)) return decision
|
|
186
|
+
const baseline = new Set(claimed)
|
|
187
|
+
const baselineIds = new Set(claimed
|
|
188
|
+
.map((message) => message?.id)
|
|
189
|
+
.filter((id) => id !== undefined && id !== null))
|
|
190
|
+
const kept = decision.messages.filter((message) =>
|
|
191
|
+
baseline.has(message)
|
|
192
|
+
|| (message?.id !== undefined && message?.id !== null && baselineIds.has(message.id))
|
|
193
|
+
|| allowKinds.has(message?.source?.kind),
|
|
194
|
+
)
|
|
195
|
+
return kept.length === decision.messages.length ? decision : { ...decision, messages: kept }
|
|
196
|
+
} catch (error) {
|
|
197
|
+
// A gate bug must never eat context: degrade to keeping every message.
|
|
198
|
+
warnOnce(`${name}: pre-step gate failed, keeping injected context: ${String((error && error.message) || error)}`)
|
|
199
|
+
return decision
|
|
200
|
+
}
|
|
201
|
+
}, { prepend: true })
|
|
202
|
+
}
|
|
@@ -104,20 +104,22 @@ export function apply(ctx) {
|
|
|
104
104
|
// harness's own code mode (`registry.schemas(exec.agent)`).
|
|
105
105
|
const schemas = ctx.tools.schemas(exec?.agent)
|
|
106
106
|
const wanted = query.toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean)
|
|
107
|
-
const
|
|
108
|
-
.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (matches.length === 0) {
|
|
107
|
+
const all = schemas.filter((schema) => {
|
|
108
|
+
const haystack = `${schema.name} ${schema.description ?? ''}`.toLowerCase()
|
|
109
|
+
return wanted.every((token) => haystack.includes(token))
|
|
110
|
+
})
|
|
111
|
+
const matches = all.slice(0, MAX_RESULTS)
|
|
112
|
+
if (all.length === 0) {
|
|
114
113
|
lines.push(`No tools match "${query}".`)
|
|
115
114
|
} else {
|
|
116
|
-
lines.push(`Matching tools (${matches.length}):`)
|
|
115
|
+
lines.push(`Matching tools (${matches.length}${all.length > MAX_RESULTS ? ` of ${all.length}` : ''}):`)
|
|
117
116
|
for (const schema of matches) {
|
|
118
117
|
const desc = (schema.description || '').split('\n')[0].slice(0, 90)
|
|
119
118
|
lines.push(`- ${schema.name}: ${desc}`)
|
|
120
119
|
}
|
|
120
|
+
if (all.length > MAX_RESULTS) {
|
|
121
|
+
lines.push(`(truncated at ${MAX_RESULTS} — add tokens to narrow the query, e.g. "mcp browser" or "mcp tavily")`)
|
|
122
|
+
}
|
|
121
123
|
lines.push('Unlock with dev_tool_search({"toolNames": ["<exact name>"]}).')
|
|
122
124
|
}
|
|
123
125
|
} catch (error) {
|