dsh-plugin-prompt-tool 0.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.
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Anchored tool bootstrap — keep the FIRST model request on the Minimal
3
+ * preset's REAL tool schema (persistent `bash` + `str_replace_editor`), free
4
+ * of auto-injected workspace/skill context, then narrow the catalog to a
5
+ * minimal RESIDENT set once the session has produced its first durable
6
+ * promotion signal.
7
+ *
8
+ * The phase is derived from durable session events, so resume and reload
9
+ * preserve it. By default (`promoteOn: 'either'`) a session promotes after the
10
+ * first `tool/call` OR the first `assistant/message`, whichever comes first:
11
+ * request #1 always sees the bootstrap catalog and request #2 always sees the
12
+ * resident catalog. The original `'tool-call'` mode is kept for compatibility,
13
+ * but it can trap a session in bootstrap forever when the first model reply
14
+ * makes no tool call — the `'either'` default removes that trap while keeping
15
+ * the first-request anchor intact.
16
+ *
17
+ * First-request conditions established by the reproduction work (issues #6
18
+ * and #11, 2026-08-15):
19
+ *
20
+ * 1. Tool schema. The API-visible first-request catalog decides whether the
21
+ * session anchors on the Minimal trajectory. At the adapter-default
22
+ * maxTokens (256000 on the official endpoint) the Minimal tool pair —
23
+ * persistent `bash` + `str_replace_editor` — anchored 5/5 runs with zero
24
+ * `let me` first-lines, while every standard-family schema (pwsh/read,
25
+ * pwsh only, sandboxed bash/read) fell into standard-like behavior
26
+ * (11/11). Bootstrap therefore exposes exactly the Minimal pair, not
27
+ * Standard's `pwsh`/`read`.
28
+ *
29
+ * 2. Output budget. On the official endpoint the first request's `max_tokens`
30
+ * also dominated the trajectory anchor at 1024 (`We need` style in 26/32
31
+ * runs against 0/5 at 256000, independent of tool descriptions). The
32
+ * Minimal tool schema, however, anchors at 256000 WITHOUT any cap, and the
33
+ * cap's delivery depends on the profile package's `prepareCall` behavior
34
+ * (it reaches the request on the 0.1.0-rc.5 source checkout; a prebuilt
35
+ * rc.6-reporting profile package observed in issue #11 overwrote it with
36
+ * `adapterDefaults.maxTokens`). `bootstrapMaxTokens` is therefore OPT-IN:
37
+ * leave it unset to run the Minimal schema at the adapter default, or set
38
+ * it to cap the first request. When set, the cap is stripped after
39
+ * promotion — the next request's seed proposal carries the previous
40
+ * header's maxTokens forward, so the release must be explicit.
41
+ *
42
+ * 3. Injected reminders. dsh-agent-instructions and dsh-tool-skill inject
43
+ * workspace instructions (AGENTS.md) and the skill catalog into the first
44
+ * step as user messages whenever such content exists. With the skill
45
+ * catalog present the anchor did not reproduce at all (0/9); without it
46
+ * the same request reproduces at ~81%. Both message kinds are therefore
47
+ * stripped during bootstrap and allowed again after promotion. The
48
+ * stripped set is configurable via `suppressedContextSources` (default
49
+ * `['skill-catalog', 'agent-instructions']`); an explicitly empty array
50
+ * disables the context filter while keeping the tool bootstrap. A
51
+ * user-initiated skill gesture (`skill-invocation`) is NOT in the default
52
+ * set: it is not an automatic injection, and stripping it would lose the
53
+ * skill content once the gesture scrolls out of the per-step claim.
54
+ *
55
+ * POST-PROMOTION RESIDENT SET (local addition, user-measured): the promoted
56
+ * phase does NOT dump the whole Standard catalog at once — that dump pulls
57
+ * the trajectory back to standard-like behavior (the root cause of the
58
+ * post-promotion regression measured on the zero variant). Instead the
59
+ * catalog narrows to the bootstrap tool pair PLUS the three discovery tools
60
+ * (`dev_tool_search`, `skill_search`, `skill_load`) plus whatever the model
61
+ * explicitly unlocked via `dev_tool_search`. Heavier Standard tools
62
+ * (web_search, subagent, workflow, …) are one `dev_tool_search` call away;
63
+ * unlocked names are derived from durable `tool/call` events, so resume and
64
+ * reload keep them. read/write/edit/glob/grep/todo/ask are deliberately NOT
65
+ * resident: bash + str_replace_editor cover file work.
66
+ *
67
+ * COMPACTION (local addition): a compaction rewrites the whole surface, so the
68
+ * first post-compaction request is a "second first request". Promotion is
69
+ * epoch-aware (see compaction-epoch.mjs): after `compaction/end` the session
70
+ * falls back to the controlled phase — the bootstrap pair plus
71
+ * `compactionTools` (a core work set, default none) — until a NEW durable
72
+ * promotion signal exists past that boundary. The model is mid-task and needs
73
+ * to keep working, but still faces a small catalog instead of the full
74
+ * Standard set.
75
+ *
76
+ * Robustness:
77
+ * - Promotion decisions are memoized per session id for this process; the
78
+ * durable event scan runs once per session per process, then O(1).
79
+ * - Subagents (delegationDepth > 0) are always promoted (resident catalog).
80
+ * - A missing bootstrap tool degrades to the full catalog with a one-time
81
+ * warning instead of throwing, so a composition drift can never brick
82
+ * every request of a session.
83
+ * - The pre-step context filter degrades to "keep everything" on failure:
84
+ * a filter bug must never eat the user's context.
85
+ * - Invalid config (bad tool lists, unknown `promoteOn`, malformed
86
+ * `suppressedContextSources`, non-positive `bootstrapMaxTokens`) fails at
87
+ * apply time, i.e. at preset mount, where it is visible and fixable.
88
+ */
89
+
90
+ import { createEpochPromotion } from './compaction-epoch.mjs'
91
+
92
+ /** Cordis plugin name used by loader diagnostics. */
93
+ export const name = 'anchored-tool-bootstrap'
94
+
95
+ /**
96
+ * Deliberately NO inject list: the listeners only touch services at event
97
+ * time. Applying without an inject — combined with this row being FIRST in
98
+ * agent.cordis.yml — registers the plugin before dsh-agent-instructions and
99
+ * dsh-tool-skill, and waterfall after-next transforms apply in reverse
100
+ * registration order, so the first-request strip below is the LAST transform.
101
+ * With an inject here those plugins register first and re-inject their
102
+ * messages after the strip. The pre-step listener additionally registers with
103
+ * `prepend: true` so the strip stays the outermost transform even against
104
+ * host-plane listeners and future row reordering.
105
+ */
106
+ export const inject = []
107
+
108
+ /** Durable session event types that count as a promotion signal per mode. */
109
+ const PROMOTE_EVENTS = {
110
+ 'tool-call': ['tool/call'],
111
+ 'assistant-message': ['assistant/message'],
112
+ either: ['tool/call', 'assistant/message'],
113
+ }
114
+
115
+ /** Every config key this plugin accepts — anything else is a typo. */
116
+ const ALLOWED_KEYS = new Set(['bootstrapTools', 'promoteOn', 'bootstrapMaxTokens', 'suppressedContextSources', 'compactionTools'])
117
+
118
+ /**
119
+ * Context sources stripped from the first request by default. Both are
120
+ * automatic `agent/pre-step` injections: the available-skills reminder
121
+ * (`skill-catalog`) and the AGENTS.md/CLAUDE.md workspace digest
122
+ * (`agent-instructions`). True Minimal mounts neither plugin.
123
+ */
124
+ const DEFAULT_SUPPRESSED_SOURCES = ['skill-catalog', 'agent-instructions']
125
+
126
+ /**
127
+ * The default first-request catalog: the OFFICIAL Minimal preset's exact tool
128
+ * pair — the persistent `bash` shell and `str_replace_editor`. Issue #11
129
+ * measured this schema anchoring 5/5 at the adapter-default maxTokens while
130
+ * every standard-family schema failed 11/11.
131
+ */
132
+ const DEFAULT_BOOTSTRAP_TOOLS = ['bash', 'str_replace_editor']
133
+
134
+ /** Discovery tools always resident after promotion (the tool-search pattern). */
135
+ const RESIDENT_DISCOVERY_TOOLS = ['dev_tool_search', 'skill_search', 'skill_load']
136
+
137
+ function stringList(value, field) {
138
+ if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== 'string' || item.length === 0)) {
139
+ throw new TypeError(`${name}: ${field} must be a non-empty array of non-empty strings`)
140
+ }
141
+ return [...new Set(value)]
142
+ }
143
+
144
+ function stringListOrEmpty(value, field) {
145
+ if (value === undefined) return []
146
+ return stringList(value, field)
147
+ }
148
+
149
+ function parsePromoteOn(value) {
150
+ if (value === undefined || value === 'either') return PROMOTE_EVENTS.either
151
+ if (value === 'tool-call' || value === 'assistant-message') return PROMOTE_EVENTS[value]
152
+ throw new TypeError(`${name}: promoteOn must be one of "tool-call", "assistant-message", "either"; got ${JSON.stringify(value)}`)
153
+ }
154
+
155
+ /**
156
+ * Validate the suppressed context sources. Unlike the bootstrap tool lists,
157
+ * an explicitly empty array is meaningful: it disables the context filter
158
+ * while keeping the tool bootstrap.
159
+ */
160
+ function sourceList(value, field, fallback) {
161
+ if (value === undefined) return new Set(fallback)
162
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || item.length === 0)) {
163
+ throw new TypeError(`${name}: ${field} must be an array of non-empty strings`)
164
+ }
165
+ return new Set(value)
166
+ }
167
+
168
+ /**
169
+ * Validate the optional first-request output cap. `undefined` means NO cap:
170
+ * the Minimal tool schema anchors at the adapter-default maxTokens, and the
171
+ * cap's delivery is profile-package dependent (see the header note), so it is
172
+ * opt-in rather than the default.
173
+ */
174
+ function optionalPositiveInt(value, field) {
175
+ if (value === undefined) return undefined
176
+ if (!Number.isSafeInteger(value) || value <= 0) {
177
+ throw new TypeError(`${name}: ${field} must be a positive safe integer`)
178
+ }
179
+ return value
180
+ }
181
+
182
+ /** Register the per-session bootstrap filters. */
183
+ export function apply(ctx, config) {
184
+ const source = config === undefined ? {} : config
185
+ if (typeof source !== 'object' || source === null || Array.isArray(source)) {
186
+ throw new TypeError(`${name}: config must be an object`)
187
+ }
188
+ const unknown = Object.keys(source).filter((key) => !ALLOWED_KEYS.has(key))
189
+ if (unknown.length > 0) {
190
+ throw new TypeError(
191
+ `${name}: unknown config key(s) ${unknown.join(', ')} — allowed keys: ${[...ALLOWED_KEYS].sort().join(', ')}`,
192
+ )
193
+ }
194
+ const bootstrapTools = stringList(source.bootstrapTools, 'bootstrapTools')
195
+ const promoteEvents = parsePromoteOn(source.promoteOn)
196
+ const bootstrapMaxTokens = optionalPositiveInt(source.bootstrapMaxTokens, 'bootstrapMaxTokens')
197
+ const suppressedSources = sourceList(source.suppressedContextSources, 'suppressedContextSources', DEFAULT_SUPPRESSED_SOURCES)
198
+ // Core work set exposed after a compaction, before re-promotion. Empty
199
+ // means "no compaction recovery catalog": the session stays on the
200
+ // bootstrap pair until a new promotion signal.
201
+ const compactionTools = stringListOrEmpty(source.compactionTools, 'compactionTools')
202
+
203
+ const promotion = createEpochPromotion(promoteEvents)
204
+ ctx.on('session/event', (session, event) => promotion.observe(session, event))
205
+
206
+ let warned = false
207
+ const warnOnce = (message) => {
208
+ if (warned) return
209
+ warned = true
210
+ try {
211
+ ctx.logger.warn(message)
212
+ } catch {
213
+ // Logger unavailable — the guard exists only to avoid spamming.
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Tool names the model explicitly unlocked via `dev_tool_search` for one
219
+ * session. Derived from durable `tool/call` events so resume/reload keeps
220
+ * them. The event's `arguments` is the raw JSON string the model produced;
221
+ * we parse it defensively and read the `toolNames` array.
222
+ */
223
+ const unlockedFor = (session) => {
224
+ const unlocked = new Set()
225
+ if (session === undefined || !Array.isArray(session.events)) return unlocked
226
+ for (const event of session.events) {
227
+ if (event.type !== 'tool/call') continue
228
+ if (event.data?.name !== 'dev_tool_search') continue
229
+ let args
230
+ try {
231
+ args = JSON.parse(event.data.arguments)
232
+ } catch {
233
+ continue
234
+ }
235
+ if (args === null || typeof args !== 'object' || Array.isArray(args)) continue
236
+ const names = args.toolNames
237
+ if (Array.isArray(names)) for (const name of names) if (typeof name === 'string' && name.length > 0) unlocked.add(name)
238
+ }
239
+ return unlocked
240
+ }
241
+
242
+ /** Narrow the assembled catalog to a keep-set; validate required names. */
243
+ const keepTools = (assembled, keep, missingAllowsFullCatalog) => {
244
+ const available = new Set(assembled.tools.map((tool) => tool.name))
245
+ const missing = [...keep].filter((toolName) => !available.has(toolName))
246
+ if (missing.length > 0) {
247
+ warnOnce(
248
+ `${name}: expected every phase tool; missing=${JSON.stringify(missing)} — `
249
+ + (missingAllowsFullCatalog ? 'bootstrap disabled, full catalog exposed' : 'continuing with what is available'),
250
+ )
251
+ if (missingAllowsFullCatalog) return assembled
252
+ }
253
+ return {
254
+ ...assembled,
255
+ tools: assembled.tools.filter((tool) => keep.has(tool.name)),
256
+ }
257
+ }
258
+
259
+ ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
260
+ // Downstream errors propagate untouched; only this filter's own logic is guarded.
261
+ const assembled = await next()
262
+ try {
263
+ const status = promotion.status(context.agent)
264
+ if (status.promoted) {
265
+ // PROMOTED: keep the minimal resident set — the bootstrap pair + the
266
+ // discovery tools + whatever the model explicitly unlocked via
267
+ // dev_tool_search — instead of dumping the whole Standard catalog at
268
+ // once (the post-promotion regression fix; see the header note).
269
+ const keep = new Set([...bootstrapTools, ...RESIDENT_DISCOVERY_TOOLS, ...unlockedFor(context.agent?.session)])
270
+ return keepTools(assembled, keep, false)
271
+ }
272
+ // Controlled phase: the bootstrap pair; after a compaction, plus the
273
+ // compaction work set so mid-task work can continue.
274
+ const { boundary } = status
275
+ const keep = new Set(bootstrapTools)
276
+ if (boundary >= 0) for (const toolName of compactionTools) keep.add(toolName)
277
+ return keepTools(assembled, keep, true)
278
+ } catch (error) {
279
+ // A filter bug must never brick a session: degrade to the full catalog.
280
+ warnOnce(`${name}: bootstrap filter failed, exposing the full catalog: ${String((error && error.message) || error)}`)
281
+ return assembled
282
+ }
283
+ })
284
+
285
+ // Optionally cap the first model request's output budget while bootstrapping.
286
+ // Unset (`bootstrapMaxTokens` omitted) means the adapter default flows — the
287
+ // Minimal tool schema anchors at 256000 without a cap (issue #11).
288
+ if (bootstrapMaxTokens !== undefined) {
289
+ // Same registration discipline as the pre-step strip below: `prepend`
290
+ // keeps this listener the OUTERMOST transform of the agent/request
291
+ // waterfall for the same registration-order reasons (loader row
292
+ // application is concurrent; row order alone does not decide listener
293
+ // order — see issue #6 and upstream PR #13), so a later listener can
294
+ // never override the first-round budget after we set it.
295
+ ctx.on('agent/request', async (payload, next) => {
296
+ const resolved = await next()
297
+ const agent = payload.agent
298
+ if (promotion.status(agent).promoted) {
299
+ // The next request's seed proposal carries the previous header's
300
+ // maxTokens forward, so the injected cap must be stripped explicitly —
301
+ // otherwise it would persist for the whole session.
302
+ if (resolved.maxTokens === bootstrapMaxTokens) {
303
+ const { maxTokens: _bootstrap, ...rest } = resolved
304
+ return rest
305
+ }
306
+ return resolved
307
+ }
308
+ return {
309
+ ...resolved,
310
+ maxTokens: bootstrapMaxTokens,
311
+ }
312
+ }, { prepend: true })
313
+ }
314
+
315
+ // Strip first-step injected reminders (skill catalog, AGENTS.md) during
316
+ // bootstrap. Because this listener is the first registered (see the inject
317
+ // note, the row order in agent.cordis.yml, and `prepend` below), the strip
318
+ // is the final waterfall transform and actually removes what later
319
+ // listeners inject.
320
+ ctx.on('agent/pre-step', async ({ agent }, next) => {
321
+ // Downstream errors propagate untouched; only this filter's own logic is guarded.
322
+ const decision = await next()
323
+ if (decision.kind === 'reject') return decision
324
+ try {
325
+ if (promotion.status(agent).promoted || suppressedSources.size === 0) return decision
326
+ if (!Array.isArray(decision.messages)) return decision
327
+ const kept = decision.messages.filter((message) => {
328
+ const kind = message?.source?.kind
329
+ return typeof kind !== 'string' || !suppressedSources.has(kind)
330
+ })
331
+ return kept.length === decision.messages.length ? decision : { ...decision, messages: kept }
332
+ } catch (error) {
333
+ // A filter bug must never eat context: degrade to keeping every message.
334
+ warnOnce(`${name}: pre-step context filter failed, keeping injected context: ${String((error && error.message) || error)}`)
335
+ return decision
336
+ }
337
+ }, { prepend: true })
338
+ }