@alwith-ai/dsh-agent 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/LICENSE +21 -0
- package/README.md +50 -0
- package/README.zh.md +50 -0
- package/THIRD_PARTY_NOTICES.md +32 -0
- package/THIRD_PARTY_NOTICES.zh.md +30 -0
- package/package.json +94 -0
- package/skills/cordis-plugin-development/SKILL.md +420 -0
- package/src/bridge.ts +903 -0
- package/src/codec.ts +55 -0
- package/src/compose.ts +60 -0
- package/src/main.ts +107 -0
- package/src/oauth.ts +138 -0
- package/src/plugins-cli.ts +200 -0
- package/src/plugins.ts +747 -0
- package/src/sessions-cli.ts +78 -0
- package/src/vendor/anchored-tool-bootstrap.d.mts +19 -0
- package/src/vendor/anchored-tool-bootstrap.mjs +495 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sessions` subcommand: session-log inspection for hosts without a live
|
|
3
|
+
* bridge (`bun src/main.ts sessions list --json`). Reads through dsh's own
|
|
4
|
+
* persistence backend — the on-disk format (zstd JSONL) stays private to it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { homedir } from "node:os"
|
|
8
|
+
import { join } from "node:path"
|
|
9
|
+
import { Context } from "@deepseek-ai/cordis"
|
|
10
|
+
import SessionStore from "@deepseek-ai/dsh-session"
|
|
11
|
+
import JsonlSessionPersistence from "@deepseek-ai/dsh-session-persistence-jsonl"
|
|
12
|
+
import type {} from "@deepseek-ai/dsh-session-persistence"
|
|
13
|
+
|
|
14
|
+
function defaultSessionsRoot(): string {
|
|
15
|
+
return process.env.ALWITH_DSH_SESSIONS_ROOT ?? join(homedir(), ".dsh-agent", "sessions")
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface CliFlags {
|
|
19
|
+
root: string
|
|
20
|
+
positional: string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseFlags(argv: string[]): CliFlags {
|
|
24
|
+
let root = defaultSessionsRoot()
|
|
25
|
+
const positional: string[] = []
|
|
26
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
27
|
+
const argument = argv[index]!
|
|
28
|
+
if (argument === "--root") {
|
|
29
|
+
const value = argv[index + 1]
|
|
30
|
+
if (value === undefined) throw new Error("--root requires a value")
|
|
31
|
+
root = value
|
|
32
|
+
index += 1
|
|
33
|
+
} else if (argument.startsWith("--")) {
|
|
34
|
+
throw new Error(`unknown flag ${argument}`)
|
|
35
|
+
} else {
|
|
36
|
+
positional.push(argument)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { root, positional }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type Persistence = NonNullable<Context["sessionPersistence"]>
|
|
43
|
+
|
|
44
|
+
async function withPersistence<T>(root: string, run: (persistence: Persistence) => Promise<T>): Promise<T> {
|
|
45
|
+
const ctx = new Context()
|
|
46
|
+
// The jsonl plugin is a backend; the coordinator that publishes
|
|
47
|
+
// ctx.sessionPersistence rides the session store.
|
|
48
|
+
await ctx.plugin(SessionStore)
|
|
49
|
+
await ctx.plugin(JsonlSessionPersistence, { root })
|
|
50
|
+
const persistence = ctx.get("sessionPersistence")
|
|
51
|
+
if (persistence === undefined) throw new Error("session persistence failed to mount")
|
|
52
|
+
try {
|
|
53
|
+
return await run(persistence)
|
|
54
|
+
} finally {
|
|
55
|
+
await ctx.fiber.dispose()
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function runSessionsCli(argv: string[]): Promise<void> {
|
|
60
|
+
const { root, positional } = parseFlags(argv)
|
|
61
|
+
const [command, ...rest] = positional
|
|
62
|
+
if (command === "list") {
|
|
63
|
+
const headers = await withPersistence(root, persistence => persistence.list())
|
|
64
|
+
const sessions = [...headers]
|
|
65
|
+
.sort((a, b) => b.createdAt - a.createdAt)
|
|
66
|
+
.map(header => ({ id: header.id, createdAt: header.createdAt, cwd: header.cwd, parentSession: header.parentSession }))
|
|
67
|
+
process.stdout.write(`${JSON.stringify({ root, sessions })}\n`)
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
if (command === "show") {
|
|
71
|
+
const [id] = rest
|
|
72
|
+
if (id === undefined) throw new Error("usage: sessions show <sessionId> [--root <dir>]")
|
|
73
|
+
const inspection = await withPersistence(root, persistence => persistence.inspect(id as never))
|
|
74
|
+
process.stdout.write(`${JSON.stringify({ root, meta: inspection.meta, events: inspection.events })}\n`)
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
throw new Error(`unknown sessions command "${command ?? ""}": expected list or show`)
|
|
78
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Context } from "@deepseek-ai/cordis"
|
|
2
|
+
|
|
3
|
+
export declare const name: string
|
|
4
|
+
export declare const inject: string[]
|
|
5
|
+
export interface AnchoredToolBootstrapConfig {
|
|
6
|
+
shellTools?: string[]
|
|
7
|
+
commonTools?: string[]
|
|
8
|
+
messageSources?: string[]
|
|
9
|
+
anchorGate?: boolean
|
|
10
|
+
maxBootstrapSteps?: number
|
|
11
|
+
promoteAfterFirstResponse?: boolean
|
|
12
|
+
bootstrapMaxTokens?: number
|
|
13
|
+
compactionTools?: string[]
|
|
14
|
+
deferredSources?: string[]
|
|
15
|
+
deferredGraceSteps?: number
|
|
16
|
+
promotedPresentation?: "code"
|
|
17
|
+
phase1FirstCallInstruction?: string
|
|
18
|
+
}
|
|
19
|
+
export declare function apply(ctx: Context, config?: AnchoredToolBootstrapConfig): void
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VENDORED — do not edit by hand.
|
|
3
|
+
*
|
|
4
|
+
* Source: dsh-web-ui (https://github.com/zhu1090093659/dsh-web-ui) at commit
|
|
5
|
+
* 0ea284c, packages/dsh-liangshen/presets/liangshen/tool-bootstrap.mjs
|
|
6
|
+
* (Apache-2.0), itself derived from
|
|
7
|
+
* https://github.com/xiaobright/dsh-anchored-standard (MIT) with the
|
|
8
|
+
* two-phase quarantine extensions. See THIRD_PARTY_NOTICES.md.
|
|
9
|
+
*
|
|
10
|
+
* Local composition notes: this sidecar promotes to the NATIVE full catalog
|
|
11
|
+
* (promotedPresentation stays unset — Code Mode is not composed here), and
|
|
12
|
+
* the deferred-injection sources are unused because the composition does not
|
|
13
|
+
* mount agent-instructions or a skill catalog.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Keep the first model request on a minimal-shaped input surface, then expose
|
|
17
|
+
* the full preset catalog once the session is safely anchored.
|
|
18
|
+
*
|
|
19
|
+
* Phase 1 (no persisted `tool/call` yet):
|
|
20
|
+
* - tool catalog: one platform shell plus `commonTools`
|
|
21
|
+
* - prompt sections: only the persona section (all other sections,
|
|
22
|
+
* including plan-mode's `plan:policy`, return after promotion)
|
|
23
|
+
* - runtime contexts: emptied (no sandbox/approval snapshot)
|
|
24
|
+
* - pre-step messages: only explicit user messages pass
|
|
25
|
+
*
|
|
26
|
+
* Promotion opens the full tool catalog and restores runtime contexts and all
|
|
27
|
+
* prompt sections. With `anchorGate` the promotion after the first tool call
|
|
28
|
+
* also requires one minimal-like reasoning block (a first block containing
|
|
29
|
+
* `we` and no `let me`) or the `maxBootstrapSteps` fallback.
|
|
30
|
+
* `promoteAfterFirstResponse` promotes a tool-less first response once it has
|
|
31
|
+
* responded, and also releases an anchor-gated session when its first turn
|
|
32
|
+
* ends (`turn/end`). With `promotedPresentation: code` the promoted catalog
|
|
33
|
+
* is presented as Code Mode (PTC): the wire shows a single `run_code` tool
|
|
34
|
+
* backed by the generated SDK, switched at the step boundary so the current
|
|
35
|
+
* step's native calls are never interrupted. `deferredSources` and
|
|
36
|
+
* `deferredGraceSteps` delay selected injected message kinds (workspace
|
|
37
|
+
* instructions, skill catalog) for a few steps after promotion.
|
|
38
|
+
*
|
|
39
|
+
* COMPACTION (local addition, ported from the upstream compaction-epoch
|
|
40
|
+
* semantics): a compaction rewrites the whole model-visible surface, so the
|
|
41
|
+
* first post-compaction request is a "second first request". A
|
|
42
|
+
* `compaction/end` event releases Code Mode (the presentation disposer) and
|
|
43
|
+
* resets the promotion state to the CONTROLLED phase — bootstrap pair plus
|
|
44
|
+
* `compactionTools` (a core work set, default none) — until a NEW durable
|
|
45
|
+
* promotion signal exists past that boundary. The reset lives both in the
|
|
46
|
+
* live `session/event` path and inside the durable-log scan, so resume and
|
|
47
|
+
* reload reconstruct the same phase.
|
|
48
|
+
*
|
|
49
|
+
* ROBUSTNESS: composition drift (a missing bootstrap shell or common tool)
|
|
50
|
+
* degrades to the full catalog with a one-time warning instead of throwing,
|
|
51
|
+
* so a broken composition can never lock a session out of every request.
|
|
52
|
+
*
|
|
53
|
+
* OPT-IN PHASE-1 INSTRUCTION (issue #274): `phase1FirstCallInstruction` is
|
|
54
|
+
* an optional string appended to the phase-1 persona; unset (the default)
|
|
55
|
+
* keeps the phase-1 persona the exact one-line Minimal anchor. Test builds
|
|
56
|
+
* use it to ask the model to ground its first answer with one Minimal-native
|
|
57
|
+
* tool call before responding, so first-turn capability questions are
|
|
58
|
+
* answered from the promoted registry instead of the cropped two-tool view.
|
|
59
|
+
*
|
|
60
|
+
* Source: https://github.com/xiaobright/dsh-anchored-standard (MIT), extended
|
|
61
|
+
* with the phase-1 quarantine and the stabilization controls above.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
65
|
+
export const name = 'anchored-tool-bootstrap'
|
|
66
|
+
|
|
67
|
+
/** Prompt assembly and the tool registry must exist before this filter runs. */
|
|
68
|
+
export const inject = ['systemPrompt', 'tools']
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Prompt section names that carry the preset persona. The `dsh-persona` row
|
|
72
|
+
* registers the preset persona as `deployment:persona` (the PERSONA_SECTION
|
|
73
|
+
* name of `@deepseek-ai/dsh-system-prompt`), shadowing the deployment
|
|
74
|
+
* default for the preset scope; `persona` is the legacy name kept for older
|
|
75
|
+
* harnesses that registered the persona section without the prefix.
|
|
76
|
+
*/
|
|
77
|
+
const PERSONA_SECTION_NAMES = new Set(['deployment:persona', 'persona'])
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Workspace line a promoted persona gains. Phase 1 keeps the exact one-line
|
|
81
|
+
* persona (the Minimal anchor); after promotion the model must also know the
|
|
82
|
+
* session's selected workspace, which the Standard persona carries through
|
|
83
|
+
* the `{{cwd}}` prompt variable. The literal cwd is read from the session
|
|
84
|
+
* header at assembly time instead, so the line stays correct after a
|
|
85
|
+
* workspace switch and a session without a selected workspace keeps the bare
|
|
86
|
+
* one-liner rather than failing prompt interpolation.
|
|
87
|
+
*/
|
|
88
|
+
const WORKSPACE_LINE_PREFIX = '\n\nYour working directory is '
|
|
89
|
+
|
|
90
|
+
/** Message-source kinds the model may see during phase 1. */
|
|
91
|
+
const DEFAULT_MESSAGE_SOURCES = ['user']
|
|
92
|
+
|
|
93
|
+
/** Message-source kinds delayed after promotion. */
|
|
94
|
+
const DEFAULT_DEFERRED_SOURCES = []
|
|
95
|
+
|
|
96
|
+
function stringList(value, field, fallback) {
|
|
97
|
+
if (value === undefined) return [...fallback]
|
|
98
|
+
if (!Array.isArray(value) || value.length === 0 || value.some(item => typeof item !== 'string' || item.length === 0)) {
|
|
99
|
+
throw new TypeError(`${name}: ${field} must be a non-empty array of non-empty strings`)
|
|
100
|
+
}
|
|
101
|
+
return [...new Set(value)]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function stringListOrEmpty(value, field) {
|
|
105
|
+
if (value === undefined) return []
|
|
106
|
+
if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || item.length === 0)) {
|
|
107
|
+
throw new TypeError(`${name}: ${field} must be an array of non-empty strings`)
|
|
108
|
+
}
|
|
109
|
+
return [...new Set(value)]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function optionalString(value, field) {
|
|
113
|
+
if (value === undefined) return ''
|
|
114
|
+
if (typeof value !== 'string') {
|
|
115
|
+
throw new TypeError(`${name}: ${field} must be a string`)
|
|
116
|
+
}
|
|
117
|
+
return value
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function integerAtLeast(value, field, minimum) {
|
|
121
|
+
if (!Number.isInteger(value) || value < minimum) {
|
|
122
|
+
throw new TypeError(`${name}: ${field} must be an integer >= ${minimum}`)
|
|
123
|
+
}
|
|
124
|
+
return value
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function countWord(text, regex) {
|
|
128
|
+
return [...text.matchAll(regex)].length
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Anchor classifier for promotion gating. A reasoning block counts as
|
|
133
|
+
* minimal-like when it contains `we` and no `let me`; a block with any
|
|
134
|
+
* `let me` is standard-like; everything else is ambiguous. This is a
|
|
135
|
+
* deliberate relaxation of the modeltest identity probe: the gate decides
|
|
136
|
+
* trajectory surface, not model identity, and `we` presence without
|
|
137
|
+
* first-person execution phrases is the stable surface marker.
|
|
138
|
+
*/
|
|
139
|
+
export function classifyReasoning(text) {
|
|
140
|
+
const trimmed = String(text ?? '').trim()
|
|
141
|
+
const we = countWord(trimmed, /\bwe\b/gi)
|
|
142
|
+
const letMe = countWord(trimmed, /\blet me\b/gi)
|
|
143
|
+
const metrics = { we, letMe }
|
|
144
|
+
if (we > 0 && letMe === 0) return { label: 'minimal-like', score: 4, metrics }
|
|
145
|
+
if (letMe > 0) return { label: 'standard-like', score: -4, metrics }
|
|
146
|
+
return { label: 'ambiguous', score: 0, metrics }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Whether the FIRST reasoning block of an assistant message classifies as
|
|
151
|
+
* minimal-like. Later blocks do not override an earlier standard-like first
|
|
152
|
+
* block.
|
|
153
|
+
*/
|
|
154
|
+
export function hasAnchoredReasoning(content) {
|
|
155
|
+
if (!Array.isArray(content)) return false
|
|
156
|
+
const first = content.find(block => block?.type === 'reasoning')
|
|
157
|
+
return first !== undefined && classifyReasoning(first.text).label === 'minimal-like'
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Whether one pre-step message is an explicit user message. Only `kind:
|
|
162
|
+
* 'user'` passes; injected kinds and source-less seed messages never pass.
|
|
163
|
+
*/
|
|
164
|
+
function isAllowedMessage(message, allowedSources) {
|
|
165
|
+
const kind = message.source?.kind
|
|
166
|
+
return kind === 'user' && allowedSources.has(kind)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Whether one pre-step message belongs to a deferred injection kind. */
|
|
170
|
+
function isDeferredMessage(message, deferredSources) {
|
|
171
|
+
const kind = message.source?.kind
|
|
172
|
+
return kind !== undefined && deferredSources.has(kind)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Phase-2 promotion state per session. Sessions append events only, so the
|
|
177
|
+
* scan resumes from the first event it has not inspected yet.
|
|
178
|
+
*/
|
|
179
|
+
const promotionBySession = new WeakMap()
|
|
180
|
+
|
|
181
|
+
/** Live agents observed by the assemble/pre-step listeners, keyed by session. */
|
|
182
|
+
const agentBySession = new WeakMap()
|
|
183
|
+
|
|
184
|
+
function stateFor(session) {
|
|
185
|
+
let state = promotionBySession.get(session)
|
|
186
|
+
if (state === undefined) {
|
|
187
|
+
state = {
|
|
188
|
+
next: 0,
|
|
189
|
+
promoted: false,
|
|
190
|
+
toolCalled: false,
|
|
191
|
+
responded: false,
|
|
192
|
+
anchored: false,
|
|
193
|
+
turnEnded: false,
|
|
194
|
+
steps: 0,
|
|
195
|
+
deferredSteps: 0,
|
|
196
|
+
presentationApplied: false,
|
|
197
|
+
hasCompacted: false,
|
|
198
|
+
presentationDisposer: undefined,
|
|
199
|
+
}
|
|
200
|
+
promotionBySession.set(session, state)
|
|
201
|
+
}
|
|
202
|
+
return state
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Reset one session back to the CONTROLLED phase after a compaction. A
|
|
207
|
+
* compaction rewrites the whole model-visible surface — the first
|
|
208
|
+
* post-compaction request is a "second first request" with the same
|
|
209
|
+
* first-token conditions the bootstrap exists to control — so the session
|
|
210
|
+
* re-anchors: promotion state is cleared (the durable `next` scan pointer is
|
|
211
|
+
* kept, so events recorded BEFORE the boundary never re-promote), and the
|
|
212
|
+
* Code Mode presentation is disposed so the next assembly sees the native
|
|
213
|
+
* catalog and the phase-1 filter can narrow it again.
|
|
214
|
+
*/
|
|
215
|
+
function resetToControlled(state) {
|
|
216
|
+
if (typeof state.presentationDisposer === 'function') {
|
|
217
|
+
try {
|
|
218
|
+
state.presentationDisposer()
|
|
219
|
+
} catch {
|
|
220
|
+
// A failed presentation reset must never break the session; the
|
|
221
|
+
// next promotion re-declares Code Mode anyway.
|
|
222
|
+
}
|
|
223
|
+
state.presentationDisposer = undefined
|
|
224
|
+
}
|
|
225
|
+
state.promoted = false
|
|
226
|
+
state.toolCalled = false
|
|
227
|
+
state.responded = false
|
|
228
|
+
state.anchored = false
|
|
229
|
+
state.turnEnded = false
|
|
230
|
+
state.steps = 0
|
|
231
|
+
state.deferredSteps = 0
|
|
232
|
+
state.presentationApplied = false
|
|
233
|
+
state.hasCompacted = true
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Switch one agent's wire presentation to Code Mode (PTC: a single `run_code`
|
|
238
|
+
* tool backed by the generated SDK) after promotion. `agent.ctx.tools` is the
|
|
239
|
+
* per-agent view of the host registry, so the switch affects this session only.
|
|
240
|
+
*/
|
|
241
|
+
function applyPresentation(agent, state, policy) {
|
|
242
|
+
if (state.presentationApplied || policy.promotedPresentation !== 'code') return
|
|
243
|
+
state.presentationApplied = true
|
|
244
|
+
const tools = agent.ctx.tools
|
|
245
|
+
if (tools === undefined) return
|
|
246
|
+
// The disposer restores the deployment-default (native) presentation; it is
|
|
247
|
+
// kept on the state so a post-compaction reset can release Code Mode and
|
|
248
|
+
// let the phase-1 catalog filter see the native tool list again.
|
|
249
|
+
state.presentationDisposer = tools.presentAs('code')
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* a) first tool call, no anchor gate — promote immediately;
|
|
254
|
+
* b) first tool call, anchored or `maxBootstrapSteps` fallback — promote;
|
|
255
|
+
* c) first tool call, still gated, but the first turn ended and
|
|
256
|
+
* `promoteAfterFirstResponse` is set — release on the new user turn (the
|
|
257
|
+
* release happens during prompt assembly, so that turn already gets the
|
|
258
|
+
* full catalog);
|
|
259
|
+
* d) tool-less first response with `promoteAfterFirstResponse` — promote.
|
|
260
|
+
*/
|
|
261
|
+
function decidePromotion(state, config) {
|
|
262
|
+
if (state.toolCalled && config.anchorGate !== true) return true
|
|
263
|
+
if (state.toolCalled && config.anchorGate === true && (state.anchored || state.steps >= config.maxBootstrapSteps)) return true
|
|
264
|
+
if (state.toolCalled && config.anchorGate === true && config.promoteAfterFirstResponse === true && state.turnEnded) return true
|
|
265
|
+
if (!state.toolCalled && state.responded && config.promoteAfterFirstResponse === true) return true
|
|
266
|
+
return false
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Scan newly appended session events and update promotion state. */
|
|
270
|
+
function scanEvents(state, session) {
|
|
271
|
+
const events = session.snapshotEvents()
|
|
272
|
+
for (; state.next < events.length; state.next += 1) {
|
|
273
|
+
const event = events[state.next]
|
|
274
|
+
if (event === undefined) continue
|
|
275
|
+
if (event.type === 'compaction/end') {
|
|
276
|
+
// A compaction rewrites the model-visible surface: the session falls
|
|
277
|
+
// back to the controlled phase until a NEW promotion signal exists
|
|
278
|
+
// past this boundary (the `next` pointer stays, so events before the
|
|
279
|
+
// boundary never re-promote). Handled inside the scan so cold starts
|
|
280
|
+
// reconstruct the same phase from the durable log.
|
|
281
|
+
resetToControlled(state)
|
|
282
|
+
} else if (event.type === 'tool/call') {
|
|
283
|
+
state.toolCalled = true
|
|
284
|
+
} else if (event.type === 'step/start') {
|
|
285
|
+
state.steps += 1
|
|
286
|
+
} else if (event.type === 'turn/end') {
|
|
287
|
+
state.turnEnded = true
|
|
288
|
+
} else if (event.type === 'assistant/message') {
|
|
289
|
+
state.responded = true
|
|
290
|
+
if (!state.anchored) state.anchored = hasAnchoredReasoning(event.data?.message?.content)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Update one agent's promotion state and apply its post-promotion presentation. */
|
|
296
|
+
function refresh(agent, policy) {
|
|
297
|
+
const session = agent?.session
|
|
298
|
+
if (session === undefined) return undefined
|
|
299
|
+
const state = stateFor(session)
|
|
300
|
+
agentBySession.set(session, agent)
|
|
301
|
+
if (!state.promoted) {
|
|
302
|
+
scanEvents(state, session)
|
|
303
|
+
if (decidePromotion(state, policy)) state.promoted = true
|
|
304
|
+
}
|
|
305
|
+
if (state.promoted) applyPresentation(agent, state, policy)
|
|
306
|
+
return state
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Append the session's working directory to the persona section of a promoted
|
|
311
|
+
* assembly. Returns the assembly unchanged when there is no persona section,
|
|
312
|
+
* no selected workspace, or the exact line is already present.
|
|
313
|
+
*/
|
|
314
|
+
function withWorkspaceLine(assembly, agent) {
|
|
315
|
+
const cwd = agent?.session?.header?.cwd
|
|
316
|
+
if (typeof cwd !== 'string' || cwd.length === 0) return assembly
|
|
317
|
+
if (!Array.isArray(assembly.sections)) return assembly
|
|
318
|
+
const line = `${WORKSPACE_LINE_PREFIX}${cwd}.`
|
|
319
|
+
const persona = assembly.sections.find(section =>
|
|
320
|
+
PERSONA_SECTION_NAMES.has(section?.name)
|
|
321
|
+
&& typeof section?.text === 'string'
|
|
322
|
+
&& !section.text.includes(line))
|
|
323
|
+
if (persona === undefined) return assembly
|
|
324
|
+
return {
|
|
325
|
+
...assembly,
|
|
326
|
+
sections: assembly.sections.map(section => section === persona
|
|
327
|
+
? { ...section, text: `${persona.text}${line}` }
|
|
328
|
+
: section),
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Register the per-session bootstrap quarantine and promotion policy. */
|
|
333
|
+
export function apply(ctx, config) {
|
|
334
|
+
const commonTools = stringList(config.commonTools, 'commonTools')
|
|
335
|
+
const shellTools = stringList(config.shellTools, 'shellTools')
|
|
336
|
+
const messageSources = new Set(stringList(config.messageSources, 'messageSources', DEFAULT_MESSAGE_SOURCES))
|
|
337
|
+
const deferredSources = new Set(stringListOrEmpty(config.deferredSources, 'deferredSources'))
|
|
338
|
+
const presentation = config.promotedPresentation ?? 'native'
|
|
339
|
+
if (presentation !== 'native' && presentation !== 'code') {
|
|
340
|
+
throw new TypeError(`${name}: promotedPresentation must be "native" or "code"`)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
let warned = false
|
|
344
|
+
const warnOnce = (message) => {
|
|
345
|
+
if (warned) return
|
|
346
|
+
warned = true
|
|
347
|
+
try {
|
|
348
|
+
ctx.logger.warn(message)
|
|
349
|
+
} catch {
|
|
350
|
+
// Logger unavailable — the guard exists only to avoid spamming.
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const bootstrapMaxTokens = config.bootstrapMaxTokens === undefined
|
|
354
|
+
? undefined
|
|
355
|
+
: integerAtLeast(config.bootstrapMaxTokens, 'bootstrapMaxTokens', 1)
|
|
356
|
+
// Core work set exposed during the post-compaction controlled phase, so a
|
|
357
|
+
// mid-task model keeps working with a small catalog instead of the full
|
|
358
|
+
// Standard set. Defaults to none: the session stays on the bootstrap pair
|
|
359
|
+
// until a new promotion signal (the composition may widen it via config).
|
|
360
|
+
const compactionTools = stringListOrEmpty(config.compactionTools, 'compactionTools')
|
|
361
|
+
// Opt-in extra line for the phase-1 persona (test builds, issue #274):
|
|
362
|
+
// asks the model to ground its first answer with a Minimal-native tool
|
|
363
|
+
// call before responding. Unset keeps the exact one-line persona.
|
|
364
|
+
const phase1FirstCallInstruction = optionalString(config.phase1FirstCallInstruction, 'phase1FirstCallInstruction')
|
|
365
|
+
const policy = {
|
|
366
|
+
anchorGate: config.anchorGate === true,
|
|
367
|
+
promoteAfterFirstResponse: config.promoteAfterFirstResponse === true,
|
|
368
|
+
maxBootstrapSteps: integerAtLeast(config.maxBootstrapSteps ?? 4, 'maxBootstrapSteps', 1),
|
|
369
|
+
deferredGraceSteps: integerAtLeast(config.deferredGraceSteps ?? 0, 'deferredGraceSteps', 0),
|
|
370
|
+
promotedPresentation: presentation,
|
|
371
|
+
bootstrapMaxTokens,
|
|
372
|
+
compactionTools,
|
|
373
|
+
phase1FirstCallInstruction,
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Promotion is applied at step/turn boundaries, never while a step is still
|
|
377
|
+
// executing tools: switching the presentation mid-step would collapse the
|
|
378
|
+
// native calls that step already planned. By `step/end` the tool-call and
|
|
379
|
+
// reasoning events are durable, so the NEXT prompt assembly already sees
|
|
380
|
+
// Code Mode with its generated SDK section. A `compaction/end` event
|
|
381
|
+
// releases Code Mode and resets the promotion state (see
|
|
382
|
+
// resetToControlled); the reset also runs inside scanEvents, so a cold
|
|
383
|
+
// start reconstructs the same controlled phase from the durable log.
|
|
384
|
+
ctx.on('session/event', (session, event) => {
|
|
385
|
+
if (event.type === 'compaction/end') {
|
|
386
|
+
resetToControlled(stateFor(session))
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
if (event.type !== 'step/end' && event.type !== 'turn/end') return
|
|
390
|
+
const state = stateFor(session)
|
|
391
|
+
if (!state.promoted) {
|
|
392
|
+
scanEvents(state, session)
|
|
393
|
+
if (decidePromotion(state, policy)) state.promoted = true
|
|
394
|
+
}
|
|
395
|
+
if (state.promoted) {
|
|
396
|
+
const agent = agentBySession.get(session)
|
|
397
|
+
if (agent !== undefined) applyPresentation(agent, state, policy)
|
|
398
|
+
}
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
// `prepend: true` puts both filters at the outermost position of their
|
|
402
|
+
// waterfall, so `await next()` always observes the complete downstream
|
|
403
|
+
// result (including messages appended by listener order, not row order)
|
|
404
|
+
// before the quarantine strips it.
|
|
405
|
+
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
|
|
406
|
+
// Downstream errors propagate untouched; only this filter's own logic is
|
|
407
|
+
// guarded (a filter bug must never brick every request of a session).
|
|
408
|
+
const assembled = await next()
|
|
409
|
+
const agent = context.agent
|
|
410
|
+
if (agent === undefined) return assembled
|
|
411
|
+
const state = refresh(agent, policy)
|
|
412
|
+
if (state.promoted) return withWorkspaceLine(assembled, agent)
|
|
413
|
+
|
|
414
|
+
const available = new Set(assembled.tools.map(tool => tool.name))
|
|
415
|
+
const selectedShells = shellTools.filter(toolName => available.has(toolName))
|
|
416
|
+
const missingCommon = commonTools.filter(toolName => !available.has(toolName))
|
|
417
|
+
if (selectedShells.length !== 1 || missingCommon.length > 0) {
|
|
418
|
+
// Composition drift must not lock a session out: degrade to the full
|
|
419
|
+
// catalog with a one-time warning instead of throwing (the bootstrap
|
|
420
|
+
// phase surfaces will simply not apply).
|
|
421
|
+
warnOnce(
|
|
422
|
+
`${name}: expected exactly one bootstrap shell and every common tool; `
|
|
423
|
+
+ `shells=${JSON.stringify(selectedShells)}, missing=${JSON.stringify(missingCommon)} — `
|
|
424
|
+
+ 'bootstrap disabled, full catalog exposed',
|
|
425
|
+
)
|
|
426
|
+
return assembled
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const bootstrap = new Set([...selectedShells, ...commonTools])
|
|
430
|
+
// After a compaction the controlled phase widens with the core work set
|
|
431
|
+
// so mid-task work can continue before re-promotion.
|
|
432
|
+
if (state.hasCompacted) for (const toolName of compactionTools) bootstrap.add(toolName)
|
|
433
|
+
const sections = Array.isArray(assembled.sections)
|
|
434
|
+
? assembled.sections.filter(section => PERSONA_SECTION_NAMES.has(section?.name))
|
|
435
|
+
: undefined
|
|
436
|
+
// Opt-in phase-1 instruction: appended once to the persona section so
|
|
437
|
+
// test builds can shift the first answer behind a Minimal-native tool
|
|
438
|
+
// call (issue #274). Unset leaves the exact one-line persona.
|
|
439
|
+
const phase1Sections = sections === undefined || phase1FirstCallInstruction === ''
|
|
440
|
+
? sections
|
|
441
|
+
: sections.map(section => {
|
|
442
|
+
if (typeof section?.text !== 'string' || section.text.includes(phase1FirstCallInstruction)) return section
|
|
443
|
+
return { ...section, text: `${section.text}${phase1FirstCallInstruction}` }
|
|
444
|
+
})
|
|
445
|
+
return {
|
|
446
|
+
...assembled,
|
|
447
|
+
tools: assembled.tools.filter(tool => bootstrap.has(tool.name)),
|
|
448
|
+
contexts: [],
|
|
449
|
+
...(phase1Sections !== undefined ? { sections: phase1Sections } : {}),
|
|
450
|
+
}
|
|
451
|
+
}, { prepend: true })
|
|
452
|
+
|
|
453
|
+
ctx.on('agent/pre-step', async (payload, next) => {
|
|
454
|
+
const decision = await next()
|
|
455
|
+
const agent = payload.agent
|
|
456
|
+
if (agent === undefined || decision.kind !== 'enter') return decision
|
|
457
|
+
const state = refresh(agent, policy)
|
|
458
|
+
if (state === undefined) return decision
|
|
459
|
+
|
|
460
|
+
if (!state.promoted) {
|
|
461
|
+
return {
|
|
462
|
+
...decision,
|
|
463
|
+
messages: decision.messages.filter(message => isAllowedMessage(message, messageSources)),
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (state.deferredSteps < policy.deferredGraceSteps) {
|
|
467
|
+
state.deferredSteps += 1
|
|
468
|
+
return {
|
|
469
|
+
...decision,
|
|
470
|
+
messages: decision.messages.filter(message => !isDeferredMessage(message, deferredSources)),
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return decision
|
|
474
|
+
}, { prepend: true })
|
|
475
|
+
|
|
476
|
+
// Phase 1 caps the next request output budget to bootstrapMaxTokens, the
|
|
477
|
+
// community-observed We-need trigger window (dsh-anchored-standard issue 6),
|
|
478
|
+
// and strips the cap again after promotion. The strip is mandatory:
|
|
479
|
+
// requestProposal(persistedHeader) carries a plain maxTokens from the
|
|
480
|
+
// previous header into the next request unless the adapter marked it a
|
|
481
|
+
// default, so an un-stripped cap would be soldered into every request.
|
|
482
|
+
ctx.on('agent/request', async (payload, next) => {
|
|
483
|
+
const resolved = await next()
|
|
484
|
+
const agent = payload?.agent
|
|
485
|
+
if (agent === undefined || policy.bootstrapMaxTokens === undefined) return resolved
|
|
486
|
+
const state = refresh(agent, policy)
|
|
487
|
+
if (state.promoted) {
|
|
488
|
+
if (resolved.maxTokens !== policy.bootstrapMaxTokens) return resolved
|
|
489
|
+
const rest = { ...resolved }
|
|
490
|
+
delete rest.maxTokens
|
|
491
|
+
return rest
|
|
492
|
+
}
|
|
493
|
+
return { ...resolved, maxTokens: policy.bootstrapMaxTokens }
|
|
494
|
+
}, { prepend: true })
|
|
495
|
+
}
|