@dsh-enhanced/assistant-delivery 0.1.31 → 0.1.34

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 (41) hide show
  1. package/README.md +13 -1
  2. package/lib/acceptance.d.ts +11 -3
  3. package/lib/acceptance.d.ts.map +1 -1
  4. package/lib/agent-runtime.d.ts +15 -0
  5. package/lib/agent-runtime.d.ts.map +1 -1
  6. package/lib/agent-runtime.js +270 -47
  7. package/lib/agent-runtime.js.map +1 -1
  8. package/lib/foreground-observation.d.ts +32 -0
  9. package/lib/foreground-observation.d.ts.map +1 -0
  10. package/lib/foreground-observation.js +13 -0
  11. package/lib/foreground-observation.js.map +1 -0
  12. package/lib/index.d.ts +6 -1
  13. package/lib/index.d.ts.map +1 -1
  14. package/lib/index.js +2 -0
  15. package/lib/index.js.map +1 -1
  16. package/lib/native-web-owner.d.ts.map +1 -1
  17. package/lib/native-web-owner.js +3 -3
  18. package/lib/native-web-owner.js.map +1 -1
  19. package/lib/reply-replay.d.ts +52 -0
  20. package/lib/reply-replay.d.ts.map +1 -0
  21. package/lib/reply-replay.js +122 -0
  22. package/lib/reply-replay.js.map +1 -0
  23. package/lib/service.d.ts +115 -2
  24. package/lib/service.d.ts.map +1 -1
  25. package/lib/service.js +483 -94
  26. package/lib/service.js.map +1 -1
  27. package/lib/session-lease-runtime.d.ts +2 -1
  28. package/lib/session-lease-runtime.d.ts.map +1 -1
  29. package/lib/session-lease-runtime.js +3 -3
  30. package/lib/session-lease-runtime.js.map +1 -1
  31. package/lib/sqlite.d.ts +1 -1
  32. package/lib/sqlite.d.ts.map +1 -1
  33. package/lib/sqlite.js +146 -3
  34. package/lib/sqlite.js.map +1 -1
  35. package/lib/store.d.ts +151 -10
  36. package/lib/store.d.ts.map +1 -1
  37. package/lib/store.js +520 -7
  38. package/lib/store.js.map +1 -1
  39. package/lib/version.d.ts +1 -1
  40. package/lib/version.js +1 -1
  41. package/package.json +14 -10
package/lib/service.js CHANGED
@@ -3,21 +3,23 @@ import { isAbsolute } from 'node:path';
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
4
  import { Context, Service } from '@deepseek-ai/cordis';
5
5
  import Schema from '@deepseek-ai/schemastery';
6
- import { acceptanceCanonicalJson } from '@dsh-enhanced/task-acceptance-contract';
6
+ import { acceptanceCanonicalJson, goalDefinitionSituation } from '@dsh-enhanced/task-acceptance-contract';
7
7
  import { approvalReviewerOf, isAutoReviewEscalation, } from '@dsh-enhanced/assistant-policy';
8
8
  import { TRUSTED_EVALUATION_PRODUCER_PROTOCOL, } from '@dsh-enhanced/assistant-evaluation';
9
9
  import { ASSISTANT_GROWTH_CONTRACT_VERSION, growthObjectDigest, validateResolvedWorkflowAutomationTemplate, validateWorkflowAutomationTemplate, validateWorkflowScope, validateWorkflowTraceProjectionReceipt, workflowArgumentShapeDigest, } from '@dsh-enhanced/assistant-growth-contract';
10
10
  import { DeliveryAdapterRegistry, DeliveryCoordinator, InboundCoordinator, } from './coordinator.js';
11
- import { DeliveryStore, DeliveryStoreError } from './store.js';
11
+ import { DeliveryStore, DeliveryStoreError, } from './store.js';
12
12
  import { DshDeliveryRuntime } from './agent-runtime.js';
13
13
  import { DeliverySessionLeases } from './session-lease-runtime.js';
14
14
  import { NativeWebOwner } from './native-web-owner.js';
15
15
  import { validateOwnerGoalOutcomeFeedbackLocator, validateOwnerGoalOutcomeFeedbackProof, } from './goal-wake-types.js';
16
+ import { isSynchronousForegroundObservationHandle, validForegroundObservationRegistration } from './foreground-observation.js';
16
17
  import { InboundImageMaterializer } from './inbound-images.js';
17
18
  import { registerDeliveryTools } from './tools.js';
18
19
  import { bindingMatchesOwnerRoute, canonicalBackgroundSourceId as canonicalHostBackgroundSourceId, canonicalConversation, canonicalLocalOperatorId, canonicalOwnerRouteAuthority, externalPrincipalId, ownerRouteAuthorityHash, ownerRouteBindingSnapshot, } from './canonical.js';
19
20
  import { isExactDeliveryCommand, parseDeliveryCommand } from './session-commands.js';
20
21
  import { deliveryT1PreferenceKeys, deliveryT1PreferenceValues } from './learning-command.js';
22
+ import { assertReplyReplayAllowed, blockAgentRepliesForReplay as createReplyReplayBlock, } from './reply-replay.js';
21
23
  import { feedbackSignalInput, parseFeedbackCommand, classifyNaturalPreferenceDirective, observedResponseLanguage, } from './feedback-command.js';
22
24
  import { DELIVERY_PREFERENCE_PROJECTION_PROTOCOL, TRUSTED_DELIVERY_PRESENTATION_PRODUCER_PROTOCOL, } from './types.js';
23
25
  const trustedDeliveryPreferenceProducers = new WeakSet();
@@ -56,6 +58,7 @@ const goalOutcomeFeedbackFooter = [
56
58
  '目标结果反馈:直接回复本消息并发送 `/feedback achieved`、`/feedback partial` 或 `/feedback not-achieved`。',
57
59
  '`helpful` 等只记录偏好,不会被当成目标成败。',
58
60
  ].join('\n');
61
+ const goalWakeCompletionNotice = '目标已通过独立验收并完成。';
59
62
  function isGoalOutcomeFeedbackAuthority(value) {
60
63
  return typeof value === 'object' && value !== null
61
64
  && typeof value.issueOwnerGoalOutcomeFeedbackTarget === 'function'
@@ -241,6 +244,23 @@ export class AssistantDeliveryError extends Error {
241
244
  this.name = 'AssistantDeliveryError';
242
245
  }
243
246
  }
