dsh-subagent-profile 0.3.4 → 0.5.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.
Files changed (38) hide show
  1. package/README.md +13 -8
  2. package/README.zh.md +13 -8
  3. package/index.mjs +99 -95
  4. package/lib/client.js +1288 -189
  5. package/lib/core/decision-trace.mjs +20 -1
  6. package/lib/core/delegation.mjs +3 -2
  7. package/lib/core/dispatch-gates.mjs +122 -9
  8. package/lib/core/dispatch-schema.mjs +45 -7
  9. package/lib/core/dispatch-tool.mjs +24 -25
  10. package/lib/core/evolution-advice.mjs +154 -34
  11. package/lib/core/evolution-assets.mjs +167 -0
  12. package/lib/core/evolution-canary.mjs +427 -0
  13. package/lib/core/evolution-draft.mjs +333 -0
  14. package/lib/core/evolution-engine.mjs +413 -0
  15. package/lib/core/evolution-generate.mjs +180 -0
  16. package/lib/core/evolution-lag.mjs +78 -0
  17. package/lib/core/evolution-ledger.mjs +135 -27
  18. package/lib/core/evolution-persistence.mjs +213 -0
  19. package/lib/core/evolution-renewal.mjs +64 -0
  20. package/lib/core/evolution-routes.mjs +234 -0
  21. package/lib/core/http-helpers.mjs +35 -0
  22. package/lib/core/http-routes.mjs +35 -39
  23. package/lib/core/model-policy.mjs +77 -0
  24. package/lib/core/presets-sync.mjs +24 -2
  25. package/lib/core/profile-directory.mjs +17 -0
  26. package/lib/core/profile-provider.mjs +9 -3
  27. package/lib/core/profiles-query.mjs +178 -0
  28. package/lib/core/profiles-store.mjs +56 -8
  29. package/lib/core/pure.mjs +1 -1
  30. package/lib/core/session-read.mjs +17 -0
  31. package/lib/core/shims.mjs +2 -1
  32. package/package.json +2 -2
  33. package/presets/orchestrator-v2/NOTICE +7 -0
  34. package/presets/{orchestrator → orchestrator-v2}/agent.cordis.yml +78 -7
  35. package/presets/orchestrator-v2/custom-bash.mjs +213 -0
  36. package/presets/orchestrator-v2/preset.yml +2 -0
  37. package/presets/orchestrator-v2/tool-bootstrap.mjs +620 -0
  38. package/presets/orchestrator/preset.yml +0 -2
