@indexnetwork/protocol 11.0.2-rc.456.1 → 11.2.0-rc.458.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/capabilities/negotiation.facade.d.ts +4 -1
  3. package/dist/capabilities/negotiation.facade.js +2 -1
  4. package/dist/capabilities/negotiation.questions.facade.d.ts +1 -1
  5. package/dist/capabilities/negotiation.questions.facade.js +1 -1
  6. package/dist/index.d.ts +8 -4
  7. package/dist/index.js +5 -1
  8. package/dist/mcp/mcp.authorization-policy.d.ts +10 -4
  9. package/dist/mcp/mcp.authorization-policy.js +64 -5
  10. package/dist/negotiation/application/negotiation.agent.d.ts +7 -1
  11. package/dist/negotiation/application/negotiation.agent.js +6 -4
  12. package/dist/negotiation/application/negotiation.detail-reader.js +3 -6
  13. package/dist/negotiation/application/negotiation.graph.d.ts +17 -29
  14. package/dist/negotiation/application/negotiation.graph.js +35 -49
  15. package/dist/negotiation/application/negotiation.tools.js +40 -43
  16. package/dist/negotiation/domain/index.d.ts +5 -0
  17. package/dist/negotiation/domain/index.js +4 -0
  18. package/dist/negotiation/domain/negotiation.consultation-policy.d.ts +2 -3
  19. package/dist/negotiation/domain/negotiation.expected-speaker.d.ts +19 -0
  20. package/dist/negotiation/domain/negotiation.expected-speaker.js +47 -0
  21. package/dist/negotiation/domain/negotiation.hermes-contract.d.ts +31 -0
  22. package/dist/negotiation/domain/negotiation.hermes-contract.js +72 -0
  23. package/dist/negotiation/domain/negotiation.protocol.d.ts +36 -66
  24. package/dist/negotiation/domain/negotiation.state.d.ts +10 -19
  25. package/dist/negotiation/domain/negotiation.turn-cap.d.ts +9 -0
  26. package/dist/negotiation/domain/negotiation.turn-cap.js +12 -0
  27. package/dist/negotiation/public/index.d.ts +4 -1
  28. package/dist/negotiation/public/index.js +3 -1
  29. package/dist/questions/application/question.input.d.ts +4 -8
  30. package/dist/questions/application/question.input.js +3 -8
  31. package/dist/questions/application/question.presets.js +10 -11
  32. package/dist/shared/agent/model-signal.d.ts +6 -1
  33. package/dist/shared/agent/model-signal.js +5 -2
  34. package/dist/shared/interfaces/agent-dispatcher.interface.d.ts +3 -0
  35. package/dist/shared/interfaces/database.interface.d.ts +1 -1
  36. package/dist/shared/interfaces/negotiation-events.interface.d.ts +12 -2
  37. package/dist/shared/schemas/mcp-auth.schema.d.ts +6 -0
  38. package/dist/shared/schemas/mcp-auth.schema.js +9 -0
  39. package/dist/shared/schemas/negotiation-state.schema.d.ts +17 -23
  40. package/dist/shared/schemas/negotiation-state.schema.js +13 -8
  41. package/package.json +1 -1
@@ -9,9 +9,11 @@ import { blocksNegotiationBeforeFirstTurn, NegotiationScreener } from "./negotia
9
9
  import { configuredScreenMode } from "../domain/negotiation.screen.contracts.js";
10
10
  import { assessDeadlock, configuredDeadlockShiftEnabled, configuredDeadlockThreshold } from "../domain/negotiation.deadlock.js";
11
11
  import { protocolLogger } from "../../shared/observability/protocol.logger.js";
12
- import { NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY, NEGOTIATION_QUESTION_GENERIC_NETWORK, negotiationQuestionSettlementId, validateInflightAskUserFields } from '../domain/negotiation.question-safety.js';
12
+ import { NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY, NEGOTIATION_QUESTION_GENERIC_NETWORK, negotiationQuestionSettlementId } from '../domain/negotiation.question-safety.js';
13
13
  import { buildIntentSnapshots } from "../domain/negotiation.intent-snapshot-provenance.js";
14
14
  import { holdsNegotiationConversationLock } from "../domain/negotiation.task-lock-policy.js";
15
+ import { isNegotiationTurnCapReached } from "../domain/negotiation.turn-cap.js";
16
+ import { expectedNegotiationSpeaker } from "../domain/negotiation.expected-speaker.js";
15
17
  import { attributedDialogueIsEmpty, buildSeededAttribution, combineAttributedDialogue } from '../negotiation.attribution.js';
16
18
  const logger = protocolLogger("NegotiationGraph");
17
19
  const initLog = protocolLogger("NegotiationGraph:Init");
