@indexnetwork/protocol 6.9.0-rc.396.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.
@@ -4,6 +4,7 @@ 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";
@@ -329,6 +330,11 @@ export class NegotiationGraphFactory {
329
330
  ...(exactContinuation?.execution.consultation
330
331
  ? { privateConsultation: exactContinuation.execution.consultation }
331
332
  : {}),
333
+ ...(exactContinuation && (() => {
334
+ const turnContext = priorTask?.metadata?.turnContext;
335
+ const reason = turnContext?.consultationPolicyReason;
336
+ return typeof reason === 'string' ? { consultationPolicyReason: reason } : {};
337
+ })()),
332
338
  ...(seedMessages.length > 0 && { messages: seedMessages }),
333
339
  };
334
340
  }
@@ -468,13 +474,12 @@ export class NegotiationGraphFactory {
468
474
  const seat = ownUser.id === (state.initiatorUserId ?? state.sourceUser.id)
469
475
  ? 'initiator'
470
476
  : 'counterparty';
471
- // ask_user availability (P3.2): flag on, full pause loop wired
477
+ // Legacy ask_user availability (P3.2): flag on, full pause loop wired
472
478
  // (questioner + answer-window timer + an opportunity to resume
473
479
  // against), v2 non-final non-opening turn, and this side's one client
474
- // consultation not yet spent (rationing). Chat-triggered runs get no
475
- // special casing the pause exits the graph at the turn boundary, so
476
- // the stream never blocks on a question; the resume is always an async
477
- // 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();
478
483
  const askUserAvailable = version === 'v2'
479
484
  && !isFinalTurn
480
485
  && configuredAskUserEnabled()
@@ -607,11 +612,55 @@ export class NegotiationGraphFactory {
607
612
  turn.action = openingAction;
608
613
  }
609
614
  }
610
- // Safety net: an ask_user that slipped past availability gating (e.g. a
611
- // locally-dispatched agent ignoring allowedActions, or rationing already
612
- // spent) is coerced to the conservative fallback BEFORE persisting — a
613
- // pause we cannot resume must never enter the turn history.
614
- if (turn.action === 'ask_user' && !askUserAvailable) {
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))) {
615
664
  turnLog.warn('ask_user emitted while unavailable, coercing to conservative fallback', {
616
665
  seat, isFinalTurn, taskId: state.taskId,
617
666
  });
@@ -710,6 +759,7 @@ export class NegotiationGraphFactory {
710
759
  indexContext: state.indexContext,
711
760
  seedAssessment: state.seedAssessment,
712
761
  ...(isSource && state.discoveryQuery && { discoveryQuery: state.discoveryQuery }),
762
+ ...(consultationPolicyReason && { consultationPolicyReason }),
713
763
  },
714
764
  ...(state.continuationExecution ? { continuationExecution: state.continuationExecution } : {}),
715
765
  });
@@ -754,6 +804,7 @@ export class NegotiationGraphFactory {
754
804
  disclosureSubject: safeAskUser.disclosureSubject,
755
805
  ...(safeAskUser.draftQuestion && { draftQuestion: safeAskUser.draftQuestion }),
756
806
  indexContext: NEGOTIATION_QUESTION_GENERIC_NETWORK,
807
+ ...(consultationPolicyReason && { consultationPolicyReason }),
757
808
  ...(userContext && { userContext }),
758
809
  },
759
810
  }).catch((error) => {
@@ -1018,6 +1069,21 @@ export class NegotiationGraphFactory {
1018
1069
  }),
1019
1070
  });
1020
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
+ }
1021
1087
  // Enqueue post-negotiation reflection (P5.2 memory write path) — fire
1022
1088
  // and forget: a reflection failure must never affect the outcome. Only
1023
1089
  // sessions that actually exchanged turns teach anything; init/turn