@@ -0,0 +1,620 @@
1
+ /**
2
+ * Keep the first model request on a minimal-shaped input surface, then expose
3
+ * the full preset catalog once the session is safely anchored.
4
+ *
5
+ * Phase 1 (no persisted `tool/call` yet):
6
+ * - tool catalog: one platform shell plus `commonTools`
7
+ * - prompt sections: only the persona section (all other sections,
8
+ * including plan-mode's `plan:policy`, return after promotion)
9
+ * - runtime contexts: emptied (no sandbox/approval snapshot)
10
+ * - pre-step messages: only whitelisted source kinds pass (direct user
11
+ * messages and goal auto-rounds by default)
12
+ *
13
+ * Promotion opens the full tool catalog and restores runtime contexts and all
14
+ * prompt sections. With `anchorGate` the promotion after the first tool call
15
+ * also requires one minimal-like reasoning block (a first block containing
16
+ * `we` and no `let me`) or the `maxBootstrapSteps` fallback.
17
+ * `promoteAfterFirstResponse` promotes a tool-less first response once it has
18
+ * responded, and also releases an anchor-gated session when its first turn
19
+ * ends (`turn/end`). With `promotedPresentation: code` the promoted catalog
20
+ * is presented as PTC Mode: the wire shows a single `run_code` tool
21
+ * backed by the generated SDK, switched at the step boundary so the current
22
+ * step's native calls are never interrupted. `deferredSources` and
23
+ * `deferredGraceSteps` delay selected injected message kinds (workspace
24
+ * instructions, skill catalog) for a few steps after promotion.
25
+ *
26
+ * COMPACTION (local addition, ported from the upstream compaction-epoch
27
+ * semantics): a compaction rewrites the whole model-visible surface, so the
28
+ * first post-compaction request is a "second first request". A
29
+ * `compaction/end` event releases PTC Mode (the presentation disposer) and
30
+ * resets the promotion state to the CONTROLLED phase — bootstrap pair plus
31
+ * `compactionTools` (a core work set, default none) — until a NEW durable
32
+ * promotion signal exists past that boundary. The reset lives both in the
33
+ * live `session/event` path and inside the durable-log scan, so resume and
34
+ * reload reconstruct the same phase.
35
+ *
36
+ * ROBUSTNESS: composition drift (a missing bootstrap shell or common tool)
37
+ * degrades to the full catalog with a one-time warning instead of throwing,
38
+ * so a broken composition can never lock a session out of every request.
39
+ *
40
+ * OPT-IN PHASE-1 INSTRUCTION (issue #274): `phase1FirstCallInstruction` is
41
+ * an optional string appended to the phase-1 persona; unset (the default)
42
+ * keeps the phase-1 persona the exact one-line Minimal anchor. Test builds
43
+ * use it to ask the model to ground its first answer with one Minimal-native
44
+ * tool call before responding, so first-turn capability questions are
45
+ * answered from the promoted registry instead of the cropped two-tool view.
46
+ *
47
+ * Source: https://github.com/xiaobright/dsh-anchored-standard (MIT), extended
48
+ * with the phase-1 quarantine and the stabilization controls above.
49
+ */
50
+
51
+ /** Cordis plugin name used by loader diagnostics. */
52
+ export const name = 'anchored-tool-bootstrap'
53
+
54
+ /** Prompt assembly and the tool registry must exist before this filter runs. */
55
+ export const inject = ['systemPrompt', 'tools']
56
+
57
+ /**
58
+ * Prompt section names that carry the preset persona. The `dsh-persona` row
59
+ * registers the preset persona as `deployment:persona` (the PERSONA_SECTION
60
+ * name of `@deepseek-ai/dsh-system-prompt`), shadowing the deployment
61
+ * default for the preset scope; `persona` is the legacy name kept for older
62
+ * harnesses that registered the persona section without the prefix.
63
+ */
64
+ const PERSONA_SECTION_NAMES = new Set(['deployment:persona', 'persona'])
65
+
66
+ /**
67
+ * Workspace line a promoted persona gains. Phase 1 keeps the exact one-line
68
+ * persona (the Minimal anchor); after promotion the model must also know the
69
+ * session's selected workspace, which the Standard persona carries through
70
+ * the `{{cwd}}` prompt variable. The literal cwd is read from the session
71
+ * header at assembly time instead, so the line stays correct after a
72
+ * workspace switch and a session without a selected workspace keeps the bare
73
+ * one-liner rather than failing prompt interpolation.
74
+ */
75
+ const WORKSPACE_LINE_PREFIX = '\n\nYour working directory is '
76
+
77
+ /**
78
+ * Message-source kinds the model may see during phase 1. Goal auto-rounds
79
+ * (source kind `goal`, issue #578) must be here: a filtered-out goal round
80
+ * never produces a response or tool call, so no promotion branch ever fires
81
+ * and the goal resume/pause loop deadlocks.
82
+ */
83
+ const DEFAULT_MESSAGE_SOURCES = ['user', 'goal']
84
+
85
+ function stringList(value, field, fallback) {
86
+ if (value === undefined) return [...fallback]
87
+ if (!Array.isArray(value) || value.length === 0 || value.some(item => typeof item !== 'string' || item.length === 0)) {
88
+ throw new TypeError(`${name}: ${field} must be a non-empty array of non-empty strings`)
89
+ }
90
+ return [...new Set(value)]
91
+ }
92
+
93
+ function stringListOrEmpty(value, field) {
94
+ if (value === undefined) return []
95
+ if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || item.length === 0)) {
96
+ throw new TypeError(`${name}: ${field} must be an array of non-empty strings`)
97
+ }
98
+ return [...new Set(value)]
99
+ }
100
+
101
+ function optionalString(value, field) {
102
+ if (value === undefined) return ''
103
+ if (typeof value !== 'string') {
104
+ throw new TypeError(`${name}: ${field} must be a string`)
105
+ }
106
+ return value
107
+ }
108
+
109
+ function integerAtLeast(value, field, minimum) {
110
+ if (!Number.isInteger(value) || value < minimum) {
111
+ throw new TypeError(`${name}: ${field} must be an integer >= ${minimum}`)
112
+ }
113
+ return value
114
+ }
115
+
116
+ export function countWord(text, regex) {
117
+ return [...text.matchAll(regex)].length
118
+ }
119
+
120
+ /**
121
+ * Anchor classifier for promotion gating. A reasoning block counts as
122
+ * minimal-like when it contains `we` and no `let me`; a block with any
123
+ * `let me` is standard-like; everything else is ambiguous. This is a
124
+ * deliberate relaxation of the modeltest identity probe: the gate decides
125
+ * trajectory surface, not model identity, and `we` presence without
126
+ * first-person execution phrases is the stable surface marker.
127
+ */
128
+ export function classifyReasoning(text) {
129
+ const trimmed = String(text ?? '').trim()
130
+ const we = countWord(trimmed, /\bwe\b/gi)
131
+ const letMe = countWord(trimmed, /\blet me\b/gi)
132
+ const metrics = { we, letMe }
133
+ if (we > 0 && letMe === 0) return { label: 'minimal-like', score: 4, metrics }
134
+ if (letMe > 0) return { label: 'standard-like', score: -4, metrics }
135
+ return { label: 'ambiguous', score: 0, metrics }
136
+ }
137
+
138
+ /**
139
+ * Whether the FIRST reasoning block of an assistant message classifies as
140
+ * minimal-like. Later blocks do not override an earlier standard-like first
141
+ * block.
142
+ */
143
+ export function hasAnchoredReasoning(content) {
144
+ if (!Array.isArray(content)) return false
145
+ const first = content.find(block => block?.type === 'reasoning')
146
+ return first !== undefined && classifyReasoning(first.text).label === 'minimal-like'
147
+ }
148
+
149
+ /**
150
+ * Whether one pre-step message belongs to a whitelisted source kind. The
151
+ * configured whitelist alone decides; injected kinds and source-less seed
152
+ * messages never pass unless explicitly named. (Before issue #578 this also
153
+ * hardcoded `kind === 'user'`, so no whitelist entry could ever admit a
154
+ * goal auto-round and `/goal` sessions deadlocked in phase 1.)
155
+ */
156
+ function isAllowedMessage(message, allowedSources) {
157
+ const kind = message.source?.kind
158
+ return kind !== undefined && allowedSources.has(kind)
159
+ }
160
+
161
+ /** Whether one pre-step message belongs to a deferred injection kind. */
162
+ function isDeferredMessage(message, deferredSources) {
163
+ const kind = message.source?.kind
164
+ return kind !== undefined && deferredSources.has(kind)
165
+ }
166
+ // Instruction-hint mode (issue #388): a full-text agent-instructions dump on
167
+ // the promotion boundary flips the anchored trajectory (upstream
168
+ // dsh-anchored-standard #49; E1/E1.5/E2 wording experiments), so the preset
169
+ // can replace it with a single non-imperative hint that names the reference
170
+ // files and lets the model read them on demand.
171
+ const INSTRUCTION_FROM_RE = /(?:^|\n) *(?:Additional |Updated )?Instructions from: ([^\n]+)/g
172
+
173
+ /** Extract the reference file list one agent-instructions message renders. */
174
+ function extractInstructionPaths(message) {
175
+ const paths = []
176
+ const blocks = Array.isArray(message?.content) ? message.content : []
177
+ for (const block of blocks) {
178
+ if (block?.type !== 'text' || typeof block.text !== 'string') continue
179
+ for (const match of block.text.matchAll(INSTRUCTION_FROM_RE)) {
180
+ const path = match[1].trim()
181
+ if (path !== '' && !paths.includes(path)) paths.push(path)
182
+ }
183
+ }
184
+ return paths
185
+ }
186
+
187
+ /** The one-time non-imperative hint replacing the full-text dump (E1.5 wording). */
188
+ function buildInstructionHint(original, paths) {
189
+ return {
190
+ // Session persistence validates every replayed user/message for a
191
+ // non-empty string id; a plugin-built message without one corrupts the
192
+ // durable journal (SessionPersistenceCorruptionError on load). Inherit
193
+ // the original instructions message id when present (#510), else mint one.
194
+ id: typeof original?.id === 'string' && original.id !== ''
195
+ ? original.id
196
+ : globalThis.crypto.randomUUID(),
197
+ role: 'user',
198
+ content: [{
199
+ type: 'text',
200
+ text: '<system-reminder>\n'
201
+ + 'Reference documents exist: ' + paths.join(', ') + '. '
202
+ + "They are reference documents about the user's environment and workspace conventions, not task instructions. "
203
+ + 'Reading the relevant file before workspace tasks is recommended, but consult them only when you need those details; the task itself never depends on them.'
204
+ + '\n</system-reminder>',
205
+ }],
206
+ source: { kind: 'instruction-hint', plugin: name },
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Swap full-text agent-instructions injections for the one-time hint. The
212
+ * first injection carrying extractable paths becomes the hint; every later
213
+ * injection is dropped silently (the model re-reads the files on demand).
214
+ * An injection with no extractable paths passes through untouched.
215
+ */
216
+ function instructionHintMessages(messages, state) {
217
+ const kept = []
218
+ for (const message of messages) {
219
+ if (message?.source?.kind !== 'agent-instructions') {
220
+ kept.push(message)
221
+ continue
222
+ }
223
+ if (state.instructionHinted) continue
224
+ const paths = extractInstructionPaths(message)
225
+ if (paths.length === 0) {
226
+ kept.push(message)
227
+ continue
228
+ }
229
+ state.instructionHinted = true
230
+ kept.push(buildInstructionHint(message, paths))
231
+ }
232
+ return kept
233
+ }
234
+
235
+ /**
236
+ * Phase-2 promotion state per session. Sessions append events only, so the
237
+ * scan resumes from the first event it has not inspected yet.
238
+ */
239
+ const promotionBySession = new WeakMap()
240
+
241
+ /** Live agents observed by the assemble/pre-step listeners, keyed by session. */
242
+ const agentBySession = new WeakMap()
243
+
244
+ function stateFor(session) {
245
+ let state = promotionBySession.get(session)
246
+ if (state === undefined) {
247
+ state = {
248
+ next: 0,
249
+ promoted: false,
250
+ toolCalled: false,
251
+ responded: false,
252
+ anchored: false,
253
+ turnEnded: false,
254
+ steps: 0,
255
+ deferredSteps: 0,
256
+ instructionHinted: false,
257
+ presentationApplied: false,
258
+ hasCompacted: false,
259
+ presentationDisposer: undefined,
260
+ }
261
+ promotionBySession.set(session, state)
262
+ }
263
+ return state
264
+ }
265
+
266
+ /**
267
+ * Reset one session back to the CONTROLLED phase after a compaction. A
268
+ * compaction rewrites the whole model-visible surface — the first
269
+ * post-compaction request is a "second first request" with the same
270
+ * first-token conditions the bootstrap exists to control — so the session
271
+ * re-anchors: promotion state is cleared (the durable `next` scan pointer is
272
+ * kept, so events recorded BEFORE the boundary never re-promote), and the
273
+ * PTC Mode presentation is disposed so the next assembly sees the native
274
+ * catalog and the phase-1 filter can narrow it again.
275
+ */
276
+ function resetToControlled(state, session) {
277
+ if (typeof state.presentationDisposer === 'function') {
278
+ try {
279
+ state.presentationDisposer()
280
+ } catch {
281
+ // A failed presentation reset must never break the session; the
282
+ // next promotion re-declares PTC Mode anyway.
283
+ }
284
+ state.presentationDisposer = undefined
285
+ const agent = session !== undefined ? agentBySession.get(session) : undefined
286
+ if (agent?.ctx && typeof agent.ctx.emit === 'function') {
287
+ agent.ctx.emit('tools/presentation-changed', { mode: 'native', session: session?.id })
288
+ }
289
+ }
290
+ state.promoted = false
291
+ state.toolCalled = false
292
+ state.responded = false
293
+ state.anchored = false
294
+ state.turnEnded = false
295
+ state.steps = 0
296
+ state.deferredSteps = 0
297
+ state.instructionHinted = false
298
+ state.presentationApplied = false
299
+ state.hasCompacted = true
300
+ }
301
+
302
+ /**
303
+ * Switch one agent's wire presentation to PTC Mode (PTC: a single `run_code`
304
+ * tool backed by the generated SDK) after promotion. `agent.ctx.tools` is the
305
+ * per-agent view of the host registry, so the switch affects this session only.
306
+ */
307
+ function applyPresentation(agent, state, policy) {
308
+ if (state.presentationApplied || policy.promotedPresentation !== 'code') return
309
+ const tools = agent?.ctx?.tools
310
+ // Latch only after the switch really happened: without a tools view there
311
+ // is nothing to present, and latching early would skip PTC Mode forever.
312
+ if (tools === undefined) return
313
+ // The disposer restores the deployment-default (native) presentation; it is
314
+ // kept on the state so a post-compaction reset can release PTC Mode and
315
+ // let the phase-1 catalog filter see the native tool list again.
316
+ state.presentationDisposer = tools.presentAs('code')
317
+ state.presentationApplied = true
318
+ // #1128: Broadcast presentation switch so external discipline / analysis
319
+ // plugins decouple presentation mode from tool failure detection.
320
+ if (typeof agent?.ctx?.emit === 'function') {
321
+ agent.ctx.emit('tools/presentation-changed', { mode: 'code', session: agent.session?.id })
322
+ }
323
+ }
324
+
325
+ /**
326
+ * a) first tool call, no anchor gate — promote immediately;
327
+ * b) first tool call, anchored or `maxBootstrapSteps` fallback — promote;
328
+ * c) first tool call, still gated, but the first turn ended and
329
+ * `promoteAfterFirstResponse` is set — release on the new user turn (the
330
+ * release happens during prompt assembly, so that turn already gets the
331
+ * full catalog);
332
+ * d) tool-less first response with `promoteAfterFirstResponse` — promote.
333
+ */
334
+ function decidePromotion(state, config) {
335
+ if (state.toolCalled && config.anchorGate !== true) return true
336
+ if (state.toolCalled && config.anchorGate === true && (state.anchored || state.steps >= config.maxBootstrapSteps)) return true
337
+ if (state.toolCalled && config.anchorGate === true && config.promoteAfterFirstResponse === true && state.turnEnded) return true
338
+ if (!state.toolCalled && state.responded && config.promoteAfterFirstResponse === true) return true
339
+ return false
340
+ }
341
+
342
+ /**
343
+ * Read one session's append-only event log. DSH >= 0.1.2 removed the public
344
+ * `session.events` property in favor of `session.snapshotEvents()` (frozen
345
+ * full-log snapshot, cached until the next append); older harnesses exposed
346
+ * the array as `session.events`. Both stay supported. A session supplying
347
+ * neither degrades to empty so a broken scan can never crash a request —
348
+ * promotion then falls back to `maxBootstrapSteps` on the live
349
+ * `session/event` path.
350
+ */
351
+ function sessionEvents(session) {
352
+ if (typeof session?.snapshotEvents === 'function') {
353
+ const snapshot = session.snapshotEvents()
354
+ return Array.isArray(snapshot) ? snapshot : []
355
+ }
356
+ if (Array.isArray(session?.events)) return session.events
357
+ return []
358
+ }
359
+
360
+ /** Scan newly appended session events and update promotion state. */
361
+ function scanEvents(state, session) {
362
+ const events = sessionEvents(session)
363
+ for (; state.next < events.length; state.next += 1) {
364
+ const event = events[state.next]
365
+ if (event === undefined) continue
366
+ if (event.type === 'compaction/end') {
367
+ // A compaction rewrites the model-visible surface: the session falls
368
+ // back to the controlled phase until a NEW promotion signal exists
369
+ // past this boundary (the `next` pointer stays, so events before the
370
+ // boundary never re-promote). Handled inside the scan so cold starts
371
+ // reconstruct the same phase from the durable log.
372
+ resetToControlled(state, session)
373
+ } else if (event.type === 'tool/call') {
374
+ state.toolCalled = true
375
+ } else if (event.type === 'step/start') {
376
+ state.steps += 1
377
+ } else if (event.type === 'turn/end') {
378
+ state.turnEnded = true
379
+ } else if (event.type === 'assistant/message') {
380
+ state.responded = true
381
+ if (!state.anchored) state.anchored = hasAnchoredReasoning(event.data?.message?.content)
382
+ }
383
+ }
384
+ }
385
+
386
+ /** Update one agent's promotion state and apply its post-promotion presentation. */
387
+ function refresh(agent, policy) {
388
+ const session = agent?.session
389
+ if (session === undefined) return undefined
390
+ const state = stateFor(session)
391
+ agentBySession.set(session, agent)
392
+ if (!state.promoted) {
393
+ scanEvents(state, session)
394
+ if (decidePromotion(state, policy)) state.promoted = true
395
+ }
396
+ if (state.promoted) applyPresentation(agent, state, policy)
397
+ return state
398
+ }
399
+
400
+ const PTC_INSTRUCTION = '\n\nNote: You are in Programmatic Tool Calling (PTC) mode. All actions (running shell commands, file operations, web tools) MUST be performed via the `run_code` tool by writing and executing TypeScript/JavaScript programs. Do not attempt to invoke tools like `bash` or `str_replace_editor` directly on the wire.'
401
+
402
+ /**
403
+ * Append the session's working directory to the persona section of a promoted
404
+ * assembly. Returns the assembly unchanged when there is no persona section,
405
+ * no selected workspace, or the exact line is already present.
406
+ */
407
+ function withWorkspaceLine(assembly, agent) {
408
+ const cwd = agent?.session?.header?.cwd
409
+ if (typeof cwd !== 'string' || cwd.length === 0) return assembly
410
+ if (!Array.isArray(assembly.sections)) return assembly
411
+ const line = `${WORKSPACE_LINE_PREFIX}${cwd}.`
412
+ const persona = assembly.sections.find(section =>
413
+ PERSONA_SECTION_NAMES.has(section?.name)
414
+ && typeof section?.text === 'string'
415
+ && !section.text.includes(line)
416
+ // Orchestrator customization: this preset's persona already carries the
417
+ // workspace through the `{{cwd}}` prompt variable (interpolated at render
418
+ // time), so appending the literal line would duplicate it. The raw
419
+ // template is still present in the section text during assembly.
420
+ && !section.text.includes('{{cwd}}'))
421
+ if (persona === undefined) return assembly
422
+ return {
423
+ ...assembly,
424
+ sections: assembly.sections.map(section => section === persona
425
+ ? { ...section, text: `${persona.text}${line}` }
426
+ : section),
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Append PTC mode instructions to the persona section when promoted to code presentation.
432
+ */
433
+ function withPtcInstruction(assembly, policy) {
434
+ if (policy?.promotedPresentation !== 'code') return assembly
435
+ if (!Array.isArray(assembly.sections)) return assembly
436
+ const persona = assembly.sections.find(section =>
437
+ PERSONA_SECTION_NAMES.has(section?.name)
438
+ && typeof section?.text === 'string'
439
+ && !section.text.includes('PTC) mode'))
440
+ if (persona === undefined) return assembly
441
+ return {
442
+ ...assembly,
443
+ sections: assembly.sections.map(section => section === persona
444
+ ? { ...section, text: `${persona.text}${PTC_INSTRUCTION}` }
445
+ : section),
446
+ }
447
+ }
448
+
449
+ /** Register the per-session bootstrap quarantine and promotion policy. */
450
+ export function apply(ctx, config) {
451
+ const commonTools = stringList(config.commonTools, 'commonTools')
452
+ const shellTools = stringList(config.shellTools, 'shellTools')
453
+ const messageSources = new Set(stringList(config.messageSources, 'messageSources', DEFAULT_MESSAGE_SOURCES))
454
+ const deferredSources = new Set(stringListOrEmpty(config.deferredSources, 'deferredSources'))
455
+ const presentation = config.promotedPresentation ?? 'native'
456
+ if (presentation !== 'native' && presentation !== 'code') {
457
+ throw new TypeError(`${name}: promotedPresentation must be "native" or "code"`)
458
+ }
459
+
460
+ let warned = false
461
+ const warnOnce = (message) => {
462
+ if (warned) return
463
+ warned = true
464
+ try {
465
+ ctx.logger.warn(message)
466
+ } catch {
467
+ // Logger unavailable — the guard exists only to avoid spamming.
468
+ }
469
+ }
470
+ const bootstrapMaxTokens = config.bootstrapMaxTokens === undefined
471
+ ? undefined
472
+ : integerAtLeast(config.bootstrapMaxTokens, 'bootstrapMaxTokens', 1)
473
+ // Core work set exposed during the post-compaction controlled phase, so a
474
+ // mid-task model keeps working with a small catalog instead of the full
475
+ // Standard set. Defaults to none: the session stays on the bootstrap pair
476
+ // until a new promotion signal (the composition may widen it via config).
477
+ const compactionTools = stringListOrEmpty(config.compactionTools, 'compactionTools')
478
+ // Opt-in extra line for the phase-1 persona (test builds, issue #274):
479
+ // asks the model to ground its first answer with a Minimal-native tool
480
+ // call before responding. Unset keeps the exact one-line persona.
481
+ const phase1FirstCallInstruction = optionalString(config.phase1FirstCallInstruction, 'phase1FirstCallInstruction')
482
+ const policy = {
483
+ anchorGate: config.anchorGate === true,
484
+ promoteAfterFirstResponse: config.promoteAfterFirstResponse === true,
485
+ maxBootstrapSteps: integerAtLeast(config.maxBootstrapSteps ?? 4, 'maxBootstrapSteps', 1),
486
+ deferredGraceSteps: integerAtLeast(config.deferredGraceSteps ?? 0, 'deferredGraceSteps', 0),
487
+ promotedPresentation: presentation,
488
+ // Opt-in (issue #388): replace the post-promotion full-text
489
+ // agent-instructions dump with a one-time non-imperative hint naming the
490
+ // reference files, so the injection never flips the anchored trajectory.
491
+ instructionHint: config.instructionHint === true,
492
+ bootstrapMaxTokens,
493
+ compactionTools,
494
+ phase1FirstCallInstruction,
495
+ }
496
+
497
+ // Promotion is applied at step/turn boundaries, never while a step is still
498
+ // executing tools: switching the presentation mid-step would collapse the
499
+ // native calls that step already planned. By `step/end` the tool-call and
500
+ // reasoning events are durable, so the NEXT prompt assembly already sees
501
+ // PTC Mode with its generated SDK section. A `compaction/end` event
502
+ // releases PTC Mode and resets the promotion state (see
503
+ // resetToControlled); the reset also runs inside scanEvents, so a cold
504
+ // start reconstructs the same controlled phase from the durable log.
505
+ ctx.on('session/event', (session, event) => {
506
+ if (event.type === 'compaction/end') {
507
+ resetToControlled(stateFor(session), session)
508
+ return
509
+ }
510
+ if (event.type !== 'step/end' && event.type !== 'turn/end') return
511
+ const state = stateFor(session)
512
+ if (!state.promoted) {
513
+ scanEvents(state, session)
514
+ if (decidePromotion(state, policy)) state.promoted = true
515
+ }
516
+ if (state.promoted) {
517
+ const agent = agentBySession.get(session)
518
+ if (agent !== undefined) applyPresentation(agent, state, policy)
519
+ }
520
+ })
521
+
522
+ // `prepend: true` puts both filters at the outermost position of their
523
+ // waterfall, so `await next()` always observes the complete downstream
524
+ // result (including messages appended by listener order, not row order)
525
+ // before the quarantine strips it.
526
+ ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
527
+ // Downstream errors propagate untouched; only this filter's own logic is
528
+ // guarded (a filter bug must never brick every request of a session).
529
+ const assembled = await next()
530
+ const agent = context.agent
531
+ if (agent === undefined) return assembled
532
+ const state = refresh(agent, policy)
533
+ if (state.promoted) return withPtcInstruction(withWorkspaceLine(assembled, agent), policy)
534
+
535
+ const available = new Set(assembled.tools.map(tool => tool.name))
536
+ const selectedShells = shellTools.filter(toolName => available.has(toolName))
537
+ const missingCommon = commonTools.filter(toolName => !available.has(toolName))
538
+ if (selectedShells.length !== 1 || missingCommon.length > 0) {
539
+ // Composition drift must not lock a session out: degrade to the full
540
+ // catalog with a one-time warning instead of throwing (the bootstrap
541
+ // phase surfaces will simply not apply).
542
+ warnOnce(
543
+ `${name}: expected exactly one bootstrap shell and every common tool; `
544
+ + `shells=${JSON.stringify(selectedShells)}, missing=${JSON.stringify(missingCommon)} — `
545
+ + 'bootstrap disabled, full catalog exposed',
546
+ )
547
+ return assembled
548
+ }
549
+
550
+ const bootstrap = new Set([...selectedShells, ...commonTools])
551
+ // After a compaction the controlled phase widens with the core work set
552
+ // so mid-task work can continue before re-promotion.
553
+ if (state.hasCompacted) for (const toolName of compactionTools) bootstrap.add(toolName)
554
+ const sections = Array.isArray(assembled.sections)
555
+ ? assembled.sections.filter(section => PERSONA_SECTION_NAMES.has(section?.name))
556
+ : undefined
557
+ // Opt-in phase-1 instruction: appended once to the persona section so
558
+ // test builds can shift the first answer behind a Minimal-native tool
559
+ // call (issue #274). Unset leaves the exact one-line persona.
560
+ const phase1Sections = sections === undefined || phase1FirstCallInstruction === ''
561
+ ? sections
562
+ : sections.map(section => {
563
+ if (typeof section?.text !== 'string' || section.text.includes(phase1FirstCallInstruction)) return section
564
+ return { ...section, text: `${section.text}${phase1FirstCallInstruction}` }
565
+ })
566
+ return {
567
+ ...assembled,
568
+ tools: assembled.tools.filter(tool => bootstrap.has(tool.name)),
569
+ contexts: [],
570
+ ...(phase1Sections !== undefined ? { sections: phase1Sections } : {}),
571
+ }
572
+ }, { prepend: true })
573
+
574
+ ctx.on('agent/pre-step', async (payload, next) => {
575
+ const decision = await next()
576
+ const agent = payload.agent
577
+ if (agent === undefined || decision.kind !== 'enter') return decision
578
+ const state = refresh(agent, policy)
579
+ if (state === undefined) return decision
580
+
581
+ if (!state.promoted) {
582
+ return {
583
+ ...decision,
584
+ messages: decision.messages.filter(message => isAllowedMessage(message, messageSources)),
585
+ }
586
+ }
587
+ let result = decision
588
+ if (state.deferredSteps < policy.deferredGraceSteps) {
589
+ state.deferredSteps += 1
590
+ result = {
591
+ ...result,
592
+ messages: result.messages.filter(message => !isDeferredMessage(message, deferredSources)),
593
+ }
594
+ }
595
+ if (policy.instructionHint) {
596
+ result = { ...result, messages: instructionHintMessages(result.messages, state) }
597
+ }
598
+ return result
599
+ }, { prepend: true })
600
+
601
+ // Phase 1 caps the next request output budget to bootstrapMaxTokens, the
602
+ // community-observed We-need trigger window (dsh-anchored-standard issue 6),
603
+ // and strips the cap again after promotion. The strip is mandatory:
604
+ // requestProposal(persistedHeader) carries a plain maxTokens from the
605
+ // previous header into the next request unless the adapter marked it a
606
+ // default, so an un-stripped cap would be soldered into every request.
607
+ ctx.on('agent/request', async (payload, next) => {
608
+ const resolved = await next()
609
+ const agent = payload?.agent
610
+ if (agent === undefined || policy.bootstrapMaxTokens === undefined) return resolved
611
+ const state = refresh(agent, policy)
612
+ if (state.promoted) {
613
+ if (resolved.maxTokens !== policy.bootstrapMaxTokens) return resolved
614
+ const rest = { ...resolved }
615
+ delete rest.maxTokens
616
+ return rest
617
+ }
618
+ return { ...resolved, maxTokens: policy.bootstrapMaxTokens }
619
+ }, { prepend: true })
620
+ }
@@ -1,2 +0,0 @@
1
- name: 编排者模式
2
- description: 主 Agent 协调模式:把复杂任务拆解后,用 dispatch / subagent / workflow 按场景委派给合适的子 Agent(预设/模型/推理强度/工具白名单可逐个覆盖),收集并整合结果。适合需要多路并行、按场景配子 Agent 的复杂任务。