247
+ function isOwnerAnchoredGoalsCapability(value) {
248
+ return typeof value === 'object' && value !== null
249
+ && typeof value.inspectOwnerVerifiedWorkflowSource === 'function'
250
+ && typeof value.inspectOwnerGoalRunProof === 'function';
251
+ }
252
+ function isOwnerGoalSnapshotLocator(value) {
253
+ if (typeof value !== 'object' || value === null)
254
+ return false;
255
+ const locator = value;
256
+ return Object.keys(locator).sort().join(',') === 'goalId,ownerRouteId,preset,principalId,sessionId,workspace'
257
+ && typeof locator['ownerRouteId'] === 'string' && locator['ownerRouteId'] !== ''
258
+ && typeof locator['principalId'] === 'string' && locator['principalId'] !== ''
259
+ && typeof locator['workspace'] === 'string' && locator['workspace'] !== ''
260
+ && typeof locator['preset'] === 'string' && locator['preset'] !== ''
261
+ && typeof locator['sessionId'] === 'string' && locator['sessionId'] !== ''
262
+ && typeof locator['goalId'] === 'string' && locator['goalId'] !== '';
263
+ }
244
264
  const configSchema = Schema.object({
245
265
  databasePath: Schema.string().required(),
246
266
  spoolPath: Schema.string().required(),
@@ -523,6 +543,7 @@ export class AssistantDeliveryService extends Service {
523
543
  preferenceProducerGeneration = `assistant-delivery-preference:${randomUUID()}`;
524
544
  acceptanceProducerGeneration = `assistant-delivery-acceptance:${randomUUID()}`;
525
545
  acceptanceSink;
546
+ foregroundObserver;
526
547
  /** Once a live verifier required acceptance, loss of that verifier must not silently bypass it. */
527
548
  acceptanceRequired = false;
528
549
  preferenceTurns = new WeakMap();
@@ -650,6 +671,10 @@ export class AssistantDeliveryService extends Service {
650
671
  maxTextBytes: config.maxTextBytes,
651
672
  prepareForegroundTaskAcceptance: (binding, envelope) => this.prepareForegroundTaskAcceptance(binding, envelope),
652
673
  completeForegroundTaskAcceptance: (handle, input) => this.completeForegroundTaskAcceptance(handle, input),
674
+ recordForegroundTaskModelSelection: (handle, input) => this.deliveryStore.recordForegroundTaskModelSelection({ contractId: handle.contractId, ...input }),
675
+ beginForegroundTaskExecution: (binding, envelope) => this.beginForegroundTaskExecution(binding, envelope),
676
+ recordForegroundExecutionModelSelection: (inboxId, input) => this.deliveryStore.recordForegroundExecutionModelSelection({ inboxId, ...input }),
677
+ completeForegroundTaskExecution: (inboxId, input) => this.completeForegroundTaskExecution(inboxId, input),
653
678
  modelPickerTtlMs: config.modelPickerTtlMs,
654
679
  permissionPickerTtlMs: config.permissionPickerTtlMs,
655
680
  getModelSelection: conversation => this.deliveryStore.getModelSelection(conversation),
@@ -730,6 +755,7 @@ export class AssistantDeliveryService extends Service {
730
755
  trustedDeliveryPreferenceProducers.delete(this);
731
756
  this.preferenceFeedbackSink = undefined;
732
757
  this.acceptanceSink = undefined;
758
+ this.foregroundObserver = undefined;
733
759
  this.evaluationSink = undefined;
734
760
  this.workflowTraceSink = undefined;
735
761
  this.automationPresentationBinding?.dispose();
@@ -1291,7 +1317,7 @@ export class AssistantDeliveryService extends Service {
1291
1317
  }));
1292
1318
  if (result.outcome === 'succeeded') {
1293
1319
  current();
1294
- if (input.includeOutput === true && result.quiescent && result.output?.trim()) {
1320
+ if (input.includeOutput === true && result.quiescent) {
1295
1321
  const text = result.output;
1296
1322
  this.goalWakeResults.set(input, () => {
1297
1323
  // Re-read the exact owner, binding generation and Session at enqueue,
@@ -1299,8 +1325,6 @@ export class AssistantDeliveryService extends Service {
1299
1325
  const target = current();
1300
1326
  if (target.conversation.channel === 'web') {
1301
1327
  const owner = this.nativeWebOwner;
1302
- // This finalizes the existing, flushed native Session reply. It does
1303
- // not create a second send or claim that the reply was read.
1304
1328
  if (!this.nativeWebBound || owner === undefined)
1305
1329
  return denied();
1306
1330
  try {
@@ -1309,8 +1333,15 @@ export class AssistantDeliveryService extends Service {
1309
1333
  catch {
1310
1334
  return denied();
1311
1335
  }
1336
+ if (typeof text !== 'string' || text.trim().length === 0) {
1337
+ return this.publishScheduledGoalCompletionNotice(input, current, denied);
1338
+ }
1339
+ // This finalizes the existing, flushed native Session reply. It does
1340
+ // not create a second send or claim that the reply was read.
1312
1341
  return Object.freeze({ kind: 'native-session', sessionId: target.sessionId });
1313
1342
  }
1343
+ if (typeof text !== 'string' || text.trim().length === 0)
1344
+ return denied();
1314
1345
  return this.publishScheduledGoalResult(input, text, current, denied);
1315
1346
  });
1316
1347
  }
@@ -1326,6 +1357,52 @@ export class AssistantDeliveryService extends Service {
1326
1357
  return publish();
1327
1358
  }
1328
1359
  publishScheduledGoalResult(input, text, current, denied) {
1360
+ const { locator, proof, binding: finalBinding } = this.resolveScheduledGoalOutcome(input, current, denied);
1361
+ const decision = this.policy.authorize({
1362
+ subject: { kind: 'background', id: 'assistant-goals-wake/v1',
1363
+ workspace: finalBinding.workspace, principal: locator.principalId },
1364
+ action: 'send', resource: { kind: 'message', id: finalBinding.id }, context: { initiator: 'background' },
1365
+ }, { idempotencyKey: `message-send:goal-outcome:${proof.proofDigest}:${finalBinding.id}` });
1366
+ if (decision.effect !== 'allow')
1367
+ throw policyDenied(decision);
1368
+ const rendered = `${text}${goalOutcomeFeedbackFooter}`;
1369
+ if (Buffer.byteLength(rendered, 'utf8') > this.config.maxTextBytes) {
1370
+ throw new AssistantDeliveryError('runtime-conflict', 'scheduled goal result exceeds the delivery text limit');
1371
+ }
1372
+ const situation = goalDefinitionSituation(proof.goal.definitionDigest);
1373
+ return this.deliveryStore.enqueueGoalOutcomeTarget({
1374
+ locator, proof,
1375
+ intent: {
1376
+ idempotencyKey: `goal-outcome:${proof.proofDigest}:${finalBinding.id}`,
1377
+ bindingId: finalBinding.id,
1378
+ target: { conversation: finalBinding.conversation, principal: finalBinding.principal },
1379
+ text: rendered, format: 'markdown',
1380
+ metadata: Object.freeze({
1381
+ 'dsh.learning.schemaVersion': '4', 'dsh.learning.kind': 'goal-outcome',
1382
+ 'dsh.learning.goalId': locator.goalId, 'dsh.learning.assessmentId': locator.assessmentId,
1383
+ 'dsh.learning.runId': proof.runId, 'dsh.learning.situation': situation,
1384
+ 'dsh.learning.occurredAt': String(proof.receipt.completedAt),
1385
+ 'dsh.learning.objectiveStatus': proof.receipt.objectiveStatus,
1386
+ 'dsh.learning.proofDigest': proof.proofDigest,
1387
+ }),
1388
+ },
1389
+ });
1390
+ }
1391
+ /** A textless terminal Web wake may notify its original owner, never impersonate a reply. */
1392
+ publishScheduledGoalCompletionNotice(input, current, denied) {
1393
+ const { locator, proof, binding } = this.resolveScheduledGoalOutcome(input, current, denied);
1394
+ if (proof.goal.phase !== 'complete' || proof.receipt.objectiveStatus !== 'achieved')
1395
+ return denied();
1396
+ const idempotencyKey = `goal-wake-completion-notice:${proof.proofDigest}:${binding.id}`;
1397
+ return this.enqueueOwnerNotification({
1398
+ sourceId: 'assistant-goals-wake/v1', ownerRouteId: locator.ownerRouteId,
1399
+ scope: { principalId: locator.principalId, principalRecordId: locator.principalRecordId,
1400
+ principalVersion: locator.principalVersion, workspace: locator.workspace, preset: locator.preset },
1401
+ sessionId: locator.sessionId, idempotencyKey, text: goalWakeCompletionNotice,
1402
+ expiresAt: Math.min(input.deadlineAt, proof.receipt.validUntil),
1403
+ });
1404
+ }
1405
+ resolveScheduledGoalOutcome(input, current, denied) {
1329
1406
  current();
1330
1407
  let resolved;
1331
1408
  try {
@@ -1380,35 +1457,7 @@ export class AssistantDeliveryService extends Service {
1380
1457
  || route.binding.generation !== finalBinding.generation
1381
1458
  || route.binding.sessionId !== finalBinding.sessionId)
1382
1459
  return denied();
1383
- const decision = this.policy.authorize({
1384
- subject: { kind: 'background', id: 'assistant-goals-wake/v1',
1385
- workspace: finalBinding.workspace, principal: locator.principalId },
1386
- action: 'send', resource: { kind: 'message', id: finalBinding.id }, context: { initiator: 'background' },
1387
- }, { idempotencyKey: `message-send:goal-outcome:${proof.proofDigest}:${finalBinding.id}` });
1388
- if (decision.effect !== 'allow')
1389
- throw policyDenied(decision);
1390
- const rendered = `${text}${goalOutcomeFeedbackFooter}`;
1391
- if (Buffer.byteLength(rendered, 'utf8') > this.config.maxTextBytes) {
1392
- throw new AssistantDeliveryError('runtime-conflict', 'scheduled goal result exceeds the delivery text limit');
1393
- }
1394
- const situation = `goal:${locator.goalId}:definition:${proof.goal.definitionVersion}`;
1395
- return this.deliveryStore.enqueueGoalOutcomeTarget({
1396
- locator, proof,
1397
- intent: {
1398
- idempotencyKey: `goal-outcome:${proof.proofDigest}:${finalBinding.id}`,
1399
- bindingId: finalBinding.id,
1400
- target: { conversation: finalBinding.conversation, principal: finalBinding.principal },
1401
- text: rendered, format: 'markdown',
1402
- metadata: Object.freeze({
1403
- 'dsh.learning.schemaVersion': '3', 'dsh.learning.kind': 'goal-outcome',
1404
- 'dsh.learning.goalId': locator.goalId, 'dsh.learning.assessmentId': locator.assessmentId,
1405
- 'dsh.learning.runId': proof.runId, 'dsh.learning.situation': situation,
1406
- 'dsh.learning.occurredAt': String(proof.receipt.completedAt),
1407
- 'dsh.learning.objectiveStatus': proof.receipt.objectiveStatus,
1408
- 'dsh.learning.proofDigest': proof.proofDigest,
1409
- }),
1410
- },
1411
- });
1460
+ return Object.freeze({ locator, proof, binding: finalBinding });
1412
1461
  }
1413
1462
  /** Private verifier producer generation; invalidated with this service. */
1414
1463
  trustedAcceptanceProducerGeneration = () => {
@@ -1451,6 +1500,91 @@ export class AssistantDeliveryService extends Service {
1451
1500
  this.acceptanceSink = undefined;
1452
1501
  };
1453
1502
  }
1503
+ beginForegroundTaskExecution(binding, envelope) {
1504
+ const owner = this.ownerLineageForBinding(binding);
1505
+ if (owner === undefined)
1506
+ return undefined;
1507
+ const inbox = this.deliveryStore.getInboxByProviderEvent(envelope.channel, envelope.account, envelope.eventId);
1508
+ if (!inbox || inbox.status !== 'claimed' || inbox.bindingId !== binding.id
1509
+ || JSON.stringify(inbox.envelope) !== JSON.stringify(envelope)) {
1510
+ throw new AssistantDeliveryError('missing-binding', 'foreground execution lacks an authenticated owner inbox');
1511
+ }
1512
+ const dispatchedAt = Date.now();
1513
+ this.deliveryStore.bindForegroundTaskExecution({ inboxId: inbox.id,
1514
+ scope: { workspace: binding.workspace, preset: binding.agentPreset }, owner, binding, dispatchedAt });
1515
+ this.observeForegroundBegin(Object.freeze({ protocol: 'assistant-delivery/foreground-task/v1', inboxId: inbox.id,
1516
+ sessionId: binding.sessionId, scope: Object.freeze({ workspace: binding.workspace, preset: binding.agentPreset }),
1517
+ owner: Object.freeze({ principalRecordId: owner.principalRecordId, principalVersion: owner.principalVersion }),
1518
+ binding: Object.freeze({ id: binding.id, version: binding.version, generation: binding.generation }), dispatchedAt }));
1519
+ return inbox.id;
1520
+ }
1521
+ registerForegroundTaskObserver(registration) {
1522
+ this.assertActive();
1523
+ const control = this.context.get('pluginControlPlane', false);
1524
+ if (!validForegroundObservationRegistration(registration) || control === undefined
1525
+ || control.ownsForegroundTaskObservationRegistration?.(registration) !== true) {
1526
+ throw new AssistantDeliveryError('runtime-conflict', 'foreground observation registration is invalid');
1527
+ }
1528
+ if (this.foregroundObserver !== undefined)
1529
+ throw new AssistantDeliveryError('runtime-conflict', 'foreground observer is already registered');
1530
+ const token = Symbol('assistant-delivery.foreground-observer');
1531
+ const sink = Object.freeze({ token, generation: registration.generation, registration,
1532
+ handles: new Map() });
1533
+ this.foregroundObserver = sink;
1534
+ let live = true;
1535
+ return () => { if (live) {
1536
+ live = false;
1537
+ if (this.foregroundObserver?.token === token)
1538
+ this.foregroundObserver = undefined;
1539
+ } };
1540
+ }
1541
+ observerCurrent(sink) {
1542
+ const control = this.context.get('pluginControlPlane', false);
1543
+ return this.active && this.foregroundObserver?.token === sink.token
1544
+ && control?.ownsForegroundTaskObservationRegistration?.(sink.registration) === true
1545
+ && sink.generation === sink.registration.generation;
1546
+ }
1547
+ observeForegroundBegin(task) {
1548
+ const sink = this.foregroundObserver;
1549
+ if (sink === undefined || !this.observerCurrent(sink) || sink.handles.size >= Math.max(16, this.config.maxConcurrency * 4))
1550
+ return;
1551
+ try {
1552
+ const handle = sink.registration.begin(task);
1553
+ if (!isSynchronousForegroundObservationHandle(handle)) {
1554
+ void Promise.resolve(handle).catch(error => this.context.logger.warn(`assistant-delivery: foreground observer begin must be synchronous: ${String(error)}`));
1555
+ return;
1556
+ }
1557
+ if (!this.observerCurrent(sink))
1558
+ return;
1559
+ sink.handles.set(task.inboxId, { task, handle });
1560
+ }
1561
+ catch (error) {
1562
+ this.context.logger.warn(`assistant-delivery: foreground observer begin failed: ${String(error)}`);
1563
+ }
1564
+ }
1565
+ completeForegroundTaskExecution(inboxId, input) {
1566
+ this.deliveryStore.finishForegroundTaskExecution({ inboxId, ...input, completedAt: Date.now() });
1567
+ const sink = this.foregroundObserver;
1568
+ const observed = sink?.handles.get(inboxId);
1569
+ if (sink === undefined || observed === undefined)
1570
+ return;
1571
+ sink.handles.delete(inboxId);
1572
+ if (!this.observerCurrent(sink))
1573
+ return;
1574
+ const actual = this.deliveryStore.inspectForegroundExecutionForOwner({ inboxId, scope: observed.task.scope, owner: observed.task.owner,
1575
+ bindingId: observed.task.binding.id, bindingVersion: observed.task.binding.version, bindingGeneration: observed.task.binding.generation });
1576
+ if (actual === null)
1577
+ return;
1578
+ try {
1579
+ const result = sink.registration.completed(observed.handle, observed.task, actual);
1580
+ if (!isSynchronousForegroundObservationHandle(result)) {
1581
+ void Promise.resolve(result).catch(error => this.context.logger.warn(`assistant-delivery: foreground observer completion must be synchronous: ${String(error)}`));
1582
+ }
1583
+ }
1584
+ catch (error) {
1585
+ this.context.logger.warn(`assistant-delivery: foreground observer completion failed: ${String(error)}`);
1586
+ }
1587
+ }
1454
1588
  prepareForegroundTaskAcceptance(binding, envelope) {
1455
1589
  const sink = this.acceptanceSink;
1456
1590
  if (sink === undefined) {
@@ -1955,6 +2089,106 @@ export class AssistantDeliveryService extends Service {
1955
2089
  }
1956
2090
  return route;
1957
2091
  }
2092
+ /**
2093
+ * Host-only owner-anchored workflow learning bridge. This method is not
2094
+ * registered as an Agent tool (same Host seam as prepareWorkflowApproval):
2095
+ * only the opt-in growth driver invokes it, and only with a locator plus one
2096
+ * bounded-wake authority. Delivery itself re-fetches both the verified Goals
2097
+ * source and the owner-run-trace proof, so the caller can never supply the
2098
+ * objective, steps, acceptance verdict or proof digest — a spoofed driver
2099
+ * learns nothing. A goal that does not honestly reduce to the single
2100
+ * no-tool `assistant.agent-turn` step is returned as `abstained` with zero
2101
+ * writes; the trace is projected through the existing outbox/drain chain.
2102
+ */
2103
+ async commitOwnerAnchoredWorkflowTrace(input) {
2104
+ this.assertActive();
2105
+ if (typeof input !== 'object' || input === null
2106
+ || !isOwnerGoalSnapshotLocator(input.locator)
2107
+ || typeof input.authority !== 'object' || input.authority === null
2108
+ || typeof input.authority.assertCurrent !== 'function') {
2109
+ throw new AssistantDeliveryError('runtime-conflict', 'owner-anchored workflow commit request is invalid');
2110
+ }
2111
+ const { locator, authority } = input;
2112
+ authority.assertCurrent();
2113
+ if (authority.ownerRouteId !== locator.ownerRouteId
2114
+ || authority.scope.principalId !== locator.principalId
2115
+ || authority.scope.workspace !== locator.workspace
2116
+ || authority.scope.preset !== locator.preset) {
2117
+ throw new AssistantDeliveryError('policy-denied', 'owner-anchored workflow authority scope does not match its locator');
2118
+ }
2119
+ const goals = this.ctx.get('assistantGoals', false);
2120
+ if (!isOwnerAnchoredGoalsCapability(goals)) {
2121
+ throw new AssistantDeliveryError('runtime-unavailable', 'owner-anchored workflow goals evidence is unavailable');
2122
+ }
2123
+ let source;
2124
+ try {
2125
+ source = await goals.inspectOwnerVerifiedWorkflowSource(locator);
2126
+ }
2127
+ catch (error) {
2128
+ if (error?.code === 'unavailable') {
2129
+ throw new AssistantDeliveryError('runtime-unavailable', 'owner-anchored workflow goals observation is unavailable');
2130
+ }
2131
+ throw new AssistantDeliveryError('missing-binding', 'owner-anchored workflow verified source is not available');
2132
+ }
2133
+ let proof;
2134
+ try {
2135
+ proof = await goals.inspectOwnerGoalRunProof({ ...locator, runId: source.runId });
2136
+ }
2137
+ catch (error) {
2138
+ if (/unavailable/u.test(error instanceof Error ? error.message : '')) {
2139
+ throw new AssistantDeliveryError('runtime-unavailable', 'owner-anchored workflow run proof is unavailable');
2140
+ }
2141
+ throw new AssistantDeliveryError('missing-binding', 'owner-anchored workflow run proof is not available');
2142
+ }
2143
+ // Re-anchor after every await: the bounded-wake authority and the live
2144
+ // owner route must still be the same generation the driver was minted for.
2145
+ authority.assertCurrent();
2146
+ const route = this.resolveOwnerRoute(locator.ownerRouteId);
2147
+ if (externalPrincipalId(route.binding.principal) !== locator.principalId
2148
+ || route.binding.workspace !== locator.workspace
2149
+ || route.binding.agentPreset !== locator.preset) {
2150
+ throw new AssistantDeliveryError('missing-binding', 'owner-anchored workflow locator does not match the live owner route');
2151
+ }
2152
+ const failedObservations = source.failedObservations ?? [];
2153
+ const proofSucceeded = proof.steps.filter(step => step.outcome === 'succeeded');
2154
+ const proofFailed = proof.steps.filter(step => step.outcome === 'failed');
2155
+ // The reduction decision below must cover exactly the settled calls the
2156
+ // trusted run-proof digest covers; a mismatch fails closed.
2157
+ if (source.scope.principalId !== locator.principalId
2158
+ || source.scope.workspace !== locator.workspace || source.scope.preset !== locator.preset
2159
+ || source.goal.id !== locator.goalId || source.goal.sessionId !== locator.sessionId
2160
+ || proof.runId !== source.runId || proof.turn !== source.turn
2161
+ || proof.definitionDigest !== source.goal.definition.digest
2162
+ || !/^[a-f0-9]{64}$/u.test(proof.traceDigest)
2163
+ || proofSucceeded.length !== source.steps.length
2164
+ || proofFailed.length !== failedObservations.length
2165
+ || JSON.stringify(proofSucceeded.map(step => `${step.id}:${step.name}`).sort())
2166
+ !== JSON.stringify(source.steps.map(step => `${step.id}:${step.toolName}`).sort())
2167
+ || JSON.stringify(proofFailed.map(step => step.id).sort())
2168
+ !== JSON.stringify(failedObservations.map(step => step.id).sort())) {
2169
+ throw new AssistantDeliveryError('missing-binding', 'owner-anchored workflow evidence does not match its locator');
2170
+ }
2171
+ const committed = this.deliveryStore.commitOwnerAnchoredWorkflowTrace({
2172
+ binding: route.binding,
2173
+ ownerRouteId: route.authorityId,
2174
+ scope: { workspace: locator.workspace, preset: locator.preset },
2175
+ goal: source.goal,
2176
+ runId: source.runId,
2177
+ turn: source.turn,
2178
+ acceptance: {
2179
+ contractId: source.acceptance.contractId,
2180
+ receiptDigest: source.acceptance.receiptDigest,
2181
+ verifiedAt: source.acceptance.verifiedAt,
2182
+ validUntil: source.acceptance.validUntil,
2183
+ },
2184
+ steps: source.steps.map(step => Object.freeze({ id: step.id, toolName: step.toolName })),
2185
+ ...(failedObservations.length === 0 ? {} : { failedObservations }),
2186
+ ownerRunTraceDigest: proof.traceDigest,
2187
+ });
2188
+ if (committed.outcome === 'trace-recorded')
2189
+ await this.drainWorkflowTraces();
2190
+ return committed;
2191
+ }
1958
2192
  /**
1959
2193
  * Host-only, content-free route minting seam for Preference promotion.
1960
2194
  * No binding id is accepted from the caller; Delivery resolves the unique
@@ -2611,12 +2845,11 @@ export class AssistantDeliveryService extends Service {
2611
2845
  return 'invalid-target';
2612
2846
  return await this.dispatchGoalOutcomeFeedback(binding, inbox, target, parsed, goalOutcomeTarget);
2613
2847
  }
2614
- // Ordinary Agent replies have no learning metadata. They are eligible
2615
- // only for Delivery's local, atomic verified-workflow receipt; the Store
2616
- // rebuilds the full Inbox/Outbox fence and abstains unless the source text
2617
- // selects a closed deterministic-deidentification template. Do not route
2618
- // these judgements through Evaluation: they are task proof for workflow
2619
- // repetition, not a cross-plugin quality vote.
2848
+ // Ordinary Agent replies have no learning metadata. A completed Host
2849
+ // execution permits an authenticated owner revision in Evaluation. The
2850
+ // separate local workflow path rebuilds its Inbox/Outbox fence and abstains
2851
+ // unless the source selects a closed deterministic-deidentification template;
2852
+ // legacy replies without an execution receipt retain that local path.
2620
2853
  if (metadata?.['dsh.learning.kind'] !== 'automation-run') {
2621
2854
  const sourceEventId = target.intent.replyToEventId;
2622
2855
  if (typeof sourceEventId !== 'string')
@@ -2631,7 +2864,17 @@ export class AssistantDeliveryService extends Service {
2631
2864
  owner: lineage,
2632
2865
  bindingId: binding.id,
2633
2866
  });
2634
- const canonicalForeground = accepted !== null;
2867
+ const foreground = lineage === undefined ? null : this.deliveryStore.inspectForegroundExecutionForOwner({
2868
+ inboxId: sourceInbox.id, scope: { workspace: binding.workspace, preset: binding.agentPreset },
2869
+ owner: lineage, bindingId: binding.id, bindingVersion: binding.version, bindingGeneration: binding.generation,
2870
+ });
2871
+ // The owner labels the objective, not whether Host work has stopped.
2872
+ // Do not let Evaluation's delivery producer turn an unknown execution
2873
+ // into a succeeded one merely because its reply reached the user.
2874
+ if ([foreground, accepted].some(execution => execution !== null
2875
+ && (execution.status !== 'succeeded' || !execution.quiescent)))
2876
+ return 'invalid-target';
2877
+ const canonicalForeground = foreground !== null || accepted !== null;
2635
2878
  const projectForegroundOwner = async (inspectOnly) => {
2636
2879
  const sink = this.evaluationSink;
2637
2880
  if (sink === undefined || lineage === undefined)
@@ -2683,7 +2926,7 @@ export class AssistantDeliveryService extends Service {
2683
2926
  if (canonicalForeground && this.evaluationSink === undefined)
2684
2927
  return 'unavailable';
2685
2928
  try {
2686
- // Evaluation commits first for accepted foreground tasks. If the sink
2929
+ // Evaluation commits first for recorded foreground tasks. If the sink
2687
2930
  // is unavailable, never acknowledge a Delivery-only success; a replay
2688
2931
  // can safely repeat the opaque command and then repair local workflow
2689
2932
  // evidence after the canonical owner revision is durable.
@@ -2831,8 +3074,8 @@ export class AssistantDeliveryService extends Service {
2831
3074
  const metadata = target.intent.metadata;
2832
3075
  const locator = durable.locator;
2833
3076
  const immutableProof = durable.proof;
2834
- const situation = `goal:${locator.goalId}:definition:${immutableProof.goal.definitionVersion}`;
2835
- if (metadata?.['dsh.learning.schemaVersion'] !== '3'
3077
+ const situation = goalDefinitionSituation(immutableProof.goal.definitionDigest);
3078
+ if (metadata?.['dsh.learning.schemaVersion'] !== '4'
2836
3079
  || metadata['dsh.learning.kind'] !== 'goal-outcome'
2837
3080
  || metadata['dsh.learning.goalId'] !== locator.goalId
2838
3081
  || metadata['dsh.learning.assessmentId'] !== locator.assessmentId
@@ -3119,38 +3362,14 @@ export class AssistantDeliveryService extends Service {
3119
3362
  return reviewer === 'auto-review' ? next() : 'unavailable';
3120
3363
  if (initialRoute.state !== 'bound')
3121
3364
  return 'unavailable';
3122
- if (signalAborted(request.signal))
3123
- return 'cancelled';
3124
- let authority;
3125
- try {
3126
- authority = this.resolveToolApprovalAuthority(ctx, request);
3127
- }
3128
- catch {
3129
- return 'unavailable';
3130
- }
3131
- if (authority === undefined || authority.binding.id !== initialRoute.binding.id)
3132
- return 'unavailable';
3133
- const sessions = ctx.get('sessions');
3134
- if (sessions === undefined)
3135
- return 'unavailable';
3136
- try {
3137
- if (!await sessions.flush(request.agent.session))
3138
- return 'unavailable';
3139
- }
3140
- catch {
3141
- return 'unavailable';
3142
- }
3143
- if (signalAborted(request.signal))
3144
- return 'cancelled';
3145
- let persisted;
3146
- try {
3147
- persisted = this.resolveToolApprovalAuthority(ctx, request);
3148
- }
3149
- catch {
3150
- return 'unavailable';
3151
- }
3152
- if (!sameToolApprovalAuthority(authority, persisted))
3153
- return 'unavailable';
3365
+ // The TTL controller is installed BEFORE every pre-card wait, not just
3366
+ // before the adapter call. resolveToolApprovalAuthority is synchronous, but
3367
+ // the durability `sessions.flush()` between the two authority reads has no
3368
+ // cancellation of its own: a stuck flush listener used to hang the whole
3369
+ // approval waterfall for hours with no card dispatched and no expiry (real
3370
+ // headless-Lark observation). Everything from here on is bounded by the
3371
+ // same fail-closed deadline; abort races resolve 'cancelled' only when the
3372
+ // owning request itself was aborted, otherwise 'unavailable'.
3154
3373
  const operationId = `tool-approval:${randomUUID()}`;
3155
3374
  const expiresAt = Date.now() + this.config.toolApprovalTtlMs;
3156
3375
  const controller = new AbortController();
@@ -3160,7 +3379,11 @@ export class AssistantDeliveryService extends Service {
3160
3379
  controller.abort(request.signal?.reason);
3161
3380
  };
3162
3381
  request.signal?.addEventListener('abort', onRequestAbort, { once: true });
3163
- const aborted = new Promise(resolve => {
3382
+ if (signalAborted(request.signal)) {
3383
+ request.signal?.removeEventListener('abort', onRequestAbort);
3384
+ return 'cancelled';
3385
+ }
3386
+ const deadlineAbort = new Promise(resolve => {
3164
3387
  controller.signal.addEventListener('abort', () => {
3165
3388
  resolve(requestCancelled ? 'cancelled' : 'unavailable');
3166
3389
  }, { once: true });
@@ -3170,26 +3393,69 @@ export class AssistantDeliveryService extends Service {
3170
3393
  }, this.config.toolApprovalTtlMs);
3171
3394
  timeout.unref?.();
3172
3395
  this.toolApprovalControllers.add(controller);
3173
- const adapterRequest = Object.freeze({
3174
- operationId,
3175
- bindingId: authority.binding.id,
3176
- target: Object.freeze({
3177
- conversation: Object.freeze({ ...authority.binding.conversation }),
3178
- principal: Object.freeze({ ...authority.binding.principal }),
3179
- }),
3180
- expiresAt,
3181
- actionHash: authority.actionHash,
3182
- toolName: request.toolName,
3183
- callId: authority.callId,
3184
- ...(request.reason === undefined ? {} : { reason: request.reason }),
3185
- arguments: authority.arguments,
3186
- });
3187
3396
  let outcome;
3397
+ // Declared outside the try block: the post-race authority re-check below
3398
+ // must compare against the exact authority that dispatched the card.
3399
+ let authority;
3188
3400
  try {
3401
+ const preCard = await Promise.race([
3402
+ (async () => {
3403
+ let authority;
3404
+ try {
3405
+ authority = this.resolveToolApprovalAuthority(ctx, request);
3406
+ }
3407
+ catch {
3408
+ return 'unavailable';
3409
+ }
3410
+ if (authority === undefined || authority.binding.id !== initialRoute.binding.id)
3411
+ return 'unavailable';
3412
+ const sessions = ctx.get('sessions');
3413
+ if (sessions === undefined)
3414
+ return 'unavailable';
3415
+ try {
3416
+ if (!await sessions.flush(request.agent.session))
3417
+ return 'unavailable';
3418
+ }
3419
+ catch {
3420
+ return 'unavailable';
3421
+ }
3422
+ if (signalAborted(request.signal))
3423
+ return 'cancelled';
3424
+ let persisted;
3425
+ try {
3426
+ persisted = this.resolveToolApprovalAuthority(ctx, request);
3427
+ }
3428
+ catch {
3429
+ return 'unavailable';
3430
+ }
3431
+ if (!sameToolApprovalAuthority(authority, persisted))
3432
+ return 'unavailable';
3433
+ return authority;
3434
+ })(),
3435
+ deadlineAbort,
3436
+ ]);
3437
+ if (typeof preCard === 'string')
3438
+ return preCard;
3439
+ const granted = preCard;
3440
+ authority = granted;
3441
+ const adapterRequest = Object.freeze({
3442
+ operationId,
3443
+ bindingId: granted.binding.id,
3444
+ target: Object.freeze({
3445
+ conversation: Object.freeze({ ...granted.binding.conversation }),
3446
+ principal: Object.freeze({ ...granted.binding.principal }),
3447
+ }),
3448
+ expiresAt,
3449
+ actionHash: granted.actionHash,
3450
+ toolName: request.toolName,
3451
+ callId: granted.callId,
3452
+ ...(request.reason === undefined ? {} : { reason: request.reason }),
3453
+ arguments: granted.arguments,
3454
+ });
3189
3455
  const answer = Promise.resolve()
3190
- .then(() => authority.adapter.requestToolApproval(adapterRequest, controller.signal))
3456
+ .then(() => granted.adapter.requestToolApproval(adapterRequest, controller.signal))
3191
3457
  .then(value => value, () => 'unavailable');
3192
- outcome = await Promise.race([answer, aborted]);
3458
+ outcome = await Promise.race([answer, deadlineAbort]);
3193
3459
  }
3194
3460
  finally {
3195
3461
  clearTimeout(timeout);
@@ -3207,7 +3473,7 @@ export class AssistantDeliveryService extends Service {
3207
3473
  catch {
3208
3474
  return 'unavailable';
3209
3475
  }
3210
- if (current === undefined || !sameToolApprovalAuthority(authority, current))
3476
+ if (authority === undefined || current === undefined || !sameToolApprovalAuthority(authority, current))
3211
3477
  return 'unavailable';
3212
3478
  if (outcome === 'allowed-once') {
3213
3479
  try {
@@ -3631,6 +3897,123 @@ export class AssistantDeliveryService extends Service {
3631
3897
  generation: resolved.snapshot.generation,
3632
3898
  });
3633
3899
  }
3900
+ /** Host-only snapshot of the model selected for this owner's external conversation. */
3901
+ inspectOwnerModelSelection(input) {
3902
+ this.validateOwnerRoute(input);
3903
+ const { binding } = this.resolveOwnerRoute(input.authorityId);
3904
+ if (input.sourceAgent !== undefined) {
3905
+ const session = input.sourceAgent.session;
3906
+ if (String(session.id) !== binding.sessionId || session.header.cwd !== binding.workspace
3907
+ || session.header.agentPreset !== binding.agentPreset) {
3908
+ throw new AssistantDeliveryError('missing-binding', 'growth source session does not match the owner route');
3909
+ }
3910
+ // Request headers reflect native model-selection hooks; agent.options
3911
+ // can still contain the initial route after the user changes models.
3912
+ const request = session.snapshotEvents().findLast(event => event.type === 'request/header');
3913
+ const selected = request?.type === 'request/header' ? request.data.header.config : undefined;
3914
+ if (!selected?.provider || !selected.model) {
3915
+ throw new AssistantDeliveryError('missing-binding', 'growth source session has no resolved model request');
3916
+ }
3917
+ return Object.freeze({ provider: selected.provider, model: selected.model,
3918
+ ...(selected.reasoningEffort === undefined ? {} : { reasoningEffort: selected.reasoningEffort }) });
3919
+ }
3920
+ // Native Web sessions own their model selection in DSH, not this external
3921
+ // conversation ledger. Never substitute Delivery defaults for those sessions.
3922
+ if (binding.conversation.channel === 'web') {
3923
+ throw new AssistantDeliveryError('missing-binding', 'native Web model selection requires a source session');
3924
+ }
3925
+ const selected = this.deliveryStore.getModelSelection(binding.conversation)
3926
+ ?? { provider: this.config.agentProvider, model: this.config.agentModel };
3927
+ return Object.freeze({ provider: selected.provider, model: selected.model,
3928
+ ...(selected.reasoningEffort === undefined ? {} : { reasoningEffort: selected.reasoningEffort }) });
3929
+ }
3930
+ /**
3931
+ * Resolve one canonical foreground result against its actual owner lineage.
3932
+ * Evaluation's workspace/preset scope alone does not establish ownership.
3933
+ * Historical Sessions remain eligible after /new, but not after owner ABA.
3934
+ */
3935
+ inspectOwnerForegroundLearningTask(input) {
3936
+ this.assertActive();
3937
+ const routeInput = { authorityId: input.authorityId, principalId: input.principalId,
3938
+ workspace: input.workspace, agentPreset: input.agentPreset };
3939
+ const owner = this.validateOwnerRoute(routeInput);
3940
+ const evaluation = this.context.get('assistantEvaluation', false);
3941
+ if (!evaluation || typeof evaluation.inspectTrustedTaskOwnerRevision !== 'function')
3942
+ return undefined;
3943
+ const scope = evaluation.canonicalHostScope({ workspace: owner.workspace, preset: owner.agentPreset });
3944
+ const canonical = evaluation.getTrustedTaskLearningProjection({ scope, outcomeId: input.outcomeId });
3945
+ if (!canonical || canonical.projection.subjectKind !== 'foreground-turn')
3946
+ return undefined;
3947
+ const inbox = this.deliveryStore.getInbox(canonical.projection.subjectRef);
3948
+ const binding = inbox?.bindingId === undefined ? undefined : this.deliveryStore.getBinding(inbox.bindingId);
3949
+ if (!inbox || !binding || binding.workspace !== owner.workspace || binding.agentPreset !== owner.agentPreset
3950
+ || externalPrincipalId(binding.principal) !== owner.principalId
3951
+ || JSON.stringify(inbox.envelope.principal) !== JSON.stringify(binding.principal)
3952
+ || JSON.stringify(inbox.envelope.conversation) !== JSON.stringify(binding.conversation))
3953
+ return undefined;
3954
+ const accepted = this.deliveryStore.inspectForegroundAcceptedExecutionForOwner({ inboxId: inbox.id,
3955
+ scope: { workspace: owner.workspace, preset: owner.agentPreset }, bindingId: binding.id,
3956
+ owner: { principalRecordId: owner.principalRecordId, principalVersion: owner.principalVersion } });
3957
+ const execution = this.deliveryStore.inspectForegroundExecutionForOwner({ inboxId: inbox.id,
3958
+ scope: { workspace: owner.workspace, preset: owner.agentPreset }, bindingId: binding.id,
3959
+ bindingVersion: binding.version, bindingGeneration: binding.generation,
3960
+ owner: { principalRecordId: owner.principalRecordId, principalVersion: owner.principalVersion } }) ?? accepted;
3961
+ if (execution === null)
3962
+ return undefined;
3963
+ const verifierComponent = (component) => accepted !== null && component.source.kind === 'evaluator' && component.source.id === 'assistant-verifier'
3964
+ && component.evaluator.id === 'assistant-verifier' && component.evaluator.version === '1'
3965
+ && component.evidence.some(ref => ref.kind === 'foreground-turn' && ref.ref === inbox.id)
3966
+ && component.evidence.some(ref => ref.kind === 'execution' && ref.ref === accepted.executionRef)
3967
+ && component.evidence.some(ref => ref.kind === 'acceptance-contract'
3968
+ && ref.ref === accepted.contractId && ref.digest === accepted.contractDigest);
3969
+ if (canonical.execution !== undefined && (!verifierComponent(canonical.execution)
3970
+ || canonical.execution.status !== accepted?.status))
3971
+ return undefined;
3972
+ let judgement = 'unresolved';
3973
+ let ownerRevision;
3974
+ const objective = canonical.objective;
3975
+ if (objective?.source.kind === 'user-feedback') {
3976
+ if (objective.source.id !== 'assistant-delivery/typed-owner-feedback')
3977
+ return undefined;
3978
+ const revision = evaluation.inspectTrustedTaskOwnerRevision({ scope, outcomeId: input.outcomeId,
3979
+ principalRecordId: owner.principalRecordId, principalVersion: owner.principalVersion });
3980
+ if (!revision || revision.outcomeId !== objective.outcomeId || revision.objectiveStatus !== objective.status)
3981
+ return undefined;
3982
+ const outboxRefs = objective.evidence.filter(ref => ref.kind === 'delivery-outbox');
3983
+ if (outboxRefs.length !== 1)
3984
+ return undefined;
3985
+ const outbox = this.deliveryStore.getOutbox(outboxRefs[0].ref);
3986
+ if (!outbox || outbox.intent.bindingId !== binding.id
3987
+ || outbox.intent.idempotencyKey !== `inbound:${inbox.id}:reply`
3988
+ || outbox.intent.replyToEventId !== inbox.envelope.eventId
3989
+ || JSON.stringify(outbox.intent.target.principal) !== JSON.stringify(binding.principal)
3990
+ || JSON.stringify(outbox.intent.target.conversation) !== JSON.stringify(binding.conversation)
3991
+ || !['accepted', 'delivered', 'read'].includes(outbox.status))
3992
+ return undefined;
3993
+ judgement = 'owner-feedback';
3994
+ ownerRevision = Object.freeze({ version: revision.version, action: revision.action });
3995
+ }
3996
+ else if (objective !== undefined) {
3997
+ if (!verifierComponent(objective))
3998
+ return undefined;
3999
+ judgement = 'independent-verifier';
4000
+ }
4001
+ // Generic execution receipts establish task provenance, never goal success.
4002
+ // Without an acceptance contract, only authenticated typed owner feedback
4003
+ // may turn a completed ordinary conversation into a learning source.
4004
+ if (accepted === null && judgement !== 'owner-feedback')
4005
+ return undefined;
4006
+ // Fence cross-ledger reads before handing any task text to the consumer.
4007
+ if (!evaluation.isTrustedTaskLearningProjectionReceipt(canonical)
4008
+ || acceptanceCanonicalJson(owner) !== acceptanceCanonicalJson(this.validateOwnerRoute(routeInput)))
4009
+ return undefined;
4010
+ const objectiveText = inbox.envelope.text;
4011
+ return Object.freeze({ protocol: 'assistant-delivery/owner-foreground-learning/v1', owner, canonical, judgement,
4012
+ ...(ownerRevision === undefined ? {} : { ownerRevision }),
4013
+ source: Object.freeze({ sessionId: binding.sessionId, inboxId: inbox.id,
4014
+ objective: objectiveText.slice(0, 4096), truncated: objectiveText.length > 4096, quiescent: execution.quiescent,
4015
+ modelSelectionState: execution.modelSelectionState, ...(execution.modelSelection === undefined ? {} : { modelSelection: execution.modelSelection }) }) });
4016
+ }
3634
4017
  /**
3635
4018
  * Enqueue against a stable Host-owned route rather than a session binding.
3636
4019
  * Policy is evaluated against the exact `route:<authorityId>` resource. The
@@ -3770,6 +4153,11 @@ export class AssistantDeliveryService extends Service {
3770
4153
  const prepared = this.prepareAgentReply(agent, input);
3771
4154
  return this.deliveryStore.enqueue(prepared.intent);
3772
4155
  }
4156
+ /** Host-only replay boundary. The returned tombstone fences this exact Agent object permanently. */
4157
+ blockAgentRepliesForReplay(agent, config) {
4158
+ this.assertActive();
4159
+ return createReplyReplayBlock(agent, config);
4160
+ }
3773
4161
  async replyCompletedPreferenceTurn(agent, expectedBinding, envelope, input) {
3774
4162
  const prepared = this.prepareAgentReply(agent, {
3775
4163
  idempotencyKey: `inbound:${envelope.eventId}:reply`,
@@ -3794,6 +4182,7 @@ export class AssistantDeliveryService extends Service {
3794
4182
  return projected ? 'recorded' : 'unavailable';
3795
4183
  }
3796
4184
  prepareAgentReply(agent, input) {
4185
+ assertReplyReplayAllowed(agent, input);
3797
4186
  this.assertActive();
3798
4187
  const binding = agent === undefined ? undefined : this.deliveryStore.getBindingBySession(String(agent.session.id));
3799
4188
  if (agent === undefined || binding === undefined || binding.status !== 'active'