@dsh-enhanced/assistant-delivery 0.1.66 → 0.1.68
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.
- package/README.md +26 -4
- package/lib/agent-runtime.d.ts.map +1 -1
- package/lib/agent-runtime.js +16 -9
- package/lib/agent-runtime.js.map +1 -1
- package/lib/attention.d.ts +5 -0
- package/lib/attention.d.ts.map +1 -1
- package/lib/feedback-command.d.ts +23 -0
- package/lib/feedback-command.d.ts.map +1 -1
- package/lib/feedback-command.js +176 -0
- package/lib/feedback-command.js.map +1 -1
- package/lib/index.d.ts +4 -4
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +2 -2
- package/lib/index.js.map +1 -1
- package/lib/native-web-owner.d.ts +15 -0
- package/lib/native-web-owner.d.ts.map +1 -1
- package/lib/native-web-owner.js +13 -1
- package/lib/native-web-owner.js.map +1 -1
- package/lib/service.d.ts +173 -1
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +465 -47
- package/lib/service.js.map +1 -1
- package/lib/store.d.ts +43 -0
- package/lib/store.d.ts.map +1 -1
- package/lib/store.js +142 -4
- package/lib/store.js.map +1 -1
- package/lib/types.d.ts +6 -0
- package/lib/types.d.ts.map +1 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/lib/work-item-feedback-store.d.ts +7 -0
- package/lib/work-item-feedback-store.d.ts.map +1 -1
- package/lib/work-item-feedback-store.js +19 -12
- package/lib/work-item-feedback-store.js.map +1 -1
- package/lib/work-item-replies-store.d.ts +8 -0
- package/lib/work-item-replies-store.d.ts.map +1 -1
- package/lib/work-item-replies-store.js +43 -33
- package/lib/work-item-replies-store.js.map +1 -1
- package/package.json +13 -10
package/lib/service.js
CHANGED
|
@@ -33,7 +33,7 @@ import { bindingMatchesOwnerRoute, canonicalBackgroundSourceId as canonicalHostB
|
|
|
33
33
|
import { isExactDeliveryCommand, parseDeliveryCommand } from './session-commands.js';
|
|
34
34
|
import { deliveryT1PreferenceKeys, deliveryT1PreferenceValues } from './learning-command.js';
|
|
35
35
|
import { assertReplyReplayAllowed, blockAgentRepliesForReplay as createReplyReplayBlock, } from './reply-replay.js';
|
|
36
|
-
import { feedbackSignalInput, parseFeedbackCommand, parseNaturalObjectiveFeedback, objectiveStateMessage, classifyNaturalPreferenceDirective, observedResponseLanguage, } from './feedback-command.js';
|
|
36
|
+
import { feedbackSignalInput, parseFeedbackCommand, parseNaturalObjectiveFeedback, parseNaturalOwnerReply, objectiveStateMessage, classifyNaturalPreferenceDirective, observedResponseLanguage, } from './feedback-command.js';
|
|
37
37
|
import { DELIVERY_PREFERENCE_PROJECTION_PROTOCOL, TRUSTED_DELIVERY_PRESENTATION_PRODUCER_PROTOCOL, } from './types.js';
|
|
38
38
|
const trustedDeliveryPreferenceProducers = new WeakSet();
|
|
39
39
|
const deliveryPreferenceProducerProbe = Symbol('assistant-delivery.preference-producer-probe');
|
|
@@ -265,6 +265,7 @@ function containsReservedLearningMetadata(metadata) {
|
|
|
265
265
|
return metadata !== undefined && Object.keys(metadata).some(key => key.startsWith(learningMetadataPrefix));
|
|
266
266
|
}
|
|
267
267
|
export const ownerNotificationWithdrawalProtocol = 'assistant-delivery/owner-notification-withdrawal/v1';
|
|
268
|
+
export const ownerNotificationReplyProtocol = 'assistant-delivery/owner-notification-reply/v1';
|
|
268
269
|
export class AssistantDeliveryError extends Error {
|
|
269
270
|
code;
|
|
270
271
|
constructor(code, message) {
|
|
@@ -338,6 +339,16 @@ const configSchema = Schema.object({
|
|
|
338
339
|
attentionAttachOverCap: Schema.boolean().default(false),
|
|
339
340
|
attentionEnforce: Schema.boolean().default(false),
|
|
340
341
|
ownerReportRouting: Schema.object({ orderAcrossLanes: Schema.boolean().default(false) }),
|
|
342
|
+
naturalFeedback: Schema.object({
|
|
343
|
+
coverage: Schema.boolean().default(false),
|
|
344
|
+
mixedIntent: Schema.boolean().default(false),
|
|
345
|
+
}),
|
|
346
|
+
peerAgents: Schema.array(Schema.object({
|
|
347
|
+
channel: Schema.string().min(1).required(),
|
|
348
|
+
account: Schema.string().min(1).required(),
|
|
349
|
+
tenant: Schema.string().min(1).required(),
|
|
350
|
+
user: Schema.string().min(1).required(),
|
|
351
|
+
})).default([]),
|
|
341
352
|
ownerRoutes: Schema.array(Schema.object({
|
|
342
353
|
id: Schema.string().min(1).required(),
|
|
343
354
|
conversation: Schema.object({
|
|
@@ -435,6 +446,23 @@ const ownerRequestedChangeArgumentBytes = 64 * 1024;
|
|
|
435
446
|
const toolApprovalIdentityBytes = 512;
|
|
436
447
|
/** Bounded owner-ordered linked fallback list for one report route. */
|
|
437
448
|
const maxLinkedReportFallback = 8;
|
|
449
|
+
/**
|
|
450
|
+
* Host-native tool arguments normalized exactly as Delivery attests them:
|
|
451
|
+
* JSON.parse then JSON.stringify, preserving key order and rejecting any
|
|
452
|
+
* non-object payload. Other components must hash this exact text instead of
|
|
453
|
+
* re-canonicalizing it, otherwise a foreground witness cannot be matched.
|
|
454
|
+
*/
|
|
455
|
+
export function normalizeForegroundToolArguments(raw) {
|
|
456
|
+
try {
|
|
457
|
+
const parsed = JSON.parse(raw);
|
|
458
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
459
|
+
return undefined;
|
|
460
|
+
return JSON.stringify(parsed);
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
return undefined;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
438
466
|
function inspectUnsettledNativeCall(events, turn, callId, toolName, argumentsJson, argumentBytes = toolApprovalArgumentBytes) {
|
|
439
467
|
if (!callId || !toolName || Buffer.byteLength(callId) > toolApprovalIdentityBytes
|
|
440
468
|
|| Buffer.byteLength(toolName) > toolApprovalIdentityBytes
|
|
@@ -463,16 +491,9 @@ function inspectUnsettledNativeCall(events, turn, callId, toolName, argumentsJso
|
|
|
463
491
|
|| events.filter(event => event.type === 'step/start' && event.data.turn === turn
|
|
464
492
|
&& event.data.step === call.data.step).length !== 1)
|
|
465
493
|
return undefined;
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
const parsed = JSON.parse(call.data.arguments);
|
|
469
|
-
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
470
|
-
return undefined;
|
|
471
|
-
normalizedArguments = JSON.stringify(parsed);
|
|
472
|
-
}
|
|
473
|
-
catch {
|
|
494
|
+
const normalizedArguments = normalizeForegroundToolArguments(call.data.arguments);
|
|
495
|
+
if (normalizedArguments === undefined)
|
|
474
496
|
return undefined;
|
|
475
|
-
}
|
|
476
497
|
const argumentsDigest = createHash('sha256').update(normalizedArguments).digest('hex');
|
|
477
498
|
if (argumentsDigest !== createHash('sha256').update(argumentsJson).digest('hex'))
|
|
478
499
|
return undefined;
|
|
@@ -696,6 +717,8 @@ export class AssistantDeliveryService extends Service {
|
|
|
696
717
|
ownerNotificationWithdrawals = new Map();
|
|
697
718
|
/** Live handles issued by `registerOwnerNotificationWithdrawal`, mapped to their (authority, source) key. */
|
|
698
719
|
ownerNotificationWithdrawalHandles = new Map();
|
|
720
|
+
/** Slice-5 reply consumers, one per (authority, canonical owner-notice source id). */
|
|
721
|
+
ownerNotificationReplies = new Map();
|
|
699
722
|
foregroundObserver;
|
|
700
723
|
/** Once a live verifier required acceptance, loss of that verifier must not silently bypass it. */
|
|
701
724
|
acceptanceRequired = false;
|
|
@@ -2091,10 +2114,53 @@ export class AssistantDeliveryService extends Service {
|
|
|
2091
2114
|
completeExecution: (inboxId, input) => this.completeForegroundTaskExecution(inboxId, input),
|
|
2092
2115
|
recordExecutionModelSelection: (inboxId, input) => this.deliveryStore.recordForegroundExecutionModelSelection({ inboxId, ...input }),
|
|
2093
2116
|
claimed: (agent, envelope, turn) => this.capturePreferenceTurn(agent, { kind: 'delivery', channel: envelope.channel, account: envelope.account, eventId: envelope.eventId }, turn),
|
|
2094
|
-
notifications: binding => this.deliveryStore.listOutbox({ bindingId: binding.id, limit: 50 }).filter(record => record.status === 'accepted'
|
|
2095
|
-
//
|
|
2117
|
+
notifications: binding => this.deliveryStore.listOutbox({ bindingId: binding.id, limit: 50 }).filter(record => (record.status === 'accepted' || record.status === 'delivered')
|
|
2118
|
+
// Acknowledged notices stay listed until read; withdrawal only gates the send attempt.
|
|
2096
2119
|
&& record.intent.metadata?.['dsh.native-notice'] === 'v1' && this.#notificationGuard(record, { withdrawal: false }) === undefined)
|
|
2097
|
-
.map(record => ({ id: record.id, text: record.intent.text, createdAt: record.createdAt
|
|
2120
|
+
.map(record => ({ id: record.id, text: record.intent.text, createdAt: record.createdAt,
|
|
2121
|
+
status: record.status === 'delivered' ? 'delivered' : 'accepted' })),
|
|
2122
|
+
acknowledgeNotices: (binding, ids, status) => {
|
|
2123
|
+
// Validate the whole batch against this binding and the exact send-path
|
|
2124
|
+
// guard before writing anything: one foreign/forged/expired id, drifted
|
|
2125
|
+
// owner/binding/route/policy or withdrawn notice rejects the batch with
|
|
2126
|
+
// zero receipts.
|
|
2127
|
+
const byId = new Map(this.deliveryStore.listOutbox({ bindingId: binding.id, limit: 100 }).map(record => [record.id, record]));
|
|
2128
|
+
const targets = ids.map(id => {
|
|
2129
|
+
const record = byId.get(id);
|
|
2130
|
+
if (record === undefined || record.intent.metadata?.['dsh.native-notice'] !== 'v1'
|
|
2131
|
+
|| (record.status !== 'accepted' && record.status !== 'delivered' && record.status !== 'read')
|
|
2132
|
+
|| record.providerMessageId === undefined) {
|
|
2133
|
+
throw new Error('assistant-delivery: native notice acknowledgement is invalid');
|
|
2134
|
+
}
|
|
2135
|
+
// Re-run the exact send-path guard: expiry, owner/binding version or
|
|
2136
|
+
// generation drift, route/policy change and withdrawal all fail closed
|
|
2137
|
+
// before any receipt is written. withdrawal:true is required so a
|
|
2138
|
+
// withdrawn notice cannot be acknowledged after it was sent.
|
|
2139
|
+
const refused = this.#nativeNoticeGuard(record.intent, { withdrawal: true });
|
|
2140
|
+
if (refused !== undefined)
|
|
2141
|
+
throw new Error(`assistant-delivery: native notice acknowledgement is ${refused.failureCode}`);
|
|
2142
|
+
return record;
|
|
2143
|
+
});
|
|
2144
|
+
const rank = { accepted: 1, delivered: 2, read: 3 };
|
|
2145
|
+
for (const record of targets) {
|
|
2146
|
+
// A late lower-rank ack is a no-op: it must not append a stale receipt
|
|
2147
|
+
// after read/delivered nor downgrade the outbox state.
|
|
2148
|
+
if (rank[status] <= rank[record.status])
|
|
2149
|
+
continue;
|
|
2150
|
+
try {
|
|
2151
|
+
this.deliveryStore.recordReceipt({ channel: binding.conversation.channel, account: binding.conversation.account,
|
|
2152
|
+
providerMessageId: record.providerMessageId, status, occurredAt: Date.now() });
|
|
2153
|
+
}
|
|
2154
|
+
catch (error) {
|
|
2155
|
+
// The web client re-acks every rendered notice. A repeat with a new
|
|
2156
|
+
// timestamp is an idempotent no-op, not a client error; other
|
|
2157
|
+
// storage failures still propagate.
|
|
2158
|
+
if (!(error instanceof DeliveryStoreError) || error.code !== 'idempotency-conflict')
|
|
2159
|
+
throw error;
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
return Object.freeze({ acknowledged: targets.length });
|
|
2163
|
+
},
|
|
2098
2164
|
}, config);
|
|
2099
2165
|
this.nativeWebOwner = access;
|
|
2100
2166
|
this.nativeWebBound = true;
|
|
@@ -2185,6 +2251,55 @@ export class AssistantDeliveryService extends Service {
|
|
|
2185
2251
|
return Object.freeze({ protocol: 'assistant-delivery/foreground-tool-input/v1',
|
|
2186
2252
|
attestation, ...page });
|
|
2187
2253
|
}
|
|
2254
|
+
/**
|
|
2255
|
+
* The live caller's unique configured owner route. A caller never names a
|
|
2256
|
+
* route here: zero matching routes (anonymous, unconfigured, or failed
|
|
2257
|
+
* revalidation) and more than one matching route (ambiguous owner authority)
|
|
2258
|
+
* both fail closed.
|
|
2259
|
+
*/
|
|
2260
|
+
ownerRouteForAgent(agent) {
|
|
2261
|
+
if (!this.active || typeof agent !== 'object' || agent === null || agent.session === undefined)
|
|
2262
|
+
return undefined;
|
|
2263
|
+
let resolved;
|
|
2264
|
+
try {
|
|
2265
|
+
for (const authorityId of this.ownerRoutes.keys()) {
|
|
2266
|
+
if (this.validateOwnerAgentForRoute(agent, authorityId) === undefined)
|
|
2267
|
+
continue;
|
|
2268
|
+
if (resolved !== undefined)
|
|
2269
|
+
return undefined;
|
|
2270
|
+
resolved = authorityId;
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
catch {
|
|
2274
|
+
return undefined;
|
|
2275
|
+
}
|
|
2276
|
+
return resolved;
|
|
2277
|
+
}
|
|
2278
|
+
/** Host-only proof resolved from the caller Agent's unique live owner route. */
|
|
2279
|
+
inspectOwnerForegroundToolCallForAgent(agent, input) {
|
|
2280
|
+
if (!input || typeof input !== 'object' || Reflect.ownKeys(input).some(key => typeof key !== 'string'
|
|
2281
|
+
|| !['callId', 'toolName', 'argumentsJson'].includes(key)))
|
|
2282
|
+
return undefined;
|
|
2283
|
+
const authorityId = this.ownerRouteForAgent(agent);
|
|
2284
|
+
if (authorityId === undefined)
|
|
2285
|
+
return undefined;
|
|
2286
|
+
return this.inspectOwnerForegroundToolCall({ agent, authorityId, callId: input.callId,
|
|
2287
|
+
toolName: input.toolName, argumentsJson: input.argumentsJson });
|
|
2288
|
+
}
|
|
2289
|
+
/** Read-only human-input pages resolved from the caller Agent's unique live
|
|
2290
|
+
* owner route; the caller cannot select an Inbox, route, owner or replacement text. */
|
|
2291
|
+
readOwnerForegroundToolInputForAgent(agent, input) {
|
|
2292
|
+
if (!input || typeof input !== 'object' || Reflect.ownKeys(input).some(key => typeof key !== 'string'
|
|
2293
|
+
|| !['callId', 'toolName', 'argumentsJson', 'offset', 'maxBytes'].includes(key)))
|
|
2294
|
+
return undefined;
|
|
2295
|
+
const authorityId = this.ownerRouteForAgent(agent);
|
|
2296
|
+
if (authorityId === undefined)
|
|
2297
|
+
return undefined;
|
|
2298
|
+
return this.readOwnerForegroundToolInput({ agent, authorityId, callId: input.callId,
|
|
2299
|
+
toolName: input.toolName, argumentsJson: input.argumentsJson,
|
|
2300
|
+
...(input.offset === undefined ? {} : { offset: input.offset }),
|
|
2301
|
+
...(input.maxBytes === undefined ? {} : { maxBytes: input.maxBytes }) });
|
|
2302
|
+
}
|
|
2188
2303
|
/** Host-only, read-only source proof for an owner's live memory or wiki tool call. */
|
|
2189
2304
|
inspectOwnerRequestedChange(execution, input) {
|
|
2190
2305
|
if (!this.active || !execution || typeof execution !== 'object'
|
|
@@ -2817,6 +2932,16 @@ export class AssistantDeliveryService extends Service {
|
|
|
2817
2932
|
return inbox !== undefined && binding !== undefined && inbox.bindingId === binding.id
|
|
2818
2933
|
&& this.isInboundAuthorized(binding, inbox.envelope);
|
|
2819
2934
|
}
|
|
2935
|
+
/**
|
|
2936
|
+
* Exact owner-configured peer bot, or `undefined`. `senderIsBot` is only the
|
|
2937
|
+
* provider's declaration; the allowlisted tuple is the actual authority.
|
|
2938
|
+
*/
|
|
2939
|
+
peerAgentAuthority(envelope) {
|
|
2940
|
+
if (envelope.metadata?.senderIsBot !== 'true')
|
|
2941
|
+
return undefined;
|
|
2942
|
+
return this.config.peerAgents?.find(peer => peer.channel === envelope.channel && peer.account === envelope.account
|
|
2943
|
+
&& peer.tenant === envelope.principal.tenant && peer.user === envelope.principal.user);
|
|
2944
|
+
}
|
|
2820
2945
|
isInboundAuthorized(binding, envelope) {
|
|
2821
2946
|
if (!this.active)
|
|
2822
2947
|
return false;
|
|
@@ -3251,6 +3376,25 @@ export class AssistantDeliveryService extends Service {
|
|
|
3251
3376
|
void this.drainWorkflowTraces();
|
|
3252
3377
|
return 'recorded';
|
|
3253
3378
|
}
|
|
3379
|
+
/** Opt-in widened deterministic grammar; off keeps the legacy exact phrases. */
|
|
3380
|
+
widenedNaturalFeedbackCoverage() {
|
|
3381
|
+
return this.config.naturalFeedback?.coverage === true;
|
|
3382
|
+
}
|
|
3383
|
+
/** Splitting one reply into result + continuation requires both opt-ins. */
|
|
3384
|
+
mixedNaturalFeedbackIntent() {
|
|
3385
|
+
return this.config.naturalFeedback?.coverage === true
|
|
3386
|
+
&& this.config.naturalFeedback?.mixedIntent === true;
|
|
3387
|
+
}
|
|
3388
|
+
/**
|
|
3389
|
+
* One canonical selection for admission and its later re-derivation fences.
|
|
3390
|
+
* The widened parser is a pure function, so a frozen intent keeps verifying
|
|
3391
|
+
* even after a restart that toggles the opt-in.
|
|
3392
|
+
*/
|
|
3393
|
+
naturalObjectiveSelection(text) {
|
|
3394
|
+
return this.widenedNaturalFeedbackCoverage()
|
|
3395
|
+
? parseNaturalOwnerReply(text)?.status
|
|
3396
|
+
: parseNaturalObjectiveFeedback(text);
|
|
3397
|
+
}
|
|
3254
3398
|
/**
|
|
3255
3399
|
* Convert only an authenticated reply to one exact Automation delivery into
|
|
3256
3400
|
* a trusted objective label. The original run timestamp is retained because
|
|
@@ -3259,7 +3403,7 @@ export class AssistantDeliveryService extends Service {
|
|
|
3259
3403
|
*/
|
|
3260
3404
|
async dispatchNaturalObjectiveFeedback(binding, envelope) {
|
|
3261
3405
|
const selection = envelope.kind === 'text' && (envelope.attachments?.length ?? 0) === 0
|
|
3262
|
-
?
|
|
3406
|
+
? this.naturalObjectiveSelection(envelope.text) : undefined;
|
|
3263
3407
|
if (selection === undefined || !this.isOwnerFeedbackController(binding, envelope))
|
|
3264
3408
|
return;
|
|
3265
3409
|
const replyTarget = envelope.metadata?.replyToProviderMessageId;
|
|
@@ -3375,7 +3519,7 @@ export class AssistantDeliveryService extends Service {
|
|
|
3375
3519
|
const naturalMatches = naturalIntent !== undefined && inbox.envelope.kind === 'text'
|
|
3376
3520
|
&& naturalIntent.envelopeHash === inbox.envelopeHash
|
|
3377
3521
|
&& naturalIntent.sourceOutboxId === target.id
|
|
3378
|
-
&&
|
|
3522
|
+
&& this.naturalObjectiveSelection(inbox.envelope.text) === naturalIntent.selection
|
|
3379
3523
|
&& (parsed.kind === 'objective-status' || (naturalIntent.status === 'frozen'
|
|
3380
3524
|
&& JSON.stringify(naturalIntent.command) === JSON.stringify(parsed)));
|
|
3381
3525
|
if ((inbox.envelope.attachments?.length ?? 0) !== 0 || (!typedMatches && !naturalMatches))
|
|
@@ -3803,7 +3947,7 @@ export class AssistantDeliveryService extends Service {
|
|
|
3803
3947
|
const sessions = ctx.get('sessions');
|
|
3804
3948
|
const agent = request.agent;
|
|
3805
3949
|
const events = agent.session.snapshotEvents();
|
|
3806
|
-
const reviewer = approvalReviewerOf(events);
|
|
3950
|
+
const reviewer = approvalReviewerOf(events, agent.session.header.version);
|
|
3807
3951
|
const reviewRoute = reviewer === 'user'
|
|
3808
3952
|
? 'user'
|
|
3809
3953
|
: reviewer === 'auto-review' && ctx.assistantPolicy.isAutoReviewEscalation(request)
|
|
@@ -3942,7 +4086,8 @@ export class AssistantDeliveryService extends Service {
|
|
|
3942
4086
|
return next();
|
|
3943
4087
|
let reviewer;
|
|
3944
4088
|
try {
|
|
3945
|
-
|
|
4089
|
+
const session = request.agent.session;
|
|
4090
|
+
reviewer = approvalReviewerOf(session.snapshotEvents(), session.header.version);
|
|
3946
4091
|
}
|
|
3947
4092
|
catch {
|
|
3948
4093
|
return 'unavailable';
|
|
@@ -4121,6 +4266,32 @@ export class AssistantDeliveryService extends Service {
|
|
|
4121
4266
|
if (!['received', 'authorized'].includes(record.status)) {
|
|
4122
4267
|
return { duplicate: accepted.duplicate, inboxId: record.id, status: record.status };
|
|
4123
4268
|
}
|
|
4269
|
+
// An explicitly allowlisted peer bot enters its own linked-principal lane.
|
|
4270
|
+
// It is still subject to the ordinary Policy ingest decision below and never
|
|
4271
|
+
// receives owner feedback/continuation behavior. The allowlist is re-read
|
|
4272
|
+
// for every provider-declared bot message: removing an entry revokes the
|
|
4273
|
+
// lane even while the old linked principal row is still active.
|
|
4274
|
+
const senderIsBot = envelope.metadata?.senderIsBot === 'true';
|
|
4275
|
+
const peerAuthority = senderIsBot ? this.peerAgentAuthority(envelope) : undefined;
|
|
4276
|
+
if (senderIsBot && peerAuthority === undefined) {
|
|
4277
|
+
record = this.deliveryStore.deadLetterInbox(record.id, 'unauthorized-principal');
|
|
4278
|
+
return { duplicate: accepted.duplicate, inboxId: record.id, status: record.status };
|
|
4279
|
+
}
|
|
4280
|
+
if (peerAuthority !== undefined && !this.deliveryStore.isAuthorizedPrincipal(envelope.principal)) {
|
|
4281
|
+
const owner = this.deliveryStore.activeOwnerPrincipal();
|
|
4282
|
+
if (owner === undefined) {
|
|
4283
|
+
record = this.deliveryStore.deadLetterInbox(record.id, 'peer-owner-unavailable');
|
|
4284
|
+
return { duplicate: accepted.duplicate, inboxId: record.id, status: record.status };
|
|
4285
|
+
}
|
|
4286
|
+
try {
|
|
4287
|
+
this.deliveryStore.ensurePeerPrincipal(envelope.principal, owner.id, { reauthorize: true });
|
|
4288
|
+
}
|
|
4289
|
+
catch (error) {
|
|
4290
|
+
this.context.logger.warn(`assistant-delivery: peer principal admission failed: ${String(error)}`);
|
|
4291
|
+
record = this.deliveryStore.deadLetterInbox(record.id, 'peer-principal-unavailable');
|
|
4292
|
+
return { duplicate: accepted.duplicate, inboxId: record.id, status: record.status };
|
|
4293
|
+
}
|
|
4294
|
+
}
|
|
4124
4295
|
if (!this.deliveryStore.isAuthorizedPrincipal(envelope.principal)) {
|
|
4125
4296
|
record = this.deliveryStore.deadLetterInbox(record.id, 'unauthorized-principal');
|
|
4126
4297
|
return { duplicate: accepted.duplicate, inboxId: record.id, status: record.status };
|
|
@@ -4225,50 +4396,114 @@ export class AssistantDeliveryService extends Service {
|
|
|
4225
4396
|
const failed = this.deliveryStore.getInbox(record.id);
|
|
4226
4397
|
return { duplicate: accepted.duplicate, inboxId: failed.id, status: failed.status };
|
|
4227
4398
|
}
|
|
4228
|
-
|
|
4229
|
-
|
|
4399
|
+
// Slice 5: a reply to the exact delivered owner-question notice is
|
|
4400
|
+
// consumed here, before any Agent admission. Applied replies are
|
|
4401
|
+
// processed; a transient consumer failure leaves the Inbox pending.
|
|
4402
|
+
const attentionReply = this.inspectOwnerQuestionReply(record, binding);
|
|
4403
|
+
if (attentionReply !== 'none') {
|
|
4404
|
+
const current = this.deliveryStore.getInbox(record.id);
|
|
4405
|
+
return { duplicate: accepted.duplicate, inboxId: current.id, status: current.status };
|
|
4406
|
+
}
|
|
4407
|
+
const feedback = this.inspectOwnerWorkItemFeedback(record, binding);
|
|
4408
|
+
const splitContinuation = feedback.kind !== 'none' && feedback.continuation;
|
|
4409
|
+
if (feedback.kind === 'ready') {
|
|
4410
|
+
// One reply may carry a result and an explicit continuation. They are two
|
|
4411
|
+
// independently fenced decisions: admit both atomically when both lanes
|
|
4412
|
+
// are permitted, otherwise record the result alone and record the reason.
|
|
4413
|
+
let admittedContinuation = false;
|
|
4414
|
+
if (splitContinuation) {
|
|
4415
|
+
const report = this.prepareWorkItemReply(record, binding);
|
|
4416
|
+
if (report !== undefined) {
|
|
4417
|
+
try {
|
|
4418
|
+
this.deliveryStore.admitOwnerWorkItemFeedbackWithReply({ feedback: feedback.source, binding, report, fence: feedback.fence });
|
|
4419
|
+
admittedContinuation = true;
|
|
4420
|
+
}
|
|
4421
|
+
catch (error) {
|
|
4422
|
+
// The combined transaction rolled back; fall back to the result lane
|
|
4423
|
+
// only. A failed result fence re-throws on that fallback, as before.
|
|
4424
|
+
this.context.logger.warn(`assistant-delivery: mixed owner feedback continuation admission failed: ${String(error)}`);
|
|
4425
|
+
}
|
|
4426
|
+
}
|
|
4427
|
+
}
|
|
4428
|
+
if (!admittedContinuation) {
|
|
4429
|
+
this.deliveryStore.workItemFeedback.admit(feedback.source, feedback.fence);
|
|
4430
|
+
if (splitContinuation)
|
|
4431
|
+
this.recordPartialMixedFeedbackAdmission(record, 'continuation-not-admitted');
|
|
4432
|
+
}
|
|
4433
|
+
if (admittedContinuation)
|
|
4434
|
+
this.notifyWorkItemReplyPending();
|
|
4230
4435
|
void this.drainWorkItemFeedback().catch(() => { });
|
|
4231
4436
|
return { duplicate: accepted.duplicate, inboxId: record.id, status: 'processed' };
|
|
4232
4437
|
}
|
|
4233
|
-
|
|
4438
|
+
// A pure recognized result never becomes execution when feedback permission
|
|
4439
|
+
// is absent; only an explicit continuation in the same reply may proceed.
|
|
4440
|
+
const continuationAllowed = feedback.kind === 'none'
|
|
4441
|
+
|| (feedback.kind === 'ordinary' && splitContinuation);
|
|
4442
|
+
if (continuationAllowed && this.admitWorkItemReply(record, binding)) {
|
|
4234
4443
|
const handedOff = this.deliveryStore.getInbox(record.id);
|
|
4235
4444
|
this.notifyWorkItemReplyPending();
|
|
4445
|
+
if (feedback.kind === 'ordinary')
|
|
4446
|
+
this.recordPartialMixedFeedbackAdmission(record, 'feedback-not-admitted');
|
|
4236
4447
|
return { duplicate: accepted.duplicate, inboxId: handedOff.id, status: handedOff.status };
|
|
4237
4448
|
}
|
|
4449
|
+
if (feedback.kind === 'ordinary' && splitContinuation)
|
|
4450
|
+
this.recordPartialMixedFeedbackAdmission(record, 'neither-lane-admitted');
|
|
4238
4451
|
record = this.deliveryStore.queueInbox(record.id, binding.id);
|
|
4239
4452
|
return { duplicate: accepted.duplicate, inboxId: record.id, status: record.status };
|
|
4240
4453
|
});
|
|
4241
4454
|
}
|
|
4455
|
+
/**
|
|
4456
|
+
* Deterministic backend classification of one authenticated owner reply to an
|
|
4457
|
+
* exact report. The legacy closed whole-sentence grammar stays in force unless
|
|
4458
|
+
* the widened coverage opt-in is on; no caller text is reinterpreted as an
|
|
4459
|
+
* instruction, and no model is called.
|
|
4460
|
+
*/
|
|
4242
4461
|
parseWorkItemFeedback(envelope) {
|
|
4243
4462
|
if ((envelope.attachments?.length ?? 0) !== 0)
|
|
4244
4463
|
return undefined;
|
|
4245
4464
|
if (envelope.kind === 'text') {
|
|
4246
4465
|
const text = envelope.text.trim();
|
|
4247
|
-
if (
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4466
|
+
if (!this.widenedNaturalFeedbackCoverage()) {
|
|
4467
|
+
if (!/^(?:问题解决了|已经解决了|现在解决了|还是不行|还没解决|问题还没解决|撤回刚才的任务反馈|撤回我刚才的任务反馈)[。.!!]?$/u.test(text))
|
|
4468
|
+
return undefined;
|
|
4469
|
+
const value = parseNaturalObjectiveFeedback(text);
|
|
4470
|
+
return value === undefined ? undefined : { selection: { kind: 'natural', value }, continuation: false };
|
|
4471
|
+
}
|
|
4472
|
+
const parsed = parseNaturalOwnerReply(text);
|
|
4473
|
+
return parsed === undefined ? undefined : {
|
|
4474
|
+
selection: { kind: 'natural', value: parsed.status },
|
|
4475
|
+
continuation: parsed.continuation && this.mixedNaturalFeedbackIntent(),
|
|
4476
|
+
};
|
|
4251
4477
|
}
|
|
4252
4478
|
const command = parseDeliveryCommand(envelope);
|
|
4253
4479
|
if (command?.name !== 'feedback')
|
|
4254
4480
|
return undefined;
|
|
4255
4481
|
const parsed = parseFeedbackCommand(command.rawInput);
|
|
4256
4482
|
return ['objective', 'objective-status', 'objective-revision'].includes(parsed.kind)
|
|
4257
|
-
? { kind: 'explicit', command: parsed } :
|
|
4483
|
+
? { selection: { kind: 'explicit', command: parsed }, continuation: false }
|
|
4484
|
+
: undefined;
|
|
4258
4485
|
}
|
|
4259
4486
|
workItemFeedbackPermission(binding, registration) {
|
|
4260
4487
|
return this.policy.evaluate({ subject: { kind: 'external', id: externalPrincipalId(binding.principal), workspace: binding.workspace, principal: externalPrincipalId(binding.principal) },
|
|
4261
4488
|
action: 'feedback-work-items', resource: { kind: 'automation', id: registration.continuationAutomationId }, context: { initiator: 'external' } }).effect === 'allow';
|
|
4262
4489
|
}
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4490
|
+
/**
|
|
4491
|
+
* Inspect one Inbox as an authenticated owner result reply to an exact report.
|
|
4492
|
+
* `ready` carries the frozen source plus its live admission fence; `ordinary`
|
|
4493
|
+
* keeps the historical "recognized result but separate feedback permission
|
|
4494
|
+
* absent" outcome and never admits the result lane by itself. The
|
|
4495
|
+
* `continuation` flag is a deterministic classification only.
|
|
4496
|
+
*/
|
|
4497
|
+
inspectOwnerWorkItemFeedback(inbox, binding) {
|
|
4498
|
+
const registration = this.currentWorkItemReplyConsumer();
|
|
4499
|
+
const parsed = this.parseWorkItemFeedback(inbox.envelope);
|
|
4500
|
+
if (!registration || registration.feedbackEnabled !== true || parsed === undefined || !inbox.envelope.metadata?.replyToProviderMessageId)
|
|
4501
|
+
return { kind: 'none' };
|
|
4267
4502
|
const report = this.deliveryStore.workItemReplies.replyReport(inbox.channel, inbox.account, inbox.envelope.metadata.replyToProviderMessageId);
|
|
4268
4503
|
if (!report || !['accepted', 'delivered', 'read'].includes(report.outbox.status) || !report.outbox.providerMessageId
|
|
4269
4504
|
|| workItemReplyCanonical(report.outbox.intent.target.conversation) !== workItemReplyCanonical(inbox.envelope.conversation)
|
|
4270
4505
|
|| workItemReplyCanonical(report.outbox.intent.target.principal) !== workItemReplyCanonical(binding.principal))
|
|
4271
|
-
return 'none';
|
|
4506
|
+
return { kind: 'none' };
|
|
4272
4507
|
let target;
|
|
4273
4508
|
try {
|
|
4274
4509
|
target = this.inspectWorkItemReport({ sourceId: registration.sourceId, itemId: report.target.itemId, reportRevision: report.target.reportRevision,
|
|
@@ -4276,25 +4511,30 @@ export class AssistantDeliveryService extends Service {
|
|
|
4276
4511
|
format: report.outbox.intent.format === 'markdown' ? 'markdown' : 'plain' }, true);
|
|
4277
4512
|
}
|
|
4278
4513
|
catch {
|
|
4279
|
-
return 'none';
|
|
4514
|
+
return { kind: 'none' };
|
|
4280
4515
|
}
|
|
4281
4516
|
if (workItemReplyCanonical(target) !== workItemReplyCanonical(report.target))
|
|
4282
|
-
return 'none';
|
|
4517
|
+
return { kind: 'none' };
|
|
4518
|
+
const continuation = parsed.continuation;
|
|
4283
4519
|
// A recognized result must not be converted into execution when its separate feedback permission is absent.
|
|
4284
4520
|
if (!this.workItemFeedbackPermission(binding, registration))
|
|
4285
|
-
return 'ordinary';
|
|
4521
|
+
return { kind: 'ordinary', continuation };
|
|
4286
4522
|
const source = { protocol: 'assistant-delivery/work-item-feedback/v1', inboxId: inbox.id, envelopeHash: inbox.envelopeHash,
|
|
4287
4523
|
inputText: inbox.envelope.text, inputSha256: createHash('sha256').update(inbox.envelope.text).digest('hex'), admissionCursor: inbox.admissionCursor, occurredAt: inbox.receivedAt,
|
|
4288
4524
|
owner: target.owner, principalId: externalPrincipalId(binding.principal), binding: { id: binding.id, version: binding.version, generation: binding.generation }, scope: target.scope,
|
|
4289
|
-
target: { outboxId: report.outbox.id, providerMessageId: report.outbox.providerMessageId, itemId: target.itemId, reportRevision: target.reportRevision, reportDigest: target.reportDigest, streamId: target.streamId }, selection,
|
|
4525
|
+
target: { outboxId: report.outbox.id, providerMessageId: report.outbox.providerMessageId, itemId: target.itemId, reportRevision: target.reportRevision, reportDigest: target.reportDigest, streamId: target.streamId }, selection: parsed.selection,
|
|
4290
4526
|
operationId: `owner-work-item-feedback:${createHash('sha256').update(JSON.stringify([inbox.id, inbox.envelopeHash])).digest('hex')}` };
|
|
4291
|
-
|
|
4527
|
+
const fence = () => {
|
|
4292
4528
|
const current = this.currentWorkItemReplyConsumer(), owner = this.ownerLineageForBinding(binding), active = this.deliveryStore.getBinding(binding.id);
|
|
4293
4529
|
if (current !== registration || current?.feedbackEnabled !== true || workItemReplyCanonical(owner) !== workItemReplyCanonical(source.owner)
|
|
4294
4530
|
|| workItemReplyCanonical(active) !== workItemReplyCanonical(binding) || !this.workItemFeedbackPermission(binding, registration))
|
|
4295
4531
|
throw new Error('feedback admission authority changed');
|
|
4296
|
-
}
|
|
4297
|
-
return '
|
|
4532
|
+
};
|
|
4533
|
+
return { kind: 'ready', source, fence, continuation };
|
|
4534
|
+
}
|
|
4535
|
+
/** One deterministic, non-durable record when a mixed reply could only admit one lane. */
|
|
4536
|
+
recordPartialMixedFeedbackAdmission(inbox, reason) {
|
|
4537
|
+
this.context.logger.warn(`assistant-delivery: mixed owner feedback ${inbox.id} admitted partially (${reason})`);
|
|
4298
4538
|
}
|
|
4299
4539
|
/** Passing an explicit registration makes this the callback-free final local authority check. */
|
|
4300
4540
|
workItemFeedbackFence(intent, recorded = false, expectedRegistration) {
|
|
@@ -4596,7 +4836,11 @@ export class AssistantDeliveryService extends Service {
|
|
|
4596
4836
|
|| !/^[a-z][a-z0-9-]{0,54}$/u.test(principal.channel))
|
|
4597
4837
|
return undefined;
|
|
4598
4838
|
const key = { channel: principal.channel, account: principal.account, tenant: principal.tenant, user: principal.user };
|
|
4599
|
-
|
|
4839
|
+
// Only the owner role is a trusted intake sender. A linked peer bot is
|
|
4840
|
+
// authorized to converse, but its messages must stay `inferred`/filtered
|
|
4841
|
+
// rather than entering the owner work-item allowlist.
|
|
4842
|
+
const principalRecord = this.deliveryStore.getPrincipal(key);
|
|
4843
|
+
const senderAllowlisted = principalRecord?.status === 'active' && principalRecord.role === 'owner';
|
|
4600
4844
|
// A follow-up counts only from an allowlisted sender replying, in the same
|
|
4601
4845
|
// conversation, to a delivered WorkItems report.
|
|
4602
4846
|
let threadFollowUp = false;
|
|
@@ -4879,32 +5123,43 @@ export class AssistantDeliveryService extends Service {
|
|
|
4879
5123
|
action: 'continue-work-items-from-reply', resource: { kind: 'automation', id: registration.continuationAutomationId },
|
|
4880
5124
|
context: { initiator: 'external' } }).effect === 'allow';
|
|
4881
5125
|
}
|
|
4882
|
-
|
|
5126
|
+
/**
|
|
5127
|
+
* Validate the continuation lane of one reply to an exact report without
|
|
5128
|
+
* writing it. Mixed-intent admission reuses this report inside its combined
|
|
5129
|
+
* transaction; the ordinary path admits it through `admitWorkItemReply`.
|
|
5130
|
+
*/
|
|
5131
|
+
prepareWorkItemReply(inbox, binding) {
|
|
4883
5132
|
const registration = this.currentWorkItemReplyConsumer();
|
|
4884
5133
|
const envelope = inbox.envelope;
|
|
4885
5134
|
if (!registration || registration.continuationEnabled === false || envelope.kind !== 'text' || envelope.text.trim().startsWith('/') || !envelope.text
|
|
4886
5135
|
|| (envelope.attachments?.length ?? 0) > 0 || !envelope.metadata?.replyToProviderMessageId)
|
|
4887
|
-
return
|
|
5136
|
+
return undefined;
|
|
4888
5137
|
const report = this.deliveryStore.workItemReplies.replyReport(envelope.channel, envelope.account, envelope.metadata.replyToProviderMessageId);
|
|
4889
5138
|
const outbox = report?.outbox;
|
|
4890
5139
|
if (!report || !outbox || !['accepted', 'delivered', 'read'].includes(outbox.status) || workItemReplyCanonical(outbox.intent.target.principal) !== workItemReplyCanonical(binding.principal)
|
|
4891
5140
|
|| workItemReplyCanonical(outbox.intent.target.conversation) !== workItemReplyCanonical(envelope.conversation))
|
|
4892
|
-
return
|
|
5141
|
+
return undefined;
|
|
4893
5142
|
try {
|
|
4894
5143
|
const target = this.inspectWorkItemReport({ sourceId: registration.sourceId, itemId: report.target.itemId,
|
|
4895
5144
|
reportRevision: report.target.reportRevision, bindingId: outbox.intent.bindingId, idempotencyKey: outbox.intent.idempotencyKey,
|
|
4896
5145
|
text: outbox.intent.text, format: outbox.intent.format === 'markdown' ? 'markdown' : 'plain' }, true);
|
|
4897
5146
|
if (workItemReplyCanonical(target) !== workItemReplyCanonical(report.target))
|
|
4898
|
-
return
|
|
5147
|
+
return undefined;
|
|
4899
5148
|
if (registration.canContinueReport?.({ itemId: target.itemId, reportRevision: target.reportRevision, bindingId: outbox.intent.bindingId,
|
|
4900
5149
|
idempotencyKey: outbox.intent.idempotencyKey, text: outbox.intent.text, format: outbox.intent.format === 'markdown' ? 'markdown' : 'plain' }) === false)
|
|
4901
|
-
return
|
|
5150
|
+
return undefined;
|
|
4902
5151
|
if (this.currentWorkItemReplyConsumer() !== registration || !this.workItemReplyPermission(binding, registration))
|
|
4903
|
-
return
|
|
5152
|
+
return undefined;
|
|
4904
5153
|
}
|
|
4905
5154
|
catch {
|
|
4906
|
-
return
|
|
5155
|
+
return undefined;
|
|
4907
5156
|
}
|
|
5157
|
+
return report;
|
|
5158
|
+
}
|
|
5159
|
+
admitWorkItemReply(inbox, binding) {
|
|
5160
|
+
const report = this.prepareWorkItemReply(inbox, binding);
|
|
5161
|
+
if (report === undefined)
|
|
5162
|
+
return false;
|
|
4908
5163
|
// From here a failed durable handoff is an error, never ordinary fallback.
|
|
4909
5164
|
this.deliveryStore.workItemReplies.admit(inbox.id, binding, report);
|
|
4910
5165
|
return true;
|
|
@@ -5115,7 +5370,7 @@ export class AssistantDeliveryService extends Service {
|
|
|
5115
5370
|
throw new AssistantDeliveryError('runtime-conflict', 'invalid notification outcome lookup');
|
|
5116
5371
|
}
|
|
5117
5372
|
return Object.freeze({ protocol: NOTIFICATION_OUTCOME_FACTS_PROTOCOL,
|
|
5118
|
-
facts: this.
|
|
5373
|
+
facts: this.notificationOutcomeFacts([...new Set(input.outboxIds)]), hasMore: false, nextAfterSequence: null });
|
|
5119
5374
|
}
|
|
5120
5375
|
const afterSequence = input.afterSequence ?? 0;
|
|
5121
5376
|
const limit = input.limit ?? 100;
|
|
@@ -5123,10 +5378,24 @@ export class AssistantDeliveryService extends Service {
|
|
|
5123
5378
|
throw new AssistantDeliveryError('runtime-conflict', 'invalid notification outcome scan');
|
|
5124
5379
|
}
|
|
5125
5380
|
const page = this.deliveryStore.attentionAudit.list(afterSequence, limit);
|
|
5126
|
-
const facts = this.
|
|
5381
|
+
const facts = this.notificationOutcomeFacts(page.entries.map(entry => entry.outboxId));
|
|
5127
5382
|
return Object.freeze({ protocol: NOTIFICATION_OUTCOME_FACTS_PROTOCOL, facts, hasMore: page.hasMore,
|
|
5128
5383
|
nextAfterSequence: page.entries.length === 0 ? null : page.entries.at(-1).sequence });
|
|
5129
5384
|
}
|
|
5385
|
+
/**
|
|
5386
|
+
* Attach the registered adapter's read-receipt completeness to each fact.
|
|
5387
|
+
* The Delivery store owns transport facts but not adapter capabilities; only
|
|
5388
|
+
* this read boundary can combine them. An undeclared capability stays absent
|
|
5389
|
+
* so older Evaluation classifiers keep their pinned v1 digests.
|
|
5390
|
+
*/
|
|
5391
|
+
notificationOutcomeFacts(outboxIds) {
|
|
5392
|
+
return this.deliveryStore.attentionAudit.outcomeFactsFor(outboxIds).map(fact => {
|
|
5393
|
+
const binding = this.deliveryStore.getBinding(fact.bindingId);
|
|
5394
|
+
const readReceipts = binding === undefined ? undefined
|
|
5395
|
+
: this.registry.get(binding.conversation.channel, binding.conversation.account)?.capabilities.readReceipts;
|
|
5396
|
+
return readReceipts === undefined ? fact : Object.freeze({ ...fact, readReceipts });
|
|
5397
|
+
});
|
|
5398
|
+
}
|
|
5130
5399
|
/**
|
|
5131
5400
|
* Read-only WP19 stage-2c progress audit page in append order; never exposes
|
|
5132
5401
|
* progress text. Entries are process-local (`durable: false`), bounded, and
|
|
@@ -5496,6 +5765,148 @@ export class AssistantDeliveryService extends Service {
|
|
|
5496
5765
|
return undefined;
|
|
5497
5766
|
return Object.freeze({ outboxId: record.id, status: record.status });
|
|
5498
5767
|
}
|
|
5768
|
+
/**
|
|
5769
|
+
* Register the slice-5 reply consumer for one owner-notice source (see
|
|
5770
|
+
* `OwnerNotificationReply`). The service in the `authority` slot must vouch
|
|
5771
|
+
* for the exact object; one live consumer per (authority, source). A
|
|
5772
|
+
* registration its authority no longer vouches for is superseded by the
|
|
5773
|
+
* next vouched one, so a restarted owner regains its replies without a new
|
|
5774
|
+
* notice. Registering never sends anything and grants no send authority.
|
|
5775
|
+
*/
|
|
5776
|
+
registerOwnerNotificationReply(registration) {
|
|
5777
|
+
this.assertActive();
|
|
5778
|
+
if (!registration || registration.protocol !== ownerNotificationReplyProtocol
|
|
5779
|
+
|| typeof registration.authority !== 'string' || !ownerNotificationAuthorityPattern.test(registration.authority)
|
|
5780
|
+
|| typeof registration.sourceId !== 'string' || typeof registration.idempotencyKeyPrefix !== 'string'
|
|
5781
|
+
|| registration.idempotencyKeyPrefix.length < 1 || registration.idempotencyKeyPrefix.length > 128
|
|
5782
|
+
|| typeof registration.consume !== 'function') {
|
|
5783
|
+
throw new AssistantDeliveryError('runtime-conflict', 'invalid owner notification reply consumer');
|
|
5784
|
+
}
|
|
5785
|
+
const authority = registration.authority;
|
|
5786
|
+
const sourceId = canonicalBackgroundSourceId(registration.sourceId);
|
|
5787
|
+
if (!this.replyVouched(authority, registration))
|
|
5788
|
+
throw new AssistantDeliveryError('runtime-conflict', 'owner notification reply is not owned by its authority');
|
|
5789
|
+
const key = ownerNotificationWithdrawalKey(authority, sourceId);
|
|
5790
|
+
const existing = this.ownerNotificationReplies.get(key);
|
|
5791
|
+
if (existing !== undefined) {
|
|
5792
|
+
if (this.replyVouched(existing.authority, existing.registration))
|
|
5793
|
+
throw new AssistantDeliveryError('runtime-conflict', 'owner notification reply already registered');
|
|
5794
|
+
this.ownerNotificationReplies.delete(key);
|
|
5795
|
+
}
|
|
5796
|
+
const entry = Object.freeze({ authority, sourceId, registration });
|
|
5797
|
+
this.ownerNotificationReplies.set(key, entry);
|
|
5798
|
+
let live = true;
|
|
5799
|
+
return Object.freeze({ unregister: () => {
|
|
5800
|
+
if (!live)
|
|
5801
|
+
return;
|
|
5802
|
+
live = false;
|
|
5803
|
+
if (this.ownerNotificationReplies.get(key) === entry)
|
|
5804
|
+
this.ownerNotificationReplies.delete(key);
|
|
5805
|
+
} });
|
|
5806
|
+
}
|
|
5807
|
+
/** The service now in `authority`'s slot vouches for this exact reply registration. */
|
|
5808
|
+
replyVouched(authority, registration) {
|
|
5809
|
+
try {
|
|
5810
|
+
const owner = this.context.get(authority, false);
|
|
5811
|
+
return typeof owner?.ownsOwnerNotificationReply === 'function' && owner.ownsOwnerNotificationReply(registration) === true;
|
|
5812
|
+
}
|
|
5813
|
+
catch {
|
|
5814
|
+
return false;
|
|
5815
|
+
}
|
|
5816
|
+
}
|
|
5817
|
+
/**
|
|
5818
|
+
* Slice 5 matcher: bind one authenticated inbound text reply to exactly one
|
|
5819
|
+
* accepted/delivered/read owner notice through `replyToProviderMessageId`,
|
|
5820
|
+
* require the live registered consumer's source and idempotency-key prefix,
|
|
5821
|
+
* an active owner binding/principal with the notice's frozen owner lineage,
|
|
5822
|
+
* and an `allow` external `answer-attention-question` Policy decision. On
|
|
5823
|
+
* `applied` the Inbox is marked processed; `transient` leaves it pending for
|
|
5824
|
+
* a retry. Everything else falls through to ordinary inbound handling.
|
|
5825
|
+
*/
|
|
5826
|
+
inspectOwnerQuestionReply(inbox, binding) {
|
|
5827
|
+
this.assertActive();
|
|
5828
|
+
const envelope = inbox.envelope;
|
|
5829
|
+
const replyTo = envelope.metadata?.replyToProviderMessageId;
|
|
5830
|
+
if (typeof replyTo !== 'string' || replyTo.length === 0 || envelope.kind !== 'text' || (envelope.attachments?.length ?? 0) > 0)
|
|
5831
|
+
return 'none';
|
|
5832
|
+
if (typeof envelope.text !== 'string' || envelope.text.length === 0 || Buffer.byteLength(envelope.text, 'utf8') > 256)
|
|
5833
|
+
return 'none';
|
|
5834
|
+
// The reply must come from the active owner binding itself, not another
|
|
5835
|
+
// conversation that happens to quote the provider message id.
|
|
5836
|
+
if (binding.status !== 'active' || externalPrincipalId(binding.principal) !== externalPrincipalId(envelope.principal)
|
|
5837
|
+
|| workItemReplyCanonical(envelope.conversation) !== workItemReplyCanonical(binding.conversation))
|
|
5838
|
+
return 'none';
|
|
5839
|
+
const active = this.deliveryStore.getActiveBinding(binding.conversation);
|
|
5840
|
+
if (active?.id !== binding.id)
|
|
5841
|
+
return 'none';
|
|
5842
|
+
const owner = this.deliveryStore.getPrincipal(binding.principal);
|
|
5843
|
+
if (owner?.status !== 'active' || owner.role !== 'owner')
|
|
5844
|
+
return 'none';
|
|
5845
|
+
let outbox;
|
|
5846
|
+
try {
|
|
5847
|
+
outbox = this.deliveryStore.getUniqueOutboxByProviderMessage(inbox.channel, inbox.account, replyTo);
|
|
5848
|
+
}
|
|
5849
|
+
catch {
|
|
5850
|
+
return 'none';
|
|
5851
|
+
}
|
|
5852
|
+
if (outbox === undefined || !['accepted', 'delivered', 'read'].includes(outbox.status))
|
|
5853
|
+
return 'none';
|
|
5854
|
+
const metadata = outbox.intent.metadata;
|
|
5855
|
+
if (metadata?.['dsh.native-notice'] !== 'v1' || typeof metadata['dsh.native-notice.sourceId'] !== 'string'
|
|
5856
|
+
|| typeof metadata['dsh.native-notice.withdrawalAuthority'] !== 'string')
|
|
5857
|
+
return 'none';
|
|
5858
|
+
let sourceId;
|
|
5859
|
+
try {
|
|
5860
|
+
sourceId = canonicalBackgroundSourceId(metadata['dsh.native-notice.sourceId']);
|
|
5861
|
+
}
|
|
5862
|
+
catch {
|
|
5863
|
+
return 'none';
|
|
5864
|
+
}
|
|
5865
|
+
const entry = this.ownerNotificationReplies.get(ownerNotificationWithdrawalKey(metadata['dsh.native-notice.withdrawalAuthority'], sourceId));
|
|
5866
|
+
if (entry === undefined || !this.replyVouched(entry.authority, entry.registration))
|
|
5867
|
+
return 'none';
|
|
5868
|
+
const registration = entry.registration;
|
|
5869
|
+
if (!outbox.intent.idempotencyKey.startsWith(registration.idempotencyKeyPrefix))
|
|
5870
|
+
return 'none';
|
|
5871
|
+
if (workItemReplyCanonical(outbox.intent.target.conversation) !== workItemReplyCanonical(envelope.conversation)
|
|
5872
|
+
|| workItemReplyCanonical(outbox.intent.target.principal) !== workItemReplyCanonical(envelope.principal))
|
|
5873
|
+
return 'none';
|
|
5874
|
+
// The notice froze an exact owner row; a re-paired/replaced owner does not
|
|
5875
|
+
// inherit permission to answer the old owner's question.
|
|
5876
|
+
if (metadata['dsh.native-notice.ownerRecordId'] !== owner.id
|
|
5877
|
+
|| metadata['dsh.native-notice.ownerVersion'] !== String(owner.version))
|
|
5878
|
+
return 'none';
|
|
5879
|
+
const decision = this.policy.evaluate({
|
|
5880
|
+
subject: { kind: 'external', id: externalPrincipalId(binding.principal), workspace: binding.workspace, principal: externalPrincipalId(binding.principal) },
|
|
5881
|
+
action: 'answer-attention-question', resource: { kind: 'message', id: binding.id }, context: { initiator: 'external' },
|
|
5882
|
+
});
|
|
5883
|
+
if (decision.effect !== 'allow')
|
|
5884
|
+
return 'none';
|
|
5885
|
+
let outcome;
|
|
5886
|
+
try {
|
|
5887
|
+
outcome = registration.consume(Object.freeze({
|
|
5888
|
+
inboxId: inbox.id, envelopeHash: inbox.envelopeHash, outboxId: outbox.id, providerMessageId: replyTo,
|
|
5889
|
+
idempotencyKey: outbox.intent.idempotencyKey, sourceId, principalId: externalPrincipalId(binding.principal),
|
|
5890
|
+
text: envelope.text, receivedAt: inbox.receivedAt,
|
|
5891
|
+
}));
|
|
5892
|
+
}
|
|
5893
|
+
catch {
|
|
5894
|
+
return 'transient';
|
|
5895
|
+
}
|
|
5896
|
+
if (outcome === 'ignored')
|
|
5897
|
+
return 'none';
|
|
5898
|
+
if (outcome !== 'applied')
|
|
5899
|
+
return 'transient';
|
|
5900
|
+
try {
|
|
5901
|
+
this.deliveryStore.markInboxProcessed(inbox.id, binding.id);
|
|
5902
|
+
}
|
|
5903
|
+
catch {
|
|
5904
|
+
// The consumer's own durable effect already committed; if recording that
|
|
5905
|
+
// here fails, never fall through and send the authorization to the Agent.
|
|
5906
|
+
return this.deliveryStore.getInbox(inbox.id)?.status === 'processed' ? 'handled' : 'transient';
|
|
5907
|
+
}
|
|
5908
|
+
return 'handled';
|
|
5909
|
+
}
|
|
5499
5910
|
/**
|
|
5500
5911
|
* Dedicated Automation result lane. Every learning field is derived from an
|
|
5501
5912
|
* authoritative Automations receipt; callers cannot supply metadata,
|
|
@@ -7181,6 +7592,13 @@ export class AssistantDeliveryService extends Service {
|
|
|
7181
7592
|
if (binding === undefined)
|
|
7182
7593
|
continue;
|
|
7183
7594
|
if (!reset && !stop) {
|
|
7595
|
+
// A pending owner-question reply is finished (or held for a retry)
|
|
7596
|
+
// before it can be queued to the Agent.
|
|
7597
|
+
const attentionReply = this.inspectOwnerQuestionReply(pending, binding);
|
|
7598
|
+
if (attentionReply === 'handled')
|
|
7599
|
+
continue;
|
|
7600
|
+
if (attentionReply === 'transient')
|
|
7601
|
+
return;
|
|
7184
7602
|
this.deliveryStore.queueInbox(pending.id, binding.id);
|
|
7185
7603
|
continue;
|
|
7186
7604
|
}
|