@dsh-enhanced/assistant-delivery 0.1.3

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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +225 -0
  3. package/cordis.patch.yml +29 -0
  4. package/lib/agent-runtime.d.ts +112 -0
  5. package/lib/agent-runtime.d.ts.map +1 -0
  6. package/lib/agent-runtime.js +1866 -0
  7. package/lib/agent-runtime.js.map +1 -0
  8. package/lib/canonical.d.ts +18 -0
  9. package/lib/canonical.d.ts.map +1 -0
  10. package/lib/canonical.js +83 -0
  11. package/lib/canonical.js.map +1 -0
  12. package/lib/coordinator.d.ts +124 -0
  13. package/lib/coordinator.d.ts.map +1 -0
  14. package/lib/coordinator.js +676 -0
  15. package/lib/coordinator.js.map +1 -0
  16. package/lib/inbound-images.d.ts +38 -0
  17. package/lib/inbound-images.d.ts.map +1 -0
  18. package/lib/inbound-images.js +267 -0
  19. package/lib/inbound-images.js.map +1 -0
  20. package/lib/index.d.ts +12 -0
  21. package/lib/index.d.ts.map +1 -0
  22. package/lib/index.js +12 -0
  23. package/lib/index.js.map +1 -0
  24. package/lib/operator.d.ts +23 -0
  25. package/lib/operator.d.ts.map +1 -0
  26. package/lib/operator.js +38 -0
  27. package/lib/operator.js.map +1 -0
  28. package/lib/service.d.ts +269 -0
  29. package/lib/service.d.ts.map +1 -0
  30. package/lib/service.js +1698 -0
  31. package/lib/service.js.map +1 -0
  32. package/lib/session-commands.d.ts +17 -0
  33. package/lib/session-commands.d.ts.map +1 -0
  34. package/lib/session-commands.js +39 -0
  35. package/lib/session-commands.js.map +1 -0
  36. package/lib/sqlite.d.ts +8 -0
  37. package/lib/sqlite.d.ts.map +1 -0
  38. package/lib/sqlite.js +411 -0
  39. package/lib/sqlite.js.map +1 -0
  40. package/lib/store.d.ts +353 -0
  41. package/lib/store.d.ts.map +1 -0
  42. package/lib/store.js +2614 -0
  43. package/lib/store.js.map +1 -0
  44. package/lib/tools.d.ts +4 -0
  45. package/lib/tools.d.ts.map +1 -0
  46. package/lib/tools.js +54 -0
  47. package/lib/tools.js.map +1 -0
  48. package/lib/types.d.ts +329 -0
  49. package/lib/types.d.ts.map +1 -0
  50. package/lib/types.js +2 -0
  51. package/lib/types.js.map +1 -0
  52. package/lib/version.d.ts +2 -0
  53. package/lib/version.d.ts.map +1 -0
  54. package/lib/version.js +2 -0
  55. package/lib/version.js.map +1 -0
  56. package/package.json +122 -0
