返回源码地图

packages/interaction/user-approval/src/index.ts

main snapshot · da00f7f5358f · 正文引用章节 17;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/**
2 * Service Definition for the approval capability seam, covering requests, cancellation, audit, and per-session policy. Missing
3 * answerers fail closed; grants apply only to the requested action.
4 * @module @deepseek-ai/dsh-user-approval
5 */
6
7import { randomUUID } from 'node:crypto'
8import { Context, Service } from '@deepseek-ai/cordis'
9import z from '@deepseek-ai/schemastery'
10import type { Agent } from '@deepseek-ai/dsh-agent'
11import { createUserMessage, type ToolCallId } from '@deepseek-ai/dsh-llm'
12import type { ContextFormed } from '@deepseek-ai/dsh-llm'
13declare module '@deepseek-ai/dsh-llm' {
14 interface MessageSourceMap {
15 'user-approval': { kind: 'user-approval' } & ContextFormed
16 }
17}
18
19import { scopeTarget } from '@deepseek-ai/dsh-scope'
20import type { Session } from '@deepseek-ai/dsh-session'
21import { SessionSeq } from '@deepseek-ai/dsh-session'
22import type {} from '@deepseek-ai/dsh-system-prompt'
23
24declare module '@deepseek-ai/cordis' {
25 interface Context {
26 approval: ApprovalService
27 }
28}
29
30declare module '@deepseek-ai/dsh-session/types' {
31 interface SessionEventMap {
32 /**
33 * The session's approval policy was switched — log-only, durable,
34 * replayable, never in the model transcript (the model learns the policy
35 * from the runtime-context snapshot and live switch notices). The LAST
36 * such event is the session's override.
37 * `source: 'delegation'` marks an override seeded into a child; an absent
38 * source is a runtime switch.
39 */
40 'approval/policy': {
41 policy: ApprovalPolicy
42 /** Marks an override seeded into a child at delegation. */
43 source?: 'delegation'
44 }
45 }
46}
47
48import { ApprovalRequestId } from './types.ts'
49import type { ApprovalOutcome, ApprovalRequestEvent } from './types.ts'
50
51export { ApprovalRequestId } from './types.ts'
52export type { ApprovalOutcome } from './types.ts'
53
54/** Every {@link ApprovalOutcome}, for runtime normalization of answerer returns. */
55const OUTCOMES: readonly ApprovalOutcome[] = ['allowed-once', 'rejected', 'cancelled', 'unavailable']
56
57/**
58 * A session's approval policy — what happens to an {@link ApprovalService}
59 * ask BEFORE any interactive answerer sees it:
60 *
61 * - `'ask'` (the default) — delegate to the composed answerers; with none
62 * composed the chain falls through to the fail-closed `'unavailable'`.
63 * - `'never'` — never prompt anyone: every ask resolves `'rejected'`
64 * deterministically. The strict headless stance (CI, unattended runs) and
65 * the policy whose outcome is knowable without asking.
66 */
67export type ApprovalPolicy = 'ask' | 'never'
68
69/** Every {@link ApprovalPolicy}, for option advertisement and runtime validation of untrusted policy strings. */
70export const APPROVAL_POLICIES: readonly ApprovalPolicy[] = ['ask', 'never']
71
72/** Model-facing statement for the deterministic `'never'` policy. */
73const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).'
74/** Model-facing statement for an interactive policy that may still fail closed. */
75const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.'
76
77/**
78 * Whether the log currently sits inside an open turn (a `turn/start` not yet
79 * closed by a `turn/end`) — the {@link ApprovalService.request} precondition.
80 * The audit pair must be turn-enclosed: the turn is the durable log's
81 * commit/replay boundary, so a bare event appended between turns is
82 * indistinguishable from a crash tail and silently dropped on reload.
83 */
84function hasOpenTurn(session: Session): boolean {
85 for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
86 // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
87 const type = session.eventAt(SessionSeq(seq))?.type
88 if (type === 'turn/start') return true
89 if (type === 'turn/end') return false
90 }
91 return false
92}
93
94/**
95 * Append the sole durable representation of a session policy override. Invalid
96 * values throw before the log changes; consumers fold the new value on each read.
97 * @param session - the session the override belongs to.
98 * @param policy - the policy in effect until the next switch.
99 */
100export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): void {
101 if (!APPROVAL_POLICIES.includes(policy)) {
102 throw new TypeError('approval policy must be one of "ask" or "never"')
103 }
104 session.append('approval/policy', { policy })
105}
106
107/**
108 * Readonly same-process permission question. `callId` links to an already
109 * presented tool call, so arguments are not duplicated here.
110 */
111export interface ApprovalRequest extends ApprovalRequestEvent {
112 /**
113 * The agent on whose behalf the question is asked. Routes the question (a
114 * UI answerer only answers for agents it owns) and receives the audit
115 * events on its session log.
116 */
117 readonly agent: Agent
118 /** The tool the question is about (presentation and audit). */
119 readonly toolName: string
120 /**
121 * The exact tool call being decided, when the asker has one — lets a UI
122 * attach the prompt to the tool call it already streamed.
123 */
124 readonly callId?: ToolCallId
125 /** The asker's human-readable explanation of WHY it is asking. */
126 readonly reason?: string
127 /**
128 * Aborting withdraws the question: the request settles `'cancelled'`
129 * immediately and a late answer from a still-pending answerer is discarded.
130 */
131 readonly signal?: AbortSignal
132}
133
134/** Plugin config. All optional — `static Config` supplies the defaults. */
135export interface Config {
136 /**
137 * The deployment's default {@link ApprovalPolicy} for sessions without an
138 * `approval/policy` override — `'ask'` delegates to the composed answerers
139 * (fail-closed with none); `'never'` auto-rejects every ask without
140 * prompting (the deterministic CI/unattended stance).
141 */
142 readonly policy?: ApprovalPolicy
143}
144
145/**
146 * Approval service that applies session policy before answerers and logs every
147 * ask/outcome pair to the requesting session. It exposes deterministic policy
148 * changes to the model through the runtime-context snapshot and switch notices.
149 */
150export class ApprovalService extends Service {
151 static Config: z<Config> = z.object({
152 policy: z.union(['ask', 'never'] as const).default('ask'),
153 })
154
155 constructor(ctx: Context, public config: Config) {
156 super(ctx, 'approval')
157
158 const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent.session)
159
160 // The complete current value travels after retained history, so switching
161 // policy does not rewrite the stable system-prompt cache prefix.
162 ctx.inject(['systemPrompt'], (scope: Context) => {
163 scope.systemPrompt.context({
164 name: 'approval:policy',
165 order: scope.systemPrompt.getContextOrder('APPROVAL_POLICY'),
166 text: (context) => {
167 const agent = context.agent
168 // A bare assemble() (tests, diagnostics) has no session to state.
169 if (agent === undefined) return ''
170 const policy = effective(agent)
171 return policy === 'never' ? NEVER_SENTENCE : ASK_SENTENCE
172 },
173 })
174 })
175 }
176
177 /**
178 * Switch one live agent's policy and queue the transition for its next model
179 * step. Session initialization uses {@link setApprovalPolicy} directly
180 * because there is no previously visible policy to change.
181 * @param agent - the live agent whose policy is changing.
182 * @param policy - the new effective policy.
183 */
184 setPolicy(agent: Agent, policy: ApprovalPolicy): void {
185 const previous = this.effectivePolicy(agent.session)
186 if (previous === policy) return
187 setApprovalPolicy(agent.session, policy)
188 agent.inject(createUserMessage({
189 content: [{
190 type: 'text',
191 text: `The approval policy changed from "${previous}" to "${policy}" (changed by the user).`,
192 }],
193 source: { kind: 'user-approval' },
194 }))
195 }
196
197 /**
198 * Ask the composed answerers to decide one readonly same-process request.
199 * The service borrows the request, agent, session, and live signal directly.
200 * The request requires an open turn because the audit pair must be enclosed
201 * by the durable log's commit/replay boundary; an idle ask rejects before
202 * appending anything. The answerer phase always produces an outcome: an
203 * aborted signal yields `'cancelled'`, a missing or throwing answerer yields
204 * `'unavailable'` (fail closed), and a rogue non-vocabulary return value is
205 * normalized to `'unavailable'`. A failure that prevents either audit append
206 * from committing still rejects because returning an unlogged decision would
207 * violate the pair. Session contains post-commit observer failures, so an
208 * authoritative append cannot reject the request or suppress its matching
209 * audit event.
210 * @param req - the pending decision (agent, tool identity, reason, signal).
211 * @returns the closed outcome; `'allowed-once'` is the only grant.
212 * @throws when no turn is open or either audit event fails before the session
213 * append commit point.
214 */
215 async request(req: ApprovalRequest): Promise<ApprovalOutcome> {
216 const session = req.agent.session
217 if (!hasOpenTurn(session)) {
218 throw new Error(
219 'approval.request() outside an open turn: the approval/asked + approval/decided audit pair '
220 + 'must be turn-enclosed (a bare event between turns is crash-tail garbage on reload). '
221 + 'Ask from inside the turn that needs the decision.',
222 )
223 }
224 const id = ApprovalRequestId(randomUUID())
225 session.append('approval/asked', {
226 id,
227 toolName: req.toolName,
228 ...req.callId !== undefined ? { callId: req.callId } : {},
229 ...req.reason !== undefined ? { reason: req.reason } : {},
230 })
231 const outcome = await this.decide(req, session)
232 session.append('approval/decided', { id, outcome })
233 return outcome
234 }
235
236 /**
237 * The session's effective policy: its own `approval/policy` fold, else the
238 * configured default (the schema already defaulted an omitted policy to
239 * `'ask'`; the `??` only narrows the optional-input TYPE).
240 * @param session - the exact accepted session whose policy applies.
241 * @returns the policy every ask for this session resolves under right now.
242 */
243 private effectivePolicy(session: Session): ApprovalPolicy {
244 return this.overrideOf(session) ?? this.config.policy ?? 'ask'
245 }
246
247 /**
248 * Read the session override without applying the configured default.
249 * @param session - session whose log supplies the override.
250 * @returns the last logged policy, or `undefined` without one.
251 */
252 overrideOf(session: Session): ApprovalPolicy | undefined {
253 for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
254 // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
255 const event = session.eventAt(SessionSeq(seq))
256 if (event?.type === 'approval/policy') return event.data.policy
257 }
258 return undefined
259 }
260
261 /**
262 * Dispatch the waterfall, contained and raced against the request signal.
263 * @param req - the borrowed public request.
264 * @param session - the request agent's session used for policy lookup.
265 * @returns the normalized closed outcome.
266 */
267 private async decide(req: ApprovalRequest, session: Session): Promise<ApprovalOutcome> {
268 const signal = req.signal
269 if (signal?.aborted) return 'cancelled'
270 // The 'never' policy is decided HERE, before any dispatch: a listener
271 // registered with `prepend: true` after this service mounts would sit
272 // ahead of any gate LISTENER, so a listener-shaped gate cannot keep the
273 // documented promise that 'never' rejects deterministically regardless
274 // of registration order — only the service's own request path can.
275 if (this.effectivePolicy(session) === 'never') return 'rejected'
276 // Enter the promise chain BEFORE dispatching: a listener that throws
277 // SYNCHRONOUSLY (before its first await) must land in the same rejection
278 // path as an async one — `Promise.resolve(call())` would let it escape
279 // the containment into the caller.
280 const answer: Promise<ApprovalOutcome> = Promise.resolve().then(
281 () => this.ctx.waterfall(
282 scopeTarget(req.agent, req.agent), 'approval/request', req,
283 () => Promise.resolve<ApprovalOutcome>('unavailable'),
284 ),
285 ).then(
286 // Normalize a rogue (non-vocabulary) answerer return to the fail-closed
287 // outcome instead of leaking it into callers' closed-union switches.
288 outcome => OUTCOMES.includes(outcome) ? outcome : 'unavailable',
289 // A throwing answerer must fail the QUESTION closed, not the caller's
290 // tool call open — the seam contains its callbacks.
291 () => 'unavailable',
292 )
293 if (signal === undefined) return answer
294 return await new Promise<ApprovalOutcome>((resolve) => {
295 const onAbort = () => {
296 signal.removeEventListener('abort', onAbort)
297 resolve('cancelled')
298 }
299 signal.addEventListener('abort', onAbort, { once: true })
300 void answer.then((outcome) => {
301 signal.removeEventListener('abort', onAbort)
302 // After an abort won the race this resolve is a settled-promise no-op:
303 // the late answer is discarded by construction.
304 resolve(outcome)
305 })
306 })
307 }
308}
309
310export default ApprovalService