@@ -196,22 +198,15 @@ export class NegotiationGraphFactory {
196
198
  // --- Load prior messages and determine continuation ---
197
199
  const priorTurns = turnsFromMessages(priorMessages);
198
200
  const isContinuation = priorTurns.length > 0;
199
- // Determine currentSpeaker from last prior message. An `ask_user` last
200
- // turn does NOT pass the floor: the sender paused to consult its own
201
- // client, so on resume the same side speaks again — now armed with the
202
- // client's answer (or its recorded absence). Flipping here would hand
203
- // the turn to the counterparty, who has nothing to respond to.
204
- let currentSpeaker = 'source';
205
- if (isContinuation && priorMessages.length > 0) {
206
- const lastMessage = priorMessages[priorMessages.length - 1];
207
- const lastAction = turnsFromMessages([lastMessage])[0]?.action;
208
- if (lastAction === 'ask_user') {
209
- currentSpeaker = lastMessage.senderId === agentIdA ? 'source' : 'candidate';
210
- }
211
- else {
212
- currentSpeaker = lastMessage.senderId === agentIdA ? 'candidate' : 'source';
213
- }
214
- }
201
+ const expectedSpeaker = expectedNegotiationSpeaker({
202
+ sourceUserId: state.sourceUser.id,
203
+ candidateUserId: state.candidateUser.id,
204
+ }, priorMessages);
205
+ if (!expectedSpeaker)
206
+ return { error: 'invalid negotiation participants' };
207
+ const currentSpeaker = expectedSpeaker === state.sourceUser.id
208
+ ? 'source'
209
+ : 'candidate';
215
210
  // Determine scenario-based maxTurns
216
211
  const scope = { action: 'manage:negotiations', scopeType: 'network', scopeId: state.indexContext.networkId };
217
212
  const [sourceHasAgent, candidateHasAgent] = await Promise.all([
@@ -485,9 +480,8 @@ export class NegotiationGraphFactory {
485
480
  const ownUser = isSource ? state.sourceUser : state.candidateUser;
486
481
  const otherUser = isSource ? state.candidateUser : state.sourceUser;
487
482
  const ownIntentId = isSource ? state.sourceIntentId : state.candidateIntentId;
488
- // Determine if this is the system agent's final allowed turn
489
- const maxTurns = state.maxTurns ?? 0;
490
- const isFinalTurn = maxTurns > 0 && (state.turnCount + 1) >= maxTurns;
483
+ // Determine if this is the system agent's final allowed turn.
484
+ const isFinalTurn = isNegotiationTurnCapReached(state.turnCount + 1, state.maxTurns);
491
485
  // Seat attribution keys on initiatorUserId (rigid v2 stamp), never on
492
486
  // parity or source/candidate position — under the conversation-scoped
493
487
  // tie-break this run's source may hold the counterparty seat.
@@ -558,6 +552,17 @@ export class NegotiationGraphFactory {
558
552
  ...(state.privateConsultation?.recipientUserId === ownUser.id
559
553
  ? { privateConsultation: state.privateConsultation }
560
554
  : {}),
555
+ ...(state.continuationExecution
556
+ ? {
557
+ timeoutContinuation: {
558
+ priorTaskId: state.continuationExecution.taskId,
559
+ settlementId: state.continuationExecution.settlementId,
560
+ successorTaskId: state.continuationExecution.successorTaskId,
561
+ token: state.continuationExecution.token,
562
+ fence: state.continuationExecution.fence,
563
+ },
564
+ }
565
+ : {}),
561
566
  };
562
567
  const scope = { action: 'manage:negotiations', scopeType: 'network', scopeId: state.indexContext.networkId };
563
568
  const dispatchResult = await dispatcher.dispatch(ownUser.id, scope, payload, { timeoutMs: state.timeoutMs });
@@ -595,7 +600,7 @@ export class NegotiationGraphFactory {
595
600
  ? { privateConsultation: state.privateConsultation }
596
601
  : {}),
597
602
  }, state.continuationExecution);
598
- await database.updateTaskState(state.taskId, "waiting_for_agent", undefined, state.continuationExecution);
603
+ await database.updateTaskState(state.taskId, "waiting_for_agent", undefined, state.continuationExecution, dispatchResult.resumeToken);
599
604
  return { status: 'waiting_for_agent' };
600
605
  }
601
606
  else {
@@ -703,7 +708,7 @@ export class NegotiationGraphFactory {
703
708
  reasoning: 'Client consultation required.',
704
709
  suggestedRoles: turn.assessment.suggestedRoles,
705
710
  },
706
- askUser: consultationPromptFor(consultationPolicyReason),
711
+ askUser: { reason: consultationPolicyReason },
707
712
  };
708
713
  emitConsultationTelemetry('asked', consultationPolicyReason);
709
714
  }
