@indexnetwork/protocol 6.8.0-rc.395.1 → 6.10.0-rc.397.1
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/CHANGELOG.md +1 -0
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/negotiation/negotiation.agent.d.ts +3 -1
- package/dist/negotiation/negotiation.agent.d.ts.map +1 -1
- package/dist/negotiation/negotiation.agent.js +4 -1
- package/dist/negotiation/negotiation.agent.js.map +1 -1
- package/dist/negotiation/negotiation.consultation-policy.d.ts +40 -0
- package/dist/negotiation/negotiation.consultation-policy.d.ts.map +1 -0
- package/dist/negotiation/negotiation.consultation-policy.js +69 -0
- package/dist/negotiation/negotiation.consultation-policy.js.map +1 -0
- package/dist/negotiation/negotiation.graph.d.ts +86 -28
- package/dist/negotiation/negotiation.graph.d.ts.map +1 -1
- package/dist/negotiation/negotiation.graph.js +311 -81
- package/dist/negotiation/negotiation.graph.js.map +1 -1
- package/dist/negotiation/negotiation.question-safety.d.ts +26 -0
- package/dist/negotiation/negotiation.question-safety.d.ts.map +1 -0
- package/dist/negotiation/negotiation.question-safety.js +56 -0
- package/dist/negotiation/negotiation.question-safety.js.map +1 -0
- package/dist/negotiation/negotiation.state.d.ts +25 -2
- package/dist/negotiation/negotiation.state.d.ts.map +1 -1
- package/dist/negotiation/negotiation.state.js +35 -0
- package/dist/negotiation/negotiation.state.js.map +1 -1
- package/dist/opportunity/opportunity.graph.d.ts +12 -1
- package/dist/opportunity/opportunity.graph.d.ts.map +1 -1
- package/dist/opportunity/opportunity.graph.js +43 -5
- package/dist/opportunity/opportunity.graph.js.map +1 -1
- package/dist/opportunity/opportunity.state.d.ts +5 -2
- package/dist/opportunity/opportunity.state.d.ts.map +1 -1
- package/dist/opportunity/opportunity.state.js +5 -0
- package/dist/opportunity/opportunity.state.js.map +1 -1
- package/dist/questioner/questioner.agent.d.ts +1 -1
- package/dist/questioner/questioner.agent.d.ts.map +1 -1
- package/dist/questioner/questioner.agent.js +5 -0
- package/dist/questioner/questioner.agent.js.map +1 -1
- package/dist/questioner/questioner.presets.d.ts.map +1 -1
- package/dist/questioner/questioner.presets.js +9 -13
- package/dist/questioner/questioner.presets.js.map +1 -1
- package/dist/questioner/questioner.types.d.ts +47 -10
- package/dist/questioner/questioner.types.d.ts.map +1 -1
- package/dist/questioner/questioner.types.js +61 -1
- package/dist/questioner/questioner.types.js.map +1 -1
- package/dist/shared/agent/tool.factory.d.ts.map +1 -1
- package/dist/shared/agent/tool.factory.js +1 -0
- package/dist/shared/agent/tool.factory.js.map +1 -1
- package/dist/shared/interfaces/agent-dispatcher.interface.d.ts +3 -0
- package/dist/shared/interfaces/agent-dispatcher.interface.d.ts.map +1 -1
- package/dist/shared/interfaces/agent-dispatcher.interface.js.map +1 -1
- package/dist/shared/interfaces/database.interface.d.ts +81 -6
- package/dist/shared/interfaces/database.interface.d.ts.map +1 -1
- package/dist/shared/interfaces/database.interface.js.map +1 -1
- package/dist/shared/interfaces/negotiation-events.interface.d.ts +14 -2
- package/dist/shared/interfaces/negotiation-events.interface.d.ts.map +1 -1
- package/dist/shared/interfaces/negotiation-events.interface.js.map +1 -1
- package/dist/shared/schemas/question.schema.d.ts +347 -28
- package/dist/shared/schemas/question.schema.d.ts.map +1 -1
- package/dist/shared/schemas/question.schema.js +132 -2
- package/dist/shared/schemas/question.schema.js.map +1 -1
- package/package.json +1 -1
|
@@ -4,9 +4,11 @@ import { requestContext } from "../shared/observability/request-context.js";
|
|
|
4
4
|
import { NegotiationGraphState } from "./negotiation.state.js";
|
|
5
5
|
import { IndexNegotiator } from "./negotiation.agent.js";
|
|
6
6
|
import { ASK_USER_LOCK_SLACK_MS, allowedActionsFor, askUserAnswerWindowMs, configuredAskUserEnabled, configuredProtocolVersion, fallbackActionFor, isRejectLikeAction, isTerminalAction, readProtocolVersion, rejectActionFor } from "./negotiation.protocol.js";
|
|
7
|
+
import { assessConsultationEligibility, consultationPromptFor, negotiationConsultationPolicyMode } from "./negotiation.consultation-policy.js";
|
|
7
8
|
import { NegotiationScreener, configuredScreenMode } from "./negotiation.screen.js";
|
|
8
9
|
import { assessDeadlock, configuredDeadlockShiftEnabled, configuredDeadlockThreshold } from "./negotiation.deadlock.js";
|
|
9
10
|
import { protocolLogger } from "../shared/observability/protocol.logger.js";
|
|
11
|
+
import { NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY, NEGOTIATION_QUESTION_GENERIC_NETWORK, negotiationQuestionSettlementId, validateInflightAskUserFields } from './negotiation.question-safety.js';
|
|
10
12
|
const logger = protocolLogger("NegotiationGraph");
|
|
11
13
|
const initLog = protocolLogger("NegotiationGraph:Init");
|
|
12
14
|
const screenNodeLog = protocolLogger("NegotiationGraph:Screen");
|
|
@@ -108,10 +110,14 @@ export class NegotiationGraphFactory {
|
|
|
108
110
|
].filter(Boolean).join("\n");
|
|
109
111
|
const initNode = async (state) => {
|
|
110
112
|
try {
|
|
111
|
-
//
|
|
113
|
+
// Exact continuations reuse the prior conversation and preclaimed
|
|
114
|
+
// successor; they must not create any mutable state before the fence.
|
|
112
115
|
const agentIdA = `agent:${state.sourceUser.id}`;
|
|
113
116
|
const agentIdB = `agent:${state.candidateUser.id}`;
|
|
114
|
-
const
|
|
117
|
+
const execution = state.continuationExecution;
|
|
118
|
+
const conversation = execution
|
|
119
|
+
? { id: execution.conversationId }
|
|
120
|
+
: await database.getOrCreateDM(agentIdA, agentIdB, 'agent');
|
|
115
121
|
// --- Lock gate: check for an active task on this conversation ---
|
|
116
122
|
const priorMessages = await database.getMessagesForConversation(conversation.id);
|
|
117
123
|
const activeStates = ['submitted', 'working', 'input_required', 'waiting_for_agent', 'claimed'];
|
|
@@ -128,10 +134,37 @@ export class NegotiationGraphFactory {
|
|
|
128
134
|
: 5 * 60 * 1000;
|
|
129
135
|
return (Date.now() - new Date(t.updatedAt).getTime()) < freshnessMs;
|
|
130
136
|
};
|
|
131
|
-
|
|
132
|
-
|
|
137
|
+
if (Boolean(state.resumeFromTaskId) !== Boolean(state.continuationSettlementId)
|
|
138
|
+
|| Boolean(state.resumeFromTaskId) !== Boolean(execution))
|
|
139
|
+
return { error: 'invalid continuation correlation' };
|
|
140
|
+
const exactContinuation = state.resumeFromTaskId && state.continuationSettlementId && execution
|
|
141
|
+
? { taskId: state.resumeFromTaskId, settlementId: state.continuationSettlementId, execution }
|
|
133
142
|
: null;
|
|
134
|
-
const
|
|
143
|
+
const priorTask = exactContinuation
|
|
144
|
+
? await database.getTask(exactContinuation.taskId)
|
|
145
|
+
: state.opportunityId
|
|
146
|
+
? await database.getNegotiationTaskForOpportunity(state.opportunityId)
|
|
147
|
+
: null;
|
|
148
|
+
const claimedSuccessor = exactContinuation
|
|
149
|
+
? await database.getTask(exactContinuation.execution.successorTaskId)
|
|
150
|
+
: null;
|
|
151
|
+
if (exactContinuation) {
|
|
152
|
+
const settlement = priorTask?.metadata?.questionSettlement;
|
|
153
|
+
const storedExecution = claimedSuccessor?.metadata?.continuationExecution;
|
|
154
|
+
if (!priorTask
|
|
155
|
+
|| priorTask.conversationId !== conversation.id
|
|
156
|
+
|| priorTask.state !== 'canceled'
|
|
157
|
+
|| priorTask.metadata?.opportunityId !== state.opportunityId
|
|
158
|
+
|| settlement?.settlementId !== exactContinuation.settlementId
|
|
159
|
+
|| settlement?.taskId !== exactContinuation.taskId
|
|
160
|
+
|| !claimedSuccessor
|
|
161
|
+
|| claimedSuccessor.conversationId !== conversation.id
|
|
162
|
+
|| storedExecution?.token !== exactContinuation.execution.token
|
|
163
|
+
|| storedExecution?.fence !== exactContinuation.execution.fence
|
|
164
|
+
|| storedExecution?.status !== 'claimed')
|
|
165
|
+
return { error: 'invalid exact continuation task' };
|
|
166
|
+
}
|
|
167
|
+
const isLocked = !exactContinuation && !!priorTask && isActiveAndFresh(priorTask);
|
|
135
168
|
if (isLocked) {
|
|
136
169
|
initLog.info('Conversation locked by active task, returning busy', {
|
|
137
170
|
conversationId: conversation.id,
|
|
@@ -186,7 +219,7 @@ export class NegotiationGraphFactory {
|
|
|
186
219
|
// Conversation-scoped prior task: reused for both the initiator tie-break
|
|
187
220
|
// (only when active+fresh) and protocol-version inheritance (any prior
|
|
188
221
|
// task on the conversation pins the version).
|
|
189
|
-
const convTask = (!readInitiator(priorTask?.metadata) || !readProtocolVersion(priorTask?.metadata))
|
|
222
|
+
const convTask = !exactContinuation && (!readInitiator(priorTask?.metadata) || !readProtocolVersion(priorTask?.metadata))
|
|
190
223
|
? await database.getLatestNegotiationTaskForConversation?.(conversation.id).catch(() => null)
|
|
191
224
|
: null;
|
|
192
225
|
if (!readInitiator(priorTask?.metadata)) {
|
|
@@ -227,24 +260,36 @@ export class NegotiationGraphFactory {
|
|
|
227
260
|
protocolVersion,
|
|
228
261
|
candidateUserId: state.candidateUser.id,
|
|
229
262
|
networkId: state.indexContext.networkId,
|
|
263
|
+
sourceIntentId: state.sourceIntentId,
|
|
264
|
+
candidateIntentId: state.candidateIntentId,
|
|
265
|
+
participantBindings: [
|
|
266
|
+
...(state.sourceIntentId ? [{ userId: state.sourceUser.id, intentId: state.sourceIntentId, networkId: state.indexContext.networkId }] : []),
|
|
267
|
+
...(state.candidateIntentId ? [{ userId: state.candidateUser.id, intentId: state.candidateIntentId, networkId: state.indexContext.networkId }] : []),
|
|
268
|
+
],
|
|
230
269
|
intentSnapshots: buildIntentSnapshots(state.sourceUser, state.candidateUser),
|
|
231
270
|
...(state.opportunityId && { opportunityId: state.opportunityId }),
|
|
232
271
|
maxTurns,
|
|
233
272
|
isContinuation,
|
|
234
273
|
priorTurnCount: priorTurns.length,
|
|
274
|
+
...(exactContinuation ? {
|
|
275
|
+
resumeFromTaskId: exactContinuation.taskId,
|
|
276
|
+
continuationSettlementId: exactContinuation.settlementId,
|
|
277
|
+
} : {}),
|
|
235
278
|
};
|
|
236
279
|
if (state.opportunityId && Boolean(state.opportunityStatus) !== Boolean(state.opportunityUpdatedAt)) {
|
|
237
280
|
throw new Error('Negotiation attempt requires both opportunity status and updatedAt');
|
|
238
281
|
}
|
|
239
|
-
const task =
|
|
240
|
-
?
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
282
|
+
const task = exactContinuation
|
|
283
|
+
? claimedSuccessor
|
|
284
|
+
: state.opportunityId && state.opportunityStatus && state.opportunityUpdatedAt
|
|
285
|
+
? await database.createNegotiationTaskForAttempt({
|
|
286
|
+
conversationId: conversation.id,
|
|
287
|
+
opportunityId: state.opportunityId,
|
|
288
|
+
expectedStatus: state.opportunityStatus,
|
|
289
|
+
expectedUpdatedAt: state.opportunityUpdatedAt,
|
|
290
|
+
metadata: taskMetadata,
|
|
291
|
+
})
|
|
292
|
+
: await database.createTask(conversation.id, taskMetadata);
|
|
248
293
|
if (!task) {
|
|
249
294
|
throw new Error('Negotiation attempt is stale or already claimed');
|
|
250
295
|
}
|
|
@@ -282,6 +327,14 @@ export class NegotiationGraphFactory {
|
|
|
282
327
|
protocolVersion,
|
|
283
328
|
priorTurnCount: priorTurns.length,
|
|
284
329
|
...(userAnswers.length > 0 && { userAnswers }),
|
|
330
|
+
...(exactContinuation?.execution.consultation
|
|
331
|
+
? { privateConsultation: exactContinuation.execution.consultation }
|
|
332
|
+
: {}),
|
|
333
|
+
...(exactContinuation && (() => {
|
|
334
|
+
const turnContext = priorTask?.metadata?.turnContext;
|
|
335
|
+
const reason = turnContext?.consultationPolicyReason;
|
|
336
|
+
return typeof reason === 'string' ? { consultationPolicyReason: reason } : {};
|
|
337
|
+
})()),
|
|
285
338
|
...(seedMessages.length > 0 && { messages: seedMessages }),
|
|
286
339
|
};
|
|
287
340
|
}
|
|
@@ -360,7 +413,7 @@ export class NegotiationGraphFactory {
|
|
|
360
413
|
screenedAt: new Date().toISOString(),
|
|
361
414
|
durationMs,
|
|
362
415
|
};
|
|
363
|
-
await database.setTaskScreenDecision?.(state.taskId, record).catch((err) => {
|
|
416
|
+
await database.setTaskScreenDecision?.(state.taskId, record, state.continuationExecution).catch((err) => {
|
|
364
417
|
screenNodeLog.error("Failed to persist screen decision", { taskId: state.taskId, error: err });
|
|
365
418
|
});
|
|
366
419
|
screenNodeLog.info("negotiation_screen", {
|
|
@@ -410,6 +463,7 @@ export class NegotiationGraphFactory {
|
|
|
410
463
|
const isSource = state.currentSpeaker === "source";
|
|
411
464
|
const ownUser = isSource ? state.sourceUser : state.candidateUser;
|
|
412
465
|
const otherUser = isSource ? state.candidateUser : state.sourceUser;
|
|
466
|
+
const ownIntentId = isSource ? state.sourceIntentId : state.candidateIntentId;
|
|
413
467
|
// Determine if this is the system agent's final allowed turn
|
|
414
468
|
const maxTurns = state.maxTurns ?? 0;
|
|
415
469
|
const isFinalTurn = maxTurns > 0 && (state.turnCount + 1) >= maxTurns;
|
|
@@ -420,19 +474,20 @@ export class NegotiationGraphFactory {
|
|
|
420
474
|
const seat = ownUser.id === (state.initiatorUserId ?? state.sourceUser.id)
|
|
421
475
|
? 'initiator'
|
|
422
476
|
: 'counterparty';
|
|
423
|
-
// ask_user availability (P3.2): flag on, full pause loop wired
|
|
477
|
+
// Legacy ask_user availability (P3.2): flag on, full pause loop wired
|
|
424
478
|
// (questioner + answer-window timer + an opportunity to resume
|
|
425
479
|
// against), v2 non-final non-opening turn, and this side's one client
|
|
426
|
-
// consultation not yet spent (rationing).
|
|
427
|
-
//
|
|
428
|
-
|
|
429
|
-
// continuation.
|
|
480
|
+
// consultation not yet spent (rationing). Shadow is observational and
|
|
481
|
+
// must preserve this legacy path byte-for-byte except for telemetry.
|
|
482
|
+
const policyMode = negotiationConsultationPolicyMode();
|
|
430
483
|
const askUserAvailable = version === 'v2'
|
|
431
484
|
&& !isFinalTurn
|
|
432
485
|
&& configuredAskUserEnabled()
|
|
433
486
|
&& !!questionerEnqueue
|
|
434
487
|
&& !!timeoutQueue?.enqueueAskUserExpiry
|
|
435
488
|
&& !!state.opportunityId
|
|
489
|
+
&& !!ownIntentId
|
|
490
|
+
&& !!state.indexContext.networkId
|
|
436
491
|
&& !(state.turnCount === 0 && !state.isContinuation)
|
|
437
492
|
&& !hasPriorAskUser(state.messages, ownUser.id);
|
|
438
493
|
// ─── Deadlock detection → persuasion→bargaining stance (IND-428) ──────
|
|
@@ -479,6 +534,9 @@ export class NegotiationGraphFactory {
|
|
|
479
534
|
allowedActions: [...allowedActionsFor(version, seat, isFinalTurn, { askUser: askUserAvailable })],
|
|
480
535
|
...(state.discoveryQuery && isSource && { discoveryQuery: state.discoveryQuery }),
|
|
481
536
|
...(ownMemory.length > 0 && { negotiatorMemory: ownMemory }),
|
|
537
|
+
...(state.privateConsultation?.recipientUserId === ownUser.id
|
|
538
|
+
? { privateConsultation: state.privateConsultation }
|
|
539
|
+
: {}),
|
|
482
540
|
};
|
|
483
541
|
const scope = { action: 'manage:negotiations', scopeType: 'network', scopeId: state.indexContext.networkId };
|
|
484
542
|
const dispatchResult = await dispatcher.dispatch(ownUser.id, scope, payload, { timeoutMs: state.timeoutMs });
|
|
@@ -512,8 +570,11 @@ export class NegotiationGraphFactory {
|
|
|
512
570
|
// candidate-side turns would make the pickup prompt frame the
|
|
513
571
|
// search as "your user searched for X" for the wrong user.
|
|
514
572
|
...(isSource && state.discoveryQuery && { discoveryQuery: state.discoveryQuery }),
|
|
515
|
-
|
|
516
|
-
|
|
573
|
+
...(state.privateConsultation && state.privateConsultation.recipientUserId === ownUser.id
|
|
574
|
+
? { privateConsultation: state.privateConsultation }
|
|
575
|
+
: {}),
|
|
576
|
+
}, state.continuationExecution);
|
|
577
|
+
await database.updateTaskState(state.taskId, "waiting_for_agent", undefined, state.continuationExecution);
|
|
517
578
|
return { status: 'waiting_for_agent' };
|
|
518
579
|
}
|
|
519
580
|
else {
|
|
@@ -534,6 +595,9 @@ export class NegotiationGraphFactory {
|
|
|
534
595
|
...(askUserAvailable && { canAskUser: true }),
|
|
535
596
|
...(bargainingMode && { bargaining: { consecutiveNonConvergent: deadlock.consecutiveNonConvergent } }),
|
|
536
597
|
...(ownMemory.length > 0 && { memory: ownMemory }),
|
|
598
|
+
...(state.privateConsultation?.recipientUserId === ownUser.id
|
|
599
|
+
? { privateConsultation: state.privateConsultation }
|
|
600
|
+
: {}),
|
|
537
601
|
});
|
|
538
602
|
}
|
|
539
603
|
traceEmitter?.({ type: "agent_end", name: agentName, durationMs: Date.now() - agentStart, summary: `${turn.action}` });
|
|
@@ -548,11 +612,55 @@ export class NegotiationGraphFactory {
|
|
|
548
612
|
turn.action = openingAction;
|
|
549
613
|
}
|
|
550
614
|
}
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
|
|
615
|
+
// IND-508 deterministic admission is evaluated only after the opening
|
|
616
|
+
// guard but before any turn is persisted. It consumes action/role enums
|
|
617
|
+
// only; free-form reasoning, messages, profiles, and evaluator inputs
|
|
618
|
+
// are intentionally unavailable to the policy.
|
|
619
|
+
let consultationPolicyReason;
|
|
620
|
+
const policyEligibility = policyMode === 'off' ? { eligible: false } : assessConsultationEligibility({
|
|
621
|
+
protocolVersion: version,
|
|
622
|
+
seat,
|
|
623
|
+
isOpeningTurn: state.turnCount === 0 && !state.isContinuation,
|
|
624
|
+
isFinalTurn,
|
|
625
|
+
screenedOut: isScreenBlocked(state),
|
|
626
|
+
action: turn.action,
|
|
627
|
+
ownSuggestedRole: turn.assessment?.suggestedRoles?.ownUser,
|
|
628
|
+
priorActions: history.map((prior) => prior.action),
|
|
629
|
+
previouslyConsulted: hasPriorAskUser(state.messages, ownUser.id),
|
|
630
|
+
hasExactResumeCoordinate: Boolean(configuredAskUserEnabled()
|
|
631
|
+
&& questionerEnqueue
|
|
632
|
+
&& timeoutQueue?.enqueueAskUserExpiry
|
|
633
|
+
&& state.taskId
|
|
634
|
+
&& state.opportunityId
|
|
635
|
+
&& ownIntentId
|
|
636
|
+
&& state.indexContext.networkId),
|
|
637
|
+
lifecycleValid: Boolean(state.taskId && state.opportunityId && ownIntentId && state.indexContext.networkId),
|
|
638
|
+
});
|
|
639
|
+
const emitConsultationTelemetry = (stage, reason) => {
|
|
640
|
+
turnLog.info('negotiation_consultation_policy', { stage, mode: policyMode, reason });
|
|
641
|
+
emitWide({ type: 'negotiation_consultation_policy', stage, mode: policyMode, reason });
|
|
642
|
+
};
|
|
643
|
+
if (policyEligibility.eligible && policyEligibility.reason) {
|
|
644
|
+
emitConsultationTelemetry('eligible', policyEligibility.reason);
|
|
645
|
+
if (policyMode === 'on') {
|
|
646
|
+
consultationPolicyReason = policyEligibility.reason;
|
|
647
|
+
turn = {
|
|
648
|
+
...turn,
|
|
649
|
+
action: 'ask_user',
|
|
650
|
+
message: null,
|
|
651
|
+
assessment: {
|
|
652
|
+
reasoning: 'Client consultation required.',
|
|
653
|
+
suggestedRoles: turn.assessment.suggestedRoles,
|
|
654
|
+
},
|
|
655
|
+
askUser: consultationPromptFor(consultationPolicyReason),
|
|
656
|
+
};
|
|
657
|
+
emitConsultationTelemetry('asked', consultationPolicyReason);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
// Safety net: off/shadow retain legacy behavior. In on, a spontaneous
|
|
661
|
+
// ask_user is admissible only when the deterministic policy just
|
|
662
|
+
// authorized it, so no unbounded pause can enter shared history.
|
|
663
|
+
if (turn.action === 'ask_user' && (!askUserAvailable || (policyMode === 'on' && !consultationPolicyReason))) {
|
|
556
664
|
turnLog.warn('ask_user emitted while unavailable, coercing to conservative fallback', {
|
|
557
665
|
seat, isFinalTurn, taskId: state.taskId,
|
|
558
666
|
});
|
|
@@ -576,7 +684,7 @@ export class NegotiationGraphFactory {
|
|
|
576
684
|
seat,
|
|
577
685
|
detectedAt: new Date().toISOString(),
|
|
578
686
|
};
|
|
579
|
-
await database.setTaskDeadlockShift?.(state.taskId, deadlockShiftRecord).catch((err) => {
|
|
687
|
+
await database.setTaskDeadlockShift?.(state.taskId, deadlockShiftRecord, state.continuationExecution).catch((err) => {
|
|
580
688
|
turnLog.error('Failed to persist deadlock shift record', { taskId: state.taskId, error: err });
|
|
581
689
|
});
|
|
582
690
|
turnLog.info('negotiation_deadlock_shift', {
|
|
@@ -606,6 +714,7 @@ export class NegotiationGraphFactory {
|
|
|
606
714
|
role: "agent",
|
|
607
715
|
parts,
|
|
608
716
|
taskId: state.taskId,
|
|
717
|
+
...(state.continuationExecution ? { continuationExecution: state.continuationExecution } : {}),
|
|
609
718
|
});
|
|
610
719
|
// ─── ask_user pause (P3.2) ────────────────────────────────────────────
|
|
611
720
|
// The negotiator consults its OWN client: persist the turn (done above),
|
|
@@ -615,50 +724,103 @@ export class NegotiationGraphFactory {
|
|
|
615
724
|
// like the waiting_for_agent suspend; the answer (or window expiry)
|
|
616
725
|
// resumes via the run-existing continuation path.
|
|
617
726
|
if (turn.action === 'ask_user') {
|
|
618
|
-
const
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
727
|
+
const counterparty = isSource ? state.candidateUser : state.sourceUser;
|
|
728
|
+
const safeAskUser = validateInflightAskUserFields({
|
|
729
|
+
disclosureSubject: turn.askUser?.disclosureSubject,
|
|
730
|
+
draftQuestion: turn.askUser?.draftQuestion,
|
|
731
|
+
forbiddenIdentifiers: [
|
|
732
|
+
counterparty.id,
|
|
733
|
+
counterparty.profile.name ?? '',
|
|
734
|
+
state.opportunityId,
|
|
735
|
+
state.taskId,
|
|
736
|
+
state.indexContext.networkId,
|
|
737
|
+
isSource ? state.candidateIntentId ?? '' : state.sourceIntentId ?? '',
|
|
738
|
+
],
|
|
739
|
+
forbiddenSourceText: [
|
|
740
|
+
counterparty.profile.bio ?? '',
|
|
741
|
+
counterparty.profile.location ?? '',
|
|
742
|
+
...counterparty.intents.flatMap((intent) => [intent.title, intent.description]),
|
|
743
|
+
state.seedAssessment.reasoning,
|
|
744
|
+
state.indexContext.prompt,
|
|
745
|
+
state.discoveryQuery ?? '',
|
|
746
|
+
],
|
|
747
|
+
});
|
|
748
|
+
const settlementId = negotiationQuestionSettlementId(state.taskId);
|
|
749
|
+
const askUserBinding = await database.captureNegotiationAskUserBinding({
|
|
750
|
+
taskId: state.taskId,
|
|
751
|
+
settlementId,
|
|
752
|
+
recipientUserId: ownUser.id,
|
|
753
|
+
recipientIntentId: ownIntentId,
|
|
754
|
+
opportunityId: state.opportunityId,
|
|
755
|
+
networkId: state.indexContext.networkId,
|
|
756
|
+
turnContext: {
|
|
757
|
+
sourceUser: state.sourceUser,
|
|
758
|
+
candidateUser: state.candidateUser,
|
|
759
|
+
indexContext: state.indexContext,
|
|
760
|
+
seedAssessment: state.seedAssessment,
|
|
761
|
+
...(isSource && state.discoveryQuery && { discoveryQuery: state.discoveryQuery }),
|
|
762
|
+
...(consultationPolicyReason && { consultationPolicyReason }),
|
|
763
|
+
},
|
|
764
|
+
...(state.continuationExecution ? { continuationExecution: state.continuationExecution } : {}),
|
|
628
765
|
});
|
|
629
|
-
// Arm the timer BEFORE flipping state:
|
|
630
|
-
//
|
|
631
|
-
// an input_required task without a timer would strand until the lock
|
|
632
|
-
// slack expires.
|
|
766
|
+
// Arm the timer BEFORE flipping state: it is the durable recovery
|
|
767
|
+
// trigger even when generation enqueues no job or persists no row.
|
|
633
768
|
const windowMs = askUserAnswerWindowMs();
|
|
634
769
|
await timeoutQueue.enqueueAskUserExpiry(state.taskId, {
|
|
770
|
+
settlementId,
|
|
635
771
|
opportunityId: state.opportunityId,
|
|
636
772
|
userId: ownUser.id,
|
|
637
|
-
|
|
773
|
+
recipientIntentId: ownIntentId,
|
|
774
|
+
networkId: state.indexContext.networkId,
|
|
775
|
+
intentFingerprint: askUserBinding.intentFingerprint,
|
|
776
|
+
opportunityStatus: askUserBinding.opportunityStatus,
|
|
777
|
+
opportunityUpdatedAt: askUserBinding.opportunityUpdatedAt,
|
|
778
|
+
counterpartyUserId: askUserBinding.counterpartyUserId,
|
|
779
|
+
counterpartyIntentId: askUserBinding.counterpartyIntentId,
|
|
638
780
|
}, windowMs);
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
781
|
+
// Persistence admission requires the exact task to be input_required.
|
|
782
|
+
// Flip before enqueue; if the structured ask_user fields fail the
|
|
783
|
+
// deterministic privacy gate, the timer alone closes the exact task.
|
|
784
|
+
await database.updateTaskState(state.taskId, 'input_required', undefined, state.continuationExecution);
|
|
785
|
+
if (safeAskUser) {
|
|
786
|
+
const userContext = (await database.getUserContext(ownUser.id, null).catch(() => null))?.text ?? '';
|
|
787
|
+
await questionerEnqueue({
|
|
788
|
+
mode: 'negotiation_inflight',
|
|
789
|
+
purpose: 'inflight_consultation',
|
|
790
|
+
userId: ownUser.id,
|
|
791
|
+
sourceType: 'opportunity',
|
|
792
|
+
sourceId: state.opportunityId,
|
|
793
|
+
negotiation: {
|
|
794
|
+
purpose: 'inflight_consultation',
|
|
795
|
+
recipientUserId: ownUser.id,
|
|
796
|
+
recipientIntentId: ownIntentId,
|
|
797
|
+
opportunityId: state.opportunityId,
|
|
798
|
+
taskId: state.taskId,
|
|
799
|
+
networkId: state.indexContext.networkId,
|
|
800
|
+
},
|
|
801
|
+
context: {
|
|
802
|
+
negotiationId: state.taskId,
|
|
803
|
+
counterpartyHint: NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY,
|
|
804
|
+
disclosureSubject: safeAskUser.disclosureSubject,
|
|
805
|
+
...(safeAskUser.draftQuestion && { draftQuestion: safeAskUser.draftQuestion }),
|
|
806
|
+
indexContext: NEGOTIATION_QUESTION_GENERIC_NETWORK,
|
|
807
|
+
...(consultationPolicyReason && { consultationPolicyReason }),
|
|
808
|
+
...(userContext && { userContext }),
|
|
809
|
+
},
|
|
810
|
+
}).catch((error) => {
|
|
811
|
+
turnLog.error('Failed to enqueue safe ask_user question; timeout recovery remains armed', {
|
|
812
|
+
taskId: state.taskId,
|
|
813
|
+
opportunityId: state.opportunityId,
|
|
814
|
+
error,
|
|
815
|
+
});
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
else {
|
|
819
|
+
turnLog.warn('Skipping unsafe or incomplete ask_user question generation', {
|
|
820
|
+
taskId: state.taskId,
|
|
821
|
+
opportunityId: state.opportunityId,
|
|
822
|
+
});
|
|
823
|
+
}
|
|
662
824
|
turnLog.info('negotiation_ask_user_pause', {
|
|
663
825
|
taskId: state.taskId,
|
|
664
826
|
opportunityId: state.opportunityId,
|
|
@@ -673,7 +835,7 @@ export class NegotiationGraphFactory {
|
|
|
673
835
|
negotiationConversationId: state.conversationId,
|
|
674
836
|
turnIndex: state.turnCount,
|
|
675
837
|
actor: isSource ? 'source' : 'candidate',
|
|
676
|
-
|
|
838
|
+
questionGenerationSafe: Boolean(safeAskUser),
|
|
677
839
|
windowMs,
|
|
678
840
|
});
|
|
679
841
|
return {
|
|
@@ -690,7 +852,7 @@ export class NegotiationGraphFactory {
|
|
|
690
852
|
...(deadlockShiftRecord && { deadlockShift: deadlockShiftRecord }),
|
|
691
853
|
};
|
|
692
854
|
}
|
|
693
|
-
await database.updateTaskState(state.taskId, "working");
|
|
855
|
+
await database.updateTaskState(state.taskId, "working", undefined, state.continuationExecution);
|
|
694
856
|
if (state.opportunityId) {
|
|
695
857
|
emitWide({
|
|
696
858
|
type: "negotiation_turn",
|
|
@@ -757,6 +919,17 @@ export class NegotiationGraphFactory {
|
|
|
757
919
|
const finalizeNode = async (state) => {
|
|
758
920
|
const traceEmitter = requestContext.getStore()?.traceEmitter;
|
|
759
921
|
const emitWide = (event) => traceEmitter?.(event);
|
|
922
|
+
const pauseReceipt = (outcome) => state.continuationExecution
|
|
923
|
+
? {
|
|
924
|
+
continuationReceipt: {
|
|
925
|
+
priorTaskId: state.continuationExecution.taskId,
|
|
926
|
+
settlementId: state.continuationExecution.settlementId,
|
|
927
|
+
successorTaskId: state.continuationExecution.successorTaskId,
|
|
928
|
+
fence: state.continuationExecution.fence,
|
|
929
|
+
outcome,
|
|
930
|
+
},
|
|
931
|
+
}
|
|
932
|
+
: undefined;
|
|
760
933
|
if (state.status === 'waiting_for_agent') {
|
|
761
934
|
if (state.opportunityId) {
|
|
762
935
|
emitWide({
|
|
@@ -767,7 +940,7 @@ export class NegotiationGraphFactory {
|
|
|
767
940
|
isContinuation: state.isContinuation,
|
|
768
941
|
});
|
|
769
942
|
}
|
|
770
|
-
return {};
|
|
943
|
+
return pauseReceipt('waiting_for_agent') ?? {};
|
|
771
944
|
}
|
|
772
945
|
// ask_user pause: no outcome, no completed state — the task stays
|
|
773
946
|
// input_required until the client answers or the window expires.
|
|
@@ -781,7 +954,7 @@ export class NegotiationGraphFactory {
|
|
|
781
954
|
isContinuation: state.isContinuation,
|
|
782
955
|
});
|
|
783
956
|
}
|
|
784
|
-
return {};
|
|
957
|
+
return pauseReceipt('input_required') ?? {};
|
|
785
958
|
}
|
|
786
959
|
// Init can fail closed before an attempt owns a task. Such a rejection is
|
|
787
960
|
// not a completed negotiation and must not write through an empty task ID,
|
|
@@ -827,12 +1000,21 @@ export class NegotiationGraphFactory {
|
|
|
827
1000
|
: {}),
|
|
828
1001
|
};
|
|
829
1002
|
try {
|
|
830
|
-
await database.updateTaskState(state.taskId, "completed");
|
|
1003
|
+
await database.updateTaskState(state.taskId, "completed", undefined, state.continuationExecution);
|
|
831
1004
|
await database.createArtifact({
|
|
832
1005
|
taskId: state.taskId,
|
|
833
1006
|
name: "negotiation-outcome",
|
|
834
1007
|
parts: [{ kind: "data", data: outcome }],
|
|
835
|
-
metadata: {
|
|
1008
|
+
metadata: {
|
|
1009
|
+
hasOpportunity,
|
|
1010
|
+
turnCount: state.turnCount,
|
|
1011
|
+
...(state.continuationExecution ? {
|
|
1012
|
+
continuationOutcome: hasOpportunity
|
|
1013
|
+
? 'accepted'
|
|
1014
|
+
: (screenedOut || isRejectLikeAction(lastTurn?.action)) ? 'rejected' : 'stalled',
|
|
1015
|
+
} : {}),
|
|
1016
|
+
},
|
|
1017
|
+
...(state.continuationExecution ? { continuationExecution: state.continuationExecution } : {}),
|
|
836
1018
|
});
|
|
837
1019
|
finalizeLog.info('Session complete', {
|
|
838
1020
|
conversationId: state.conversationId,
|
|
@@ -852,7 +1034,7 @@ export class NegotiationGraphFactory {
|
|
|
852
1034
|
: (screenedOut || isRejectLikeAction(lastTurn?.action))
|
|
853
1035
|
? 'rejected'
|
|
854
1036
|
: 'stalled';
|
|
855
|
-
await database.updateOpportunityStatus(state.opportunityId, nextStatus).catch((err) => {
|
|
1037
|
+
await database.updateOpportunityStatus(state.opportunityId, nextStatus, undefined, state.continuationExecution).catch((err) => {
|
|
856
1038
|
finalizeLog.error("Failed to update opportunity status", { opportunityId: state.opportunityId, nextStatus, error: err });
|
|
857
1039
|
});
|
|
858
1040
|
}
|
|
@@ -887,11 +1069,26 @@ export class NegotiationGraphFactory {
|
|
|
887
1069
|
}),
|
|
888
1070
|
});
|
|
889
1071
|
}
|
|
1072
|
+
if (state.consultationPolicyReason) {
|
|
1073
|
+
// Stable, content-free terminal funnel telemetry. This executes only
|
|
1074
|
+
// after a fenced continuation has reached its terminal outcome.
|
|
1075
|
+
finalizeLog.info('negotiation_consultation_policy', {
|
|
1076
|
+
stage: 'terminal_outcome',
|
|
1077
|
+
reason: state.consultationPolicyReason,
|
|
1078
|
+
outcome: hasOpportunity ? 'accepted' : (screenedOut || isRejectLikeAction(lastTurn?.action)) ? 'rejected' : 'stalled',
|
|
1079
|
+
});
|
|
1080
|
+
emitWide({
|
|
1081
|
+
type: 'negotiation_consultation_policy',
|
|
1082
|
+
stage: 'terminal_outcome',
|
|
1083
|
+
reason: state.consultationPolicyReason,
|
|
1084
|
+
outcome: hasOpportunity ? 'accepted' : (screenedOut || isRejectLikeAction(lastTurn?.action)) ? 'rejected' : 'stalled',
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
890
1087
|
// Enqueue post-negotiation reflection (P5.2 memory write path) — fire
|
|
891
1088
|
// and forget: a reflection failure must never affect the outcome. Only
|
|
892
1089
|
// sessions that actually exchanged turns teach anything; init/turn
|
|
893
1090
|
// errors with turnCount 0 are skipped.
|
|
894
|
-
if (reflectEnqueue && state.turnCount > 0) {
|
|
1091
|
+
if (reflectEnqueue && state.turnCount > 0 && !state.continuationExecution) {
|
|
895
1092
|
reflectEnqueue({
|
|
896
1093
|
negotiationId: state.taskId,
|
|
897
1094
|
conversationId: state.conversationId,
|
|
@@ -915,24 +1112,36 @@ export class NegotiationGraphFactory {
|
|
|
915
1112
|
}
|
|
916
1113
|
// Enqueue question generation for stalled/capped negotiations (not accepted or explicitly rejected).
|
|
917
1114
|
// Require turnCount > 0 so early init/turn errors don't enqueue with empty context.
|
|
918
|
-
if (!hasOpportunity && !isRejectLikeAction(lastTurn?.action) && state.turnCount > 0 && state.opportunityId && questionerEnqueue) {
|
|
1115
|
+
if (!hasOpportunity && !isRejectLikeAction(lastTurn?.action) && state.turnCount > 0 && state.opportunityId && state.sourceIntentId && state.indexContext.networkId && questionerEnqueue && !state.continuationExecution) {
|
|
919
1116
|
const stallReason = atCap
|
|
920
1117
|
? 'turn_cap'
|
|
921
1118
|
: (state.error && /timeout/i.test(state.error))
|
|
922
1119
|
? 'timeout'
|
|
923
1120
|
: 'stalled';
|
|
924
1121
|
const userContext = (await database.getUserContext(state.sourceUser.id, null))?.text ?? '';
|
|
1122
|
+
const sourceIntent = state.sourceUser.intents.find((intent) => intent.id === state.sourceIntentId);
|
|
925
1123
|
questionerEnqueue({
|
|
926
1124
|
mode: 'negotiation',
|
|
1125
|
+
purpose: 'stalled_followup',
|
|
927
1126
|
userId: state.sourceUser.id,
|
|
928
1127
|
sourceType: 'opportunity',
|
|
929
1128
|
sourceId: state.opportunityId,
|
|
1129
|
+
negotiation: {
|
|
1130
|
+
purpose: 'stalled_followup',
|
|
1131
|
+
recipientUserId: state.sourceUser.id,
|
|
1132
|
+
recipientIntentId: state.sourceIntentId,
|
|
1133
|
+
opportunityId: state.opportunityId,
|
|
1134
|
+
taskId: state.taskId,
|
|
1135
|
+
networkId: state.indexContext.networkId,
|
|
1136
|
+
},
|
|
930
1137
|
context: {
|
|
931
1138
|
negotiationId: state.taskId,
|
|
932
|
-
counterpartyHint:
|
|
933
|
-
indexContext:
|
|
1139
|
+
counterpartyHint: NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY,
|
|
1140
|
+
indexContext: NEGOTIATION_QUESTION_GENERIC_NETWORK,
|
|
934
1141
|
outcomeReason: stallReason,
|
|
935
|
-
|
|
1142
|
+
recipientIntent: sourceIntent
|
|
1143
|
+
? `${sourceIntent.title}: ${sourceIntent.description}`
|
|
1144
|
+
: 'the signal attached to this match',
|
|
936
1145
|
userContext,
|
|
937
1146
|
},
|
|
938
1147
|
}).catch((err) => finalizeLog.error('Failed to enqueue negotiation question generation', {
|
|
@@ -940,7 +1149,20 @@ export class NegotiationGraphFactory {
|
|
|
940
1149
|
error: err,
|
|
941
1150
|
}));
|
|
942
1151
|
}
|
|
943
|
-
|
|
1152
|
+
const terminalReceipt = state.continuationExecution
|
|
1153
|
+
? {
|
|
1154
|
+
continuationReceipt: {
|
|
1155
|
+
priorTaskId: state.continuationExecution.taskId,
|
|
1156
|
+
settlementId: state.continuationExecution.settlementId,
|
|
1157
|
+
successorTaskId: state.continuationExecution.successorTaskId,
|
|
1158
|
+
fence: state.continuationExecution.fence,
|
|
1159
|
+
outcome: hasOpportunity
|
|
1160
|
+
? 'accepted'
|
|
1161
|
+
: (screenedOut || isRejectLikeAction(lastTurn?.action)) ? 'rejected' : 'stalled',
|
|
1162
|
+
},
|
|
1163
|
+
}
|
|
1164
|
+
: undefined;
|
|
1165
|
+
return { outcome, status: 'completed', ...terminalReceipt };
|
|
944
1166
|
};
|
|
945
1167
|
const workflow = new StateGraph(NegotiationGraphState)
|
|
946
1168
|
.addNode("init", initNode)
|
|
@@ -972,7 +1194,7 @@ export class NegotiationGraphFactory {
|
|
|
972
1194
|
* @returns Only candidates that produced an opportunity
|
|
973
1195
|
*/
|
|
974
1196
|
export async function negotiateCandidates(negotiationGraph, sourceUser, candidates, indexContext, opts) {
|
|
975
|
-
const { maxTurns, traceEmitter, indexContextOverrides, timeoutMs, onCandidateResolved, trigger, initiatorUserId } = opts ?? {};
|
|
1197
|
+
const { maxTurns, traceEmitter, indexContextOverrides, timeoutMs, onCandidateResolved, trigger, initiatorUserId, resumeFromTaskId, continuationSettlementId, continuationExecution, } = opts ?? {};
|
|
976
1198
|
// Local helper to emit events whose shape is wider than the declared
|
|
977
1199
|
// `TraceEmitter` union (mirrors the cast used in chat.agent at the relay sink
|
|
978
1200
|
// and inside turn/finalize nodes above).
|
|
@@ -1001,6 +1223,8 @@ export async function negotiateCandidates(negotiationGraph, sourceUser, candidat
|
|
|
1001
1223
|
const result = await invokeWithAbortSignal(negotiationGraph, {
|
|
1002
1224
|
sourceUser,
|
|
1003
1225
|
candidateUser: candidate.candidateUser,
|
|
1226
|
+
...(candidate.sourceIntentId && { sourceIntentId: candidate.sourceIntentId }),
|
|
1227
|
+
...(candidate.candidateIntentId && { candidateIntentId: candidate.candidateIntentId }),
|
|
1004
1228
|
indexContext: candidateIndexContext,
|
|
1005
1229
|
seedAssessment: {
|
|
1006
1230
|
reasoning: candidate.reasoning,
|
|
@@ -1011,6 +1235,11 @@ export async function negotiateCandidates(negotiationGraph, sourceUser, candidat
|
|
|
1011
1235
|
...(candidate.opportunityStatus && { opportunityStatus: candidate.opportunityStatus }),
|
|
1012
1236
|
...(candidate.opportunityUpdatedAt && { opportunityUpdatedAt: candidate.opportunityUpdatedAt }),
|
|
1013
1237
|
...(initiatorUserId && { initiatorUserId }),
|
|
1238
|
+
...(resumeFromTaskId && continuationSettlementId && continuationExecution ? {
|
|
1239
|
+
resumeFromTaskId,
|
|
1240
|
+
continuationSettlementId,
|
|
1241
|
+
continuationExecution,
|
|
1242
|
+
} : {}),
|
|
1014
1243
|
...(maxTurns !== undefined && { maxTurns }),
|
|
1015
1244
|
...(timeoutMs !== undefined && { timeoutMs }),
|
|
1016
1245
|
});
|
|
@@ -1064,6 +1293,7 @@ export async function negotiateCandidates(negotiationGraph, sourceUser, candidat
|
|
|
1064
1293
|
accepted,
|
|
1065
1294
|
turns: turnHistory,
|
|
1066
1295
|
outcome: resolvedOutcome,
|
|
1296
|
+
...(result.continuationReceipt ? { continuationReceipt: result.continuationReceipt } : {}),
|
|
1067
1297
|
});
|
|
1068
1298
|
}
|
|
1069
1299
|
catch (hookErr) {
|