dsh-auto-review 0.4.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/CHANGELOG.md +91 -0
- package/LICENSE +201 -0
- package/README.es.md +226 -0
- package/README.hi.md +226 -0
- package/README.md +226 -0
- package/README.pt.md +226 -0
- package/README.zh.md +226 -0
- package/cordis.patch.yml +27 -0
- package/lib/client.js +494 -0
- package/lib/client.js.map +1 -0
- package/lib/events-CFl6CHZi.js +344 -0
- package/lib/index.js +5305 -0
- package/lib/invariant.js +223 -0
- package/lib/types/client/ReviewPanel.d.ts +29 -0
- package/lib/types/client/ReviewPanel.d.ts.map +1 -0
- package/lib/types/client/ReviewPanel.js +63 -0
- package/lib/types/client/ReviewPanel.js.map +1 -0
- package/lib/types/client/index.d.ts +31 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/index.js +51 -0
- package/lib/types/client/index.js.map +1 -0
- package/lib/types/client/locales.d.ts +49 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/locales.js +82 -0
- package/lib/types/client/locales.js.map +1 -0
- package/lib/types/client/present.d.ts +25 -0
- package/lib/types/client/present.d.ts.map +1 -0
- package/lib/types/client/present.js +36 -0
- package/lib/types/client/present.js.map +1 -0
- package/lib/types/client/styles.d.ts +9 -0
- package/lib/types/client/styles.d.ts.map +1 -0
- package/lib/types/client/styles.js +130 -0
- package/lib/types/client/styles.js.map +1 -0
- package/lib/types/config.d.ts +200 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/config.js +154 -0
- package/lib/types/config.js.map +1 -0
- package/lib/types/events.d.ts +347 -0
- package/lib/types/events.d.ts.map +1 -0
- package/lib/types/events.js +369 -0
- package/lib/types/events.js.map +1 -0
- package/lib/types/index.d.ts +26 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +26 -0
- package/lib/types/index.js.map +1 -0
- package/lib/types/invariant.d.ts +28 -0
- package/lib/types/invariant.d.ts.map +1 -0
- package/lib/types/invariant.js +306 -0
- package/lib/types/invariant.js.map +1 -0
- package/lib/types/messages.d.ts +38 -0
- package/lib/types/messages.d.ts.map +1 -0
- package/lib/types/messages.js +65 -0
- package/lib/types/messages.js.map +1 -0
- package/lib/types/projection-types.d.ts +61 -0
- package/lib/types/projection-types.d.ts.map +1 -0
- package/lib/types/projection-types.js +8 -0
- package/lib/types/projection-types.js.map +1 -0
- package/lib/types/projection.d.ts +93 -0
- package/lib/types/projection.d.ts.map +1 -0
- package/lib/types/projection.js +197 -0
- package/lib/types/projection.js.map +1 -0
- package/lib/types/review.d.ts +141 -0
- package/lib/types/review.d.ts.map +1 -0
- package/lib/types/review.js +389 -0
- package/lib/types/review.js.map +1 -0
- package/lib/types/runtime.d.ts +160 -0
- package/lib/types/runtime.d.ts.map +1 -0
- package/lib/types/runtime.js +486 -0
- package/lib/types/runtime.js.map +1 -0
- package/package.json +132 -0
- package/src/client/ReviewPanel.tsx +164 -0
- package/src/client/index.ts +72 -0
- package/src/client/locales.ts +87 -0
- package/src/client/present.ts +58 -0
- package/src/client/styles.ts +131 -0
- package/src/config.ts +346 -0
- package/src/events.ts +553 -0
- package/src/index.ts +28 -0
- package/src/invariant.ts +326 -0
- package/src/messages.ts +91 -0
- package/src/projection-types.ts +61 -0
- package/src/projection.ts +224 -0
- package/src/review.ts +428 -0
- package/src/runtime.ts +562 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host projection unit for the `autoReview` panel value: folds the log-only
|
|
3
|
+
* `autoReview/*` events into one wire-JSON whole value the browser review
|
|
4
|
+
* panel reads via `useProjection('autoReview')`. Pure mathematics only — the
|
|
5
|
+
* framework owns subscriptions, watermarks, change feeds, and persistence.
|
|
6
|
+
* The fold mirrors the `events.ts` pure folds (budgets, statistics, denials).
|
|
7
|
+
* @module dsh-auto-review/projection
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { z } from 'zod'
|
|
11
|
+
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
|
12
|
+
import type { SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session'
|
|
13
|
+
import type { AutoReviewDenyView, AutoReviewProjection, AutoReviewVerdictView } from './projection-types.ts'
|
|
14
|
+
|
|
15
|
+
/** How many verdict rows the panel carries. */
|
|
16
|
+
const RECENT_CAP = 8
|
|
17
|
+
/** How many approve candidates the panel offers. */
|
|
18
|
+
const DENIES_CAP = 5
|
|
19
|
+
|
|
20
|
+
const VERDICT_VIEW_SCHEMA = z.object({
|
|
21
|
+
reviewId: z.string(),
|
|
22
|
+
toolName: z.string(),
|
|
23
|
+
at: z.number(),
|
|
24
|
+
durationMs: z.number().int().nonnegative(),
|
|
25
|
+
decision: z.union([z.literal('allow'), z.literal('deny')]).optional(),
|
|
26
|
+
riskLevel: z.union([z.literal('low'), z.literal('medium'), z.literal('high')]).optional(),
|
|
27
|
+
escalation: z.literal('risk-policy').optional(),
|
|
28
|
+
fallback: z.string().optional(),
|
|
29
|
+
outcome: z.union([z.literal('allowed-once'), z.literal('rejected'), z.literal('cancelled'), z.literal('unavailable')]).optional(),
|
|
30
|
+
reason: z.string().optional(),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const DENY_VIEW_SCHEMA = z.object({
|
|
34
|
+
reviewId: z.string(),
|
|
35
|
+
toolName: z.string(),
|
|
36
|
+
reason: z.string().optional(),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const CIRCUIT_VIEW_SCHEMA = z.object({
|
|
40
|
+
action: z.union([z.literal('delegate'), z.literal('reject'), z.literal('abort-turn')]),
|
|
41
|
+
trip: z.object({
|
|
42
|
+
kind: z.union([z.literal('consecutive'), z.literal('window')]),
|
|
43
|
+
count: z.number().int().positive(),
|
|
44
|
+
}),
|
|
45
|
+
toolName: z.string(),
|
|
46
|
+
}).nullable()
|
|
47
|
+
|
|
48
|
+
/** Strict wire schema — validates the `view` output before it leaves the host. */
|
|
49
|
+
export const AUTO_REVIEW_PROJECTION_SCHEMA = z.object({
|
|
50
|
+
enabled: z.boolean(),
|
|
51
|
+
verdictsUsed: z.number().int().nonnegative(),
|
|
52
|
+
failuresUsed: z.number().int().nonnegative(),
|
|
53
|
+
allows: z.number().int().nonnegative(),
|
|
54
|
+
denies: z.number().int().nonnegative(),
|
|
55
|
+
fallbacks: z.number().int().nonnegative(),
|
|
56
|
+
neverRejects: z.number().int().nonnegative(),
|
|
57
|
+
avgDurationMs: z.number().int().nonnegative(),
|
|
58
|
+
circuit: CIRCUIT_VIEW_SCHEMA,
|
|
59
|
+
recent: z.array(VERDICT_VIEW_SCHEMA),
|
|
60
|
+
recentDenies: z.array(DENY_VIEW_SCHEMA),
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
/** The fold's plain-JSON state (the persisted-cache precondition). */
|
|
64
|
+
export interface AutoReviewProjectionState {
|
|
65
|
+
readonly enabled: boolean
|
|
66
|
+
readonly turnOpen: boolean
|
|
67
|
+
readonly verdictsUsed: number
|
|
68
|
+
readonly failuresUsed: number
|
|
69
|
+
readonly allows: number
|
|
70
|
+
readonly denies: number
|
|
71
|
+
readonly fallbacks: number
|
|
72
|
+
readonly neverRejects: number
|
|
73
|
+
readonly durationSum: number
|
|
74
|
+
readonly decided: number
|
|
75
|
+
readonly circuit: AutoReviewProjection['circuit']
|
|
76
|
+
readonly recent: readonly AutoReviewVerdictView[]
|
|
77
|
+
readonly recentDenies: readonly AutoReviewDenyView[]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Drop `readonly` from a view type so a fold can fill optional fields conditionally. */
|
|
81
|
+
type Mutable<T> = { -readonly [K in keyof T]: T[K] }
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* State for the empty log. `enabled` starts at the resolved
|
|
85
|
+
* `enableByDefault` — the projection cannot know the plugin config on its
|
|
86
|
+
* own, so the unit is built per mount by {@link makeAutoReviewProjection}.
|
|
87
|
+
* @param enabledByDefault - the resolved `enableByDefault` config value.
|
|
88
|
+
*/
|
|
89
|
+
export function initAutoReviewProjection(enabledByDefault: boolean): AutoReviewProjectionState {
|
|
90
|
+
return {
|
|
91
|
+
enabled: enabledByDefault,
|
|
92
|
+
turnOpen: false,
|
|
93
|
+
verdictsUsed: 0,
|
|
94
|
+
failuresUsed: 0,
|
|
95
|
+
allows: 0,
|
|
96
|
+
denies: 0,
|
|
97
|
+
fallbacks: 0,
|
|
98
|
+
neverRejects: 0,
|
|
99
|
+
durationSum: 0,
|
|
100
|
+
decided: 0,
|
|
101
|
+
circuit: null,
|
|
102
|
+
recent: [],
|
|
103
|
+
recentDenies: [],
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** One verdict event as its panel row. */
|
|
108
|
+
function verdictView(data: SessionEventMap['autoReview/verdict'], at: number): AutoReviewVerdictView {
|
|
109
|
+
const view: Mutable<AutoReviewVerdictView> = {
|
|
110
|
+
reviewId: String(data.reviewId),
|
|
111
|
+
toolName: data.toolName,
|
|
112
|
+
at,
|
|
113
|
+
durationMs: data.durationMs,
|
|
114
|
+
}
|
|
115
|
+
if (data.decision !== undefined) view.decision = data.decision
|
|
116
|
+
if (data.riskLevel !== undefined) view.riskLevel = data.riskLevel
|
|
117
|
+
if (data.escalation !== undefined) view.escalation = data.escalation
|
|
118
|
+
if (data.fallback !== undefined) view.fallback = data.fallback
|
|
119
|
+
if (data.outcome !== undefined) view.outcome = data.outcome
|
|
120
|
+
if (data.reason !== undefined) view.reason = data.reason
|
|
121
|
+
return view
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** One denial event as its approve row. */
|
|
125
|
+
function denyView(data: SessionEventMap['autoReview/verdict']): AutoReviewDenyView {
|
|
126
|
+
const view: Mutable<AutoReviewDenyView> = {
|
|
127
|
+
reviewId: String(data.reviewId),
|
|
128
|
+
toolName: data.toolName,
|
|
129
|
+
}
|
|
130
|
+
if (data.reason !== undefined) view.reason = data.reason
|
|
131
|
+
return view
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Pure transition: previous state + one committed event → next state.
|
|
136
|
+
* Uninterested events return the same state reference.
|
|
137
|
+
* @param state - the state covering all prior events.
|
|
138
|
+
* @param event - the next committed session event.
|
|
139
|
+
* @returns the next state (same reference when the event is not this unit's).
|
|
140
|
+
*/
|
|
141
|
+
export function applyAutoReview(state: AutoReviewProjectionState, event: SessionEvent): AutoReviewProjectionState {
|
|
142
|
+
switch (event.type) {
|
|
143
|
+
case 'turn/start':
|
|
144
|
+
return state.turnOpen
|
|
145
|
+
? state
|
|
146
|
+
: { ...state, turnOpen: true, verdictsUsed: 0, failuresUsed: 0, circuit: null }
|
|
147
|
+
case 'turn/end':
|
|
148
|
+
return state.turnOpen ? { ...state, turnOpen: false } : state
|
|
149
|
+
case 'autoReview/state':
|
|
150
|
+
return state.enabled === event.data.enabled ? state : { ...state, enabled: event.data.enabled }
|
|
151
|
+
case 'autoReview/circuit':
|
|
152
|
+
return {
|
|
153
|
+
...state,
|
|
154
|
+
circuit: { action: event.data.action, trip: event.data.trip, toolName: event.data.toolName },
|
|
155
|
+
}
|
|
156
|
+
case 'autoReview/rejection':
|
|
157
|
+
return { ...state, neverRejects: state.neverRejects + 1 }
|
|
158
|
+
case 'autoReview/verdict': {
|
|
159
|
+
const data = event.data
|
|
160
|
+
const decision = data.decision !== undefined
|
|
161
|
+
const failure = data.fallback !== undefined && data.fallback !== 'cancelled'
|
|
162
|
+
const denial = data.decision === 'deny' || data.escalation === 'risk-policy'
|
|
163
|
+
const row = verdictView(data, event.time)
|
|
164
|
+
return {
|
|
165
|
+
...state,
|
|
166
|
+
verdictsUsed: state.turnOpen && decision ? state.verdictsUsed + 1 : state.verdictsUsed,
|
|
167
|
+
failuresUsed: state.turnOpen && failure ? state.failuresUsed + 1 : state.failuresUsed,
|
|
168
|
+
allows: state.allows + (data.decision === 'allow' ? 1 : 0),
|
|
169
|
+
denies: state.denies + (data.decision === 'deny' ? 1 : 0),
|
|
170
|
+
fallbacks: state.fallbacks + (decision ? 0 : 1),
|
|
171
|
+
durationSum: state.durationSum + (decision ? data.durationMs : 0),
|
|
172
|
+
decided: state.decided + (decision ? 1 : 0),
|
|
173
|
+
recent: [row, ...state.recent].slice(0, RECENT_CAP),
|
|
174
|
+
recentDenies: denial
|
|
175
|
+
? [denyView(data), ...state.recentDenies].slice(0, DENIES_CAP)
|
|
176
|
+
: state.recentDenies,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
default:
|
|
180
|
+
return state
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** State → wire payload (the read-side projection). */
|
|
185
|
+
export function viewAutoReview(state: AutoReviewProjectionState): AutoReviewProjection {
|
|
186
|
+
return {
|
|
187
|
+
enabled: state.enabled,
|
|
188
|
+
verdictsUsed: state.verdictsUsed,
|
|
189
|
+
failuresUsed: state.failuresUsed,
|
|
190
|
+
allows: state.allows,
|
|
191
|
+
denies: state.denies,
|
|
192
|
+
fallbacks: state.fallbacks,
|
|
193
|
+
neverRejects: state.neverRejects,
|
|
194
|
+
avgDurationMs: state.decided === 0 ? 0 : Math.round(state.durationSum / state.decided),
|
|
195
|
+
circuit: state.circuit,
|
|
196
|
+
recent: state.recent,
|
|
197
|
+
recentDenies: state.recentDenies,
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Build the registered unit for one mount. The unit's initial `enabled` is
|
|
203
|
+
* the mount's resolved `enableByDefault` (the pure `init` cannot see plugin
|
|
204
|
+
* config otherwise), and the panel therefore reports the same switch state
|
|
205
|
+
* the answerer applies. Registrants sharing the `autoReview` key share one
|
|
206
|
+
* unit; the first mount's default wins, which only matters for a double
|
|
207
|
+
* mount with conflicting `enableByDefault` values.
|
|
208
|
+
* @param enabledByDefault - the resolved `enableByDefault` config value.
|
|
209
|
+
* @returns the projection unit for `ctx.sessionProjections.register`.
|
|
210
|
+
*/
|
|
211
|
+
export function makeAutoReviewProjection(enabledByDefault: boolean): ProjectionDefinition<'autoReview', AutoReviewProjectionState> {
|
|
212
|
+
return {
|
|
213
|
+
key: 'autoReview',
|
|
214
|
+
// zod v4's `.optional()` output carries `| undefined`, which exact-optional
|
|
215
|
+
// property types reject; the runtime validation is what matters on the wire.
|
|
216
|
+
schema: AUTO_REVIEW_PROJECTION_SCHEMA as unknown as ProjectionDefinition<'autoReview', AutoReviewProjectionState>['schema'],
|
|
217
|
+
init: () => initAutoReviewProjection(enabledByDefault),
|
|
218
|
+
apply: applyAutoReview,
|
|
219
|
+
view: viewAutoReview,
|
|
220
|
+
// v2: `init.enabled` follows `enableByDefault` (was hardcoded true) and the
|
|
221
|
+
// wire gained the cumulative `neverRejects` counter.
|
|
222
|
+
stateVersion: 2,
|
|
223
|
+
}
|
|
224
|
+
}
|
package/src/review.ts
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reviewer subagent: one-shot in-process child with a read-only tool
|
|
3
|
+
* allow-list and a structured verdict schema, raced against the configured
|
|
4
|
+
* timeout and the request's cancellation signal. Any failure resolves to a
|
|
5
|
+
* {@link ReviewFailure} — the caller applies the fallback policy, never a
|
|
6
|
+
* grant (fail closed by default).
|
|
7
|
+
* @module dsh-auto-review/review
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { randomUUID } from 'node:crypto'
|
|
11
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
12
|
+
import type { CallId, ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
13
|
+
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
|
14
|
+
import type { ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
|
|
15
|
+
import type {} from '@deepseek-ai/dsh-subagent'
|
|
16
|
+
import { assertObjectJsonSchema, type ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
|
|
17
|
+
import type { AutoReviewFallback } from './events.ts'
|
|
18
|
+
import { AutoReviewCircuitId, AutoReviewRejectionId, AutoReviewVerdictId, findPresentedCall } from './events.ts'
|
|
19
|
+
import type { ContextBudgetConfig, ResolvedConfig, RiskLevel } from './config.ts'
|
|
20
|
+
|
|
21
|
+
/** A reviewer verdict, validated against the closed decision vocabulary. */
|
|
22
|
+
export interface ReviewerVerdict {
|
|
23
|
+
readonly decision: 'allow' | 'deny'
|
|
24
|
+
readonly reason: string
|
|
25
|
+
readonly riskLevel?: RiskLevel
|
|
26
|
+
/** The reviewer child's session id (its own log is auditable). */
|
|
27
|
+
readonly reviewerSessionId?: SessionId
|
|
28
|
+
/** The explicitly configured reviewer model, when one was set. */
|
|
29
|
+
readonly model?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A review that produced no verdict. The caller maps the failure to the
|
|
34
|
+
* configured fallback policy.
|
|
35
|
+
*/
|
|
36
|
+
export interface ReviewFailure {
|
|
37
|
+
readonly fallback: AutoReviewFallback
|
|
38
|
+
readonly error: string
|
|
39
|
+
readonly reviewerSessionId?: SessionId
|
|
40
|
+
readonly model?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The closed result of one review attempt. */
|
|
44
|
+
export type ReviewResolution = ReviewerVerdict | ReviewFailure
|
|
45
|
+
|
|
46
|
+
/** Distinguish a verdict resolution from a failure. */
|
|
47
|
+
export function isReviewFailure(resolution: ReviewResolution): resolution is ReviewFailure {
|
|
48
|
+
return (resolution as ReviewFailure).fallback !== undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Object-rooted verdict schema enforced on the child's structured_output capture. */
|
|
52
|
+
export const VERDICT_SCHEMA: ObjectJsonSchema = {
|
|
53
|
+
type: 'object',
|
|
54
|
+
properties: {
|
|
55
|
+
decision: { type: 'string', enum: ['allow', 'deny'] },
|
|
56
|
+
reason: { type: 'string' },
|
|
57
|
+
riskLevel: { type: 'string', enum: ['low', 'medium', 'high'] },
|
|
58
|
+
},
|
|
59
|
+
required: ['decision', 'reason'],
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Prove at load time that the verdict schema obeys the enforced JSON-schema
|
|
63
|
+
// subset; a regression here must fail the plugin loudly, not every review.
|
|
64
|
+
assertObjectJsonSchema(VERDICT_SCHEMA)
|
|
65
|
+
|
|
66
|
+
/** Keys whose values are redacted from the reviewer prompt. */
|
|
67
|
+
const SENSITIVE_KEY_PATTERN = /(?:api[_-]?key|token|secret|password|passwd|pwd|authorization|auth|credential|private[_-]?key|access[_-]?key)/iu
|
|
68
|
+
|
|
69
|
+
/** The redaction placeholder for sensitive argument values. */
|
|
70
|
+
const REDACTED = '[REDACTED]'
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Recursively redact sensitive values from a JSON value: any object key
|
|
74
|
+
* matching {@link SENSITIVE_KEY_PATTERN} has its value replaced.
|
|
75
|
+
* @param value - the detached JSON value to sanitize.
|
|
76
|
+
* @returns a detached copy with sensitive values redacted.
|
|
77
|
+
*/
|
|
78
|
+
export function sanitizeArguments(value: unknown): unknown {
|
|
79
|
+
if (Array.isArray(value)) return value.map(item => sanitizeArguments(item))
|
|
80
|
+
if (typeof value === 'object' && value !== null) {
|
|
81
|
+
const copy: Record<string, unknown> = {}
|
|
82
|
+
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
|
83
|
+
copy[key] = SENSITIVE_KEY_PATTERN.test(key) ? REDACTED : sanitizeArguments(item)
|
|
84
|
+
}
|
|
85
|
+
return copy
|
|
86
|
+
}
|
|
87
|
+
return value
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The already-presented call arguments, parsed and key-redacted, as pretty
|
|
92
|
+
* JSON — the raw material for both the reviewer prompt section and the
|
|
93
|
+
* `arguments` risk-rule field.
|
|
94
|
+
* @param events - the requesting session's log.
|
|
95
|
+
* @param callId - the call to render.
|
|
96
|
+
* @returns the sanitized JSON text, or undefined when the log lacks a parseable call.
|
|
97
|
+
*/
|
|
98
|
+
export function sanitizedArgumentsText(events: readonly SessionEvent[], callId: CallId): string | undefined {
|
|
99
|
+
const raw = findPresentedCall(events, callId)
|
|
100
|
+
if (raw === undefined) return undefined
|
|
101
|
+
let parsed: unknown
|
|
102
|
+
try {
|
|
103
|
+
parsed = JSON.parse(raw) as unknown
|
|
104
|
+
} catch {
|
|
105
|
+
return undefined
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
return JSON.stringify(sanitizeArguments(parsed), undefined, 2)
|
|
109
|
+
} catch {
|
|
110
|
+
return String(parsed)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Render the tool-call context for the reviewer: the already-streamed call
|
|
116
|
+
* arguments (parsed, redacted, truncated) or a note when the log lacks them.
|
|
117
|
+
* @param events - the requesting session's log.
|
|
118
|
+
* @param callId - the call to render.
|
|
119
|
+
* @param maxChars - the preview cap (shared with reason truncation).
|
|
120
|
+
* @returns the prompt section text.
|
|
121
|
+
*/
|
|
122
|
+
export function renderCallContext(events: readonly SessionEvent[], callId: CallId | undefined, maxChars: number): string {
|
|
123
|
+
if (callId === undefined) return 'Tool call arguments: (not available — the asker attached no call id)'
|
|
124
|
+
const text = sanitizedArgumentsText(events, callId)
|
|
125
|
+
if (text === undefined) return 'Tool call arguments: (not found or unparseable in the presented transcript)'
|
|
126
|
+
return `Tool call arguments (sensitive values redacted):\n${truncate(text, maxChars)}`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** How many characters one transcript line may take before truncation. */
|
|
130
|
+
const CONTEXT_LINE_BUDGET = 300
|
|
131
|
+
|
|
132
|
+
/** Extract the text blocks of a message's content as one string. */
|
|
133
|
+
function contentText(content: readonly ContentBlock[]): string {
|
|
134
|
+
return content
|
|
135
|
+
.filter(block => block.type === 'text')
|
|
136
|
+
.map(block => (block as { text: string }).text)
|
|
137
|
+
.join('\n')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** One transcript event rendered as a compact line, or undefined when the event carries no usable text. */
|
|
141
|
+
function contextLine(event: SessionEvent): string | undefined {
|
|
142
|
+
switch (event.type) {
|
|
143
|
+
case 'user/message': return `[user] ${truncate(contentText(event.data.content), CONTEXT_LINE_BUDGET)}`
|
|
144
|
+
case 'assistant/message': return `[agent] ${truncate(contentText(event.data.message.content), CONTEXT_LINE_BUDGET)}`
|
|
145
|
+
case 'tool/call': {
|
|
146
|
+
const args = event.data.arguments
|
|
147
|
+
return `[tool call ${event.data.name}] ${truncate(args, CONTEXT_LINE_BUDGET)}`
|
|
148
|
+
}
|
|
149
|
+
case 'tool/result': {
|
|
150
|
+
const block = event.data.message.content[0]
|
|
151
|
+
if (block === undefined || block.type !== 'tool-result') return undefined
|
|
152
|
+
const text = contentText(block.content)
|
|
153
|
+
return text === '' ? undefined : `[tool result] ${truncate(text, CONTEXT_LINE_BUDGET)}`
|
|
154
|
+
}
|
|
155
|
+
default: return undefined
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Build the compact-transcript section for the reviewer: the tail of the
|
|
161
|
+
* session's user/assistant messages and tool calls/results, most recent
|
|
162
|
+
* last, bounded by {@link ContextBudgetConfig}. `turns: 0` yields an empty
|
|
163
|
+
* section. Tool-call arguments and tool-result text enter verbatim (they are
|
|
164
|
+
* already-presented transcript content); the presented call of the pending
|
|
165
|
+
* request is redacted separately in {@link renderCallContext}.
|
|
166
|
+
* @param events - the requesting session's log.
|
|
167
|
+
* @param budget - how much context to include.
|
|
168
|
+
* @returns the prompt section text (empty when disabled).
|
|
169
|
+
*/
|
|
170
|
+
export function buildContextSection(events: readonly SessionEvent[], budget: ContextBudgetConfig): string {
|
|
171
|
+
if (budget.turns === 0) return ''
|
|
172
|
+
const lines: string[] = []
|
|
173
|
+
let crossed = 0
|
|
174
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
175
|
+
const event = events[index] as SessionEvent
|
|
176
|
+
if (event.type === 'turn/end') {
|
|
177
|
+
crossed += 1
|
|
178
|
+
if (crossed >= budget.turns) break
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
181
|
+
if (event.type === 'turn/start') continue
|
|
182
|
+
const line = contextLine(event)
|
|
183
|
+
if (line !== undefined) lines.push(line)
|
|
184
|
+
}
|
|
185
|
+
lines.reverse()
|
|
186
|
+
const joined = lines.join('\n')
|
|
187
|
+
if (joined === '') return '(no transcript content yet)'
|
|
188
|
+
return truncate(joined, budget.maxChars)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Context of a pending human override, passed into the reviewer prompt. */
|
|
192
|
+
export interface OverrideContext {
|
|
193
|
+
readonly reviewId: AutoReviewVerdictId
|
|
194
|
+
readonly toolName: string
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Truncate a string to a character budget without cutting surrogate pairs.
|
|
199
|
+
* @param text - the string to cap.
|
|
200
|
+
* @param maxChars - the positive budget.
|
|
201
|
+
* @returns the capped string.
|
|
202
|
+
*/
|
|
203
|
+
export function truncate(text: string, maxChars: number): string {
|
|
204
|
+
if (text.length <= maxChars) return text
|
|
205
|
+
const cut = text.slice(0, maxChars)
|
|
206
|
+
const last = cut.charCodeAt(cut.length - 1)
|
|
207
|
+
if (last >= 0xd800 && last <= 0xdbff) return cut.slice(0, -1)
|
|
208
|
+
return `${cut}…`
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Build the reviewer prompt: role, the pending request's tool/reason/context/
|
|
213
|
+
* workspace, the resolved risk rules, the ruling policy text, the optional
|
|
214
|
+
* human-override context, and the verdict contract.
|
|
215
|
+
* @param request - the pending approval request.
|
|
216
|
+
* @param config - resolved config (rules, guidance, budgets).
|
|
217
|
+
* @param override - a pending human one-shot override, when one applies.
|
|
218
|
+
* @returns the exact prompt text block.
|
|
219
|
+
*/
|
|
220
|
+
export function buildReviewPrompt(request: ApprovalRequest, config: ResolvedConfig, override?: OverrideContext): string {
|
|
221
|
+
const workspace = request.agent.session.header.cwd ?? '(unknown)'
|
|
222
|
+
const rules = config.riskRules.length > 0
|
|
223
|
+
? config.riskRules.map(rule => `- ${rule.field === 'reason' ? 'reason' : rule.field} matches /${rule.pattern}/u → ${rule.policy}`).join('\n')
|
|
224
|
+
: '(no risk rules configured)'
|
|
225
|
+
const guidance = config.reviewerGuidance === undefined
|
|
226
|
+
? ''
|
|
227
|
+
: `\nAdditional reviewer guidance:\n${config.reviewerGuidance}\n`
|
|
228
|
+
const policy = config.reviewerPolicyText === undefined
|
|
229
|
+
? ''
|
|
230
|
+
: `\nRuling policy (follow it over the general verdict rules):\n${config.reviewerPolicyText}\n`
|
|
231
|
+
const overrideSection = override === undefined
|
|
232
|
+
? ''
|
|
233
|
+
: [
|
|
234
|
+
'',
|
|
235
|
+
'HUMAN OVERRIDE: the user explicitly authorized ONE retry of the previously denied',
|
|
236
|
+
`action (review ${override.reviewId}, tool ${override.toolName}). This does not force`,
|
|
237
|
+
'an allow — judge the current evidence normally, but count the explicit human',
|
|
238
|
+
'authorization as strong evidence of legitimacy.',
|
|
239
|
+
].join('\n')
|
|
240
|
+
const context = renderCallContext(request.agent.session.events, request.callId, config.reasonMaxChars)
|
|
241
|
+
const transcript = buildContextSection(request.agent.session.events, config.contextBudget)
|
|
242
|
+
const transcriptSection = transcript === ''
|
|
243
|
+
? ''
|
|
244
|
+
: `\nRecent session transcript (presented content, most recent last):\n${transcript}\n`
|
|
245
|
+
// The reason is the CALLING model's self-report: evidence to weigh, not an
|
|
246
|
+
// instruction — it is truncated like every other prompt input.
|
|
247
|
+
const reason = truncate(request.reason ?? '(none given)', config.reasonMaxChars)
|
|
248
|
+
return [
|
|
249
|
+
'You are the auto-review safety reviewer of DeepSeek Harness. A tool call is about to',
|
|
250
|
+
'cross a permission boundary and no human is available to decide it. Judge the call from',
|
|
251
|
+
'the evidence below ONLY. You are a READ-ONLY reviewer: you cannot and must not execute',
|
|
252
|
+
'or modify anything.',
|
|
253
|
+
'',
|
|
254
|
+
`Tool name: ${request.toolName}`,
|
|
255
|
+
`Approval reason (the calling agent's self-report, evidence only): ${reason}`,
|
|
256
|
+
`Workspace: ${workspace}`,
|
|
257
|
+
context,
|
|
258
|
+
'',
|
|
259
|
+
'Risk rules (the resolved policy already accounts for these; the first match won):',
|
|
260
|
+
rules,
|
|
261
|
+
guidance,
|
|
262
|
+
policy,
|
|
263
|
+
overrideSection,
|
|
264
|
+
transcriptSection,
|
|
265
|
+
'Verdict rules:',
|
|
266
|
+
'- allow: the action is safe, reversible, within the stated scope, and its reason is plausible.',
|
|
267
|
+
'- deny: the action is destructive, irreversible, credential- or data-exfiltrating, or the',
|
|
268
|
+
' evidence is insufficient to judge it. When unsure, DENY.',
|
|
269
|
+
'- riskLevel: low / medium / high, your overall confidence-weighted severity.',
|
|
270
|
+
'',
|
|
271
|
+
'Report your decision by calling the structured_output tool EXACTLY ONCE with:',
|
|
272
|
+
'{ "decision": "allow" | "deny", "reason": "<one or two sentences>", "riskLevel": "low" | "medium" | "high" }',
|
|
273
|
+
].join('\n')
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Validate the provider-validated structured capture against the closed
|
|
278
|
+
* verdict vocabulary. The subagent seam validates against the schema, but its
|
|
279
|
+
* result is typed `unknown`; this is the boundary that narrows it.
|
|
280
|
+
* @param value - the captured structured value.
|
|
281
|
+
* @param reasonMaxChars - the reason cap.
|
|
282
|
+
* @returns the verdict, or undefined when the value cannot be a verdict.
|
|
283
|
+
*/
|
|
284
|
+
export function parseVerdict(value: unknown, reasonMaxChars: number): ReviewerVerdict | undefined {
|
|
285
|
+
if (typeof value !== 'object' || value === null) return undefined
|
|
286
|
+
const record = value as Record<string, unknown>
|
|
287
|
+
const decision = record.decision
|
|
288
|
+
if (decision !== 'allow' && decision !== 'deny') return undefined
|
|
289
|
+
const reason = record.reason
|
|
290
|
+
if (typeof reason !== 'string' || reason.trim().length === 0) return undefined
|
|
291
|
+
const riskLevel = record.riskLevel
|
|
292
|
+
if (riskLevel !== undefined && riskLevel !== 'low' && riskLevel !== 'medium' && riskLevel !== 'high') return undefined
|
|
293
|
+
return {
|
|
294
|
+
decision,
|
|
295
|
+
reason: truncate(reason.trim(), reasonMaxChars),
|
|
296
|
+
...riskLevel === undefined ? {} : { riskLevel },
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Run one review attempt: start a one-shot reviewer child on the configured
|
|
302
|
+
* provider, await its structured verdict, and race both the configured
|
|
303
|
+
* timeout and the request signal. Every failure path resolves to a
|
|
304
|
+
* {@link ReviewFailure} — never to a grant.
|
|
305
|
+
* @param ctx - the plugin context (`ctx.subagents` and the request's parent agent).
|
|
306
|
+
* @param config - resolved config (provider, timeout, tools, model, budgets).
|
|
307
|
+
* @param request - the pending approval request (parent, signal, and evidence).
|
|
308
|
+
* @param reviewerSessions - the runtime's live reviewer session ids, so the
|
|
309
|
+
* answerer can refuse the child's own approval asks while the review runs.
|
|
310
|
+
* @param override - a pending human one-shot override, when one applies.
|
|
311
|
+
* @returns the verdict or the failure.
|
|
312
|
+
*/
|
|
313
|
+
export async function runReview(
|
|
314
|
+
ctx: Context,
|
|
315
|
+
config: ResolvedConfig,
|
|
316
|
+
request: ApprovalRequest,
|
|
317
|
+
reviewerSessions: Set<SessionId>,
|
|
318
|
+
override?: OverrideContext,
|
|
319
|
+
): Promise<ReviewResolution> {
|
|
320
|
+
const provider = config.reviewerProvider
|
|
321
|
+
const providerInfo = ctx.subagents.getProvider(provider)
|
|
322
|
+
if (providerInfo === undefined) {
|
|
323
|
+
return { fallback: 'unavailable', error: `subagent provider "${provider}" is not registered` }
|
|
324
|
+
}
|
|
325
|
+
const capabilities = providerInfo.capabilities
|
|
326
|
+
// Absent capabilities (mock providers) skip the precheck; the service
|
|
327
|
+
// rejects unsupported requests at start time either way.
|
|
328
|
+
if (capabilities?.outputSchema === false) {
|
|
329
|
+
return { fallback: 'unavailable', error: `subagent provider "${provider}" does not support outputSchema, which the structured verdict requires` }
|
|
330
|
+
}
|
|
331
|
+
if (capabilities?.toolFilter === false) {
|
|
332
|
+
return { fallback: 'unavailable', error: `subagent provider "${provider}" does not support toolFilter, which the read-only reviewer face requires` }
|
|
333
|
+
}
|
|
334
|
+
const prompt: ContentBlock[] = [{ type: 'text', text: buildReviewPrompt(request, config, override) }]
|
|
335
|
+
const controller = new AbortController()
|
|
336
|
+
let timedOut = false
|
|
337
|
+
const timer = setTimeout(() => {
|
|
338
|
+
timedOut = true
|
|
339
|
+
controller.abort()
|
|
340
|
+
}, config.reviewerTimeoutMs)
|
|
341
|
+
const onAbort = (): void => {
|
|
342
|
+
controller.abort()
|
|
343
|
+
}
|
|
344
|
+
request.signal?.addEventListener('abort', onAbort, { once: true })
|
|
345
|
+
let reviewerSessionId: SessionId | undefined
|
|
346
|
+
let model: string | undefined
|
|
347
|
+
let run: import('@deepseek-ai/dsh-subagent').SubagentRun | undefined
|
|
348
|
+
const failure = (fallback: AutoReviewFallback, error: string): ReviewFailure => ({
|
|
349
|
+
fallback,
|
|
350
|
+
error,
|
|
351
|
+
...reviewerSessionId !== undefined ? { reviewerSessionId } : {},
|
|
352
|
+
...model !== undefined ? { model } : {},
|
|
353
|
+
})
|
|
354
|
+
try {
|
|
355
|
+
run = await ctx.subagents.start(provider, {
|
|
356
|
+
label: `auto-review: ${request.toolName}`,
|
|
357
|
+
prompt,
|
|
358
|
+
parent: request.agent,
|
|
359
|
+
signal: controller.signal,
|
|
360
|
+
toolFilter: { allow: config.reviewerTools },
|
|
361
|
+
outputSchema: VERDICT_SCHEMA,
|
|
362
|
+
// The cap equals the reviewer's OWN computed depth (parent depth + 1):
|
|
363
|
+
// the reviewer may run, but any child it tries to spawn would exceed it.
|
|
364
|
+
maxDepth: (request.agent.session.header.delegationDepth ?? 0) + 1,
|
|
365
|
+
...config.reviewerModel !== undefined ? { agentOptions: { model: config.reviewerModel } } : {},
|
|
366
|
+
})
|
|
367
|
+
reviewerSessionId = run.id
|
|
368
|
+
reviewerSessions.add(run.id)
|
|
369
|
+
model = config.reviewerModel
|
|
370
|
+
const result = await run.result
|
|
371
|
+
if (timedOut) {
|
|
372
|
+
return failure('timeout', `reviewer exceeded ${config.reviewerTimeoutMs} ms`)
|
|
373
|
+
}
|
|
374
|
+
if (request.signal?.aborted === true) {
|
|
375
|
+
return failure('cancelled', 'approval request was cancelled while the reviewer ran')
|
|
376
|
+
}
|
|
377
|
+
if (result.stopReason !== 'completed') {
|
|
378
|
+
return failure('unavailable', `reviewer ended with stopReason "${result.stopReason}"`)
|
|
379
|
+
}
|
|
380
|
+
const verdict = parseVerdict(result.structured, config.reasonMaxChars)
|
|
381
|
+
if (verdict === undefined) {
|
|
382
|
+
return failure('schema', 'reviewer returned no valid structured verdict')
|
|
383
|
+
}
|
|
384
|
+
return {
|
|
385
|
+
...verdict,
|
|
386
|
+
...reviewerSessionId !== undefined ? { reviewerSessionId } : {},
|
|
387
|
+
...model !== undefined ? { model } : {},
|
|
388
|
+
}
|
|
389
|
+
} catch (error: unknown) {
|
|
390
|
+
// A start/run rejection can be the timeout's or the request's abort
|
|
391
|
+
// surfacing (e.g. the fork driver's pre-publication abort); classify from
|
|
392
|
+
// the two races BEFORE the generic unavailable bucket, or the verdict's
|
|
393
|
+
// `fallback`/`outcome` would contradict the service's `approval/decided`.
|
|
394
|
+
if (timedOut) return failure('timeout', `reviewer exceeded ${config.reviewerTimeoutMs} ms`)
|
|
395
|
+
if (request.signal?.aborted === true) return failure('cancelled', 'approval request was cancelled before the reviewer delivered')
|
|
396
|
+
return failure('unavailable', `reviewer subagent failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
397
|
+
} finally {
|
|
398
|
+
if (run !== undefined) await run.dispose()
|
|
399
|
+
if (reviewerSessionId !== undefined) reviewerSessions.delete(reviewerSessionId)
|
|
400
|
+
clearTimeout(timer)
|
|
401
|
+
request.signal?.removeEventListener('abort', onAbort)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Mint one verdict id. Wrapped for testability (audit uniqueness is a UUID).
|
|
407
|
+
* @returns a fresh branded id.
|
|
408
|
+
*/
|
|
409
|
+
export function newVerdictId(): AutoReviewVerdictId {
|
|
410
|
+
return AutoReviewVerdictId(randomUUID())
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Mint one circuit-trip id. Wrapped for testability (audit uniqueness is a UUID).
|
|
415
|
+
* @returns a fresh branded id.
|
|
416
|
+
*/
|
|
417
|
+
export function newCircuitId(): AutoReviewCircuitId {
|
|
418
|
+
return AutoReviewCircuitId(randomUUID())
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Mint one hard-disable rejection id. Wrapped for testability (audit
|
|
423
|
+
* uniqueness is a UUID).
|
|
424
|
+
* @returns a fresh branded id.
|
|
425
|
+
*/
|
|
426
|
+
export function newRejectionId(): AutoReviewRejectionId {
|
|
427
|
+
return AutoReviewRejectionId(randomUUID())
|
|
428
|
+
}
|