/** * AI-reviewed auto-approval over the approval answerer waterfall. When enabled * for a session, every pending approval ask is judged by one auxiliary model * call BEFORE any human answerer is consulted: a safe operation is granted * (`'allowed-once'`) without interrupting anyone, and every other outcome — * escalation, review failure, deadline, malformed output — reaches the * remaining answerers with the review's analysis attached to the request, so * the human prompt states what the operation would do and what it risks. The * plugin ships inert: without `/auto-approve on` (or `enabled: true`) it * delegates every ask unchanged. * * @module dsh-approval-ai-review */ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { BlockAssembler, createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import { deadline, MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { deepFreeze } from '@deepseek-ai/dsh-util-values' import type { ApprovalOutcome, ApprovalRequest } from '@deepseek-ai/dsh-user-approval' // Side-effect type import: declaration-merges `ctx.commands` (the optional // registry the /auto-approve toggle registers into) without a value dependency. import type {} from '@deepseek-ai/dsh-commands' // Side-effect type import: declaration-merges `ctx.sessionProjections`. import type {} from '@deepseek-ai/dsh-session-projection' import { APPROVAL_REVIEW_TIMEOUT_CODE, EMPTY_REVIEW_STATE, applyReviewEvent, attachAnalysis, buildReviewInput, extractReviewText, finishErrorOf, parseVerdict, resolveReviewRoute, reviewStateSchema, reviewSystemPrompt, } from './reviewer.ts' import type { ReviewReasoningEffort } from './types.ts' export type { ReviewReasoningEffort, ReviewVerdict, ReviewVerdictRecord } from './types.ts' /** Plugin config. All fields optional — this module's `Config` schema supplies the defaults. */ export interface Config { /** * The composition default for sessions without an `approval/review/enabled` * override. Defaults to `false`: composing the plugin changes nothing until * the user runs `/auto-approve on` or sets this to `true`. */ readonly enabled?: boolean /** * Explicit review-model provider; must be paired with `reviewModel`. When * both are absent the review follows the agent's routed conversation model * (the logged request header, then `AgentOptions`). */ readonly reviewProvider?: string /** Explicit review-model id; must be paired with `reviewProvider`. */ readonly reviewModel?: string /** Reasoning effort for the review call (default `'low'`). */ readonly reasoningEffort?: ReviewReasoningEffort /** Output-token cap for one review call (default `2048`). */ readonly maxTokens?: number /** End-to-end review deadline in milliseconds (default `30000`). */ readonly reviewTimeoutMs?: number /** * Per-call cap, in UTF-16 code units, on the tool-call arguments the * projection retains for review input (default `4000`). Longer arguments are * truncated with a marker before they enter the projection state, so the * retained state stays bounded regardless of tool-call sizes. */ readonly maxArgumentsChars?: number } /** Validated immutable plugin config with every schema default materialized. */ export interface ResolvedConfig extends Config { /** The composition default (schema-defaulted to `false`). */ readonly enabled: boolean /** The schema-defaulted review effort. */ readonly reasoningEffort: ReviewReasoningEffort /** The schema-defaulted output-token cap. */ readonly maxTokens: number /** The schema-defaulted review deadline. */ readonly reviewTimeoutMs: number /** The schema-defaulted per-call arguments cap. */ readonly maxArgumentsChars: number } /** Cordis plugin name used by loader diagnostics. */ export const name = 'approval-ai-review' /** Required services: the LLM seam and the projection registry the review reads. */ export const inject = ['llm', 'sessionProjections'] /** Loader configuration schema with the shipped defaults. */ export const Config: z = z.object({ enabled: z.boolean().default(false), reviewProvider: z.string(), reviewModel: z.string(), reasoningEffort: z.union(['off', 'low', 'high', 'max'] as const).default('low'), maxTokens: z.number().step(1).min(1).default(2048), reviewTimeoutMs: z.number().step(1).min(1).max(MAX_TIMER_DELAY_MS).default(30_000), maxArgumentsChars: z.number().step(1).min(1).default(4000), }) /** * Validate the config and record the schema-filled defaults. Every mount goes * through cordis, which applies the static `Config` schema (and its defaults) * before `apply` runs; the pairing rule is not expressible in the schema, so * it is enforced here. * @param config - schema-processed plugin configuration. * @returns the frozen resolved config. * @throws when exactly one of `reviewProvider`/`reviewModel` is set — the * route override is self-contained, so the misconfiguration fails loud at load. */ function resolveConfig(config: Config): ResolvedConfig { if ((config.reviewProvider === undefined) !== (config.reviewModel === undefined)) { throw new Error('approval-ai-review: reviewProvider and reviewModel must be supplied together') } // The schema defaulted every field — the cast records that runtime fact // (the permission-presets precedent). return deepFreeze(config as ResolvedConfig) } /** * Register the prepend reviewer on the approval waterfall and the * `/auto-approve` toggle command. The commands child activates only when a * command registry is composed. * @param ctx - the mounting context; must expose `ctx.llm`. * @param config - untrusted plugin configuration. */ export function apply(ctx: Context, config: Config = {}): void { const resolved = resolveConfig(config) // The projection is the reviewer's only session knowledge: it folds the // enabled switch and the bounded tool-call argument index incrementally, so // the review path never reads event history synchronously. ctx.sessionProjections.register({ key: 'approval-ai-review', stateVersion: 1, stateSchema: reviewStateSchema, init: () => EMPTY_REVIEW_STATE, apply: (state, event) => applyReviewEvent(state, event, resolved.maxArgumentsChars), }) // Prepend: the review must settle before any UI or ACP answerer runs, so an // auto-grant never flashes a prompt and an escalation carries the analysis // into the first prompt the human sees. ctx.on('approval/request', (req, next) => reviewRequest(ctx, resolved, req, next), { prepend: true }) ctx.inject(['commands'], (commandCtx) => { commandCtx.commands.register({ name: 'auto-approve', description: 'Toggle AI-reviewed auto-approval: safe asks are granted automatically, everything else reaches you with the review attached', input: { hint: '[on|off]' }, handler: ({ agent, rawInput }) => { const argument = rawInput.trim().toLowerCase() const override = ctx.sessionProjections.stateOf(agent.session, 'approval-ai-review')?.enabled ?? null if (argument === '' || argument === 'status') { const effective = override ?? resolved.enabled return { kind: 'success', text: `AI review is ${effective ? 'enabled' : 'disabled'}` + (override === null ? ' (composition default)' : ' (session override)'), } } if (argument !== 'on' && argument !== 'off') { return { kind: 'error', text: 'usage: /auto-approve [on|off]' } } const enabled = argument === 'on' agent.session.append('approval/review/enabled', { enabled }) return { kind: 'success', text: `AI review ${enabled ? 'enabled' : 'disabled'} for this session` } }, }) }) } /** * The waterfall listener: apply the enabled switch, then either own the * decision (a validated `approve` verdict) or delegate with the analysis * attached. Every review failure escalates — this listener never rejects on * its own authority and never leaves the chain. * @param ctx - the mounting context, for the LLM seam and the logger. * @param config - resolved plugin configuration. * @param req - the pending request shared with downstream answerers. * @param next - the remaining answerer chain. * @returns the closed outcome. */ async function reviewRequest( ctx: Context, config: ResolvedConfig, req: ApprovalRequest, next: () => Promise, ): Promise { const session = req.agent.session const state = ctx.sessionProjections.stateOf(session, 'approval-ai-review') // The last logged switch wins; its absence falls to the composition default // (and a missing projection unit degrades to the default as well). if (!(state?.enabled ?? config.enabled)) return next() if (req.signal?.aborted) return await next() try { return await reviewAsk(ctx, config, req, next) } catch (error: unknown) { // Any review failure — no route, transport error, deadline, malformed // output — escalates to the remaining answerers; without one, the seam's // own fail-closed 'unavailable' applies. The failure is logged, not // swallowed: the human keeps the final decision either way. ctx.logger.warn(`approval-ai-review: review failed, escalating to the answerer chain: ${String(error)}`) return await next() } } /** * Review one pending ask through the auxiliary model call. * @param ctx - context providing the LLM seam. * @param config - resolved plugin configuration. * @param req - the pending request shared with downstream answerers. * @param next - the remaining answerer chain, delegated to on escalation. * @returns `'allowed-once'` after a validated approve verdict; otherwise the * delegated chain outcome with the analysis attached to the request. */ async function reviewAsk( ctx: Context, config: ResolvedConfig, req: ApprovalRequest, next: () => Promise, ): Promise { const session = req.agent.session const route = resolveReviewRoute(config, req.agent) const toolArguments = req.callId === undefined ? undefined : ctx.sessionProjections.stateOf(session, 'approval-ai-review')?.toolCalls[req.callId] const system = reviewSystemPrompt() const input = buildReviewInput(req.toolName, toolArguments, req.reason) using callDeadline = deadline(req.signal, config.reviewTimeoutMs, APPROVAL_REVIEW_TIMEOUT_CODE) const messages = [createUserMessage({ content: [{ type: 'text', text: input }], source: { kind: 'plugin', plugin: 'approval-ai-review' }, })] const options: GenerateOptions = deepFreeze({ provider: route.provider, model: route.model, reasoningEffort: ReasoningEffortId(config.reasoningEffort), messages, system, maxTokens: config.maxTokens, sessionId: session.id, signal: callDeadline.signal, }) // The review request is model-visible input: log the exact call before // dispatch so the auxiliary request is reconstructable from the session log. session.append('approval/review/request', { route, reasoningEffort: config.reasoningEffort, toolName: req.toolName, ...(req.callId !== undefined ? { callId: req.callId } : {}), ...(req.reason !== undefined ? { askerReason: req.reason } : {}), ...(toolArguments !== undefined ? { toolArguments } : {}), system, input, maxTokens: config.maxTokens, }) callDeadline.signal.throwIfAborted() const assembler = new BlockAssembler() for await (const chunk of ctx.llm.stream(options)) { callDeadline.signal.throwIfAborted() assembler.push(chunk) } callDeadline.signal.throwIfAborted() const failure = finishErrorOf(assembler.finish) if (failure !== undefined) throw failure const verdict = parseVerdict(extractReviewText(assembler.blocks())) session.append('approval/review/verdict', { verdict: verdict.verdict, explanation: verdict.explanation, ...(verdict.risks !== undefined ? { risks: verdict.risks } : {}), }) if (verdict.verdict === 'approve') return 'allowed-once' attachAnalysis(req, verdict) return await next() }