@@ -775,27 +780,10 @@ export class NegotiationGraphFactory {
775
780
  // like the waiting_for_agent suspend; the answer (or window expiry)
776
781
  // resumes via the run-existing continuation path.
777
782
  if (turn.action === 'ask_user') {
778
- const counterparty = isSource ? state.candidateUser : state.sourceUser;
779
- const safeAskUser = validateInflightAskUserFields({
780
- disclosureSubject: turn.askUser?.disclosureSubject,
781
- draftQuestion: turn.askUser?.draftQuestion,
782
- forbiddenIdentifiers: [
783
- counterparty.id,
784
- counterparty.profile.name ?? '',
785
- state.opportunityId,
786
- state.taskId,
787
- state.indexContext.networkId,
788
- isSource ? state.candidateIntentId ?? '' : state.sourceIntentId ?? '',
789
- ],
790
- forbiddenSourceText: [
791
- counterparty.profile.bio ?? '',
792
- counterparty.profile.location ?? '',
793
- ...counterparty.intents.flatMap((intent) => [intent.title, intent.description]),
794
- state.seedAssessment.reasoning,
795
- state.indexContext.prompt,
796
- state.discoveryQuery ?? '',
797
- ],
798
- });
783
+ const consultationReason = turn.askUser?.reason;
784
+ const safeAskUser = consultationReason
785
+ ? consultationPromptFor(consultationReason)
786
+ : null;
799
787
  const settlementId = negotiationQuestionSettlementId(state.taskId);
800
788
  const askUserBinding = await database.captureNegotiationAskUserBinding({
801
789
  taskId: state.taskId,
@@ -810,7 +798,7 @@ export class NegotiationGraphFactory {
810
798
  indexContext: state.indexContext,
811
799
  seedAssessment: state.seedAssessment,
812
800
  ...(isSource && state.discoveryQuery && { discoveryQuery: state.discoveryQuery }),
813
- ...(consultationPolicyReason && { consultationPolicyReason }),
801
+ ...(consultationReason && { consultationPolicyReason: consultationReason }),
814
802
  },
815
803
  ...(state.continuationExecution ? { continuationExecution: state.continuationExecution } : {}),
816
804
  });
@@ -852,10 +840,8 @@ export class NegotiationGraphFactory {
852
840
  context: {
853
841
  negotiationId: state.taskId,
854
842
  counterpartyHint: NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY,
855
- disclosureSubject: safeAskUser.disclosureSubject,
856
- ...(safeAskUser.draftQuestion && { draftQuestion: safeAskUser.draftQuestion }),
857
843
  indexContext: NEGOTIATION_QUESTION_GENERIC_NETWORK,
858
- ...(consultationPolicyReason && { consultationPolicyReason }),
844
+ consultationPolicyReason: consultationReason,
859
845
  ...(userContext && { userContext }),
860
846
  },
861
847
  }).catch((error) => {
@@ -965,7 +951,7 @@ export class NegotiationGraphFactory {
965
951
  if (isTerminalAction(state.lastTurn.action))
966
952
  return "finalize";
967
953
  // question routes same as counter — next turn
968
- if ((state.maxTurns ?? 0) > 0 && state.turnCount >= state.maxTurns)
954
+ if (isNegotiationTurnCapReached(state.turnCount, state.maxTurns))
969
955
  return "finalize";
970
956
  return "turn";
971
957
  };
@@ -1038,7 +1024,7 @@ export class NegotiationGraphFactory {
1038
1024
  const blockedByScreenNode = blocksNegotiationBeforeFirstTurn(state.screenDecision, state.turnCount);
1039
1025
  const refusedAtOpeningTurn = state.firstTurnScreenedOut === true;
1040
1026
  const screenedOut = blockedByScreenNode || refusedAtOpeningTurn;
1041
- const atCap = !screenedOut && (state.maxTurns ?? 0) > 0 && state.turnCount >= state.maxTurns && !isTerminalAction(lastTurn?.action);
1027
+ const atCap = !screenedOut && isNegotiationTurnCapReached(state.turnCount, state.maxTurns) && !isTerminalAction(lastTurn?.action);
1042
1028
  let agreedRoles = [];
1043
1029
  if (hasOpportunity && history.length >= 2) {
1044
1030
  const acceptTurn = history[history.length - 1];
@@ -7,6 +7,8 @@ import { protocolLogger } from '../../shared/observability/protocol.logger.js';
7
7
  import { focusedIntentId, focusedNetworkId } from '../../shared/agent/tool.scope.js';
8
8
  import { readAuthorizedNegotiationDetail } from './negotiation.detail-reader.js';
9
9
  import { buildLifecycleNarration } from '../domain/negotiation.lifecycle-narration.js';
10
+ import { isNegotiationTurnCapReached } from '../domain/negotiation.turn-cap.js';
11
+ import { expectedNegotiationSpeaker } from '../domain/negotiation.expected-speaker.js';
10
12
  export { buildLifecycleNarration } from '../domain/negotiation.lifecycle-narration.js';
11
13
  const logger = protocolLogger('ChatTools:Negotiation');
12
14
  /**
@@ -154,21 +156,14 @@ export function createNegotiationTools(defineTool, deps) {
154
156
  const lastTurnData = lastMessage
155
157
  ? lastMessage.parts?.find(p => p.kind === 'data')?.data
156
158
  : undefined;
157
- // Determine whose turn it is from the last message's sender — not
158
- // parity, which misattributes across continuation sessions. Rows
159
- // without senderId (legacy) fall back to parity.
160
159
  const turnCount = messages.length;
161
- const lastSenderId = turnCount > 0 ? messages[turnCount - 1].senderId : null;
162
- const currentSpeaker = lastSenderId
163
- ? (lastSenderId === `agent:${meta.sourceUserId}` ? 'candidate' : 'source')
164
- : (turnCount % 2 === 0 ? 'source' : 'candidate');
160
+ const expectedSpeaker = expectedNegotiationSpeaker(meta, messages);
165
161
  // Map task state to tool status
166
162
  const status = task.state === 'working' ? 'active'
167
163
  : task.state === 'waiting_for_agent' ? 'waiting_for_agent'
168
164
  : task.state === 'completed' ? 'completed'
169
165
  : task.state;
170
- const isUsersTurn = status !== 'completed' &&
171
- ((isSource && currentSpeaker === 'source') || (!isSource && currentSpeaker === 'candidate'));
166
+ const isUsersTurn = status !== 'completed' && expectedSpeaker === context.userId;
172
167
  const base = {
173
168
  id: task.id,
174
169
  counterpartyId: counterpartyId ?? 'unknown',
@@ -355,6 +350,15 @@ export function createNegotiationTools(defineTool, deps) {
355
350
  if (meta?.type !== 'negotiation') {
356
351
  return error('Negotiation not found.');
357
352
  }
353
+ const timeoutContinuation = meta.continuationExecution
354
+ ? {
355
+ priorTaskId: meta.continuationExecution.priorTaskId,
356
+ settlementId: meta.continuationExecution.settlementId,
357
+ successorTaskId: meta.continuationExecution.successorTaskId,
358
+ token: meta.continuationExecution.token,
359
+ fence: meta.continuationExecution.fence,
360
+ }
361
+ : undefined;
358
362
  // Network-scope check (mirrors get_negotiation): a network-bound agent
359
363
  // must not act on negotiations outside its bound network.
360
364
  const scopedNetworkId = focusedNetworkId(context);
@@ -382,17 +386,10 @@ export function createNegotiationTools(defineTool, deps) {
382
386
  if (!allowedActionsFor(protocolVersion, seat).includes(query.action)) {
383
387
  return error(seatViolationMessage(query.action, seat, protocolVersion));
384
388
  }
385
- // Determine whose turn it is from the last message's sender — not
386
- // parity, which misattributes across continuation sessions. Rows
387
- // without senderId (legacy) fall back to the parity heuristic.
388
389
  const messages = await negotiationDatabase.getMessagesForConversation(task.conversationId);
389
390
  const turnCount = messages.length;
390
- const lastSenderId = turnCount > 0 ? messages[turnCount - 1].senderId : null;
391
- const paritySpeaker = turnCount % 2 === 0 ? 'source' : 'candidate';
392
- const isUsersTurn = lastSenderId
393
- ? lastSenderId !== `agent:${context.userId}`
394
- : ((isSource && paritySpeaker === 'source') || (!isSource && paritySpeaker === 'candidate'));
395
- if (!isUsersTurn) {
391
+ const expectedSpeaker = expectedNegotiationSpeaker(meta, messages);
392
+ if (expectedSpeaker !== context.userId) {
396
393
  return error('It is not your turn to respond in this negotiation.');
397
394
  }
398
395
  // The caller is the current speaker (verified above).
@@ -402,8 +399,8 @@ export function createNegotiationTools(defineTool, deps) {
402
399
  return error(`A message is required when using "${query.action}". Explain what you want to change or clarify.`);
403
400
  }
404
401
  // ── Cancel pending timeout ──
405
- if (deps.negotiationTimeoutQueue) {
406
- await deps.negotiationTimeoutQueue.cancelTimeout(task.id);
402
+ if (deps.negotiationTimeoutQueue && meta.negotiationParkGeneration) {
403
+ await deps.negotiationTimeoutQueue.cancelTimeout(task.id, meta.negotiationParkGeneration);
407
404
  }
408
405
  // ── Build and persist the external agent's turn ──
409
406
  const turnData = {
@@ -451,8 +448,7 @@ export function createNegotiationTools(defineTool, deps) {
451
448
  });
452
449
  }
453
450
  // ── Handle counter/question: check if under max turns ──
454
- const maxTurns = meta.maxTurns ?? 6; // Read from task metadata; fallback to system default
455
- if (newTurnCount >= maxTurns) {
451
+ if (isNegotiationTurnCapReached(newTurnCount, meta.maxTurns)) {
456
452
  // Max turns reached — finalize with turn_cap
457
453
  const allMessages = [...messages, { id: turnMessage.id, senderId: turnMessage.senderId, role: turnMessage.role, parts: turnMessage.parts, createdAt: turnMessage.createdAt }];
458
454
  const history = turnsFromMessages(allMessages);
@@ -480,7 +476,7 @@ export function createNegotiationTools(defineTool, deps) {
480
476
  // Build the current turn history for dispatcher payload
481
477
  const allMessagesWithTurn = [...messages, { id: turnMessage.id, senderId: turnMessage.senderId, role: turnMessage.role, parts: turnMessage.parts, createdAt: turnMessage.createdAt }];
482
478
  const historyForDispatch = turnsFromMessages(allMessagesWithTurn);
483
- const isFinalTurn = newTurnCount + 1 >= maxTurns;
479
+ const isFinalTurn = isNegotiationTurnCapReached(newTurnCount + 1, meta.maxTurns);
484
480
  const ownUserCtx = { id: counterpartyUserId, intents: [], profile: {} };
485
481
  const otherUserCtx = { id: context.userId, intents: [], profile: {} };
486
482
  const seedAssessment = { reasoning: 'Continued negotiation', valencyRole: 'peer' };
@@ -496,16 +492,15 @@ export function createNegotiationTools(defineTool, deps) {
496
492
  seat: counterpartySeat,
497
493
  protocolVersion,
498
494
  allowedActions: [...allowedActionsFor(protocolVersion, counterpartySeat, isFinalTurn)],
495
+ ...(timeoutContinuation ? { timeoutContinuation } : {}),
499
496
  };
500
497
  const scope = { action: 'negotiation.respond', scopeType: 'negotiation', scopeId: task.id };
501
498
  const timeoutMs = AMBIENT_PARK_WINDOW_MS;
502
499
  const dispatchResult = await deps.agentDispatcher?.dispatch(counterpartyUserId, scope, dispatchPayload, { timeoutMs });
503
500
  if (dispatchResult?.handled === false && dispatchResult.reason === 'waiting') {
504
- // Counterparty's agent acknowledged yield and wait
505
- await negotiationDatabase.updateTaskState(task.id, 'waiting_for_agent');
506
- if (deps.negotiationTimeoutQueue) {
507
- await deps.negotiationTimeoutQueue.enqueueTimeout(task.id, newTurnCount, timeoutMs);
508
- }
501
+ // The dispatcher armed this exact generation before acknowledging;
502
+ // persist that same token with the waiting state.
503
+ await negotiationDatabase.updateTaskState(task.id, 'waiting_for_agent', undefined, undefined, dispatchResult.resumeToken);
509
504
  return success({
510
505
  message: `${query.action === 'question' ? 'Question' : query.action === 'propose' ? 'Proposal' : query.action === 'outreach' ? 'Outreach' : 'Counter-proposal'} submitted. Waiting for counterparty response.`,
511
506
  negotiationId: task.id,
@@ -597,7 +592,7 @@ export function createNegotiationTools(defineTool, deps) {
597
592
  });
598
593
  }
599
594
  // Counterparty countered/questioned — check if max turns reached
600
- if (finalTurnCount >= maxTurns) {
595
+ if (isNegotiationTurnCapReached(finalTurnCount, meta.maxTurns)) {
601
596
  const fullHistory = [...historyForDispatch, aiTurn];
602
597
  const outcome = buildNegotiationOutcome(fullHistory, finalTurnCount, 'counter', meta.sourceUserId, meta.candidateUserId, counterpartySpeaker === 'source' ? 'candidate' : 'source');
603
598
  await negotiationDatabase.updateTaskState(task.id, 'completed');
@@ -624,19 +619,22 @@ export function createNegotiationTools(defineTool, deps) {
624
619
  indexContext: { networkId: '' },
625
620
  seedAssessment,
626
621
  history: [...historyForDispatch, aiTurn],
627
- isFinalTurn: finalTurnCount + 1 >= maxTurns,
622
+ isFinalTurn: isNegotiationTurnCapReached(finalTurnCount + 1, meta.maxTurns),
628
623
  isDiscoverer: true,
629
624
  seat,
630
625
  protocolVersion,
631
- allowedActions: [...allowedActionsFor(protocolVersion, seat, finalTurnCount + 1 >= maxTurns)],
626
+ allowedActions: [...allowedActionsFor(protocolVersion, seat, isNegotiationTurnCapReached(finalTurnCount + 1, meta.maxTurns))],
627
+ ...(timeoutContinuation ? { timeoutContinuation } : {}),
632
628
  };
633
629
  const userDispatchResult = await deps.agentDispatcher?.dispatch(context.userId, scope, userDispatchPayload, { timeoutMs });
634
630
  if (!userDispatchResult || (userDispatchResult.handled === false && userDispatchResult.reason === 'no_agent')) {
635
- // No agent for user — set back to waiting_for_agent so they can use respond_to_negotiation
636
- await negotiationDatabase.updateTaskState(task.id, 'waiting_for_agent');
631
+ // No agent for user — arm and persist one exact generation so they
632
+ // can still use respond_to_negotiation while fallback is bounded.
633
+ const parkGeneration = crypto.randomUUID();
637
634
  if (deps.negotiationTimeoutQueue) {
638
- await deps.negotiationTimeoutQueue.enqueueTimeout(task.id, finalTurnCount, timeoutMs);
635
+ await deps.negotiationTimeoutQueue.enqueueTimeout(task.id, finalTurnCount, timeoutMs, parkGeneration, timeoutContinuation);
639
636
  }
637
+ await negotiationDatabase.updateTaskState(task.id, 'waiting_for_agent', undefined, undefined, parkGeneration);
640
638
  return success({
641
639
  message: `${query.action === 'question' ? 'Question' : 'Counter'} submitted. Counterparty responded. Your turn to respond.`,
642
640
  negotiationId: task.id,
@@ -647,11 +645,8 @@ export function createNegotiationTools(defineTool, deps) {
647
645
  });
648
646
  }
649
647
  if (userDispatchResult.handled === false && userDispatchResult.reason === 'waiting') {
650
- // User's agent acknowledged yield and wait
651
- await negotiationDatabase.updateTaskState(task.id, 'waiting_for_agent');
652
- if (deps.negotiationTimeoutQueue) {
653
- await deps.negotiationTimeoutQueue.enqueueTimeout(task.id, finalTurnCount, timeoutMs);
654
- }
648
+ // The dispatcher armed this exact generation before acknowledging.
649
+ await negotiationDatabase.updateTaskState(task.id, 'waiting_for_agent', undefined, undefined, userDispatchResult.resumeToken);
655
650
  return success({
656
651
  message: `${query.action === 'question' ? 'Question' : 'Counter'} submitted. Counterparty countered back. Waiting for your agent's response.`,
657
652
  negotiationId: task.id,
@@ -693,7 +688,7 @@ export function createNegotiationTools(defineTool, deps) {
693
688
  outcome,
694
689
  });
695
690
  }
696
- if (userTurnCount >= maxTurns) {
691
+ if (isNegotiationTurnCapReached(userTurnCount, meta.maxTurns)) {
697
692
  const fullHistory = [...historyForDispatch, aiTurn, userAgentTurn];
698
693
  const outcome = buildNegotiationOutcome(fullHistory, userTurnCount, 'counter', meta.sourceUserId, meta.candidateUserId, isSource ? 'candidate' : 'source');
699
694
  await negotiationDatabase.updateTaskState(task.id, 'completed');
@@ -712,11 +707,13 @@ export function createNegotiationTools(defineTool, deps) {
712
707
  outcome,
713
708
  });
714
709
  }
715
- // User's agent countered/questioned — arm timeout for counterparty's next turn
716
- await negotiationDatabase.updateTaskState(task.id, 'waiting_for_agent');
710
+ // User's agent countered/questioned — arm one exact generation for
711
+ // the counterparty's next turn.
712
+ const parkGeneration = crypto.randomUUID();
717
713
  if (deps.negotiationTimeoutQueue) {
718
- await deps.negotiationTimeoutQueue.enqueueTimeout(task.id, userTurnCount, timeoutMs);
714
+ await deps.negotiationTimeoutQueue.enqueueTimeout(task.id, userTurnCount, timeoutMs, parkGeneration, timeoutContinuation);
719
715
  }
716
+ await negotiationDatabase.updateTaskState(task.id, 'waiting_for_agent', undefined, undefined, parkGeneration);
720
717
  return success({
721
718
  message: `Your agent responded with ${userAgentTurn.action}. Waiting for counterparty.`,
722
719
  negotiationId: task.id,
@@ -35,7 +35,12 @@ export { NEGOTIATOR_STANCES, DEFAULT_NEGOTIATOR_STANCE, configuredNegotiatorStan
35
35
  export type { NegotiatorStance } from "./negotiation.stance.contracts.js";
36
36
  export { NegotiationTurnSchema, SystemNegotiationTurnSchema, FinalNegotiationTurnSchema, NegotiationOutcomeSchema, NegotiationGraphState, } from "./negotiation.state.js";
37
37
  export type { NegotiationTurn, NegotiationOutcome, UserNegotiationContext, SeedAssessment, NegotiationGraphLike, NegotiationMessage, } from "./negotiation.state.js";
38
+ export { DEFAULT_NEGOTIATION_MAX_TURNS, isNegotiationTurnCapReached, } from "./negotiation.turn-cap.js";
39
+ export { expectedNegotiationSpeaker } from "./negotiation.expected-speaker.js";
40
+ export type { NegotiationSpeakerParticipants, NegotiationSpeakerMessage, } from "./negotiation.expected-speaker.js";
38
41
  export { InitiatorTurnSchema, CounterpartyTurnSchema, FinalInitiatorTurnSchema, FinalCounterpartyTurnSchema, InitiatorAskUserTurnSchema, CounterpartyAskUserTurnSchema, allowedActionsFor, turnSchemaFor, isTerminalAction, isRejectLikeAction, fallbackActionFor, rejectActionFor, readProtocolVersion, configuredProtocolVersion, configuredAskUserEnabled, askUserAnswerWindowMs, ASK_USER_LOCK_SLACK_MS, DEFAULT_ASK_USER_WINDOW_MS, resolveSeat, seatViolationMessage, } from "./negotiation.protocol.js";
42
+ export { HERMES_OWNER_DIRECTIVE, HERMES_SHARED_MESSAGE_TEMPLATES, HermesNegotiationActionSchema, HermesNegotiationResponseSchema, HermesOwnerDirectiveSchema, HermesRoleAlignmentSchema, allowedHermesActionsFor, buildHermesNegotiationTurn, } from "./negotiation.hermes-contract.js";
43
+ export type { HermesNegotiationAction, HermesNegotiationResponse, HermesOwnerDirective, HermesRoleAlignment, } from "./negotiation.hermes-contract.js";
39
44
  export { configuredDeadlockShiftEnabled, configuredDeadlockThreshold, assessDeadlock, renderBargainingShiftSection, DEFAULT_DEADLOCK_THRESHOLD, MIN_DEADLOCK_THRESHOLD, } from "./negotiation.deadlock.js";
40
45
  export type { DeadlockAssessment } from "./negotiation.deadlock.js";
41
46
  export type { DeadlockShiftRecord } from "./negotiation.deadlock.contracts.js";
@@ -36,7 +36,11 @@ export { NEGOTIATOR_STANCES, DEFAULT_NEGOTIATOR_STANCE, configuredNegotiatorStan
36
36
  // ── Graph state and DTOs ──────────────────────────────────────────────────────
37
37
  export { NegotiationTurnSchema, SystemNegotiationTurnSchema, FinalNegotiationTurnSchema, NegotiationOutcomeSchema, NegotiationGraphState, } from "./negotiation.state.js";
38
38
  // ── Protocol rules ────────────────────────────────────────────────────────────
39
+ export { DEFAULT_NEGOTIATION_MAX_TURNS, isNegotiationTurnCapReached, } from "./negotiation.turn-cap.js";
40
+ export { expectedNegotiationSpeaker } from "./negotiation.expected-speaker.js";
39
41
  export { InitiatorTurnSchema, CounterpartyTurnSchema, FinalInitiatorTurnSchema, FinalCounterpartyTurnSchema, InitiatorAskUserTurnSchema, CounterpartyAskUserTurnSchema, allowedActionsFor, turnSchemaFor, isTerminalAction, isRejectLikeAction, fallbackActionFor, rejectActionFor, readProtocolVersion, configuredProtocolVersion, configuredAskUserEnabled, askUserAnswerWindowMs, ASK_USER_LOCK_SLACK_MS, DEFAULT_ASK_USER_WINDOW_MS, resolveSeat, seatViolationMessage, } from "./negotiation.protocol.js";
42
+ // ── Hermes structural response contract ──────────────────────────────────────
43
+ export { HERMES_OWNER_DIRECTIVE, HERMES_SHARED_MESSAGE_TEMPLATES, HermesNegotiationActionSchema, HermesNegotiationResponseSchema, HermesOwnerDirectiveSchema, HermesRoleAlignmentSchema, allowedHermesActionsFor, buildHermesNegotiationTurn, } from "./negotiation.hermes-contract.js";
40
44
  // ── Deadlock detection ────────────────────────────────────────────────────────
41
45
  export { configuredDeadlockShiftEnabled, configuredDeadlockThreshold, assessDeadlock, renderBargainingShiftSection, DEFAULT_DEADLOCK_THRESHOLD, MIN_DEADLOCK_THRESHOLD, } from "./negotiation.deadlock.js";
42
46
  // ── Lifecycle narration ───────────────────────────────────────────────────────
@@ -1,8 +1,7 @@
1
- import type { NegotiationAction, NegotiationProtocolVersion, NegotiationSeat } from "../../shared/schemas/negotiation-state.schema.js";
1
+ import type { NegotiationAction, NegotiationConsultationReason, NegotiationProtocolVersion, NegotiationSeat } from "../../shared/schemas/negotiation-state.schema.js";
2
+ export type { NegotiationConsultationReason } from "../../shared/schemas/negotiation-state.schema.js";
2
3
  /** Independent rollout modes for IND-508's deterministic consultation policy. */
3
4
  export type NegotiationConsultationPolicyMode = "off" | "shadow" | "on";
4
- /** Stable, content-free categories emitted by the consultation funnel. */
5
- export type NegotiationConsultationReason = "unresolved_owner_constraint" | "consequential_disclosure_permission" | "repeated_non_convergence" | "insufficient_commitment_authority";
6
5
  /** The only data the policy may inspect: action/role enums and routing coordinates. */
7
6
  export interface ConsultationEligibilityInput {
8
7
  protocolVersion: NegotiationProtocolVersion;
@@ -0,0 +1,19 @@
1
+ export interface NegotiationSpeakerParticipants {
2
+ sourceUserId?: unknown;
3
+ candidateUserId?: unknown;
4
+ }
5
+ export interface NegotiationSpeakerMessage {
6
+ senderId?: unknown;
7
+ parts?: unknown;
8
+ }
9
+ /**
10
+ * Resolves the participant whose agent owns the next canonical bilateral turn.
11
+ *
12
+ * Participant identities must be nonempty and distinct. Unrelated agent,
13
+ * system, and owner-settlement messages are ignored while finding the latest
14
+ * source/candidate message. An ordinary canonical message passes the floor to
15
+ * the other participant; `ask_user` retains it for the consulting sender's
16
+ * exact successor. A valid conversation with no canonical history starts with
17
+ * the source participant. Invalid participant metadata always fails closed.
18
+ */
19
+ export declare function expectedNegotiationSpeaker(participants: NegotiationSpeakerParticipants, messages: readonly NegotiationSpeakerMessage[]): string | null;
@@ -0,0 +1,47 @@
1
+ function canonicalAction(message) {
2
+ if (!Array.isArray(message.parts))
3
+ return null;
4
+ for (const part of message.parts) {
5
+ if (!part || typeof part !== 'object' || Array.isArray(part))
6
+ continue;
7
+ const partRecord = part;
8
+ if (partRecord.kind !== 'data' || !partRecord.data || typeof partRecord.data !== 'object' || Array.isArray(partRecord.data))
9
+ continue;
10
+ const action = partRecord.data.action;
11
+ return typeof action === 'string' ? action : null;
12
+ }
13
+ return null;
14
+ }
15
+ function participantId(value) {
16
+ return typeof value === 'string' && value.trim().length > 0 ? value : null;
17
+ }
18
+ /**
19
+ * Resolves the participant whose agent owns the next canonical bilateral turn.
20
+ *
21
+ * Participant identities must be nonempty and distinct. Unrelated agent,
22
+ * system, and owner-settlement messages are ignored while finding the latest
23
+ * source/candidate message. An ordinary canonical message passes the floor to
24
+ * the other participant; `ask_user` retains it for the consulting sender's
25
+ * exact successor. A valid conversation with no canonical history starts with
26
+ * the source participant. Invalid participant metadata always fails closed.
27
+ */
28
+ export function expectedNegotiationSpeaker(participants, messages) {
29
+ const source = participantId(participants.sourceUserId);
30
+ const candidate = participantId(participants.candidateUserId);
31
+ if (!source || !candidate || source === candidate)
32
+ return null;
33
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
34
+ const message = messages[index];
35
+ const sender = message?.senderId === `agent:${source}`
36
+ ? source
37
+ : message?.senderId === `agent:${candidate}`
38
+ ? candidate
39
+ : null;
40
+ if (!sender)
41
+ continue;
42
+ return canonicalAction(message) === 'ask_user'
43
+ ? sender
44
+ : sender === source ? candidate : source;
45
+ }
46
+ return source;
47
+ }
@@ -0,0 +1,31 @@
1
+ import { z } from 'zod';
2
+ import type { NegotiationAction, NegotiationTurn } from '../../shared/schemas/negotiation-state.schema.js';
3
+ /** Model-visible directives for the dedicated Hermes negotiation bridge. */
4
+ export declare const HermesNegotiationActionSchema: z.ZodEnum<["accept", "decline", "request_time", "continue"]>;
5
+ export type HermesNegotiationAction = z.infer<typeof HermesNegotiationActionSchema>;
6
+ export declare const HermesRoleAlignmentSchema: z.ZodEnum<["peers", "owner_leads", "counterparty_leads"]>;
7
+ export type HermesRoleAlignment = z.infer<typeof HermesRoleAlignmentSchema>;
8
+ /**
9
+ * No model-authored prose is accepted at this boundary. Strict parsing also
10
+ * prevents model-selected run IDs or capabilities from entering tool arguments.
11
+ */
12
+ export declare const HermesNegotiationResponseSchema: z.ZodObject<{
13
+ action: z.ZodEnum<["accept", "decline", "request_time", "continue"]>;
14
+ roleAlignment: z.ZodEnum<["peers", "owner_leads", "counterparty_leads"]>;
15
+ }, "strict", z.ZodTypeAny, {
16
+ action: "accept" | "decline" | "request_time" | "continue";
17
+ roleAlignment: "peers" | "owner_leads" | "counterparty_leads";
18
+ }, {
19
+ action: "accept" | "decline" | "request_time" | "continue";
20
+ roleAlignment: "peers" | "owner_leads" | "counterparty_leads";
21
+ }>;
22
+ export type HermesNegotiationResponse = z.infer<typeof HermesNegotiationResponseSchema>;
23
+ /** Only privacy-reviewed server prose can enter the shared transcript. */
24
+ export declare const HERMES_SHARED_MESSAGE_TEMPLATES: Readonly<Record<HermesNegotiationAction, string>>;
25
+ export declare const HermesOwnerDirectiveSchema: z.ZodEnum<["protect_private_context"]>;
26
+ export type HermesOwnerDirective = z.infer<typeof HermesOwnerDirectiveSchema>;
27
+ export declare const HERMES_OWNER_DIRECTIVE: HermesOwnerDirective;
28
+ /** Project exact seat/version actions into the closed Hermes vocabulary. */
29
+ export declare function allowedHermesActionsFor(allowedActions: readonly NegotiationAction[]): HermesNegotiationAction[];
30
+ /** Build the complete persisted turn without using any model-authored prose. */
31
+ export declare function buildHermesNegotiationTurn(input: HermesNegotiationResponse, allowedActions: readonly NegotiationAction[]): NegotiationTurn | null;
@@ -0,0 +1,72 @@
1
+ import { z } from 'zod';
2
+ /** Model-visible directives for the dedicated Hermes negotiation bridge. */
3
+ export const HermesNegotiationActionSchema = z.enum([
4
+ 'accept',
5
+ 'decline',
6
+ 'request_time',
7
+ 'continue',
8
+ ]);
9
+ export const HermesRoleAlignmentSchema = z.enum([
10
+ 'peers',
11
+ 'owner_leads',
12
+ 'counterparty_leads',
13
+ ]);
14
+ /**
15
+ * No model-authored prose is accepted at this boundary. Strict parsing also
16
+ * prevents model-selected run IDs or capabilities from entering tool arguments.
17
+ */
18
+ export const HermesNegotiationResponseSchema = z.object({
19
+ action: HermesNegotiationActionSchema,
20
+ roleAlignment: HermesRoleAlignmentSchema,
21
+ }).strict();
22
+ /** Only privacy-reviewed server prose can enter the shared transcript. */
23
+ export const HERMES_SHARED_MESSAGE_TEMPLATES = Object.freeze({
24
+ accept: 'I am ready to proceed with this opportunity.',
25
+ decline: 'I am going to decline this opportunity.',
26
+ request_time: 'I need more time before deciding.',
27
+ continue: 'I am open to continuing within the current scope.',
28
+ });
29
+ export const HermesOwnerDirectiveSchema = z.enum(['protect_private_context']);
30
+ export const HERMES_OWNER_DIRECTIVE = 'protect_private_context';
31
+ const ACTION_CANDIDATES = Object.freeze({
32
+ accept: ['accept'],
33
+ decline: ['decline', 'withdraw', 'reject'],
34
+ request_time: ['counter', 'outreach', 'propose'],
35
+ continue: ['question', 'outreach', 'propose', 'counter'],
36
+ });
37
+ const ACTION_ORDER = [
38
+ 'accept',
39
+ 'decline',
40
+ 'request_time',
41
+ 'continue',
42
+ ];
43
+ /** Project exact seat/version actions into the closed Hermes vocabulary. */
44
+ export function allowedHermesActionsFor(allowedActions) {
45
+ const allowed = new Set(allowedActions);
46
+ return ACTION_ORDER.filter((action) => ACTION_CANDIDATES[action].some((candidate) => allowed.has(candidate)));
47
+ }
48
+ function protocolActionFor(action, allowedActions) {
49
+ const allowed = new Set(allowedActions);
50
+ return ACTION_CANDIDATES[action].find((candidate) => allowed.has(candidate)) ?? null;
51
+ }
52
+ function suggestedRolesFor(alignment) {
53
+ if (alignment === 'owner_leads')
54
+ return { ownUser: 'agent', otherUser: 'patient' };
55
+ if (alignment === 'counterparty_leads')
56
+ return { ownUser: 'patient', otherUser: 'agent' };
57
+ return { ownUser: 'peer', otherUser: 'peer' };
58
+ }
59
+ /** Build the complete persisted turn without using any model-authored prose. */
60
+ export function buildHermesNegotiationTurn(input, allowedActions) {
61
+ const action = protocolActionFor(input.action, allowedActions);
62
+ if (!action)
63
+ return null;
64
+ return {
65
+ action,
66
+ message: HERMES_SHARED_MESSAGE_TEMPLATES[input.action],
67
+ assessment: {
68
+ reasoning: `Hermes selected the closed ${input.action} directive.`,
69
+ suggestedRoles: suggestedRolesFor(input.roleAlignment),
70
+ },
71
+ };
72
+ }