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.
@@ -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
- # ── bootstrap (must stay FIRST) ─────────────────────────────────────────────
22
-
23
- # This row deliberately sits before every other row: dsh-agent-instructions and
24
- # dsh-tool-skill inject workspace instructions and the skill catalog into the
25
- # first request via the agent/pre-step waterfall, and waterfall after-next
26
- # transforms apply in reverse registration order. Registering first (plus the
27
- # plugin empty inject list and the listener's `prepend` flag) makes this filter
28
- # strip the final transform, so request #1 stays Minimal-exact (see issue #6).
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
- # V4 Pro conditions strongly on the API tool catalog AND the first request
31
- # output budget. Bootstrap request #1 with the OFFICIAL Minimal preset's real
32
- # tool pair — persistent `bash` + `str_replace_editor` — which anchors at the
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
- suppressedContextSources: [agent-instructions, skill-catalog]
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 matches = schemas
108
- .filter((schema) => {
109
- const haystack = `${schema.name} ${schema.description ?? ''}`.toLowerCase()
110
- return wanted.every((token) => haystack.includes(token))
111
- })
112
- .slice(0, MAX_RESULTS)
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) {