@@ -0,0 +1,1866 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, stat } from 'node:fs/promises';
3
+ import { installModelSelection, } from '@deepseek-ai/dsh-agent';
4
+ import { contentHasImage, createUserMessage, ReasoningEffortId, } from '@deepseek-ai/dsh-llm';
5
+ import { effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy';
6
+ import { SessionId } from '@deepseek-ai/dsh-session';
7
+ import { effectiveApprovalPolicy, setApprovalPolicy, } from '@deepseek-ai/dsh-user-approval';
8
+ import { approvalReviewerOf, setApprovalReviewer, waitForApprovalReviewerSessionEventReady, } from '@dsh-enhanced/assistant-policy';
9
+ import { resolveLlmRouteCapability } from '@dsh-enhanced/llm-route-capabilities';
10
+ import { externalPrincipalId } from './canonical.js';
11
+ import { isExactDeliveryCommand, parseDeliveryCommand, permissionDispatchRecoveryCode, } from './session-commands.js';
12
+ import { DeliveryStoreError } from './store.js';
13
+ const SAFE_NATIVE_COMMANDS = new Set(['compact']);
14
+ class UserSessionCancellation extends Error {
15
+ command;
16
+ constructor(command) {
17
+ super(`assistant-delivery: session task cancelled by /${command}`);
18
+ this.command = command;
19
+ this.name = 'UserSessionCancellation';
20
+ }
21
+ }
22
+ const MAX_CATALOG_MODELS = 50;
23
+ const MAX_PROGRESS_TODOS = 20;
24
+ const MAX_PROGRESS_TEXT_CHARS = 240;
25
+ class DurableAgentIdentityError extends Error {
26
+ }
27
+ class ApprovalReviewerReaderUnavailableError extends Error {
28
+ constructor(cause) {
29
+ super('assistant-delivery: approval reviewer persistence reader is not proven', { cause });
30
+ this.name = 'ApprovalReviewerReaderUnavailableError';
31
+ }
32
+ }
33
+ class ToolCapabilityAdmissionError extends Error {
34
+ provider;
35
+ model;
36
+ presetId;
37
+ constructor(provider, model, presetId) {
38
+ super(`assistant-delivery: ${provider}/${model} cannot execute the tools mounted by preset ${presetId}`);
39
+ this.provider = provider;
40
+ this.model = model;
41
+ this.presetId = presetId;
42
+ this.name = 'ToolCapabilityAdmissionError';
43
+ }
44
+ }
45
+ class ImageCapabilityAdmissionError extends Error {
46
+ provider;
47
+ model;
48
+ constructor(provider, model) {
49
+ super(`assistant-delivery: ${provider}/${model} does not declare image input support`);
50
+ this.provider = provider;
51
+ this.model = model;
52
+ this.name = 'ImageCapabilityAdmissionError';
53
+ }
54
+ }
55
+ function requireToolCapability(llm, provider, model, presetId, toolCount, declaredToolCapableProviders, unknownRouteToolCalls) {
56
+ if (toolCount === 0)
57
+ return;
58
+ const capability = resolveLlmRouteCapability(llm, provider, model);
59
+ if (capability?.toolCalls === 'native' || capability?.toolCalls === 'bridge')
60
+ return;
61
+ // A provider-owned declaration is authoritative in both directions: an
62
+ // explicit `none` still fails closed even when the route is allowlisted or
63
+ // unknown routes are admitted.
64
+ if (capability?.toolCalls === 'none')
65
+ throw new ToolCapabilityAdmissionError(provider, model, presetId);
66
+ // Only routes with no declaration at all reach the host policy. The DSH
67
+ // `generate` contract accepts `tools` for every adapter, so tool calling is
68
+ // the adapter baseline rather than an opt-in: gateway and built-in providers
69
+ // (pi-ai and friends) never publish registry metadata, and denying them by
70
+ // default made every such route unusable behind a tool-mounting preset.
71
+ if (declaredToolCapableProviders.has(provider))
72
+ return;
73
+ if (unknownRouteToolCalls === 'allow')
74
+ return;
75
+ throw new ToolCapabilityAdmissionError(provider, model, presetId);
76
+ }
77
+ function toolCapabilityReply(error) {
78
+ return {
79
+ text: `当前模型 ${error.provider}/${error.model} 已声明不支持工具调用,无法执行 preset “${error.presetId}” 暴露的工具。`
80
+ + '请发送 /model 切换到支持工具调用的 provider,或改用不挂载工具的 preset。',
81
+ format: 'plain',
82
+ };
83
+ }
84
+ function imageCapabilityReply(error) {
85
+ return {
86
+ text: `当前模型 ${error.provider}/${error.model} 不支持图片输入。`
87
+ + '请发送 /model 切换到明确支持图片的模型后重新发送图片。',
88
+ format: 'plain',
89
+ };
90
+ }
91
+ async function requireImageCapability(llm, provider, model, signal) {
92
+ const info = await llm.resolveModelInfo(provider, model, signal);
93
+ if (info.inputModalities?.includes('image') !== true) {
94
+ throw new ImageCapabilityAdmissionError(provider, model);
95
+ }
96
+ }
97
+ function boundedProgressText(value) {
98
+ return [...value].slice(0, MAX_PROGRESS_TEXT_CHARS).join('');
99
+ }
100
+ /**
101
+ * Convert one durable session fact into a deliberately narrow, user-visible progress update.
102
+ * Raw reasoning chunks, tool arguments, result content, and error details never cross this boundary.
103
+ *
104
+ * The `reasoning` block of a durable `assistant/message` is the assistant's own settled summary of
105
+ * the step, not a streaming fragment, so it is the one reasoning form safe to surface. Only some
106
+ * providers emit it at all: subscription CLIs declare an effort capability but still return text
107
+ * only, and the ACP thought channel is not mapped to DSH reasoning yet. `step/start` therefore also
108
+ * yields a neutral phase label, so a turn reports progress even on providers that never reason
109
+ * out loud.
110
+ */
111
+ export function deliveryProgressFromSessionEvent(event) {
112
+ if (event.type === 'step/start') {
113
+ const step = Number(event.data.step);
114
+ return {
115
+ kind: 'step',
116
+ text: Number.isSafeInteger(step) && step > 1 ? `正在继续处理(第 ${step} 步)…` : '正在处理请求…',
117
+ };
118
+ }
119
+ if (event.type === 'assistant/message') {
120
+ const reasoning = event.data.message.content
121
+ .filter(value => value.type === 'reasoning')
122
+ .map(value => (value.type === 'reasoning' ? value.text.trim() : ''))
123
+ .filter(text => text !== '')
124
+ .join('\n');
125
+ return reasoning === '' ? undefined : { kind: 'step', text: boundedProgressText(reasoning) };
126
+ }
127
+ if (event.type === 'tool/call') {
128
+ return {
129
+ kind: 'tool-started',
130
+ callId: String(event.data.callId),
131
+ toolName: boundedProgressText(event.data.name),
132
+ };
133
+ }
134
+ if (event.type === 'tool/result') {
135
+ const block = event.data.message.content.find(value => value.type === 'tool-result');
136
+ const callId = block?.type === 'tool-result'
137
+ ? block.toolCallId
138
+ : event.data.message.source?.callId;
139
+ if (callId === undefined)
140
+ return undefined;
141
+ return {
142
+ kind: 'tool-finished',
143
+ callId: String(callId),
144
+ failed: event.data.error !== undefined || (block?.type === 'tool-result' && block.isError === true),
145
+ };
146
+ }
147
+ if (event.type === 'todo/write') {
148
+ return { kind: 'todos', todos: event.data.todos.slice(0, MAX_PROGRESS_TODOS).map(todo => ({
149
+ content: boundedProgressText(todo.content),
150
+ status: todo.status,
151
+ })) };
152
+ }
153
+ return undefined;
154
+ }
155
+ function parseModelRoute(value) {
156
+ const separator = value.indexOf('/');
157
+ if (separator <= 0 || separator === value.length - 1)
158
+ return undefined;
159
+ const provider = value.slice(0, separator).trim();
160
+ const model = value.slice(separator + 1).trim();
161
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/u.test(provider)
162
+ || model.length > 512 || /[\s\p{Cc}]/u.test(model))
163
+ return undefined;
164
+ return { provider, model };
165
+ }
166
+ function modelCommand(command) {
167
+ return command?.name === 'model' ? command.rawInput.trim() : undefined;
168
+ }
169
+ function permissionCommand(command) {
170
+ return command?.name === 'permission' || command?.name === 'permissions'
171
+ ? command.rawInput.trim()
172
+ : undefined;
173
+ }
174
+ function parsePermissionCommand(argument) {
175
+ const tokens = argument === '' ? [] : argument.split(/\s+/u);
176
+ if (tokens.length === 0)
177
+ return { kind: 'show' };
178
+ if (tokens.length === 1 && tokens[0] === 'ask')
179
+ return { kind: 'switch', level: 'ask' };
180
+ if (tokens.length === 1 && tokens[0] === 'auto')
181
+ return { kind: 'switch', level: 'auto' };
182
+ if (tokens.length === 1 && tokens[0] === 'full')
183
+ return { kind: 'warn-full' };
184
+ if (tokens.length === 2 && tokens[0] === 'full' && tokens[1] === 'confirm') {
185
+ return { kind: 'switch', level: 'full' };
186
+ }
187
+ return { kind: 'invalid' };
188
+ }
189
+ const PERMISSION_PICKER_OPERATION_METADATA = 'permission-picker-operation';
190
+ const PERMISSION_PICKER_STATE_METADATA = 'permission-picker-state';
191
+ const PERMISSION_PICKER_EXPIRY_METADATA = 'permission-picker-expires-at';
192
+ const PERMISSION_PICKER_EMERGENCY_VERSION_METADATA = 'permission-picker-emergency-version';
193
+ function permissionPickerGuard(envelope) {
194
+ const operationId = envelope.metadata?.[PERMISSION_PICKER_OPERATION_METADATA];
195
+ const expectedStateHash = envelope.metadata?.[PERMISSION_PICKER_STATE_METADATA];
196
+ const expiresAtText = envelope.metadata?.[PERMISSION_PICKER_EXPIRY_METADATA];
197
+ const emergencyStopVersionText = envelope.metadata?.[PERMISSION_PICKER_EMERGENCY_VERSION_METADATA];
198
+ if (operationId === undefined && expectedStateHash === undefined && expiresAtText === undefined
199
+ && emergencyStopVersionText === undefined)
200
+ return undefined;
201
+ if (operationId === undefined || expectedStateHash === undefined || expiresAtText === undefined
202
+ || emergencyStopVersionText === undefined
203
+ || !/^permission-picker-[a-f0-9]{32}$/u.test(operationId)
204
+ || !/^[a-f0-9]{64}$/u.test(expectedStateHash)
205
+ || !/^[1-9][0-9]{0,15}$/u.test(expiresAtText)
206
+ || !/^(?:0|[1-9][0-9]{0,15})$/u.test(emergencyStopVersionText))
207
+ return 'invalid';
208
+ const expiresAt = Number(expiresAtText);
209
+ const emergencyStopVersion = Number(emergencyStopVersionText);
210
+ if (!Number.isSafeInteger(expiresAt) || !Number.isSafeInteger(emergencyStopVersion))
211
+ return 'invalid';
212
+ return { operationId, expectedStateHash, expiresAt, emergencyStopVersion };
213
+ }
214
+ function permissionPresetTargets(service) {
215
+ const workspace = [];
216
+ const full = [];
217
+ const table = [];
218
+ for (const name of service.names) {
219
+ const spec = service.resolve(name);
220
+ table.push([name, spec.sandbox, spec.approval]);
221
+ if (spec.sandbox === 'workspace-write' && spec.approval === 'ask')
222
+ workspace.push(name);
223
+ if (spec.sandbox === 'danger-full-access' && spec.approval === 'never')
224
+ full.push(name);
225
+ }
226
+ if (workspace.length > 2 || full.length > 1) {
227
+ throw new Error('ambiguous permission preset table');
228
+ }
229
+ const ask = workspace[0];
230
+ const auto = workspace[1] ?? ask;
231
+ return {
232
+ ...(ask === undefined ? {} : { ask, auto: auto }),
233
+ ...(full[0] === undefined ? {} : { full: full[0] }),
234
+ tableFingerprint: createHash('sha256').update(JSON.stringify(table)).digest('hex'),
235
+ };
236
+ }
237
+ function missingPermissionBundle(targets) {
238
+ if (targets.ask === undefined || targets.auto === undefined)
239
+ return 'workspace-write + ask';
240
+ return targets.full === undefined ? 'danger-full-access + never' : undefined;
241
+ }
242
+ function explicitSandboxMode(events) {
243
+ const event = events.findLast(candidate => candidate.type === 'sandbox/mode');
244
+ if (event?.data.mode === 'workspace-write' || event?.data.mode === 'danger-full-access')
245
+ return event.data.mode;
246
+ return undefined;
247
+ }
248
+ function explicitApprovalPolicy(events) {
249
+ const event = events.findLast(candidate => candidate.type === 'approval/policy');
250
+ if (event?.data.policy === 'ask' || event?.data.policy === 'never')
251
+ return event.data.policy;
252
+ return undefined;
253
+ }
254
+ async function appendApprovalReviewer(session, reviewer) {
255
+ try {
256
+ await waitForApprovalReviewerSessionEventReady(session);
257
+ return setApprovalReviewer(session, reviewer);
258
+ }
259
+ catch (error) {
260
+ throw new ApprovalReviewerReaderUnavailableError(error);
261
+ }
262
+ }
263
+ function currentPermissionLevel(service, targets, events) {
264
+ const current = service.current(events);
265
+ if (current === targets.full) {
266
+ return effectiveSandboxMode(events) === 'danger-full-access'
267
+ && effectiveApprovalPolicy(events) === 'never'
268
+ && approvalReviewerOf(events) === 'none'
269
+ ? 'full'
270
+ : 'custom';
271
+ }
272
+ if (effectiveSandboxMode(events) !== 'workspace-write' || effectiveApprovalPolicy(events) !== 'ask')
273
+ return 'custom';
274
+ const reviewer = approvalReviewerOf(events);
275
+ if (targets.ask === targets.auto && current === targets.ask)
276
+ return reviewer === 'auto-review' ? 'auto' : 'ask';
277
+ if (current === targets.ask)
278
+ return reviewer === 'user' ? 'ask' : 'custom';
279
+ if (current === targets.auto)
280
+ return reviewer === 'auto-review' ? 'auto' : 'custom';
281
+ return 'custom';
282
+ }
283
+ function canonicalPresetReviewer(name) {
284
+ if (name === 'workspace-write')
285
+ return 'user';
286
+ if (name === 'auto')
287
+ return 'auto-review';
288
+ if (name === 'danger-full-access')
289
+ return 'none';
290
+ return undefined;
291
+ }
292
+ async function ensurePermissionReviewer(session, preset, reviewer) {
293
+ if (approvalReviewerOf(session.events) === reviewer)
294
+ return;
295
+ if (canonicalPresetReviewer(preset) === reviewer) {
296
+ // PermissionPresetService deliberately makes a net-zero selection a no-op.
297
+ // Re-append its official event when an older legacy reviewer event would
298
+ // otherwise remain newer and incorrectly win the durable fold.
299
+ session.append('permission/preset', { preset });
300
+ return;
301
+ }
302
+ await appendApprovalReviewer(session, reviewer);
303
+ }
304
+ function ensureSandboxMode(session, mode) {
305
+ if (explicitSandboxMode(session.events) === mode)
306
+ return;
307
+ setSandboxMode(session, mode);
308
+ if (explicitSandboxMode(session.events) !== mode)
309
+ session.append('sandbox/mode', { mode });
310
+ }
311
+ function ensureApprovalPolicy(session, policy) {
312
+ if (explicitApprovalPolicy(session.events) === policy)
313
+ return;
314
+ setApprovalPolicy(session, policy);
315
+ if (explicitApprovalPolicy(session.events) !== policy)
316
+ session.append('approval/policy', { policy });
317
+ }
318
+ function permissionLevelLabel(level) {
319
+ if (level === 'ask')
320
+ return '请求批准(ask)';
321
+ if (level === 'auto')
322
+ return '帮我批准(auto)';
323
+ if (level === 'full')
324
+ return '完全访问权限(full)';
325
+ return '自定义安全组合(custom)';
326
+ }
327
+ export function permissionStateHash(events, tableFingerprint = '') {
328
+ const facts = events
329
+ .filter(event => [
330
+ 'permission/preset',
331
+ 'sandbox/mode',
332
+ 'approval/policy',
333
+ 'assistant-policy/approval-reviewer',
334
+ ].includes(String(event.type)))
335
+ .map(event => ({ seq: event.seq, type: String(event.type), data: event.data }));
336
+ return createHash('sha256')
337
+ .update('assistant-delivery:permission-state:v2\0')
338
+ .update(JSON.stringify([tableFingerprint, facts]))
339
+ .digest('hex');
340
+ }
341
+ export function permissionPickerOperationId(conversation, eventId) {
342
+ return `permission-picker-${createHash('sha256').update(JSON.stringify({
343
+ channel: conversation.channel,
344
+ account: conversation.account,
345
+ tenant: conversation.tenant,
346
+ kind: conversation.kind,
347
+ chat: conversation.chat,
348
+ thread: conversation.thread ?? null,
349
+ eventId,
350
+ })).digest('hex').slice(0, 32)}`;
351
+ }
352
+ function permissionOverview(level, picker) {
353
+ const text = [
354
+ `当前权限:${permissionLevelLabel(level)}`,
355
+ '',
356
+ '三档说明:',
357
+ '- 请求批准(ask):仅可写工作区;需要授权时由你确认。',
358
+ '- 帮我批准(auto):仅可写工作区;由自动审核器处理授权请求。',
359
+ '- 完全访问权限(full):🟠 Host sandbox 可访问网络及任意文件,且不再请求批准;管理员 Policy 硬门仍生效。',
360
+ '',
361
+ '档位只控制运行权限,不会安装或挂载技能/插件;已挂载工具还必须有 Policy 可达规则。',
362
+ 'full 仍不绕过显式 Policy 拒绝、紧急停止、身份和预算硬门。',
363
+ '',
364
+ '切换:/permission ask、/permission auto、/permission full confirm',
365
+ ].join('\n');
366
+ return {
367
+ text,
368
+ format: picker === undefined ? 'plain' : 'permission-picker',
369
+ ...(picker === undefined ? {} : { permissionPicker: picker, fallbackText: text }),
370
+ };
371
+ }
372
+ const PERMISSION_USAGE = {
373
+ text: '用法:/permission、/permission ask、/permission auto、/permission full confirm',
374
+ format: 'plain',
375
+ };
376
+ const PERMISSION_FULL_WARNING = {
377
+ text: '🟠 危险:full 会启用 danger-full-access,可访问网络及任意文件,并关闭批准请求。'
378
+ + '管理员 Policy 的显式拒绝、紧急停止、身份和预算硬门仍生效。'
379
+ + '如确需切换,请明确发送 /permission full confirm。当前权限未改变。',
380
+ format: 'plain',
381
+ };
382
+ function routeLabel(route) {
383
+ return `${route.provider}/${route.model}${route.reasoningEffort === undefined ? '' : `,effort:${route.reasoningEffort}`}`;
384
+ }
385
+ function displayName(value) {
386
+ return [...value].slice(0, 120).join('');
387
+ }
388
+ export function modelPickerOperationId(conversation, eventId) {
389
+ return `model-picker-${createHash('sha256').update(JSON.stringify({
390
+ channel: conversation.channel,
391
+ account: conversation.account,
392
+ tenant: conversation.tenant,
393
+ kind: conversation.kind,
394
+ chat: conversation.chat,
395
+ thread: conversation.thread ?? null,
396
+ eventId,
397
+ })).digest('hex').slice(0, 32)}`;
398
+ }
399
+ function toModelRoute(route) {
400
+ return {
401
+ provider: route.provider,
402
+ model: route.model,
403
+ ...(route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort }),
404
+ };
405
+ }
406
+ function agentSelection(route) {
407
+ return {
408
+ provider: route.provider,
409
+ model: route.model,
410
+ ...(route.reasoningEffort === undefined
411
+ ? {}
412
+ : { reasoningEffort: ReasoningEffortId(route.reasoningEffort) }),
413
+ };
414
+ }
415
+ async function modelCatalog(llm, conversation, current, isDefault, eventId, expiresAt, signal) {
416
+ const providers = llm.listProviders();
417
+ const catalogs = await Promise.all(providers.map(async (provider) => {
418
+ try {
419
+ return { provider, models: await llm.listModels(provider.id) };
420
+ }
421
+ catch {
422
+ return { provider, models: undefined };
423
+ }
424
+ }));
425
+ const fallback = [
426
+ `当前模型:${routeLabel(current)}${isDefault ? '(默认)' : ''}`,
427
+ '',
428
+ '可用模型(来自 DSH 实时目录):',
429
+ ];
430
+ const models = [];
431
+ const visibleProviders = new Map();
432
+ let shown = 0;
433
+ let hidden = 0;
434
+ for (const catalog of catalogs) {
435
+ fallback.push(`${catalog.provider.name} [${catalog.provider.id}]`);
436
+ if (catalog.models === undefined) {
437
+ fallback.push('- 目录暂不可用');
438
+ continue;
439
+ }
440
+ if (catalog.models.length === 0) {
441
+ fallback.push('- 未提供可枚举模型');
442
+ continue;
443
+ }
444
+ for (const model of catalog.models) {
445
+ if (shown >= MAX_CATALOG_MODELS) {
446
+ hidden += 1;
447
+ continue;
448
+ }
449
+ const route = `${catalog.provider.id}/${model.id}`;
450
+ const marker = catalog.provider.id === current.provider && model.id === current.model ? '(当前)' : '';
451
+ const display = model.name === model.id ? '' : ` — ${model.name}`;
452
+ fallback.push(`- ${route}${display}${marker}`);
453
+ visibleProviders.set(catalog.provider.id, {
454
+ id: catalog.provider.id,
455
+ name: displayName(catalog.provider.name),
456
+ });
457
+ models.push({ provider: catalog.provider.id, id: model.id, name: displayName(model.name), effortIds: [] });
458
+ shown += 1;
459
+ }
460
+ }
461
+ if (providers.length === 0)
462
+ fallback.push('- 当前没有已注册的模型 provider');
463
+ if (hidden > 0)
464
+ fallback.push(`- 另有 ${hidden} 个模型未展示`);
465
+ fallback.push('', '切换:/model use <provider/model>', '恢复默认:/model reset');
466
+ if (models.length === 0)
467
+ return { text: fallback.join('\n'), format: 'plain' };
468
+ const resolved = await Promise.allSettled(models.map(model => llm.resolveModelInfo(model.provider, model.id, signal)));
469
+ const efforts = new Map();
470
+ const linkedModels = models.map((model, index) => {
471
+ const result = resolved[index];
472
+ if (result?.status !== 'fulfilled')
473
+ return model;
474
+ const effortIds = new Set();
475
+ for (const effort of result.value.reasoning?.efforts ?? []) {
476
+ const id = String(effort.id);
477
+ if (!efforts.has(id) && efforts.size >= 20)
478
+ continue;
479
+ efforts.set(id, displayName(effort.name));
480
+ if (effortIds.size < 20)
481
+ effortIds.add(id);
482
+ }
483
+ return { ...model, effortIds: [...effortIds] };
484
+ });
485
+ return {
486
+ text: `当前模型:${routeLabel(current)}${isDefault ? '(默认)' : ''}`,
487
+ format: 'model-picker',
488
+ modelPicker: {
489
+ operationId: modelPickerOperationId(conversation, eventId),
490
+ expiresAt,
491
+ current,
492
+ providers: [...visibleProviders.values()],
493
+ models: linkedModels,
494
+ efforts: [...efforts].map(([id, name]) => ({ id, name })),
495
+ },
496
+ fallbackText: fallback.join('\n'),
497
+ };
498
+ }
499
+ async function runModelCommand(argument, llm, conversation, defaults, options, signal, eventId) {
500
+ const current = options.getModelSelection(conversation);
501
+ const route = toModelRoute(current ?? defaults);
502
+ if (argument === '') {
503
+ return modelCatalog(llm, conversation, route, current === undefined, eventId, Date.now() + options.modelPickerTtlMs, signal);
504
+ }
505
+ if (argument === 'reset') {
506
+ const epoch = options.beginModelCommand(conversation);
507
+ const committed = options.commitModelCommand({ conversation, expectedEpoch: epoch });
508
+ if (!committed.applied)
509
+ return { text: '模型选择已被更晚的操作取代,请重新发送 /model。' };
510
+ const changed = current !== undefined;
511
+ return { text: changed
512
+ ? `已恢复默认模型 ${routeLabel(defaults)}。\n下一条消息起生效,上下文保留。`
513
+ : `本会话已在使用默认模型 ${routeLabel(defaults)}。` };
514
+ }
515
+ if (!argument.startsWith('use ')) {
516
+ return { text: '用法:/model、/model use <provider/model>、/model reset' };
517
+ }
518
+ const target = argument.slice('use '.length).trim();
519
+ const selected = parseModelRoute(target);
520
+ if (selected === undefined)
521
+ return { text: '用法:/model use <provider/model>' };
522
+ if (!llm.listProviders().some(provider => provider.id === selected.provider)) {
523
+ return { text: `没有注册 provider “${selected.provider}”。发送 /model 查看当前可用模型。` };
524
+ }
525
+ const epoch = options.beginModelCommand(conversation);
526
+ try {
527
+ await llm.resolveModelInfo(selected.provider, selected.model, signal);
528
+ }
529
+ catch (error) {
530
+ if (signal.aborted)
531
+ throw error;
532
+ const code = typeof error === 'object' && error !== null && 'code' in error
533
+ && typeof error.code === 'string'
534
+ ? `(${error.code})`
535
+ : '';
536
+ return { text: `模型 ${routeLabel(selected)} 当前不可用${code}。发送 /model 重新选择。` };
537
+ }
538
+ const changed = current?.provider !== selected.provider || current.model !== selected.model;
539
+ if (!changed && current.reasoningEffort === undefined)
540
+ return { text: `本会话已在使用 ${routeLabel(selected)}。` };
541
+ const committed = options.commitModelCommand({ conversation, expectedEpoch: epoch, route: selected });
542
+ if (!committed.applied)
543
+ return { text: '模型选择已被更晚的操作取代,请重新发送 /model。' };
544
+ return { text: `已切换到 ${routeLabel(selected)}。\n下一条消息起生效,上下文保留。` };
545
+ }
546
+ function modelCommandFailure(error) {
547
+ const deterministic = error instanceof DeliveryStoreError
548
+ && (error.code === 'invalid-intent' || error.code === 'invalid-binding');
549
+ return {
550
+ outcome: 'not-processed',
551
+ failureCode: deterministic ? 'model-command-invalid' : 'model-command-failed',
552
+ retryable: !deterministic,
553
+ };
554
+ }
555
+ function sessionId(namespace, conversation, generation) {
556
+ const hash = createHash('sha256')
557
+ .update(JSON.stringify({ namespace, conversation, generation }))
558
+ .digest('hex')
559
+ .slice(0, 32);
560
+ return SessionId(`delivery-${hash}-g${generation}`);
561
+ }
562
+ async function ensureWorkspace(workspace) {
563
+ try {
564
+ await mkdir(workspace, { recursive: true, mode: 0o700 });
565
+ }
566
+ catch (error) {
567
+ throw new Error(`assistant-delivery: failed to ensure workspace "${workspace}": ${String(error)}`, {
568
+ cause: error,
569
+ });
570
+ }
571
+ }
572
+ async function requireWorkspace(workspace) {
573
+ try {
574
+ const metadata = await stat(workspace);
575
+ if (!metadata.isDirectory())
576
+ throw new Error('path is not a directory');
577
+ }
578
+ catch (error) {
579
+ throw new Error(`assistant-delivery: durable workspace "${workspace}" is unavailable: ${String(error)}`, {
580
+ cause: error,
581
+ });
582
+ }
583
+ }
584
+ function causedByDurableIdentity(error) {
585
+ let current = error;
586
+ const visited = new Set();
587
+ while (current instanceof Error && !visited.has(current)) {
588
+ if (current instanceof DurableAgentIdentityError)
589
+ return true;
590
+ visited.add(current);
591
+ current = current.cause;
592
+ }
593
+ return false;
594
+ }
595
+ function causedByUserCancellation(error) {
596
+ let current = error;
597
+ const visited = new Set();
598
+ while (current instanceof Error && !visited.has(current)) {
599
+ if (current instanceof UserSessionCancellation)
600
+ return true;
601
+ visited.add(current);
602
+ current = current.cause;
603
+ }
604
+ return false;
605
+ }
606
+ function errorChainHasName(error, expected) {
607
+ let current = error;
608
+ const visited = new Set();
609
+ while (current instanceof Error && !visited.has(current)) {
610
+ if (current.name === expected)
611
+ return true;
612
+ visited.add(current);
613
+ current = current.cause;
614
+ }
615
+ return false;
616
+ }
617
+ function assertAdoptableUnstartedSession(session, workspace, presetId) {
618
+ if (session.header.cwd !== workspace || session.header.agentPreset !== presetId) {
619
+ throw new DurableAgentIdentityError('assistant-delivery: durable orphan session identity does not match the requested generation');
620
+ }
621
+ if (session.events.some(event => event.type === 'turn/start' || event.type === 'user/message')
622
+ || session.deriveMessages().length > 0) {
623
+ throw new DurableAgentIdentityError('assistant-delivery: refusing to adopt a deterministic session that already started a user turn');
624
+ }
625
+ }
626
+ function sessionResumeFailureCode(error) {
627
+ if (causedByDurableIdentity(error))
628
+ return 'agent-identity-mismatch';
629
+ if (errorChainHasName(error, 'SessionFormatUnsupportedError'))
630
+ return 'session-format-unsupported';
631
+ if (errorChainHasName(error, 'SessionPersistenceCorruptionError'))
632
+ return 'session-persistence-corrupt';
633
+ return 'session-resume-unavailable';
634
+ }
635
+ function sessionFingerprint(value) {
636
+ return createHash('sha256').update(value).digest('hex').slice(0, 8);
637
+ }
638
+ function finalAssistant(events, from, expectedTurn) {
639
+ if (expectedTurn === undefined)
640
+ return { text: '', completed: false, stopped: false };
641
+ let text = '';
642
+ let completed = false;
643
+ let stopped = false;
644
+ let failureCode;
645
+ for (const event of events.slice(from)) {
646
+ if (event.type === 'assistant/message' && event.data.turn === expectedTurn) {
647
+ text = event.data.message.content.filter(block => block.type === 'text')
648
+ .map(block => block.type === 'text' ? block.text : '').join('');
649
+ }
650
+ if (event.type === 'turn/end' && event.data.turn === expectedTurn) {
651
+ const reason = event.data.reason;
652
+ completed = typeof reason === 'object' && reason !== null && 'kind' in reason
653
+ && ['completed', 'max-tokens'].includes(reason.kind);
654
+ stopped = typeof reason === 'object' && reason !== null
655
+ && reason.kind === 'aborted'
656
+ && typeof reason.reason === 'object'
657
+ && reason.reason?.kind === 'user';
658
+ // Keep only the short upstream code: the provider message can quote the prompt or payload.
659
+ const error = typeof reason === 'object' && reason !== null && 'error' in reason
660
+ ? reason.error
661
+ : undefined;
662
+ failureCode = completed || typeof error?.code !== 'string' ? undefined : error.code;
663
+ }
664
+ }
665
+ return { text, completed, stopped, ...(failureCode === undefined ? {} : { failureCode }) };
666
+ }
667
+ export class DshDeliveryRuntime {
668
+ ctx;
669
+ policy;
670
+ options;
671
+ dispatchControl = 'explicit';
672
+ activeSessionControls = new Map();
673
+ constructor(ctx, policy, options) {
674
+ this.ctx = ctx;
675
+ this.policy = policy;
676
+ this.options = options;
677
+ }
678
+ async cancelActive(binding, command) {
679
+ const sessionId = binding.sessionId;
680
+ const control = this.activeSessionControls.get(sessionId);
681
+ const agents = this.ctx.get('agents');
682
+ const agent = agents?.get(SessionId(sessionId));
683
+ const activeAgent = agent !== undefined && agent.status !== 'idle';
684
+ if (control === undefined && !activeAgent)
685
+ return false;
686
+ if (control !== undefined) {
687
+ // A generation boundary is stronger than a stop acknowledgement. Never
688
+ // let a later `/stop` downgrade an in-flight `/new` cancellation.
689
+ control.command = control.command === 'new' || command === 'new' ? 'new' : 'stop';
690
+ control.resolveCancelRequested();
691
+ if (!control.controller.signal.aborted) {
692
+ control.controller.abort(new UserSessionCancellation(control.command));
693
+ }
694
+ }
695
+ // rc.8 `whenIdle()` follows every later wakeup. Waiting on it here lets an
696
+ // unrelated followup keep `/stop` or `/new` alive forever. Clear pending
697
+ // input and wait only for Delivery's exact reply-scheduling boundary.
698
+ if (activeAgent)
699
+ agent.cancel({ kind: 'user' });
700
+ if (control !== undefined)
701
+ await control.replySafe;
702
+ return true;
703
+ }
704
+ beginSessionControl(sessionId, outer) {
705
+ if (this.activeSessionControls.has(sessionId)) {
706
+ throw new Error(`assistant-delivery: session ${sessionFingerprint(sessionId)} is already processing an inbound`);
707
+ }
708
+ const controller = new AbortController();
709
+ let resolveCancelRequested;
710
+ let resolveReplySafe;
711
+ const cancelRequested = new Promise(resolve => { resolveCancelRequested = resolve; });
712
+ const replySafe = new Promise(resolve => { resolveReplySafe = resolve; });
713
+ const abort = () => controller.abort(outer.reason);
714
+ outer.addEventListener('abort', abort, { once: true });
715
+ if (outer.aborted)
716
+ abort();
717
+ const control = {
718
+ controller,
719
+ cancelRequested,
720
+ replySafe,
721
+ removeOuterAbort: () => outer.removeEventListener('abort', abort),
722
+ resolveCancelRequested,
723
+ resolveReplySafe,
724
+ };
725
+ this.activeSessionControls.set(sessionId, control);
726
+ return control;
727
+ }
728
+ endSessionControl(sessionId, control) {
729
+ control.removeOuterAbort();
730
+ control.resolveReplySafe();
731
+ if (this.activeSessionControls.get(sessionId) === control)
732
+ this.activeSessionControls.delete(sessionId);
733
+ }
734
+ async disposeAfterReplyBoundary(sessionId, handle) {
735
+ const control = this.activeSessionControls.get(sessionId);
736
+ control?.resolveReplySafe();
737
+ if (handle === undefined)
738
+ return;
739
+ const disposal = Promise.resolve().then(() => handle.dispose());
740
+ const disposition = control === undefined
741
+ ? await disposal.then(() => 'disposed')
742
+ : await Promise.race([
743
+ disposal.then(() => 'disposed'),
744
+ control.cancelRequested.then(() => 'cancelled'),
745
+ ]);
746
+ if (disposition === 'cancelled') {
747
+ // The Delivery turn is already cancelled and cannot enqueue another
748
+ // reply. Teardown remains owned, but a third-party disposer must not
749
+ // hold the conversation transition or its fresh generation hostage.
750
+ void disposal.catch(error => {
751
+ this.ctx.logger.warn(`assistant-delivery: cancelled session disposer failed for ${sessionFingerprint(sessionId)}: ${String(error)}`);
752
+ });
753
+ return;
754
+ }
755
+ }
756
+ /** Wait for only the turn that claims Delivery's exact message identity. */
757
+ followupTurn(agent, message) {
758
+ return new Promise((resolve, reject) => {
759
+ let claimedTurn;
760
+ let settled = false;
761
+ const cleanup = () => {
762
+ removeClaimed();
763
+ removeDiscarded();
764
+ removeSessionEvent();
765
+ };
766
+ const settle = (turn) => {
767
+ if (settled)
768
+ return;
769
+ settled = true;
770
+ cleanup();
771
+ resolve(turn);
772
+ };
773
+ const fail = (error) => {
774
+ if (settled)
775
+ return;
776
+ settled = true;
777
+ cleanup();
778
+ reject(error);
779
+ };
780
+ const removeClaimed = this.ctx.on('agent/inbox/claimed', event => {
781
+ if (event.agent === agent && event.message.id === message.id)
782
+ claimedTurn = event.turn;
783
+ });
784
+ const removeDiscarded = this.ctx.on('agent/inbox/discarded', event => {
785
+ if (event.agent === agent && event.message.id === message.id)
786
+ settle(undefined);
787
+ });
788
+ const removeSessionEvent = this.ctx.on('session/event', (session, event) => {
789
+ if (session === agent.session && event.type === 'turn/end' && event.data.turn === claimedTurn) {
790
+ settle(claimedTurn);
791
+ }
792
+ });
793
+ try {
794
+ agent.followup(message);
795
+ }
796
+ catch (error) {
797
+ fail(error);
798
+ }
799
+ });
800
+ }
801
+ async setupAgent(agentCtx, workspace, presetId, principal, selected, agentPresets) {
802
+ const agent = agentCtx.agent;
803
+ if (agent === undefined)
804
+ throw new Error('assistant-delivery: unpublished Agent identity is missing');
805
+ if (agent.session.header.cwd !== workspace || agent.session.header.agentPreset !== presetId) {
806
+ throw new DurableAgentIdentityError('assistant-delivery: durable Agent identity does not match its conversation binding');
807
+ }
808
+ const unbind = this.policy.bindInitiator(agent, 'external', principal);
809
+ agentCtx.effect(() => unbind, 'assistant-delivery.external-initiator');
810
+ installModelSelection(agentCtx, { current: selected, assembled: undefined });
811
+ await agentPresets?.mount(agentCtx, presetId);
812
+ }
813
+ async reconcileNativeFullPermissionReviewer(session) {
814
+ const result = await this.policy.reconcileNativeFullReviewer(session);
815
+ if (result === 'unavailable') {
816
+ throw new ApprovalReviewerReaderUnavailableError(new Error('assistant-policy: native full reviewer reconciliation is unavailable'));
817
+ }
818
+ }
819
+ async runPermissionCommand(argument, binding, envelope, signal, markDispatching, recovery) {
820
+ let dispatchMarked = false;
821
+ const markPermissionDispatch = () => {
822
+ signal.throwIfAborted();
823
+ if (dispatchMarked)
824
+ return;
825
+ markDispatching();
826
+ dispatchMarked = true;
827
+ };
828
+ const checkController = () => {
829
+ try {
830
+ return this.options.isPermissionController(binding, envelope) ? 'authorized' : 'revoked';
831
+ }
832
+ catch {
833
+ return 'check-failed';
834
+ }
835
+ };
836
+ const checkEmergencyStopVersion = (expected) => {
837
+ if (expected === undefined)
838
+ return 'authorized';
839
+ try {
840
+ const emergencyStop = this.policy.getEmergencyStop();
841
+ return !emergencyStop.enabled && emergencyStop.version === expected ? 'authorized' : 'revoked';
842
+ }
843
+ catch {
844
+ return 'check-failed';
845
+ }
846
+ };
847
+ const authorizationFailure = (state, committed = false) => state === 'revoked'
848
+ ? { outcome: 'not-processed', failureCode: 'permission-authorization-revoked', retryable: false }
849
+ : { outcome: 'not-processed', failureCode: 'permission-authorization-check-failed', retryable: !committed };
850
+ const reply = (input, committed = false) => {
851
+ const authorization = checkController();
852
+ if (authorization !== 'authorized')
853
+ return authorizationFailure(authorization, committed);
854
+ markPermissionDispatch();
855
+ signal.throwIfAborted();
856
+ try {
857
+ this.options.replyCommand(binding, envelope.eventId, input);
858
+ return { outcome: 'processed' };
859
+ }
860
+ catch {
861
+ if (input.format === 'permission-picker' && input.fallbackText !== undefined) {
862
+ try {
863
+ this.options.replyCommand(binding, envelope.eventId, { text: input.fallbackText, format: 'plain' });
864
+ return { outcome: 'processed' };
865
+ }
866
+ catch { }
867
+ }
868
+ return { outcome: 'not-processed', failureCode: 'permission-command-reply-failed', retryable: !committed };
869
+ }
870
+ };
871
+ const retryPermissionRecovery = (mode = recovery ?? 'failure-notice') => ({
872
+ outcome: 'not-processed',
873
+ failureCode: permissionDispatchRecoveryCode(mode),
874
+ retryable: true,
875
+ });
876
+ const preservePermissionRecovery = (result, mode) => result.outcome === 'processed' ? result : retryPermissionRecovery(mode);
877
+ const authorizeTerminalReply = () => {
878
+ try {
879
+ return this.options.authorizePermissionReply(binding, envelope);
880
+ }
881
+ catch {
882
+ return false;
883
+ }
884
+ };
885
+ const action = parsePermissionCommand(argument);
886
+ if (recovery === undefined) {
887
+ const initialAuthorization = checkController();
888
+ if (initialAuthorization !== 'authorized')
889
+ return authorizationFailure(initialAuthorization);
890
+ if (!authorizeTerminalReply()) {
891
+ return {
892
+ outcome: 'not-processed',
893
+ failureCode: 'permission-reply-authorization-denied',
894
+ retryable: false,
895
+ };
896
+ }
897
+ }
898
+ if (recovery === undefined && action.kind === 'invalid')
899
+ return reply(PERMISSION_USAGE);
900
+ if (recovery === undefined && action.kind === 'warn-full')
901
+ return reply(PERMISSION_FULL_WARNING);
902
+ const parsedPickerGuard = permissionPickerGuard(envelope);
903
+ if (parsedPickerGuard === 'invalid' && recovery === undefined) {
904
+ return reply({ text: '权限卡片来源无效;当前权限未改变。请重新发送 /permissions。', format: 'plain' });
905
+ }
906
+ const pickerGuard = parsedPickerGuard === 'invalid' ? undefined : parsedPickerGuard;
907
+ if (pickerGuard !== undefined && Date.now() >= pickerGuard.expiresAt && recovery === undefined) {
908
+ return reply({ text: '权限卡片已过期;当前权限未改变。请重新发送 /permissions。', format: 'plain' });
909
+ }
910
+ const agents = this.ctx.get('agents');
911
+ const sessions = this.ctx.get('sessions');
912
+ const permissionPresets = this.ctx.get('permissionPresets');
913
+ const approval = this.ctx.get('approval');
914
+ if (agents === undefined || sessions === undefined || permissionPresets === undefined || approval === undefined) {
915
+ if (recovery !== undefined)
916
+ return retryPermissionRecovery(recovery);
917
+ return reply({ text: '权限服务不可用,当前权限未改变。', format: 'plain' });
918
+ }
919
+ let partialTargets;
920
+ try {
921
+ partialTargets = permissionPresetTargets(permissionPresets);
922
+ }
923
+ catch {
924
+ if (recovery !== undefined)
925
+ return retryPermissionRecovery(recovery);
926
+ return reply({ text: '权限服务不可用,无法安全解析权限组合;当前权限未改变。', format: 'plain' });
927
+ }
928
+ if (recovery === undefined) {
929
+ const afterResolutionAuthorization = checkController();
930
+ if (afterResolutionAuthorization !== 'authorized')
931
+ return authorizationFailure(afterResolutionAuthorization);
932
+ }
933
+ const missingBundle = missingPermissionBundle(partialTargets);
934
+ if (missingBundle !== undefined) {
935
+ if (recovery !== undefined)
936
+ return retryPermissionRecovery(recovery);
937
+ return reply({ text: `权限配置缺少 ${missingBundle} 组合,已拒绝切换。`, format: 'plain' });
938
+ }
939
+ const targets = partialTargets;
940
+ const selected = agentSelection(toModelRoute(this.options.getModelSelection(envelope.conversation)
941
+ ?? { provider: this.options.provider, model: this.options.model }));
942
+ let handle;
943
+ let mutationAttempted = false;
944
+ let compensateToAsk;
945
+ try {
946
+ const agentPresets = this.options.getAgentPresets();
947
+ const presetId = agentPresets === undefined
948
+ ? binding.agentPreset
949
+ : (await agentPresets.resolve(binding.agentPreset)).id;
950
+ signal.throwIfAborted();
951
+ await requireWorkspace(binding.workspace);
952
+ signal.throwIfAborted();
953
+ handle = await agents.resume({
954
+ resumeSessionId: SessionId(binding.sessionId),
955
+ signal,
956
+ agentOptions: {
957
+ provider: selected.provider,
958
+ model: selected.model,
959
+ maxTokens: this.options.maxOutputTokens,
960
+ },
961
+ setup: async (agentCtx) => {
962
+ await this.setupAgent(agentCtx, binding.workspace, presetId, externalPrincipalId(binding.principal), selected, agentPresets);
963
+ },
964
+ });
965
+ signal.throwIfAborted();
966
+ if (recovery === undefined) {
967
+ const afterResumeAuthorization = checkController();
968
+ if (afterResumeAuthorization !== 'authorized')
969
+ return authorizationFailure(afterResumeAuthorization);
970
+ }
971
+ const agent = handle.agent;
972
+ const session = agent.session;
973
+ await this.reconcileNativeFullPermissionReviewer(session);
974
+ compensateToAsk = async () => {
975
+ // Try the bundle first for its audit marker, then independently force
976
+ // both execution knobs through their canonical writers. This remains
977
+ // fail-closed even when a preset observer throws partway through.
978
+ // Every operation is best-effort because the original mutation has
979
+ // already become ambiguous.
980
+ try {
981
+ permissionPresets.set(session, targets.ask);
982
+ }
983
+ catch { }
984
+ try {
985
+ ensureSandboxMode(session, 'workspace-write');
986
+ }
987
+ catch { }
988
+ try {
989
+ ensureApprovalPolicy(session, 'ask');
990
+ }
991
+ catch { }
992
+ let reviewerReady = true;
993
+ try {
994
+ await ensurePermissionReviewer(session, targets.ask, 'user');
995
+ }
996
+ catch {
997
+ reviewerReady = false;
998
+ }
999
+ try {
1000
+ const flushed = await sessions.flush(session);
1001
+ return reviewerReady
1002
+ && flushed
1003
+ && currentPermissionLevel(permissionPresets, targets, session.events) === 'ask';
1004
+ }
1005
+ catch {
1006
+ return false;
1007
+ }
1008
+ };
1009
+ const recoveryReply = (input, mode) => {
1010
+ if (!authorizeTerminalReply())
1011
+ return retryPermissionRecovery(mode);
1012
+ return preservePermissionRecovery(reply(input, true), mode);
1013
+ };
1014
+ const reportPermissionFailure = (safeAskPersisted) => {
1015
+ if (!safeAskPersisted)
1016
+ return retryPermissionRecovery('failure-notice');
1017
+ return preservePermissionRecovery(reply({
1018
+ text: '权限切换失败;已安全恢复并持久化为请求批准(ask)。请重新发送 /permissions 核对。',
1019
+ format: 'plain',
1020
+ }, true), 'failure-notice');
1021
+ };
1022
+ const reportCommittedReplyFailure = (safeAskPersisted) => {
1023
+ if (!safeAskPersisted)
1024
+ return retryPermissionRecovery('failure-notice');
1025
+ const authorization = checkController();
1026
+ if (authorization !== 'authorized')
1027
+ return retryPermissionRecovery('failure-notice');
1028
+ signal.throwIfAborted();
1029
+ try {
1030
+ this.options.replyCommand(binding, envelope.eventId, {
1031
+ text: '权限切换的成功回复入队失败;为避免静默保留新权限,已安全恢复并持久化为请求批准(ask)。请重新发送 /permissions 核对。',
1032
+ format: 'plain',
1033
+ }, `inbound:${envelope.eventId}:reply`);
1034
+ return { outcome: 'processed' };
1035
+ }
1036
+ catch {
1037
+ return retryPermissionRecovery('failure-notice');
1038
+ }
1039
+ };
1040
+ if (recovery === 'cancelled' || recovery === 'failure-notice') {
1041
+ mutationAttempted = true;
1042
+ const safeAskPersisted = await compensateToAsk();
1043
+ signal.throwIfAborted();
1044
+ if (!safeAskPersisted)
1045
+ return retryPermissionRecovery(recovery);
1046
+ return recoveryReply({
1047
+ text: recovery === 'cancelled'
1048
+ ? '权限切换已停止;已安全恢复并持久化为请求批准(ask)。'
1049
+ : '权限切换未完成;已安全恢复并持久化为请求批准(ask)。请重新发送 /permissions 核对。',
1050
+ format: 'plain',
1051
+ }, recovery);
1052
+ }
1053
+ if (recovery === 'commit' && action.kind !== 'switch') {
1054
+ mutationAttempted = true;
1055
+ const safeAskPersisted = await compensateToAsk();
1056
+ signal.throwIfAborted();
1057
+ if (!safeAskPersisted)
1058
+ return retryPermissionRecovery('commit');
1059
+ return recoveryReply({
1060
+ text: '权限恢复记录无效;已安全保持并持久化为请求批准(ask)。请重新选择。',
1061
+ format: 'plain',
1062
+ }, 'commit');
1063
+ }
1064
+ if (action.kind === 'invalid' || action.kind === 'warn-full') {
1065
+ return retryPermissionRecovery(recovery ?? 'failure-notice');
1066
+ }
1067
+ const currentStateHash = permissionStateHash(session.events, targets.tableFingerprint);
1068
+ const beforeLevel = currentPermissionLevel(permissionPresets, targets, session.events);
1069
+ const recoveredCommittedTarget = recovery === 'commit'
1070
+ && action.kind === 'switch'
1071
+ && beforeLevel === action.level;
1072
+ if (recovery === 'commit' && action.kind === 'switch') {
1073
+ if (recoveredCommittedTarget) {
1074
+ if (!authorizeTerminalReply()) {
1075
+ mutationAttempted = true;
1076
+ await compensateToAsk();
1077
+ signal.throwIfAborted();
1078
+ return retryPermissionRecovery('commit');
1079
+ }
1080
+ const recoveredReply = reply({
1081
+ text: `已恢复确认:当前为 ${permissionLevelLabel(beforeLevel)}。`,
1082
+ format: 'plain',
1083
+ }, true);
1084
+ if (recoveredReply.outcome === 'processed')
1085
+ return recoveredReply;
1086
+ mutationAttempted = true;
1087
+ await compensateToAsk();
1088
+ signal.throwIfAborted();
1089
+ return retryPermissionRecovery('commit');
1090
+ }
1091
+ // The dispatch fence may have been written before the first permission
1092
+ // event. Only an already-durable target proves that replay is a
1093
+ // terminal reconciliation. Otherwise converge to ask; never recreate
1094
+ // a possibly cancelled elevation from the original command text. This
1095
+ // recovery decision deliberately precedes picker TTL/state checks:
1096
+ // stale callback metadata must not strand a partial permission state.
1097
+ mutationAttempted = true;
1098
+ const safeAskPersisted = await compensateToAsk();
1099
+ signal.throwIfAborted();
1100
+ if (!safeAskPersisted)
1101
+ return retryPermissionRecovery('commit');
1102
+ return recoveryReply({
1103
+ text: '权限切换在确认持久化前中断;已安全保持并持久化为请求批准(ask)。请重新选择。',
1104
+ format: 'plain',
1105
+ }, 'commit');
1106
+ }
1107
+ if (pickerGuard !== undefined && !recoveredCommittedTarget
1108
+ && currentStateHash !== pickerGuard.expectedStateHash) {
1109
+ return reply({ text: '权限卡片已过期或状态已变化;当前权限未改变。请重新发送 /permissions。', format: 'plain' });
1110
+ }
1111
+ if (action.kind === 'show') {
1112
+ const level = currentPermissionLevel(permissionPresets, targets, session.events);
1113
+ const emergencyStop = this.policy.getEmergencyStop();
1114
+ if (emergencyStop.enabled)
1115
+ return authorizationFailure('revoked');
1116
+ const issuedAt = Date.now();
1117
+ const picker = envelope.conversation.kind !== 'dm'
1118
+ ? undefined
1119
+ : {
1120
+ operationId: permissionPickerOperationId(envelope.conversation, envelope.eventId),
1121
+ issuedAt,
1122
+ expiresAt: issuedAt + this.options.permissionPickerTtlMs,
1123
+ current: level,
1124
+ expectedStateHash: permissionStateHash(session.events, targets.tableFingerprint),
1125
+ emergencyStopVersion: emergencyStop.version,
1126
+ bindingVersion: binding.version,
1127
+ sessionId: binding.sessionId,
1128
+ };
1129
+ return reply(permissionOverview(level, picker));
1130
+ }
1131
+ if (pickerGuard !== undefined && !recoveredCommittedTarget && Date.now() >= pickerGuard.expiresAt) {
1132
+ return reply({ text: '权限卡片已过期;当前权限未改变。请重新发送 /permissions。', format: 'plain' });
1133
+ }
1134
+ const beforeMutationAuthorization = checkController();
1135
+ if (beforeMutationAuthorization !== 'authorized')
1136
+ return authorizationFailure(beforeMutationAuthorization);
1137
+ markPermissionDispatch();
1138
+ signal.throwIfAborted();
1139
+ const fencedAuthorization = checkController();
1140
+ if (fencedAuthorization !== 'authorized')
1141
+ return authorizationFailure(fencedAuthorization);
1142
+ const emergencyFence = checkEmergencyStopVersion(pickerGuard?.emergencyStopVersion);
1143
+ if (emergencyFence !== 'authorized')
1144
+ return authorizationFailure(emergencyFence);
1145
+ // A callback can enter the durable Inbox just before its deadline and
1146
+ // then wait on workspace/preset/session I/O. Recheck at the final fence,
1147
+ // immediately before any permission event is appended.
1148
+ if (pickerGuard !== undefined && Date.now() >= pickerGuard.expiresAt) {
1149
+ return reply({ text: '权限卡片已过期;当前权限未改变。请重新发送 /permissions。', format: 'plain' });
1150
+ }
1151
+ mutationAttempted = true;
1152
+ if (action.level === 'full') {
1153
+ // Close approval before widening the sandbox. Canonical full derives
1154
+ // reviewer=none from its official preset; legacy dynamic bundles add
1155
+ // their compatibility reviewer only after both execution knobs match.
1156
+ ensureApprovalPolicy(session, 'never');
1157
+ permissionPresets.set(session, targets.full);
1158
+ // Preset setters intentionally omit overrides that equal deployment
1159
+ // defaults. Persist the exact execution facts anyway so full access
1160
+ // cannot depend on an implicit default or a future default change.
1161
+ ensureSandboxMode(session, 'danger-full-access');
1162
+ await ensurePermissionReviewer(session, targets.full, 'none');
1163
+ }
1164
+ else {
1165
+ // Confinement comes first when leaving full access. The preset setter
1166
+ // writes sandbox before approval; only then may the reviewer be enabled.
1167
+ const target = action.level === 'auto' ? targets.auto : targets.ask;
1168
+ permissionPresets.set(session, target);
1169
+ // Legacy/seeded sessions may have no explicit knob events even when
1170
+ // their effective defaults match this preset. Materialize both facts
1171
+ // before enabling either reviewer so auto is a coherent, replayable
1172
+ // ask + workspace-write state rather than an inference from defaults.
1173
+ ensureSandboxMode(session, 'workspace-write');
1174
+ ensureApprovalPolicy(session, 'ask');
1175
+ const reviewer = action.level === 'auto' ? 'auto-review' : 'user';
1176
+ await ensurePermissionReviewer(session, target, reviewer);
1177
+ }
1178
+ const afterReviewerEmergencyFence = checkEmergencyStopVersion(pickerGuard?.emergencyStopVersion);
1179
+ if (afterReviewerEmergencyFence !== 'authorized') {
1180
+ await compensateToAsk();
1181
+ signal.throwIfAborted();
1182
+ return retryPermissionRecovery('failure-notice');
1183
+ }
1184
+ const flushed = await sessions.flush(session);
1185
+ signal.throwIfAborted();
1186
+ if (!flushed) {
1187
+ const safeAskPersisted = await compensateToAsk();
1188
+ signal.throwIfAborted();
1189
+ return reportPermissionFailure(safeAskPersisted);
1190
+ }
1191
+ const finalAuthorization = checkController();
1192
+ const finalEmergencyFence = checkEmergencyStopVersion(pickerGuard?.emergencyStopVersion);
1193
+ if (finalAuthorization !== 'authorized' || finalEmergencyFence !== 'authorized') {
1194
+ await compensateToAsk();
1195
+ signal.throwIfAborted();
1196
+ return retryPermissionRecovery('failure-notice');
1197
+ }
1198
+ const afterLevel = currentPermissionLevel(permissionPresets, targets, session.events);
1199
+ if (afterLevel !== action.level) {
1200
+ const safeAskPersisted = await compensateToAsk();
1201
+ signal.throwIfAborted();
1202
+ return reportPermissionFailure(safeAskPersisted);
1203
+ }
1204
+ const successReply = reply({
1205
+ text: beforeLevel === afterLevel
1206
+ ? `当前已是 ${permissionLevelLabel(afterLevel)}。`
1207
+ : `已切换到 ${permissionLevelLabel(afterLevel)}。`,
1208
+ format: 'plain',
1209
+ }, true);
1210
+ if (successReply.outcome === 'processed')
1211
+ return successReply;
1212
+ const safeAskPersisted = await compensateToAsk();
1213
+ signal.throwIfAborted();
1214
+ return reportCommittedReplyFailure(safeAskPersisted);
1215
+ }
1216
+ catch (error) {
1217
+ if (recovery !== undefined) {
1218
+ if (mutationAttempted)
1219
+ await compensateToAsk?.();
1220
+ return retryPermissionRecovery(recovery);
1221
+ }
1222
+ if (mutationAttempted) {
1223
+ const safeAskPersisted = await compensateToAsk?.() ?? false;
1224
+ if (signal.aborted || causedByUserCancellation(error)) {
1225
+ return {
1226
+ outcome: 'not-processed',
1227
+ failureCode: permissionDispatchRecoveryCode('cancelled'),
1228
+ retryable: true,
1229
+ };
1230
+ }
1231
+ if (errorChainHasName(error, 'ApprovalReviewerReaderUnavailableError')) {
1232
+ return retryPermissionRecovery('failure-notice');
1233
+ }
1234
+ if (!safeAskPersisted)
1235
+ return retryPermissionRecovery('failure-notice');
1236
+ return preservePermissionRecovery(reply({
1237
+ text: '权限切换失败;已安全恢复并持久化为请求批准(ask)。请重新发送 /permissions 核对。',
1238
+ format: 'plain',
1239
+ }, true), 'failure-notice');
1240
+ }
1241
+ if (signal.aborted)
1242
+ throw error;
1243
+ if (errorChainHasName(error, 'ApprovalReviewerReaderUnavailableError')) {
1244
+ return {
1245
+ outcome: 'not-processed',
1246
+ failureCode: 'permission-reviewer-reader-unavailable',
1247
+ retryable: true,
1248
+ };
1249
+ }
1250
+ const finalAuthorization = checkController();
1251
+ if (finalAuthorization !== 'authorized')
1252
+ return authorizationFailure(finalAuthorization);
1253
+ if (causedByDurableIdentity(error)) {
1254
+ return { outcome: 'not-processed', failureCode: 'permission-session-mismatch', retryable: false };
1255
+ }
1256
+ return reply({
1257
+ text: '权限命令未完成;当前权限保持或收紧,未确认任何权限提升。',
1258
+ format: 'plain',
1259
+ });
1260
+ }
1261
+ finally {
1262
+ await this.disposeAfterReplyBoundary(binding.sessionId, handle);
1263
+ }
1264
+ }
1265
+ async createSession(input) {
1266
+ const agents = this.ctx.get('agents');
1267
+ const sessions = this.ctx.get('sessions');
1268
+ if (agents === undefined || sessions === undefined)
1269
+ throw new Error('assistant-delivery: agents and sessions services are required');
1270
+ const id = sessionId(this.options.sessionNamespace, input.envelope.conversation, input.generation);
1271
+ const workspace = input.previous?.workspace ?? this.options.workspace;
1272
+ const requestedPreset = input.previous?.agentPreset ?? this.options.agentPreset;
1273
+ const agentPresets = this.options.getAgentPresets();
1274
+ const presetId = agentPresets === undefined
1275
+ ? requestedPreset
1276
+ : (await agentPresets.resolve(requestedPreset)).id;
1277
+ await ensureWorkspace(workspace);
1278
+ const selected = agentSelection(toModelRoute(this.options.getModelSelection(input.envelope.conversation)
1279
+ ?? { provider: this.options.provider, model: this.options.model }));
1280
+ const setup = async (agentCtx) => {
1281
+ await this.setupAgent(agentCtx, workspace, presetId, externalPrincipalId(input.envelope.principal), selected, agentPresets);
1282
+ };
1283
+ let handle;
1284
+ let adopted = false;
1285
+ try {
1286
+ const agentOptions = {
1287
+ provider: selected.provider,
1288
+ model: selected.model,
1289
+ maxTokens: this.options.maxOutputTokens,
1290
+ };
1291
+ const persistence = this.ctx.get('sessionPersistence');
1292
+ const persisted = persistence === undefined
1293
+ ? false
1294
+ : (await persistence.list(input.signal)).some(header => String(header.id) === String(id));
1295
+ if (persisted) {
1296
+ handle = await agents.resume({
1297
+ resumeSessionId: id,
1298
+ agentOptions,
1299
+ signal: input.signal,
1300
+ setup,
1301
+ });
1302
+ adopted = true;
1303
+ }
1304
+ if (handle === undefined) {
1305
+ handle = await agents.create({
1306
+ sessionId: id,
1307
+ meta: { cwd: workspace, agentPreset: presetId },
1308
+ agentOptions,
1309
+ signal: input.signal,
1310
+ setup,
1311
+ });
1312
+ }
1313
+ const session = handle.agent.session;
1314
+ if (adopted)
1315
+ assertAdoptableUnstartedSession(session, workspace, presetId);
1316
+ await this.reconcileNativeFullPermissionReviewer(session);
1317
+ if (!await sessions.flush(session)) {
1318
+ throw new Error('assistant-delivery: newly created Agent session was not durable');
1319
+ }
1320
+ return { sessionId: String(id), workspace, agentPreset: presetId,
1321
+ policyRef: input.previous?.policyRef ?? this.options.policyRef };
1322
+ }
1323
+ finally {
1324
+ await handle?.dispose();
1325
+ }
1326
+ }
1327
+ async prepare(binding, envelope, signal, context) {
1328
+ const imageCount = (envelope.attachments ?? [])
1329
+ .filter(attachment => attachment.resourceType === 'image').length;
1330
+ if (envelope.kind === 'command') {
1331
+ return { outcome: 'prepared', message: {
1332
+ imageAttachments: [],
1333
+ ...(context.permissionDispatchRecovery === undefined
1334
+ ? {}
1335
+ : { permissionDispatchRecovery: context.permissionDispatchRecovery }),
1336
+ } };
1337
+ }
1338
+ const selected = toModelRoute(this.options.getModelSelection(envelope.conversation)
1339
+ ?? { provider: this.options.provider, model: this.options.model });
1340
+ if (imageCount === 0) {
1341
+ return { outcome: 'prepared', message: { imageAttachments: [], modelRoute: selected } };
1342
+ }
1343
+ if (!this.options.isInboundAuthorized(binding, envelope)) {
1344
+ return { outcome: 'not-processed', failureCode: 'image-authorization-revoked', retryable: false };
1345
+ }
1346
+ const llm = this.ctx.get('llm');
1347
+ if (llm === undefined) {
1348
+ return { outcome: 'not-processed', failureCode: 'model-directory-unavailable', retryable: true };
1349
+ }
1350
+ try {
1351
+ await requireImageCapability(llm, selected.provider, selected.model, signal);
1352
+ }
1353
+ catch (error) {
1354
+ if (signal.aborted)
1355
+ throw error;
1356
+ if (!(error instanceof ImageCapabilityAdmissionError)) {
1357
+ return { outcome: 'not-processed', failureCode: 'image-model-unavailable', retryable: true };
1358
+ }
1359
+ try {
1360
+ this.options.replyCommand(binding, envelope.eventId, imageCapabilityReply(error));
1361
+ }
1362
+ catch {
1363
+ return { outcome: 'not-processed', failureCode: 'image-capability-notice-failed', retryable: true };
1364
+ }
1365
+ return { outcome: 'not-processed', failureCode: 'model-image-input-unsupported', retryable: false };
1366
+ }
1367
+ const result = await this.options.imageMaterializer.materialize({
1368
+ ...context,
1369
+ binding,
1370
+ envelope,
1371
+ signal,
1372
+ });
1373
+ return result.outcome === 'ready'
1374
+ ? { outcome: 'prepared', message: { imageAttachments: result.imageAttachments, modelRoute: selected } }
1375
+ : { outcome: 'not-processed', failureCode: result.failureCode, retryable: result.retryable,
1376
+ ...(result.retryAfterMs === undefined ? {} : { retryAfterMs: result.retryAfterMs }) };
1377
+ }
1378
+ replySessionCommand(binding, envelope, reply, signal, markDispatching) {
1379
+ if (markDispatching === undefined) {
1380
+ return { outcome: 'not-processed', failureCode: 'dispatch-gate-unavailable', retryable: true };
1381
+ }
1382
+ signal.throwIfAborted();
1383
+ markDispatching();
1384
+ signal.throwIfAborted();
1385
+ this.options.replyCommand(binding, envelope.eventId, reply);
1386
+ return { outcome: 'processed' };
1387
+ }
1388
+ sessionHelp(native) {
1389
+ const lines = [
1390
+ '会话命令:',
1391
+ '- /new(/clear):开始空白新会话;旧会话保留。',
1392
+ '- /stop:停止当前任务;当前会话与已完成上下文保留。',
1393
+ '- /status(/session):查看当前 session、上下文与模型。',
1394
+ '- /model:查看或切换模型;上下文保留。',
1395
+ '- /permission:查看或切换运行权限。',
1396
+ '- /help:显示当前实际可用命令。',
1397
+ ];
1398
+ const visible = native.filter(command => SAFE_NATIVE_COMMANDS.has(command.name));
1399
+ if (visible.length > 0) {
1400
+ lines.push('', 'DSH / 当前 preset 命令:');
1401
+ for (const command of visible)
1402
+ lines.push(`- /${command.name}:${command.description}`);
1403
+ }
1404
+ return lines.join('\n');
1405
+ }
1406
+ async runSessionCommand(command, binding, envelope, signal, markDispatching) {
1407
+ const args = command.rawInput.trim();
1408
+ const local = new Set(['clear', 'help', 'new', 'session', 'status', 'stop']);
1409
+ if (local.has(command.name) && args !== '') {
1410
+ return this.replySessionCommand(binding, envelope, {
1411
+ text: `/${command.name} 不接受参数。发送 /help 查看用法。`,
1412
+ format: 'plain',
1413
+ }, signal, markDispatching);
1414
+ }
1415
+ const agents = this.ctx.get('agents');
1416
+ const sessions = this.ctx.get('sessions');
1417
+ if (agents === undefined || sessions === undefined) {
1418
+ return { outcome: 'not-processed', failureCode: 'agent-runtime-unavailable', retryable: true };
1419
+ }
1420
+ const selectedRoute = toModelRoute(this.options.getModelSelection(envelope.conversation)
1421
+ ?? { provider: this.options.provider, model: this.options.model });
1422
+ const selected = agentSelection(selectedRoute);
1423
+ let handle;
1424
+ let resumed = false;
1425
+ let nativeDispatchStarted = false;
1426
+ const authorizationFailure = () => {
1427
+ try {
1428
+ return this.options.isInboundAuthorized(binding, envelope)
1429
+ ? undefined
1430
+ : { outcome: 'not-processed', failureCode: 'inbound-authorization-revoked', retryable: false };
1431
+ }
1432
+ catch {
1433
+ return { outcome: 'not-processed', failureCode: 'inbound-authorization-check-failed', retryable: true };
1434
+ }
1435
+ };
1436
+ try {
1437
+ const agentPresets = this.options.getAgentPresets();
1438
+ const presetId = agentPresets === undefined
1439
+ ? binding.agentPreset
1440
+ : (await agentPresets.resolve(binding.agentPreset)).id;
1441
+ await requireWorkspace(binding.workspace);
1442
+ handle = await agents.resume({
1443
+ resumeSessionId: SessionId(binding.sessionId),
1444
+ signal,
1445
+ agentOptions: {
1446
+ provider: selected.provider,
1447
+ model: selected.model,
1448
+ maxTokens: this.options.maxOutputTokens,
1449
+ },
1450
+ setup: async (agentCtx) => {
1451
+ await this.setupAgent(agentCtx, binding.workspace, presetId, externalPrincipalId(binding.principal), selected, agentPresets);
1452
+ },
1453
+ });
1454
+ resumed = true;
1455
+ signal.throwIfAborted();
1456
+ const agent = handle.agent;
1457
+ const commands = this.ctx.get('commands');
1458
+ const native = commands?.list(agent) ?? [];
1459
+ const revoked = authorizationFailure();
1460
+ if (revoked !== undefined)
1461
+ return revoked;
1462
+ if (command.name === 'help') {
1463
+ return this.replySessionCommand(binding, envelope, {
1464
+ text: this.sessionHelp(native),
1465
+ format: 'plain',
1466
+ }, signal, markDispatching);
1467
+ }
1468
+ if (command.name === 'status' || command.name === 'session') {
1469
+ const messages = agent.session.deriveMessages().length;
1470
+ const turns = agent.session.events.filter(event => event.type === 'turn/end').length;
1471
+ return this.replySessionCommand(binding, envelope, {
1472
+ text: [
1473
+ '当前会话',
1474
+ `- 第 ${binding.generation} 代(${sessionFingerprint(binding.sessionId)})`,
1475
+ `- 状态:${agent.status === 'idle' ? '空闲' : '运行中'}`,
1476
+ `- 上下文消息:${messages}`,
1477
+ `- 已记录轮次:${turns}`,
1478
+ `- 模型:${routeLabel(selectedRoute)}`,
1479
+ '',
1480
+ '/new 会开始空白新 session 并保留旧会话;/stop 只停止当前任务,不清空上下文。',
1481
+ ].join('\n'),
1482
+ format: 'plain',
1483
+ }, signal, markDispatching);
1484
+ }
1485
+ const descriptor = SAFE_NATIVE_COMMANDS.has(command.name)
1486
+ ? native.find(candidate => candidate.name === command.name)
1487
+ : undefined;
1488
+ if (commands === undefined || descriptor === undefined) {
1489
+ return this.replySessionCommand(binding, envelope, {
1490
+ text: `未知命令 /${command.name}。发送 /help 查看当前实际可用命令。`,
1491
+ format: 'plain',
1492
+ }, signal, markDispatching);
1493
+ }
1494
+ if ((envelope.attachments?.length ?? 0) > 0) {
1495
+ return this.replySessionCommand(binding, envelope, {
1496
+ text: `当前渠道暂不支持为 /${command.name} 附带文件或图片。`,
1497
+ format: 'plain',
1498
+ }, signal, markDispatching);
1499
+ }
1500
+ if (markDispatching === undefined) {
1501
+ return { outcome: 'not-processed', failureCode: 'dispatch-gate-unavailable', retryable: true };
1502
+ }
1503
+ signal.throwIfAborted();
1504
+ const preExecutionAuthorization = authorizationFailure();
1505
+ if (preExecutionAuthorization !== undefined)
1506
+ return preExecutionAuthorization;
1507
+ markDispatching();
1508
+ nativeDispatchStarted = true;
1509
+ const execution = await commands.execute(agent, envelope.text, [], signal);
1510
+ signal.throwIfAborted();
1511
+ if (execution === undefined) {
1512
+ return {
1513
+ outcome: 'not-processed',
1514
+ failureCode: 'native-command-unresolved-after-dispatch',
1515
+ retryable: false,
1516
+ };
1517
+ }
1518
+ if (!await sessions.flush(agent.session)) {
1519
+ return {
1520
+ outcome: 'not-processed',
1521
+ failureCode: 'command-session-flush-failed',
1522
+ retryable: false,
1523
+ };
1524
+ }
1525
+ signal.throwIfAborted();
1526
+ const finalAuthorization = authorizationFailure();
1527
+ if (finalAuthorization !== undefined)
1528
+ return { ...finalAuthorization, retryable: false };
1529
+ const result = execution.result;
1530
+ this.options.replyCommand(binding, envelope.eventId, {
1531
+ text: result?.text ?? (result?.kind === 'error'
1532
+ ? `/${command.name} 未完成。`
1533
+ : `/${command.name} 已完成。`),
1534
+ format: 'plain',
1535
+ });
1536
+ return { outcome: 'processed' };
1537
+ }
1538
+ catch (error) {
1539
+ if (signal.aborted || causedByUserCancellation(error))
1540
+ throw error;
1541
+ if (nativeDispatchStarted) {
1542
+ this.ctx.logger.warn(`assistant-delivery: native command /${command.name} became ambiguous for session `
1543
+ + sessionFingerprint(binding.sessionId));
1544
+ return {
1545
+ outcome: 'not-processed',
1546
+ failureCode: 'native-command-execution-ambiguous',
1547
+ retryable: false,
1548
+ };
1549
+ }
1550
+ if (resumed) {
1551
+ return {
1552
+ outcome: 'not-processed',
1553
+ failureCode: 'session-command-runtime-failed',
1554
+ retryable: true,
1555
+ };
1556
+ }
1557
+ const failureCode = sessionResumeFailureCode(error);
1558
+ this.ctx.logger.warn(`assistant-delivery: ${failureCode} for session ${sessionFingerprint(binding.sessionId)}`);
1559
+ try {
1560
+ return this.replySessionCommand(binding, envelope, {
1561
+ text: [
1562
+ '当前会话暂时无法恢复;原历史未删除。',
1563
+ `- 会话:第 ${binding.generation} 代(${sessionFingerprint(binding.sessionId)})`,
1564
+ `- 诊断:${failureCode}`,
1565
+ '',
1566
+ '请先修复或升级对应 DSH 插件后重试;也可发送 /new 开始空白新会话,旧会话仍会保留。',
1567
+ ].join('\n'),
1568
+ format: 'plain',
1569
+ }, signal, markDispatching);
1570
+ }
1571
+ catch {
1572
+ return {
1573
+ outcome: 'not-processed',
1574
+ failureCode: 'session-diagnostic-reply-failed',
1575
+ retryable: failureCode !== 'agent-identity-mismatch' && failureCode !== 'session-persistence-corrupt',
1576
+ };
1577
+ }
1578
+ }
1579
+ finally {
1580
+ await this.disposeAfterReplyBoundary(binding.sessionId, handle);
1581
+ }
1582
+ }
1583
+ async process(binding, envelope, outerSignal, prepared, markDispatching) {
1584
+ const sessionCommand = parseDeliveryCommand(envelope);
1585
+ if (envelope.kind === 'command' && sessionCommand === undefined) {
1586
+ return this.replySessionCommand(binding, envelope, {
1587
+ text: '命令格式无效。命令必须以小写 /name 开头,并使用普通空格分隔参数;发送 /help 查看用法。',
1588
+ format: 'plain',
1589
+ }, outerSignal, markDispatching);
1590
+ }
1591
+ if (isExactDeliveryCommand(sessionCommand, 'new', 'clear')) {
1592
+ return this.replySessionCommand(binding, envelope, {
1593
+ text: `已开始新会话(第 ${binding.generation} 代,${sessionFingerprint(binding.sessionId)});`
1594
+ + '旧会话已保留。新 session 的对话上下文为空。',
1595
+ format: 'plain',
1596
+ }, outerSignal, markDispatching);
1597
+ }
1598
+ if (isExactDeliveryCommand(sessionCommand, 'stop')) {
1599
+ return this.replySessionCommand(binding, envelope, {
1600
+ text: '已处理停止请求;当前 session 与已完成上下文保留。',
1601
+ format: 'plain',
1602
+ }, outerSignal, markDispatching);
1603
+ }
1604
+ const control = this.beginSessionControl(binding.sessionId, outerSignal);
1605
+ try {
1606
+ return await this.processControlled(binding, envelope, control.controller.signal, sessionCommand, prepared, markDispatching);
1607
+ }
1608
+ catch (error) {
1609
+ if (control.command !== undefined && causedByUserCancellation(error)) {
1610
+ return permissionCommand(sessionCommand) === undefined
1611
+ ? { outcome: 'processed' }
1612
+ : {
1613
+ outcome: 'not-processed',
1614
+ failureCode: permissionDispatchRecoveryCode('cancelled'),
1615
+ retryable: true,
1616
+ };
1617
+ }
1618
+ throw error;
1619
+ }
1620
+ finally {
1621
+ this.endSessionControl(binding.sessionId, control);
1622
+ }
1623
+ }
1624
+ async processControlled(binding, envelope, signal, sessionCommand, prepared, markDispatching) {
1625
+ const permissions = permissionCommand(sessionCommand);
1626
+ if (permissions !== undefined) {
1627
+ if (markDispatching === undefined) {
1628
+ return { outcome: 'not-processed', failureCode: 'dispatch-gate-unavailable', retryable: true };
1629
+ }
1630
+ return await this.runPermissionCommand(permissions, binding, envelope, signal, markDispatching, prepared?.permissionDispatchRecovery);
1631
+ }
1632
+ const command = modelCommand(sessionCommand);
1633
+ if (command !== undefined) {
1634
+ const llm = this.ctx.get('llm');
1635
+ if (llm === undefined) {
1636
+ return { outcome: 'not-processed', failureCode: 'model-directory-unavailable', retryable: true };
1637
+ }
1638
+ let reply;
1639
+ try {
1640
+ reply = await runModelCommand(command, llm, envelope.conversation, {
1641
+ provider: this.options.provider,
1642
+ model: this.options.model,
1643
+ }, this.options, signal, envelope.eventId);
1644
+ this.options.replyCommand(binding, envelope.eventId, reply);
1645
+ return { outcome: 'processed' };
1646
+ }
1647
+ catch (error) {
1648
+ if (signal.aborted || causedByUserCancellation(error))
1649
+ throw error;
1650
+ if (reply?.format === 'model-picker' && reply.fallbackText !== undefined
1651
+ && error instanceof DeliveryStoreError
1652
+ && (error.code === 'invalid-intent' || error.code === 'invalid-binding')) {
1653
+ try {
1654
+ this.options.replyCommand(binding, envelope.eventId, { text: reply.fallbackText, format: 'plain' });
1655
+ return { outcome: 'processed' };
1656
+ }
1657
+ catch (fallbackError) {
1658
+ return modelCommandFailure(fallbackError);
1659
+ }
1660
+ }
1661
+ return modelCommandFailure(error);
1662
+ }
1663
+ }
1664
+ if (sessionCommand !== undefined) {
1665
+ return await this.runSessionCommand(sessionCommand, binding, envelope, signal, markDispatching);
1666
+ }
1667
+ const imageDescriptorCount = (envelope.attachments ?? [])
1668
+ .filter(attachment => attachment.resourceType === 'image').length;
1669
+ const imageAttachments = prepared?.imageAttachments ?? [];
1670
+ const checkAuthorization = () => {
1671
+ try {
1672
+ return this.options.isInboundAuthorized(binding, envelope) ? 'authorized' : 'revoked';
1673
+ }
1674
+ catch {
1675
+ return 'check-failed';
1676
+ }
1677
+ };
1678
+ const preDispatchAuthorizationFailure = () => {
1679
+ const authorization = checkAuthorization();
1680
+ if (authorization === 'authorized')
1681
+ return undefined;
1682
+ return authorization === 'revoked'
1683
+ ? { outcome: 'not-processed', failureCode: 'inbound-authorization-revoked', retryable: false }
1684
+ : { outcome: 'not-processed', failureCode: 'inbound-authorization-check-failed', retryable: true };
1685
+ };
1686
+ if (imageAttachments.length !== imageDescriptorCount
1687
+ || (imageDescriptorCount > 0 && prepared?.modelRoute === undefined)) {
1688
+ return { outcome: 'not-processed', failureCode: 'image-preparation-missing', retryable: false };
1689
+ }
1690
+ if (imageDescriptorCount > 0) {
1691
+ const authorizationFailure = preDispatchAuthorizationFailure();
1692
+ if (authorizationFailure !== undefined)
1693
+ return authorizationFailure;
1694
+ }
1695
+ const content = [
1696
+ ...(envelope.text === '' ? [] : [{ type: 'text', text: envelope.text }]),
1697
+ ...imageAttachments.map(attachment => ({ type: 'image', attachment })),
1698
+ ];
1699
+ if (content.length === 0) {
1700
+ return { outcome: 'not-processed', failureCode: 'inbound-content-empty', retryable: false };
1701
+ }
1702
+ if (markDispatching === undefined) {
1703
+ return { outcome: 'not-processed', failureCode: 'dispatch-gate-unavailable', retryable: true };
1704
+ }
1705
+ const agents = this.ctx.get('agents');
1706
+ const sessions = this.ctx.get('sessions');
1707
+ if (agents === undefined || sessions === undefined) {
1708
+ return { outcome: 'not-processed', failureCode: 'agent-runtime-unavailable', retryable: true };
1709
+ }
1710
+ const selectedRoute = toModelRoute(prepared?.modelRoute
1711
+ ?? this.options.getModelSelection(envelope.conversation)
1712
+ ?? { provider: this.options.provider, model: this.options.model });
1713
+ const selected = agentSelection(selectedRoute);
1714
+ let handle;
1715
+ let dispatched = false;
1716
+ let removeAbort;
1717
+ let removeProgress;
1718
+ let progressOpen = true;
1719
+ let progressQueue = Promise.resolve();
1720
+ const publishProgress = (update) => {
1721
+ if (!progressOpen)
1722
+ return;
1723
+ progressQueue = progressQueue
1724
+ .then(async () => {
1725
+ if (checkAuthorization() !== 'authorized')
1726
+ return;
1727
+ await this.options.progress(binding, envelope.eventId, update);
1728
+ })
1729
+ // Progress is presentation-only: its provider failure must never retry an Agent turn.
1730
+ .catch(() => { });
1731
+ };
1732
+ try {
1733
+ const agentPresets = this.options.getAgentPresets();
1734
+ const presetId = agentPresets === undefined
1735
+ ? binding.agentPreset
1736
+ : (await agentPresets.resolve(binding.agentPreset)).id;
1737
+ await requireWorkspace(binding.workspace);
1738
+ handle = await agents.resume({ resumeSessionId: SessionId(binding.sessionId), signal,
1739
+ agentOptions: { provider: selected.provider, model: selected.model,
1740
+ maxTokens: this.options.maxOutputTokens },
1741
+ setup: async (agentCtx) => {
1742
+ await this.setupAgent(agentCtx, binding.workspace, presetId, externalPrincipalId(binding.principal), selected, agentPresets);
1743
+ const llm = this.ctx.get('llm');
1744
+ if (llm === undefined)
1745
+ throw new Error('assistant-delivery: llm service is required');
1746
+ requireToolCapability(llm, selected.provider, selected.model, presetId, agentCtx.tools.schemas(agentCtx.agent).length, this.options.toolCapableProviders, this.options.unknownRouteToolCalls);
1747
+ } });
1748
+ const agent = handle.agent;
1749
+ await this.reconcileNativeFullPermissionReviewer(agent.session);
1750
+ signal.throwIfAborted();
1751
+ const llm = this.ctx.get('llm');
1752
+ if (llm === undefined)
1753
+ throw new Error('assistant-delivery: llm service is required');
1754
+ if (agent.session.deriveMessages().some(message => contentHasImage(message.content))
1755
+ || imageAttachments.length > 0) {
1756
+ await requireImageCapability(llm, selected.provider, selected.model, signal);
1757
+ }
1758
+ if (imageDescriptorCount > 0) {
1759
+ const authorizationFailure = preDispatchAuthorizationFailure();
1760
+ if (authorizationFailure !== undefined)
1761
+ return authorizationFailure;
1762
+ }
1763
+ const from = agent.session.events.length;
1764
+ removeProgress = this.ctx.on('session/event', (session, event) => {
1765
+ if (session !== agent.session)
1766
+ return;
1767
+ const update = deliveryProgressFromSessionEvent(event);
1768
+ if (update !== undefined)
1769
+ publishProgress(update);
1770
+ });
1771
+ publishProgress({ kind: 'started' });
1772
+ const abort = () => {
1773
+ const command = this.activeSessionControls.get(binding.sessionId)?.command;
1774
+ agent.cancel(command === undefined
1775
+ ? { kind: 'hook', reason: 'assistant-delivery-signal' }
1776
+ : { kind: 'user' });
1777
+ };
1778
+ signal.addEventListener('abort', abort, { once: true });
1779
+ removeAbort = () => signal.removeEventListener('abort', abort);
1780
+ signal.throwIfAborted();
1781
+ const authorizationFailure = preDispatchAuthorizationFailure();
1782
+ if (authorizationFailure !== undefined)
1783
+ return authorizationFailure;
1784
+ markDispatching();
1785
+ // Once the durable marker exists, even a synchronous followup failure is ambiguous:
1786
+ // implementations may enqueue before throwing, so no retry is safe.
1787
+ dispatched = true;
1788
+ const message = createUserMessage({ content, source: {
1789
+ kind: 'delivery', channel: envelope.channel, account: envelope.account, eventId: envelope.eventId,
1790
+ trust: 'untrusted',
1791
+ } });
1792
+ const deliveryTurn = await this.followupTurn(agent, message);
1793
+ const output = finalAssistant(agent.session.events, from, deliveryTurn);
1794
+ if (!await sessions.flush(agent.session)) {
1795
+ publishProgress({ kind: 'failed', code: 'session-persistence-unavailable' });
1796
+ return { outcome: 'not-processed', failureCode: 'session-flush-failed', retryable: false };
1797
+ }
1798
+ const cancelled = this.activeSessionControls.get(binding.sessionId)?.command;
1799
+ if (cancelled !== undefined || output.stopped) {
1800
+ publishProgress({ kind: 'failed', code: cancelled === 'new' ? 'new-session' : 'user-stopped' });
1801
+ return { outcome: 'processed' };
1802
+ }
1803
+ const finalAuthorization = checkAuthorization();
1804
+ if (finalAuthorization !== 'authorized') {
1805
+ return finalAuthorization === 'revoked'
1806
+ ? { outcome: 'not-processed', failureCode: 'inbound-authorization-revoked', retryable: false }
1807
+ : { outcome: 'not-processed', failureCode: 'inbound-authorization-check-failed', retryable: false };
1808
+ }
1809
+ if (!output.completed) {
1810
+ publishProgress({ kind: 'failed', ...(output.failureCode === undefined ? {} : { code: output.failureCode }) });
1811
+ return { outcome: 'not-processed', failureCode: 'agent-turn-incomplete', retryable: false };
1812
+ }
1813
+ // Agent answers are authored as Markdown (tables, bold, inline code), so request Markdown
1814
+ // rendering; sending them as plain text shows the raw `|---|` and `**` syntax to the user.
1815
+ if (output.text !== '') {
1816
+ this.options.reply(agent, envelope.eventId, { text: output.text, format: 'markdown' });
1817
+ }
1818
+ publishProgress({ kind: 'completed' });
1819
+ return { outcome: 'processed' };
1820
+ }
1821
+ catch (error) {
1822
+ if (causedByUserCancellation(error)) {
1823
+ if (handle !== undefined && !await sessions.flush(handle.agent.session)) {
1824
+ return { outcome: 'not-processed', failureCode: 'session-flush-failed', retryable: false };
1825
+ }
1826
+ const command = this.activeSessionControls.get(binding.sessionId)?.command;
1827
+ publishProgress({ kind: 'failed', code: command === 'new' ? 'new-session' : 'user-stopped' });
1828
+ return { outcome: 'processed' };
1829
+ }
1830
+ if (dispatched) {
1831
+ publishProgress({ kind: 'failed' });
1832
+ throw new Error(`assistant-delivery: Agent turn became ambiguous: ${String(error)}`, { cause: error });
1833
+ }
1834
+ if (causedByDurableIdentity(error)) {
1835
+ return { outcome: 'not-processed', failureCode: 'agent-identity-mismatch', retryable: false };
1836
+ }
1837
+ if (error instanceof ImageCapabilityAdmissionError) {
1838
+ try {
1839
+ this.options.replyCommand(binding, envelope.eventId, imageCapabilityReply(error));
1840
+ return { outcome: 'processed' };
1841
+ }
1842
+ catch {
1843
+ return { outcome: 'not-processed', failureCode: 'image-capability-notice-failed', retryable: true };
1844
+ }
1845
+ }
1846
+ if (error instanceof ToolCapabilityAdmissionError) {
1847
+ try {
1848
+ this.options.replyCommand(binding, envelope.eventId, toolCapabilityReply(error));
1849
+ return { outcome: 'processed' };
1850
+ }
1851
+ catch {
1852
+ return { outcome: 'not-processed', failureCode: 'tool-capability-notice-failed', retryable: true };
1853
+ }
1854
+ }
1855
+ this.ctx.logger.warn(`assistant-delivery: ${sessionResumeFailureCode(error)} for session ${sessionFingerprint(binding.sessionId)}`);
1856
+ return { outcome: 'not-processed', failureCode: 'agent-resume-failed', retryable: true };
1857
+ }
1858
+ finally {
1859
+ removeAbort?.();
1860
+ removeProgress?.();
1861
+ progressOpen = false;
1862
+ await this.disposeAfterReplyBoundary(binding.sessionId, handle);
1863
+ }
1864
+ }
1865
+ }
1866
+ //# sourceMappingURL=agent-runtime.js.map