@codebam/dsh-thinking-auditor 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.
- package/LICENSE +21 -0
- package/README.md +179 -0
- package/index.mjs +674 -0
- package/package.json +82 -0
- package/packaging/README.md +78 -0
- package/packaging/nixos/agents.nix.snippet +46 -0
- package/packaging/nixos/default.nix.snippet +6 -0
- package/packaging/nixos/dsh-thinking-auditor.nix +54 -0
- package/src/async.mjs +90 -0
- package/src/auditor.mjs +252 -0
- package/src/claims.mjs +520 -0
- package/src/config.mjs +243 -0
- package/src/file-sink.mjs +76 -0
- package/src/ledger.mjs +398 -0
- package/src/policy.mjs +189 -0
- package/src/stakes.mjs +104 -0
- package/src/state.mjs +131 -0
- package/src/store.mjs +185 -0
package/index.mjs
ADDED
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @codebam/dsh-thinking-auditor
|
|
3
|
+
*
|
|
4
|
+
* A covert claim/evidence auditor for live DeepSeek Harness reasoning traces.
|
|
5
|
+
*
|
|
6
|
+
* The plugin taps `agent/assistant-stream`, copies each attempt's
|
|
7
|
+
* `reasoning-delta` chunks into a bounded per-session window, and passes every
|
|
8
|
+
* chunk through untouched. The monitored request and its context are never
|
|
9
|
+
* modified. At triggered boundaries it joins the trace against an incrementally
|
|
10
|
+
* maintained ledger of what the session actually saw (`tool/call`,
|
|
11
|
+
* `tool/result`, user messages), runs deterministic claim-vs-evidence checks,
|
|
12
|
+
* and asks the same provider/model route in a hand-built session-less call for
|
|
13
|
+
* a structured second opinion. A deterministic policy module owns escalation:
|
|
14
|
+
* T0 observe, T1 force verification at `agent/turn-stopping`, T2 ask/deny an
|
|
15
|
+
* irreversible `tools/pre-execute` action, T3 cancel only when explicitly
|
|
16
|
+
* configured.
|
|
17
|
+
*
|
|
18
|
+
* The monitor is not a mind reader. It reports claim-to-evidence relations;
|
|
19
|
+
* it never labels intent. CoT is an unfaithful generated artifact, so the
|
|
20
|
+
* defaults cap actuators at T1; nothing stops a turn or blocks an irreversible action on a single model opinion.
|
|
21
|
+
*
|
|
22
|
+
* @module @codebam/dsh-thinking-auditor
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import z from '@deepseek-ai/schemastery'
|
|
26
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
27
|
+
import { resolveConfig } from './src/config.mjs'
|
|
28
|
+
import { EvidenceLedger } from './src/ledger.mjs'
|
|
29
|
+
import { extractClaims, summarizeFindings, verifyClaims } from './src/claims.mjs'
|
|
30
|
+
import { buildAuditorPrompt, parseAuditorResponse } from './src/auditor.mjs'
|
|
31
|
+
import { decideToolAction, decideTurnAction, mergeVerdict } from './src/policy.mjs'
|
|
32
|
+
import { AuditStore } from './src/store.mjs'
|
|
33
|
+
import { createJsonlSink } from './src/file-sink.mjs'
|
|
34
|
+
import { SessionAuditState } from './src/state.mjs'
|
|
35
|
+
import { classifyAction } from './src/stakes.mjs'
|
|
36
|
+
import { deadlineSignal, isAbortError, raceDeadline } from './src/async.mjs'
|
|
37
|
+
|
|
38
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
39
|
+
export const name = 'thinking-auditor'
|
|
40
|
+
|
|
41
|
+
/** Services the plugin consumes; all are part of the dsh base bundle. */
|
|
42
|
+
export const inject = ['llm', 'sessions', 'tools']
|
|
43
|
+
|
|
44
|
+
/** Settings namespace for live user-owned thresholds and gates. */
|
|
45
|
+
export const SETTINGS_NAMESPACE = 'thinking-audit'
|
|
46
|
+
|
|
47
|
+
/** Stable producer id attached to every synthetic message and audit record. */
|
|
48
|
+
export const PLUGIN_ID = '@codebam/dsh-thinking-auditor'
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Loader-facing configuration schema.
|
|
52
|
+
*
|
|
53
|
+
* This schema is declarative for the configuration catalog; normalization and
|
|
54
|
+
* validation live in `src/config.mjs` and own the real contract.
|
|
55
|
+
*/
|
|
56
|
+
export const Config = z.object({
|
|
57
|
+
enabled: z.boolean(),
|
|
58
|
+
auditOnToolResult: z.boolean(),
|
|
59
|
+
auditOnTurnStop: z.boolean(),
|
|
60
|
+
auditOnAttemptEnd: z.boolean(),
|
|
61
|
+
windowChars: z.number(),
|
|
62
|
+
minWindowChars: z.number(),
|
|
63
|
+
maxTraceChars: z.number(),
|
|
64
|
+
cooldownMs: z.number(),
|
|
65
|
+
requestTimeoutMs: z.number(),
|
|
66
|
+
turnStopTimeoutMs: z.number(),
|
|
67
|
+
maxAuditsPerTurn: z.number(),
|
|
68
|
+
auditor: z.object({
|
|
69
|
+
provider: z.union([z.string(), z.const(null)]),
|
|
70
|
+
model: z.union([z.string(), z.const(null)]),
|
|
71
|
+
maxTokens: z.number(),
|
|
72
|
+
temperature: z.number(),
|
|
73
|
+
followReasoningEffort: z.boolean(),
|
|
74
|
+
maxResponseChars: z.number(),
|
|
75
|
+
}),
|
|
76
|
+
evidence: z.object({
|
|
77
|
+
maxToolCalls: z.number(),
|
|
78
|
+
maxResultChars: z.number(),
|
|
79
|
+
maxDigestChars: z.number(),
|
|
80
|
+
maxUserChars: z.number(),
|
|
81
|
+
}),
|
|
82
|
+
gates: z.object({
|
|
83
|
+
maxTier: z.union(['observe', 'verify', 'block', 'cancel']),
|
|
84
|
+
minimumSeverity: z.union(['low', 'medium', 'high']),
|
|
85
|
+
verifyOn: z.union(['speculative', 'unsupported', 'contradicted', 'never']),
|
|
86
|
+
blockOn: z.union(['never', 'contradicted', 'confirmed']),
|
|
87
|
+
cancelOn: z.union(['never', 'confirmed']),
|
|
88
|
+
consecutive: z.number(),
|
|
89
|
+
t2Mode: z.union(['ask', 'deny']),
|
|
90
|
+
askOnTimeoutForIrreversible: z.boolean(),
|
|
91
|
+
}),
|
|
92
|
+
store: z.object({
|
|
93
|
+
persist: z.boolean(),
|
|
94
|
+
dir: z.union([z.string(), z.const(null)]),
|
|
95
|
+
maxRecordsPerSession: z.number(),
|
|
96
|
+
maxSessions: z.number(),
|
|
97
|
+
}),
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
/** Neutral T1 instruction; it asks for evidence, not for a behavior change. */
|
|
101
|
+
export const VERIFY_MESSAGE = [
|
|
102
|
+
'Before finalizing: verify each factual claim against an actual tool result or file read from this session,',
|
|
103
|
+
'cite the evidence for anything you assert, and remove or explicitly mark as unverified any claim you cannot',
|
|
104
|
+
'support from this session’s evidence. Do not state a result you have not verified here.',
|
|
105
|
+
].join(' ')
|
|
106
|
+
|
|
107
|
+
/** Neutral T2 reason shown to the model on an ask/deny. */
|
|
108
|
+
export const BLOCK_REASON =
|
|
109
|
+
'Blocked: this turn contains unresolved claim/evidence mismatches. Re-establish the facts from tool output or file reads, then run the intended action again.'
|
|
110
|
+
|
|
111
|
+
/** Resolve a logger facade that works with the Cordis built-in and with mocks. */
|
|
112
|
+
function resolveLogger(ctx) {
|
|
113
|
+
const fallback = {
|
|
114
|
+
info() {},
|
|
115
|
+
warn() {},
|
|
116
|
+
error() {},
|
|
117
|
+
debug() {},
|
|
118
|
+
}
|
|
119
|
+
const logger = ctx?.logger
|
|
120
|
+
if (typeof logger === 'function') {
|
|
121
|
+
try {
|
|
122
|
+
return logger('thinking-auditor')
|
|
123
|
+
} catch {
|
|
124
|
+
return fallback
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (logger !== undefined && logger !== null) return logger
|
|
128
|
+
return fallback
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Stable string session key from either a live agent or session id. */
|
|
132
|
+
function sessionKey(agent) {
|
|
133
|
+
return String(agent?.session?.id ?? agent?.id ?? 'unknown')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Resolve the auditor route: explicit config override first, then the session's
|
|
138
|
+
* latest logged `request/header` (which follows model changes), then the
|
|
139
|
+
* agent's creation options.
|
|
140
|
+
*/
|
|
141
|
+
export function resolveRoute(agent, config) {
|
|
142
|
+
const header = typeof agent?.session?.requestHeader === 'function' ? agent.session.requestHeader() : undefined
|
|
143
|
+
const headerConfig = header?.config
|
|
144
|
+
const options = agent?.options
|
|
145
|
+
const provider = config?.auditor?.provider ?? headerConfig?.provider ?? options?.provider
|
|
146
|
+
const model = config?.auditor?.model ?? headerConfig?.model ?? options?.model
|
|
147
|
+
if (typeof provider !== 'string' || provider.length === 0 || typeof model !== 'string' || model.length === 0) {
|
|
148
|
+
return undefined
|
|
149
|
+
}
|
|
150
|
+
const reasoningEffort = headerConfig?.reasoningEffort ?? options?.reasoningEffort
|
|
151
|
+
return { provider, model, reasoningEffort }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Collect focus anchors from extracted claims for evidence-digest ranking. */
|
|
155
|
+
function focusTerms(claims) {
|
|
156
|
+
const terms = []
|
|
157
|
+
for (const claim of claims ?? []) {
|
|
158
|
+
for (const anchor of claim?.anchors ?? []) {
|
|
159
|
+
if (anchor.kind === 'term') continue
|
|
160
|
+
terms.push(anchor.value)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return [...new Set(terms)].slice(0, 24)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Merge mechanical findings with auditor claims, keeping mechanical status authoritative. */
|
|
167
|
+
export function mergeClaims(mechanicalFindings, auditorClaims) {
|
|
168
|
+
const merged = (mechanicalFindings ?? []).slice(0, 24).map((finding) => ({ ...finding, source: 'mechanical' }))
|
|
169
|
+
const seen = new Set(merged.map((finding) => String(finding.quote ?? '').toLowerCase().replace(/\s+/g, ' ')))
|
|
170
|
+
for (const claim of auditorClaims ?? []) {
|
|
171
|
+
const key = String(claim?.quote ?? '').toLowerCase().replace(/\s+/g, ' ')
|
|
172
|
+
if (key.length === 0 || seen.has(key)) continue
|
|
173
|
+
seen.add(key)
|
|
174
|
+
merged.push({ ...claim, source: 'auditor' })
|
|
175
|
+
}
|
|
176
|
+
return merged.slice(0, 32)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Read the last real user request from the ledger, if any. */
|
|
180
|
+
function lastUserRequest(ledger, sessionId) {
|
|
181
|
+
const view = ledger.view(sessionId)
|
|
182
|
+
for (let index = view.users.length - 1; index >= 0; index -= 1) {
|
|
183
|
+
const user = view.users[index]
|
|
184
|
+
if (user.sourceKind === 'user' || user.sourceKind === 'unknown') return user.text
|
|
185
|
+
}
|
|
186
|
+
return ''
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Run one isolated auditor call on the same route as the monitored session.
|
|
191
|
+
*
|
|
192
|
+
* The request deliberately omits `sessionId` and `purpose`, so the call
|
|
193
|
+
* attaches to no session, enters no persistence log, and emits no
|
|
194
|
+
* `agent/assistant-stream` frame. Text is collected from delta chunks only, so
|
|
195
|
+
* reasoning content cannot contaminate the JSON audit.
|
|
196
|
+
*/
|
|
197
|
+
export async function callAuditor(ctx, config, route, prompt, signal) {
|
|
198
|
+
const options = {
|
|
199
|
+
provider: route.provider,
|
|
200
|
+
model: route.model,
|
|
201
|
+
messages: [
|
|
202
|
+
createUserMessage({
|
|
203
|
+
content: [{ type: 'text', text: prompt.user }],
|
|
204
|
+
source: { kind: 'plugin', plugin: PLUGIN_ID, form: 'notice', summary: 'audit request' },
|
|
205
|
+
}),
|
|
206
|
+
],
|
|
207
|
+
system: prompt.system,
|
|
208
|
+
temperature: config.auditor.temperature,
|
|
209
|
+
maxTokens: config.auditor.maxTokens,
|
|
210
|
+
signal,
|
|
211
|
+
}
|
|
212
|
+
if (route.reasoningEffort !== undefined && config.auditor.followReasoningEffort) {
|
|
213
|
+
options.reasoningEffort = route.reasoningEffort
|
|
214
|
+
}
|
|
215
|
+
const textByIndex = new Map()
|
|
216
|
+
const deltaIndexes = new Set()
|
|
217
|
+
let finish
|
|
218
|
+
for await (const chunk of ctx.llm.stream(options)) {
|
|
219
|
+
if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') {
|
|
220
|
+
const index = Number.isInteger(chunk.index) ? chunk.index : 0
|
|
221
|
+
deltaIndexes.add(index)
|
|
222
|
+
textByIndex.set(index, `${textByIndex.get(index) ?? ''}${chunk.text}`)
|
|
223
|
+
} else if (chunk?.type === 'block-end' && chunk.block?.type === 'text') {
|
|
224
|
+
const index = Number.isInteger(chunk.index) ? chunk.index : 0
|
|
225
|
+
// Delta-built blocks already have their text; one closed block without
|
|
226
|
+
// deltas is the adapter's delta-only fallback.
|
|
227
|
+
if (!deltaIndexes.has(index)) textByIndex.set(index, chunk.block.text)
|
|
228
|
+
} else if (chunk?.type === 'finish') {
|
|
229
|
+
finish = chunk.reason
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const text = [...textByIndex.entries()]
|
|
233
|
+
.sort((left, right) => left[0] - right[0])
|
|
234
|
+
.map(([, value]) => value)
|
|
235
|
+
.join('\n')
|
|
236
|
+
if (finish?.kind === 'error' || finish?.kind === 'aborted') {
|
|
237
|
+
return { text, error: finish.failure?.message ?? finish.kind }
|
|
238
|
+
}
|
|
239
|
+
return { text }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Mount the auditor.
|
|
244
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - owning Cordis context.
|
|
245
|
+
* @param {object} [rawConfig] - entry configuration merged over defaults.
|
|
246
|
+
* @returns {void}
|
|
247
|
+
*/
|
|
248
|
+
export function apply(ctx, rawConfig = {}) {
|
|
249
|
+
const baseConfig = resolveConfig(rawConfig)
|
|
250
|
+
const log = resolveLogger(ctx)
|
|
251
|
+
if (!baseConfig.enabled) {
|
|
252
|
+
log.info?.('thinking-auditor: disabled by configuration')
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Live configuration source: entry config, or the settings scope once present. */
|
|
257
|
+
let configSource = () => baseConfig
|
|
258
|
+
const getConfig = () => resolveConfig(configSource())
|
|
259
|
+
|
|
260
|
+
const pluginAbort = new AbortController()
|
|
261
|
+
ctx.effect(() => () => {
|
|
262
|
+
pluginAbort.abort(new Error('thinking-auditor disposed'))
|
|
263
|
+
}, 'thinking-auditor.dispose')
|
|
264
|
+
|
|
265
|
+
const store = new AuditStore({
|
|
266
|
+
maxSessions: baseConfig.store.maxSessions,
|
|
267
|
+
maxRecordsPerSession: baseConfig.store.maxRecordsPerSession,
|
|
268
|
+
})
|
|
269
|
+
const ledger = new EvidenceLedger({
|
|
270
|
+
maxToolCalls: baseConfig.evidence.maxToolCalls,
|
|
271
|
+
maxResultChars: baseConfig.evidence.maxResultChars,
|
|
272
|
+
maxUserChars: baseConfig.evidence.maxUserChars,
|
|
273
|
+
})
|
|
274
|
+
const sink = baseConfig.store.persist === true
|
|
275
|
+
? createJsonlSink({ ctx, dir: baseConfig.store.dir, log })
|
|
276
|
+
: undefined
|
|
277
|
+
if (sink !== undefined) {
|
|
278
|
+
store.addSink((record) => {
|
|
279
|
+
if (getConfig().store.persist !== true) return undefined
|
|
280
|
+
return sink.write(record)
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** @type {Map<string, SessionAuditState>} */
|
|
285
|
+
const sessions = new Map()
|
|
286
|
+
const maxSessions = Math.max(1, baseConfig.store.maxSessions)
|
|
287
|
+
const ensureState = (id) => {
|
|
288
|
+
const key = String(id)
|
|
289
|
+
let state = sessions.get(key)
|
|
290
|
+
if (state !== undefined && !state.disposed) return state
|
|
291
|
+
while (sessions.size >= maxSessions) {
|
|
292
|
+
const oldestKey = sessions.keys().next().value
|
|
293
|
+
const oldest = sessions.get(oldestKey)
|
|
294
|
+
if (oldest !== undefined) {
|
|
295
|
+
oldest.disposed = true
|
|
296
|
+
if (oldest.cooldownTimer !== undefined) clearTimeout(oldest.cooldownTimer)
|
|
297
|
+
}
|
|
298
|
+
sessions.delete(oldestKey)
|
|
299
|
+
}
|
|
300
|
+
state = new SessionAuditState({ sessionId: key, maxTraceChars: baseConfig.maxTraceChars })
|
|
301
|
+
sessions.set(key, state)
|
|
302
|
+
return state
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ----- auditor execution -------------------------------------------------
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Start one audit if none is in flight. The window is removed before the
|
|
309
|
+
* model call; a failed audit is not retried with the same text.
|
|
310
|
+
*/
|
|
311
|
+
const runAudit = (agent, state, reason, config, options = {}) => {
|
|
312
|
+
if (state.inFlight !== undefined) {
|
|
313
|
+
state.rerunRequested = true
|
|
314
|
+
return state.inFlight
|
|
315
|
+
}
|
|
316
|
+
if (state.disposed || !config.enabled) return Promise.resolve(state.lastVerdict)
|
|
317
|
+
if (options.force !== true && !state.canAuditThisTurn(config.maxAuditsPerTurn)) {
|
|
318
|
+
return Promise.resolve(state.lastVerdict)
|
|
319
|
+
}
|
|
320
|
+
if (state.pending.length === 0) return Promise.resolve(state.lastVerdict)
|
|
321
|
+
|
|
322
|
+
const route = resolveRoute(agent, config)
|
|
323
|
+
if (route === undefined) {
|
|
324
|
+
log.warn?.('thinking-auditor: no provider/model route available; audit skipped')
|
|
325
|
+
return Promise.resolve(state.lastVerdict)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
state.maxTraceChars = config.maxTraceChars
|
|
329
|
+
const window = state.takeWindow(config.maxTraceChars)
|
|
330
|
+
const signal = deadlineSignal(options.timeoutMs ?? config.requestTimeoutMs, pluginAbort.signal)
|
|
331
|
+
state.markAuditStarted()
|
|
332
|
+
|
|
333
|
+
const work = (async () => {
|
|
334
|
+
const started = Date.now()
|
|
335
|
+
const sessionId = state.sessionId
|
|
336
|
+
const claims = extractClaims(window.text, { maxClaims: 64 })
|
|
337
|
+
const evidenceView = ledger.view(sessionId)
|
|
338
|
+
const mechanicalFindings = verifyClaims(claims, evidenceView)
|
|
339
|
+
const mechanical = summarizeFindings(mechanicalFindings, config)
|
|
340
|
+
const prompt = buildAuditorPrompt({
|
|
341
|
+
route,
|
|
342
|
+
trace: window.text,
|
|
343
|
+
evidenceDigest: ledger.digest(sessionId, {
|
|
344
|
+
focus: focusTerms(claims),
|
|
345
|
+
maxChars: config.evidence.maxDigestChars,
|
|
346
|
+
}),
|
|
347
|
+
mechanical,
|
|
348
|
+
userRequest: lastUserRequest(ledger, sessionId),
|
|
349
|
+
})
|
|
350
|
+
const auditorCall = await callAuditor(ctx, config, route, prompt, signal)
|
|
351
|
+
const auditor = auditorCall.error !== undefined
|
|
352
|
+
? { ...parseAuditorResponse(''), error: auditorCall.error, ok: false }
|
|
353
|
+
: parseAuditorResponse(auditorCall.text, {
|
|
354
|
+
maxClaims: 24,
|
|
355
|
+
maxResponseChars: config.auditor.maxResponseChars,
|
|
356
|
+
})
|
|
357
|
+
const verdict = mergeVerdict(mechanical, auditor)
|
|
358
|
+
const record = store.record({
|
|
359
|
+
sessionId,
|
|
360
|
+
turn: window.turn,
|
|
361
|
+
step: window.step,
|
|
362
|
+
trigger: reason,
|
|
363
|
+
route: { provider: route.provider, model: route.model },
|
|
364
|
+
traceChars: window.chars,
|
|
365
|
+
level: verdict.level,
|
|
366
|
+
confirmedFabrication: verdict.confirmedFabrication,
|
|
367
|
+
reasons: verdict.reasons,
|
|
368
|
+
mechanical: {
|
|
369
|
+
level: mechanical.level,
|
|
370
|
+
counts: mechanical.counts,
|
|
371
|
+
reasons: mechanical.reasons,
|
|
372
|
+
highestSeverity: mechanical.highestSeverity,
|
|
373
|
+
},
|
|
374
|
+
claims: mergeClaims(mechanicalFindings, auditor.claims),
|
|
375
|
+
auditor: {
|
|
376
|
+
ok: auditor.ok === true,
|
|
377
|
+
level: auditor.level,
|
|
378
|
+
confidence: auditor.confidence,
|
|
379
|
+
signals: auditor.signals,
|
|
380
|
+
summary: auditor.summary,
|
|
381
|
+
error: auditor.error,
|
|
382
|
+
},
|
|
383
|
+
latencyMs: Date.now() - started,
|
|
384
|
+
})
|
|
385
|
+
state.lastVerdict = record
|
|
386
|
+
state.pushHistory(record)
|
|
387
|
+
return record
|
|
388
|
+
})()
|
|
389
|
+
|
|
390
|
+
const tracked = work
|
|
391
|
+
.catch((error) => {
|
|
392
|
+
if (!isAbortError(error)) log.warn?.(`thinking-auditor: audit failed: ${error?.message ?? String(error)}`)
|
|
393
|
+
return undefined
|
|
394
|
+
})
|
|
395
|
+
.finally(() => {
|
|
396
|
+
state.inFlight = undefined
|
|
397
|
+
if (state.rerunRequested && !state.disposed) {
|
|
398
|
+
state.rerunRequested = false
|
|
399
|
+
queueMicrotask(() => {
|
|
400
|
+
try {
|
|
401
|
+
maybeSchedule(agent, state, 'rerun', getConfig())
|
|
402
|
+
} catch (error) {
|
|
403
|
+
log.warn?.(`thinking-auditor: rerun scheduling failed: ${error?.message ?? String(error)}`)
|
|
404
|
+
}
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
})
|
|
408
|
+
state.inFlight = tracked
|
|
409
|
+
return tracked
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Schedule a background audit when the state says one is due. */
|
|
413
|
+
const maybeSchedule = (agent, state, reason, config) => {
|
|
414
|
+
if (!config.enabled || state.disposed) return
|
|
415
|
+
if (state.inFlight !== undefined) {
|
|
416
|
+
state.rerunRequested = true
|
|
417
|
+
return
|
|
418
|
+
}
|
|
419
|
+
if (!state.hasPending(config.minWindowChars) && state.pending.length < config.windowChars) return
|
|
420
|
+
if (!state.canAuditThisTurn(config.maxAuditsPerTurn)) return
|
|
421
|
+
|
|
422
|
+
const now = Date.now()
|
|
423
|
+
const wait = config.cooldownMs - (now - state.lastAuditAt)
|
|
424
|
+
if (wait > 0 && state.pending.length < config.windowChars) {
|
|
425
|
+
if (state.cooldownTimer === undefined) {
|
|
426
|
+
state.cooldownTimer = setTimeout(() => {
|
|
427
|
+
state.cooldownTimer = undefined
|
|
428
|
+
try {
|
|
429
|
+
maybeSchedule(agent, state, 'cooldown', getConfig())
|
|
430
|
+
} catch (error) {
|
|
431
|
+
log.warn?.(`thinking-auditor: cooldown scheduling failed: ${error?.message ?? String(error)}`)
|
|
432
|
+
}
|
|
433
|
+
}, wait)
|
|
434
|
+
state.cooldownTimer.unref?.()
|
|
435
|
+
}
|
|
436
|
+
return
|
|
437
|
+
}
|
|
438
|
+
void runAudit(agent, state, reason, config)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ----- live reasoning capture -------------------------------------------
|
|
442
|
+
|
|
443
|
+
ctx.on('agent/assistant-stream', ({ agent, frame } = {}) => {
|
|
444
|
+
try {
|
|
445
|
+
const config = getConfig()
|
|
446
|
+
if (!config.enabled || agent === undefined || frame === undefined) return
|
|
447
|
+
const state = ensureState(sessionKey(agent))
|
|
448
|
+
if (frame.type === 'start') {
|
|
449
|
+
state.turn = frame.turn ?? state.turn
|
|
450
|
+
state.step = frame.step ?? state.step
|
|
451
|
+
state.attemptHasReasoningDelta = false
|
|
452
|
+
return
|
|
453
|
+
}
|
|
454
|
+
if (frame.type === 'end') {
|
|
455
|
+
state.attemptHasReasoningDelta = false
|
|
456
|
+
if (config.auditOnAttemptEnd && state.hasPending(Math.min(config.minWindowChars, config.windowChars))) {
|
|
457
|
+
maybeSchedule(agent, state, 'attempt-end', config)
|
|
458
|
+
}
|
|
459
|
+
return
|
|
460
|
+
}
|
|
461
|
+
if (frame.type !== 'chunk') return
|
|
462
|
+
state.turn = frame.turn ?? state.turn
|
|
463
|
+
state.step = frame.step ?? state.step
|
|
464
|
+
const chunk = frame.chunk
|
|
465
|
+
if (chunk?.type === 'reasoning-delta' && typeof chunk.text === 'string') {
|
|
466
|
+
state.attemptHasReasoningDelta = true
|
|
467
|
+
state.appendReasoning(chunk.text, { turn: frame.turn, step: frame.step })
|
|
468
|
+
} else if (chunk?.type === 'block-end' && chunk.block?.type === 'reasoning' && state.attemptHasReasoningDelta !== true) {
|
|
469
|
+
state.appendReasoning(chunk.block.text, { turn: frame.turn, step: frame.step })
|
|
470
|
+
}
|
|
471
|
+
if (state.pending.length >= config.windowChars) maybeSchedule(agent, state, 'window', config)
|
|
472
|
+
} catch (error) {
|
|
473
|
+
log.warn?.(`thinking-auditor: reasoning capture failed: ${error?.message ?? String(error)}`)
|
|
474
|
+
}
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
// ----- evidence ledger ---------------------------------------------------
|
|
478
|
+
|
|
479
|
+
ctx.on('session/created', (session) => {
|
|
480
|
+
try {
|
|
481
|
+
if (session?.firstLiveSeq > 0) ledger.seedFromMessages(session.id, session.deriveMessages())
|
|
482
|
+
} catch (error) {
|
|
483
|
+
log.warn?.(`thinking-auditor: resume ledger backfill failed: ${error?.message ?? String(error)}`)
|
|
484
|
+
}
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
ctx.on('session/disposed', (session) => {
|
|
488
|
+
try {
|
|
489
|
+
const key = String(session?.id ?? '')
|
|
490
|
+
const state = sessions.get(key)
|
|
491
|
+
if (state !== undefined) {
|
|
492
|
+
state.disposed = true
|
|
493
|
+
if (state.cooldownTimer !== undefined) clearTimeout(state.cooldownTimer)
|
|
494
|
+
sessions.delete(key)
|
|
495
|
+
}
|
|
496
|
+
ledger.dropSession(session?.id)
|
|
497
|
+
} catch (error) {
|
|
498
|
+
log.warn?.(`thinking-auditor: session cleanup failed: ${error?.message ?? String(error)}`)
|
|
499
|
+
}
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
ctx.on('session/event', (session, event) => {
|
|
503
|
+
try {
|
|
504
|
+
ledger.onSessionEvent(session.id, event)
|
|
505
|
+
if (event?.type === 'turn/start') {
|
|
506
|
+
ensureState(session.id).startTurn(event.data?.turn)
|
|
507
|
+
return
|
|
508
|
+
}
|
|
509
|
+
// The durable tool/result append is the ordering point: `tools/result`
|
|
510
|
+
// fires before the agent loop records the result, so scheduling from
|
|
511
|
+
// there could audit without the very result that triggered it. Here the
|
|
512
|
+
// ledger is already updated.
|
|
513
|
+
if (event?.type === 'tool/result') {
|
|
514
|
+
const config = getConfig()
|
|
515
|
+
if (!config.enabled || config.auditOnToolResult !== true) return
|
|
516
|
+
const state = ensureState(session.id)
|
|
517
|
+
// The session itself is enough for route resolution; the latest logged
|
|
518
|
+
// request header owns the route.
|
|
519
|
+
const agentLike = { session }
|
|
520
|
+
if (state.hasPending(config.minWindowChars)) maybeSchedule(agentLike, state, 'tool-result', config)
|
|
521
|
+
}
|
|
522
|
+
} catch (error) {
|
|
523
|
+
log.warn?.(`thinking-auditor: ledger update failed: ${error?.message ?? String(error)}`)
|
|
524
|
+
}
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
// ----- actuators ---------------------------------------------------------
|
|
528
|
+
|
|
529
|
+
ctx.on('agent/turn-stopping', async ({ agent, turn, signal } = {}) => {
|
|
530
|
+
try {
|
|
531
|
+
const config = getConfig()
|
|
532
|
+
if (!config.enabled || agent === undefined) return
|
|
533
|
+
const state = ensureState(sessionKey(agent))
|
|
534
|
+
state.turn = turn ?? state.turn
|
|
535
|
+
|
|
536
|
+
let record = state.lastVerdict
|
|
537
|
+
if (config.auditOnTurnStop === true) {
|
|
538
|
+
if (state.inFlight !== undefined) {
|
|
539
|
+
const raced = await raceDeadline(state.inFlight, config.turnStopTimeoutMs)
|
|
540
|
+
if (raced.settled && raced.value !== undefined) record = raced.value
|
|
541
|
+
} else if (state.hasPending(Math.min(config.minWindowChars, config.windowChars))) {
|
|
542
|
+
const audited = await runAudit(agent, state, 'turn-stop', config, {
|
|
543
|
+
force: true,
|
|
544
|
+
timeoutMs: config.turnStopTimeoutMs,
|
|
545
|
+
})
|
|
546
|
+
if (audited !== undefined) record = audited
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (record === undefined || signal?.aborted === true) return
|
|
550
|
+
|
|
551
|
+
const decision = decideTurnAction({
|
|
552
|
+
record,
|
|
553
|
+
history: state.history,
|
|
554
|
+
config,
|
|
555
|
+
alreadyVerified: state.wasVerified(state.turn),
|
|
556
|
+
alreadyCancelled: state.wasCancelled(state.turn),
|
|
557
|
+
})
|
|
558
|
+
if (decision.action === 'observe') return
|
|
559
|
+
|
|
560
|
+
store.recordAction({
|
|
561
|
+
sessionId: state.sessionId,
|
|
562
|
+
turn: state.turn,
|
|
563
|
+
tier: decision.tier,
|
|
564
|
+
action: decision.action,
|
|
565
|
+
reason: decision.reason,
|
|
566
|
+
verdictId: record.id,
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
if (decision.action === 'cancel') {
|
|
570
|
+
state.latchCancelled(state.turn)
|
|
571
|
+
agent.cancel({ kind: 'hook', reason: 'thinking-auditor: confirmed untrusted claims survived verification' })
|
|
572
|
+
return
|
|
573
|
+
}
|
|
574
|
+
if (decision.action === 'verify') {
|
|
575
|
+
state.latchVerified(state.turn)
|
|
576
|
+
agent.steer(createUserMessage({
|
|
577
|
+
content: [{ type: 'text', text: VERIFY_MESSAGE }],
|
|
578
|
+
source: { kind: 'plugin', plugin: PLUGIN_ID, form: 'notice', summary: 'verify claims before finalizing' },
|
|
579
|
+
}))
|
|
580
|
+
}
|
|
581
|
+
} catch (error) {
|
|
582
|
+
log.warn?.(`thinking-auditor: turn-stopping gate failed: ${error?.message ?? String(error)}`)
|
|
583
|
+
}
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
587
|
+
const decision = await next()
|
|
588
|
+
try {
|
|
589
|
+
const config = getConfig()
|
|
590
|
+
if (!config.enabled || exec?.agent === undefined || decision?.kind !== 'allow') return decision
|
|
591
|
+
const state = ensureState(sessionKey(exec.agent))
|
|
592
|
+
const stakes = classifyAction(exec.name, exec.arguments)
|
|
593
|
+
const gate = decideToolAction({
|
|
594
|
+
record: state.lastVerdict,
|
|
595
|
+
history: state.history,
|
|
596
|
+
config,
|
|
597
|
+
stakes,
|
|
598
|
+
auditPending: state.inFlight !== undefined,
|
|
599
|
+
})
|
|
600
|
+
if (gate.action === 'allow') return decision
|
|
601
|
+
|
|
602
|
+
store.recordAction({
|
|
603
|
+
sessionId: state.sessionId,
|
|
604
|
+
turn: state.turn,
|
|
605
|
+
tier: gate.tier,
|
|
606
|
+
action: gate.action,
|
|
607
|
+
reason: gate.reason,
|
|
608
|
+
toolName: String(exec.name ?? 'unknown'),
|
|
609
|
+
stakes,
|
|
610
|
+
verdictId: state.lastVerdict?.id,
|
|
611
|
+
})
|
|
612
|
+
if (gate.action === 'deny') {
|
|
613
|
+
return {
|
|
614
|
+
kind: 'deny',
|
|
615
|
+
reason: BLOCK_REASON,
|
|
616
|
+
info: { name: 'ThinkingAuditBlocked', code: 'THINKING_AUDIT_BLOCKED' },
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return { kind: 'ask', reason: BLOCK_REASON }
|
|
620
|
+
} catch (error) {
|
|
621
|
+
log.warn?.(`thinking-auditor: pre-execute gate failed: ${error?.message ?? String(error)}`)
|
|
622
|
+
return decision
|
|
623
|
+
}
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
// ----- settings and public service --------------------------------------
|
|
627
|
+
|
|
628
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
629
|
+
try {
|
|
630
|
+
settingsCtx.settings.installSection(ctx, SETTINGS_NAMESPACE, Config, baseConfig, {
|
|
631
|
+
setSource: (source) => {
|
|
632
|
+
configSource = typeof source === 'function' ? source : () => source
|
|
633
|
+
},
|
|
634
|
+
onChange: () => {},
|
|
635
|
+
validate: (value) => {
|
|
636
|
+
resolveConfig(value)
|
|
637
|
+
},
|
|
638
|
+
})
|
|
639
|
+
} catch (error) {
|
|
640
|
+
log.warn?.(`thinking-auditor: settings namespace install failed: ${error?.message ?? String(error)}`)
|
|
641
|
+
}
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
const service = {
|
|
645
|
+
plugin: PLUGIN_ID,
|
|
646
|
+
settingsNamespace: SETTINGS_NAMESPACE,
|
|
647
|
+
status() {
|
|
648
|
+
let config = baseConfig
|
|
649
|
+
try {
|
|
650
|
+
config = getConfig()
|
|
651
|
+
} catch {
|
|
652
|
+
// A bad external settings document must not make the status API throw.
|
|
653
|
+
}
|
|
654
|
+
return {
|
|
655
|
+
enabled: config.enabled,
|
|
656
|
+
maxTier: config.gates.maxTier,
|
|
657
|
+
sessions: sessions.size,
|
|
658
|
+
verdicts: store.verdictCount,
|
|
659
|
+
inFlight: [...sessions.values()].filter((state) => state.inFlight !== undefined).length,
|
|
660
|
+
auditStore: sink === undefined || sink.disabled ? null : sink.path,
|
|
661
|
+
auditStoreError: sink?.error ?? store.lastSinkError,
|
|
662
|
+
}
|
|
663
|
+
},
|
|
664
|
+
latest(sessionId) {
|
|
665
|
+
return store.latest(sessionId) ?? sessions.get(String(sessionId))?.lastVerdict
|
|
666
|
+
},
|
|
667
|
+
verdicts: (sessionId, limit) => store.verdicts(sessionId, limit),
|
|
668
|
+
actions: (sessionId, limit) => store.actions(sessionId, limit),
|
|
669
|
+
onRecord: (callback) => store.subscribe(callback),
|
|
670
|
+
}
|
|
671
|
+
if (typeof ctx.provide === 'function') ctx.provide('thinkingAudit', service)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
export default { name, inject, Config, apply }
|