/** * Pure review policy: route resolution, reviewer prompts, verdict parsing, and * the session-log reads the review pipeline needs. No cordis or I/O here so * every rule is directly unit-testable. * * @module dsh-approval-ai-review/reviewer */ import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock, FinishReason } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { ApprovalRequest } from '@deepseek-ai/dsh-user-approval' import { z as zod } from 'zod' import type { ApprovalReviewProjectionState, ReviewVerdictRecord } from './types.ts' /** The route-override fields the review-route resolution reads. */ export interface ReviewRouteConfig { /** Explicit review-model provider, when configured. */ readonly reviewProvider?: string /** Explicit review-model id, when configured. */ readonly reviewModel?: string } /** Marker prefix distinguishing this plugin's analysis inside a request reason. */ export const REVIEW_ANALYSIS_PREFIX = '[AI review]' /** Capability-owned timeout reason code for auxiliary review requests. */ export const APPROVAL_REVIEW_TIMEOUT_CODE = 'APPROVAL_REVIEW_TIMEOUT' /** Suffix appended to tool-call arguments truncated at `maxArgumentsChars`. */ export const ARGUMENTS_TRUNCATION_MARK = '…[truncated by approval-ai-review]' /** State for the empty log: no switch, no indexed calls. */ export const EMPTY_REVIEW_STATE: ApprovalReviewProjectionState = { enabled: null, toolCalls: {} } /** Validates persisted projection state before it seeds a fold. */ export const reviewStateSchema: zod.ZodType = zod.object({ enabled: zod.boolean().nullable(), toolCalls: zod.record(zod.string(), zod.string()), }).strict() /** * One-event projection transition (the projection unit's `apply`), bounded at * write time: tool-call arguments longer than `maxArgumentsChars` are * truncated with {@link ARGUMENTS_TRUNCATION_MARK} before entering the state. * Unrelated events return the same reference — the registry's change gate. * @param state - the folded state before `event`. * @param event - one committed session event. * @param maxArgumentsChars - the resolved per-call arguments cap. * @returns the next state; the same reference when the event is unrelated. */ export function applyReviewEvent( state: ApprovalReviewProjectionState, event: SessionEvent, maxArgumentsChars: number, ): ApprovalReviewProjectionState { switch (event.type) { case 'approval/review/enabled': return { ...state, enabled: event.data.enabled } case 'tool/call': { const raw = event.data.arguments const bounded = raw.length > maxArgumentsChars ? raw.slice(0, maxArgumentsChars) + ARGUMENTS_TRUNCATION_MARK : raw return { ...state, toolCalls: { ...state.toolCalls, [event.data.callId]: bounded } } } default: return state } } /** * Stable reviewer system instruction. The contract asks for one JSON object * and no other text, names the closed verdict vocabulary, and delegates the * response language to the conversation. */ const REVIEW_SYSTEM_PROMPT = [ 'You are the safety reviewer for an AI coding agent. You receive ONE pending tool-approval request as JSON: the tool name, its raw arguments when available, and the requesting agent\'s stated reason.', 'Decide whether approving this one request is safe:', '- "approve" only operations that are reversible, scoped to the user\'s workspace, and clearly serve the user\'s current task.', '- "escalate" every destructive, irreversible, or far-reaching operation (deletions outside obvious build output, force pushes, credential or secret access, network publication, system configuration), and every request whose effect you cannot determine.', 'Respond with ONLY one JSON object and nothing else — no Markdown fences, no prefix, no explanation outside it:', '{"verdict":"approve","explanation":"","risks":""}', '"verdict" is exactly "approve" or "escalate".', 'Write "explanation" and "risks" in the same language the request\'s reason uses (match it; default to English when there is no reason).', ].join('\n') /** * The stable reviewer system instruction, exported for tests and diagnostics. * @returns the reviewer system prompt text. */ export function reviewSystemPrompt(): string { return REVIEW_SYSTEM_PROMPT } /** * Frame the pending ask as one JSON object so request text cannot break * structural delimiters. Malformed tool arguments embed as the raw string. * @param toolName - the tool the pending ask is about. * @param toolArguments - the raw arguments JSON from the session log, when found. * @param askerReason - the asker's stated reason, when supplied. * @returns the verbatim reviewer user input. */ export function buildReviewInput(toolName: string, toolArguments: string | undefined, askerReason: string | undefined): string { let argumentsField: unknown = toolArguments if (toolArguments !== undefined) { try { argumentsField = JSON.parse(toolArguments) } catch { argumentsField = toolArguments } } return `Review this pending tool-approval request:\n${JSON.stringify({ tool: toolName, ...(toolArguments === undefined ? {} : { arguments: argumentsField }), ...(askerReason === undefined ? {} : { reason: askerReason }), })}` } /** * Resolve the review-model route: the explicit config pair, then the agent's * routed conversation model (the logged request header), then `AgentOptions`. * @param config - validated plugin config. * @param agent - the agent whose ask is under review. * @returns the provider/model pair for the auxiliary call. * @throws when no route is available — the deployment must either configure * `reviewProvider`/`reviewModel` together or have routed at least one request. */ export function resolveReviewRoute(config: ReviewRouteConfig, agent: Agent): { provider: string; model: string } { const configured = config.reviewProvider !== undefined && config.reviewModel !== undefined ? { provider: config.reviewProvider, model: config.reviewModel } : undefined const latest = agent.session.requestHeader()?.config const routed = latest !== undefined ? { provider: latest.provider, model: latest.model } : undefined const options = agent.options.provider !== undefined && agent.options.provider.length > 0 && agent.options.model !== undefined && agent.options.model.length > 0 ? { provider: agent.options.provider, model: agent.options.model } : undefined const target = configured ?? routed ?? options if (target === undefined) { throw new Error( 'approval-ai-review: no provider/model available for the review call; configure reviewProvider and ' + 'reviewModel together, route one request, or set both AgentOptions fields', ) } return target } /** Cap echoed reviewer output inside error messages. */ const ECHO_LIMIT = 200 /** * Parse one reviewer response at the model-JSON boundary. The prompt demands a * bare JSON object; anything else fails the review (which escalates) instead * of being coerced. * @param text - the reviewer's complete text output. * @returns the validated, frozen verdict record. * @throws when the output is not a JSON object with a known verdict and a * non-empty explanation. */ export function parseVerdict(text: string): ReviewVerdictRecord { let parsed: unknown try { parsed = JSON.parse(text) } catch { throw new Error(`approval-ai-review: reviewer output is not JSON: ${text.slice(0, ECHO_LIMIT)}`) } if (typeof parsed !== 'object' || parsed === null) { throw new Error(`approval-ai-review: reviewer output is not a JSON object: ${text.slice(0, ECHO_LIMIT)}`) } const record = parsed as { verdict?: unknown; explanation?: unknown; risks?: unknown } if (record.verdict !== 'approve' && record.verdict !== 'escalate') { throw new Error(`approval-ai-review: reviewer verdict must be "approve" or "escalate", got ${String(record.verdict).slice(0, ECHO_LIMIT)}`) } if (typeof record.explanation !== 'string' || record.explanation.trim().length === 0) { throw new Error('approval-ai-review: reviewer explanation must be a non-empty string') } if (record.risks !== undefined && typeof record.risks !== 'string') { throw new Error('approval-ai-review: reviewer risks must be a string when supplied') } return Object.freeze({ verdict: record.verdict, explanation: record.explanation, ...typeof record.risks === 'string' && record.risks.length > 0 ? { risks: record.risks } : {}, }) } /** * Extract the reviewer's text output. A review response must be text only; a * tool call means the model ignored the contract and fails the review. * @param blocks - the assembled response blocks. * @returns the joined text, trimmed. * @throws when the response contains a tool-call block or no text at all. */ export function extractReviewText(blocks: readonly ContentBlock[]): string { if (blocks.some(block => block.type === 'tool-call')) { throw new Error('approval-ai-review: reviewer output must contain text only') } const text = blocks .filter((block): block is Extract<(typeof blocks)[number], { type: 'text' }> => block.type === 'text') .map(block => block.text) .join(' ') .trim() if (text.length === 0) throw new Error('approval-ai-review: reviewer produced no text') return text } /** * Translate a terminal finish reason into a review failure. Merge-extensible * sum: unknown kinds fall through to failure (fail-closed) rather than being * trusted. * @param finish - the assembler's terminal finish reason. * @returns the error to throw, or `undefined` for a clean `stop`. */ export function finishErrorOf(finish: FinishReason): Error | undefined { switch (finish.kind) { case 'stop': return undefined case 'error': case 'aborted': { const error = new Error(finish.failure.message) as Error & { code?: string } error.code = finish.failure.code return error } case 'max-tokens': return new Error('approval-ai-review: review output reached maxTokens') case 'tool-calls': return new Error('approval-ai-review: reviewer unexpectedly requested a tool') default: return new Error(`approval-ai-review: unsupported finish reason "${String((finish as { kind?: unknown }).kind)}"`) } } /** * Attach the review's analysis to the shared request's reason so every * remaining answerer (the Web approval card, the ACP bridge) presents what the * operation would do and its risks alongside the asker's own reason. The * mutate-then-delegate on the shared waterfall request is the sanctioned * cooperative pattern; the `approval/asked` audit event is committed before * dispatch, so the asker's verbatim reason stays in the log. * `ApprovalRequest.reason` is readonly to answerers, not to the dispatch * pipeline that owns the request's lifetime. * @param req - the pending request shared with downstream answerers. * @param verdict - the parsed verdict whose analysis is attached. */ export function attachAnalysis(req: ApprovalRequest, verdict: ReviewVerdictRecord): void { const lines = [ ...(req.reason !== undefined ? [req.reason] : []), `${REVIEW_ANALYSIS_PREFIX} ${verdict.explanation}`, ...(verdict.risks !== undefined ? [`${REVIEW_ANALYSIS_PREFIX} Risks: ${verdict.risks}`] : []), ] ;(req as { reason?: string }).reason = lines.join('\n') }