@indexnetwork/protocol 22.0.0-rc.495.1 → 22.0.0-rc.496.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.
@@ -5,7 +5,8 @@ import { turnSchemaFor, fallbackActionFor } from "./negotiation.protocol.js";
5
5
  import { renderNegotiatorMemorySection } from "./negotiation.memory.js";
6
6
  import { renderNegotiatorClientDmSection } from "./negotiation.client-dm.js";
7
7
  import { renderBargainingShiftSection } from "./negotiation.deadlock.js";
8
- import { configuredNegotiatorStance, stanceActionRules, stanceJobFraming, stanceQuerySatisfiedRule } from "./negotiation.stance.contracts.js";
8
+ import { configuredNegotiatorStance, stanceActionRules, stanceJobFraming, stancePreContactConsultRule, stanceQuerySatisfiedRule } from "./negotiation.stance.contracts.js";
9
+ import { isPreContactConsultResume } from "./negotiation.consultation-policy.js";
9
10
  import { attributedDialogueIsEmpty, renderAttributedPriorDialogue } from "./negotiation.attribution.js";
10
11
  import { protocolLogger } from "../shared/observability/protocol.logger.js";
11
12
  const agentLog = protocolLogger("IndexNegotiator");
@@ -93,6 +94,29 @@ const ASK_USER_RULE = `
93
94
  */
94
95
  const ASK_USER_DM_GROUNDING_RULE = `
95
96
  - Ground the question in your conversation with {userName} about this signal (shown below) as well as in the exchange above. Do NOT ask what they have already answered there: if their own words settle the point, act on them and spend your one consultation on what is genuinely still open. Use their terms for the thing at stake — the words, numbers, and framing they used, not your paraphrase of them.`;
97
+ /**
98
+ * The turn-0 third verdict, appended to `ASK_USER_RULE` on an opening
99
+ * initiator turn that holds the grant.
100
+ *
101
+ * Base seat-level, deliberately NOT a stance fragment. The stance renderers
102
+ * carry a byte-identity constraint between stances (`advocate` must render the
103
+ * legacy string), so delivering this through them would make the verdict
104
+ * available under some stances and not others — while the vocabulary the graph
105
+ * grants is the same for all three. A stance may still lean on a close call;
106
+ * that is `stancePreContactConsultRule`, appended after this.
107
+ *
108
+ * The two halves matter equally. The first says the pause is FREE: the whole
109
+ * cost of an outreach is that it reaches someone, and this one has not, so the
110
+ * pause is invisible and an unanswered pause lands exactly where a pass would.
111
+ * The second draws the line the admission policy cannot see — a doubt about
112
+ * the client's OWN criteria is theirs to settle; a candidate who plainly
113
+ * contradicts the signal is the agent's to judge, and asking about that spends
114
+ * the client's attention to confirm something already known.
115
+ */
116
+ const PRE_CONTACT_ASK_USER_RULE = `
117
+ - BEFORE ANY CONTACT, "ask_user" is a THIRD verdict on this opening turn, alongside reaching out and letting the match pass. Nothing has been sent and nothing is sent while you wait: the counterparty is never told this match was considered, and if {userName} does not answer in time the match simply passes — the same outcome as passing now, reached later.
118
+ - Use it when ONE fact you do not hold is what stands between you and the decision, and only {userName} holds it: how their own criteria bound this search, what they meant by a term in their own signal, whether a strong candidate just outside the literal wording is in scope. Ask about the SIGNAL's scope, not about this candidate — their answer has to hold for the next candidate too.
119
+ - Do NOT use it when the evidence in front of you already decides: if this candidate plainly does not satisfy what {userName} asked for, pass, and pass silently. A contradiction is yours to judge; making {userName} confirm it spends their attention on a decision you could already make.`;
96
120
  /** v2 counterparty seat: receiving stance — acceptance is this seat's decision alone. */
97
121
  const V2_COUNTERPARTY_RULES = `- You hold the RECEIVING seat: the other side reached out to {userName}. Whether to accept is YOUR seat's decision alone.
98
122
  - Evaluate the initiator's arguments. Either:
@@ -155,6 +179,18 @@ export class IndexNegotiator {
155
179
  // otherwise render the client's private thread with no rule explaining
156
180
  // what it is for.
157
181
  const clientDm = canAskUser ? input.clientDm ?? [] : [];
182
+ // The opening initiator turn: nothing has been sent, so a granted
183
+ // consultation is the pre-contact verdict rather than a mid-exchange
184
+ // pause. Derived, not passed: the graph grants `canAskUser` on a turn-0
185
+ // initiator turn only when the pre-contact admission and its per-signal
186
+ // bound both hold, so the grant plus the turn's own shape is the fact.
187
+ const preContactConsult = canAskUser && seat === "initiator"
188
+ && input.history.length === 0 && input.isContinuation !== true;
189
+ // The resume after such a pause. The negotiation's whole record is its own
190
+ // consultation park, so this is still the opening decision — the client
191
+ // answered, and the seat now reaches out or lets the match pass.
192
+ const preContactResume = version === "v2" && seat === "initiator"
193
+ && isPreContactConsultResume(input.history);
158
194
  // Negotiator stance (IND-611). Resolved from the environment once per turn
159
195
  // via the domain contract, exactly like `configuredScreenMode()`. Under the
160
196
  // `advocate` default every stance fragment below is the legacy string, so
@@ -171,7 +207,11 @@ export class IndexNegotiator {
171
207
  const actionRules = (version === "v2"
172
208
  ? (seat === "initiator" ? V2_INITIATOR_RULES : V2_COUNTERPARTY_RULES)
173
209
  : V1_ACTION_RULES) + stanceActionRules(stance)
174
- + (canAskUser ? ASK_USER_RULE + (clientDm.length > 0 ? ASK_USER_DM_GROUNDING_RULE : "") : "");
210
+ + (canAskUser
211
+ ? ASK_USER_RULE
212
+ + (preContactConsult ? PRE_CONTACT_ASK_USER_RULE + stancePreContactConsultRule(stance) : "")
213
+ + (clientDm.length > 0 ? ASK_USER_DM_GROUNDING_RULE : "")
214
+ : "");
175
215
  const finalTurnInstruction = input.isFinalTurn
176
216
  ? (version === "v2"
177
217
  ? (seat === "initiator"
@@ -245,11 +285,20 @@ ${stanceQuerySatisfiedRule(stance, otherName, userName)}`
245
285
  // "make your own case for it" — which, for the initiator seat, reads as
246
286
  // an instruction to re-open, and produced a fresh outreach on every one
247
287
  // of its turns instead of a reply.
248
- const priorDialoguePolicy = input.isContinuation
249
- ? 'Policy: You are continuing a prior dialogue. If this signal is materially the same as one you previously evaluated, you may resolve quickly. If materially different, evaluate on its own merits.'
250
- : input.history.length > 0
251
- ? 'Policy: This negotiation is already under way — the turns above under the current opportunity are THIS exchange. Respond to the counterparty\'s latest turn; do not restate or re-pitch your opening.'
252
- : 'Policy: This signal is NEW — you have not negotiated it before. The dialogue above concluded on other signals and is background only. Evaluate this one on its own merits and make your own case for it.';
288
+ //
289
+ // A pre-contact resume is checked FIRST. It reads as a continuation to
290
+ // every existing test here (`isContinuation` is true — the negotiation has
291
+ // spoken), but the only thing it said was its own pause, and both the
292
+ // continuation policy ("you may resolve quickly") and the mid-exchange
293
+ // policy ("respond to the counterparty's latest turn") describe an
294
+ // exchange that has not happened.
295
+ const priorDialoguePolicy = preContactResume
296
+ ? `Policy: You have NOT contacted ${otherName} about this signal. The only turn above is your own pause to consult ${userName} before deciding — there is no exchange to respond to, and nothing has been sent. This is still the opening decision.`
297
+ : input.isContinuation
298
+ ? 'Policy: You are continuing a prior dialogue. If this signal is materially the same as one you previously evaluated, you may resolve quickly. If materially different, evaluate on its own merits.'
299
+ : input.history.length > 0
300
+ ? 'Policy: This negotiation is already under way — the turns above under the current opportunity are THIS exchange. Respond to the counterparty\'s latest turn; do not restate or re-pitch your opening.'
301
+ : 'Policy: This signal is NEW — you have not negotiated it before. The dialogue above concluded on other signals and is background only. Evaluate this one on its own merits and make your own case for it.';
253
302
  const priorDialogueContext = hasPriorDialogue
254
303
  ? `\n\n--- Prior dialogue with this counterparty ---\n${attributionPreamble}${priorDialogueBody}\n\n--- New signal under evaluation ---\n${input.discoveryQuery
255
304
  ? `Discovery query: "${input.discoveryQuery}"`
@@ -291,7 +340,11 @@ ${input.otherUser.intents.map((i) => `- ${i.title}: ${i.description}`).join("\n"
291
340
 
292
341
  Why this match was suggested: ${input.seedAssessment.reasoning}${hasPriorDialogue ? priorDialogueContext : historyText}${clientDmContext}${userAnswersContext}${privateConsultationContext}
293
342
  ${discoveryQueryReminder}
294
- ${input.history.length === 0 && !input.isContinuation ? (version === "v2" && seat === "initiator" ? "This is the opening turn. Make the outreach case." : "This is the opening turn. Propose the connection case.") : "Evaluate the latest arguments and respond."}`;
343
+ ${preContactResume
344
+ ? `You paused this opening turn to ask ${userName} the one thing you could not decide without. Their answer is above. Take the opening decision now: "outreach" to make the case, or "withdraw" to let the match pass without ever contacting ${otherName}.`
345
+ : input.history.length === 0 && !input.isContinuation
346
+ ? (version === "v2" && seat === "initiator" ? "This is the opening turn. Make the outreach case." : "This is the opening turn. Propose the connection case.")
347
+ : "Evaluate the latest arguments and respond."}`;
295
348
  const chatMessages = [
296
349
  { role: "system", content: systemPrompt },
297
350
  { role: "user", content: userMessage },
@@ -36,3 +36,60 @@ export declare function consultationPromptFor(reason: NegotiationConsultationRea
36
36
  disclosureSubject: string;
37
37
  draftQuestion: string;
38
38
  };
39
+ /**
40
+ * How many pre-contact consultations one intent may hold OPEN at once.
41
+ *
42
+ * A vague signal can surface many candidates in a batch, and the doubt that
43
+ * blocks the first ("does 'academic linguistics' strictly bound this, or is
44
+ * adjacent depth in scope?") is usually the same doubt that blocks all of
45
+ * them. One answer generalizes: it lands in the signal's DM, and the DM is
46
+ * injected into every later turn-0 decision on that signal. The cap exists
47
+ * for the agent that does not internalize that and would interrogate its
48
+ * client candidate-by-candidate.
49
+ *
50
+ * Two, not one: a second genuinely different question about the same signal
51
+ * is plausible, a third is a pattern. Past the cap the action is simply not
52
+ * offered and the seat falls back to today's binary reach-out-or-pass.
53
+ */
54
+ export declare const MAX_OPEN_PRE_CONTACT_CONSULTS_PER_INTENT = 2;
55
+ /**
56
+ * Whether a negotiation's own turns show it has never contacted the
57
+ * counterparty — every turn it holds is a client-consultation park.
58
+ *
59
+ * This is what makes a post-consult resume still-the-opening. A mid-flight
60
+ * consult always has an `outreach` behind it (the counterparty seat cannot
61
+ * even speak before one), so this is false for every park the pre-contact
62
+ * verdict did not create, and the resume rules it gates stay off the
63
+ * mid-flight path by construction rather than by a flag.
64
+ */
65
+ export declare function isPreContactConsultResume(turns: readonly {
66
+ action: NegotiationAction;
67
+ }[]): boolean;
68
+ /**
69
+ * Turn-context key stamped on a pre-contact park. Written at park time beside
70
+ * the ask-user binding, read back by {@link countOpenPreContactConsults} — the
71
+ * park row is the only durable record of the consultation, so the marker lives
72
+ * with it rather than in a separate counter that could drift.
73
+ */
74
+ export declare const PRE_CONTACT_CONSULT_MARKER = "preContactConsult";
75
+ /** Minimal task shape the open-consult count reads; matches `getTasksForUser`. */
76
+ export interface PreContactConsultTaskRow {
77
+ id: string;
78
+ state: string;
79
+ metadata: Record<string, unknown> | null;
80
+ }
81
+ /**
82
+ * Count the pre-contact consultations currently open for one `(user, intent)`
83
+ * scope, from the user's own negotiation tasks.
84
+ *
85
+ * "Open" is derived from the durable park itself — an `input_required` task
86
+ * whose captured ask-user binding names this recipient pair and whose turn
87
+ * context carries the pre-contact stamp — so answering, expiry, and resume all
88
+ * retire a park from the count with no counter to keep in step. Mid-flight
89
+ * consults carry no stamp and never count.
90
+ */
91
+ export declare function countOpenPreContactConsults(tasks: readonly PreContactConsultTaskRow[], scope: {
92
+ userId: string;
93
+ intentId: string;
94
+ excludeTaskId?: string;
95
+ }): number;
@@ -13,7 +13,6 @@ export function negotiationConsultationPolicyMode() {
13
13
  */
14
14
  export function assessConsultationEligibility(input) {
15
15
  if (input.protocolVersion !== "v2"
16
- || input.isOpeningTurn
17
16
  || input.isFinalTurn
18
17
  || input.screenedOut
19
18
  || input.previouslyConsulted
@@ -21,6 +20,40 @@ export function assessConsultationEligibility(input) {
21
20
  || !input.lifecycleValid
22
21
  || isObviousTerminal(input.action))
23
22
  return { eligible: false };
23
+ // ─── Pre-contact consultation (the opening turn) ──────────────────────────
24
+ // Before this branch the opening turn was a blanket exclusion, so the
25
+ // initiator's turn-0 vocabulary was binary: reach out, or pass with the
26
+ // counterparty never contacted. A third verdict is admissible when the
27
+ // blocking doubt is one only the client can settle — how their own criteria
28
+ // bound the search — and the pause costs the counterparty nothing, because
29
+ // nothing has been sent.
30
+ //
31
+ // The admission is deliberately NARROW, and narrower than the mid-flight
32
+ // rules below:
33
+ //
34
+ // - INITIATOR ONLY. A turn-0 counterparty seat (tie-break inheritance) is
35
+ // responding to an outreach, so its client's criteria are not what is
36
+ // blocking; there is no pre-contact position to protect either.
37
+ // - A MODEL-AUTHORED `ask_user` ONLY. Every rule below infers a
38
+ // consultation from a draft that asked for something else, which needs
39
+ // history to be a safe inference — and at turn 0 there is none. The
40
+ // acting agent is the only party that has read the client's own signal,
41
+ // so the pre-contact case is the one where volunteering the pause is the
42
+ // evidence, not a substitute for it. This also keeps the distinction the
43
+ // graph prompt draws — client-resolvable scope doubt consults;
44
+ // counterparty evidence that contradicts the match passes silently —
45
+ // resolvable HERE only as "the agent did not ask", which is exactly what
46
+ // a contradiction-shaped doubt produces.
47
+ //
48
+ // The category is fixed rather than read off the draft: a pre-contact pause
49
+ // is by construction an unresolved constraint the OWNER controls. The
50
+ // consult counts as an ordinary ask round (the graph reads the same
51
+ // `negotiationAskRoundsCap` substrate before granting the action at all).
52
+ if (input.isOpeningTurn) {
53
+ return input.seat === "initiator" && input.action === "ask_user"
54
+ ? { eligible: true, reason: "unresolved_owner_constraint" }
55
+ : { eligible: false };
56
+ }
24
57
  // A patient-side counter is a schema-constrained, source-safe signal that
25
58
  // the owner must decide whether a consequential disclosure or permission is
26
59
  // acceptable. This is reachable under the normal v2 action vocabulary; it
@@ -66,3 +99,61 @@ export function consultationPromptFor(reason) {
66
99
  function isObviousTerminal(action) {
67
100
  return action === "accept" || action === "reject" || action === "withdraw" || action === "decline";
68
101
  }
102
+ // ─── Pre-contact consultation: bounds and recognition ────────────────────────
103
+ /**
104
+ * How many pre-contact consultations one intent may hold OPEN at once.
105
+ *
106
+ * A vague signal can surface many candidates in a batch, and the doubt that
107
+ * blocks the first ("does 'academic linguistics' strictly bound this, or is
108
+ * adjacent depth in scope?") is usually the same doubt that blocks all of
109
+ * them. One answer generalizes: it lands in the signal's DM, and the DM is
110
+ * injected into every later turn-0 decision on that signal. The cap exists
111
+ * for the agent that does not internalize that and would interrogate its
112
+ * client candidate-by-candidate.
113
+ *
114
+ * Two, not one: a second genuinely different question about the same signal
115
+ * is plausible, a third is a pattern. Past the cap the action is simply not
116
+ * offered and the seat falls back to today's binary reach-out-or-pass.
117
+ */
118
+ export const MAX_OPEN_PRE_CONTACT_CONSULTS_PER_INTENT = 2;
119
+ /**
120
+ * Whether a negotiation's own turns show it has never contacted the
121
+ * counterparty — every turn it holds is a client-consultation park.
122
+ *
123
+ * This is what makes a post-consult resume still-the-opening. A mid-flight
124
+ * consult always has an `outreach` behind it (the counterparty seat cannot
125
+ * even speak before one), so this is false for every park the pre-contact
126
+ * verdict did not create, and the resume rules it gates stay off the
127
+ * mid-flight path by construction rather than by a flag.
128
+ */
129
+ export function isPreContactConsultResume(turns) {
130
+ return turns.length > 0 && turns.every((turn) => turn.action === "ask_user");
131
+ }
132
+ /**
133
+ * Turn-context key stamped on a pre-contact park. Written at park time beside
134
+ * the ask-user binding, read back by {@link countOpenPreContactConsults} — the
135
+ * park row is the only durable record of the consultation, so the marker lives
136
+ * with it rather than in a separate counter that could drift.
137
+ */
138
+ export const PRE_CONTACT_CONSULT_MARKER = "preContactConsult";
139
+ /**
140
+ * Count the pre-contact consultations currently open for one `(user, intent)`
141
+ * scope, from the user's own negotiation tasks.
142
+ *
143
+ * "Open" is derived from the durable park itself — an `input_required` task
144
+ * whose captured ask-user binding names this recipient pair and whose turn
145
+ * context carries the pre-contact stamp — so answering, expiry, and resume all
146
+ * retire a park from the count with no counter to keep in step. Mid-flight
147
+ * consults carry no stamp and never count.
148
+ */
149
+ export function countOpenPreContactConsults(tasks, scope) {
150
+ return tasks.filter((task) => {
151
+ if (task.state !== "input_required" || task.id === scope.excludeTaskId)
152
+ return false;
153
+ const turnContext = task.metadata?.turnContext;
154
+ if (turnContext?.[PRE_CONTACT_CONSULT_MARKER] !== true)
155
+ return false;
156
+ const binding = turnContext.askUserBinding;
157
+ return binding?.recipientUserId === scope.userId && binding.recipientIntentId === scope.intentId;
158
+ }).length;
159
+ }
@@ -3,12 +3,48 @@
3
3
  */
4
4
  import { requestContext } from "../shared/observability/request-context.js";
5
5
  import { allowedActionsFor, askUserAnswerWindowMs, configuredAskUserEnabled, fallbackActionFor, negotiationAskRoundsCap, rejectActionFor } from "./negotiation.protocol.js";
6
- import { assessConsultationEligibility, consultationPromptFor, negotiationConsultationPolicyMode } from "./negotiation.consultation-policy.js";
6
+ import { assessConsultationEligibility, consultationPromptFor, countOpenPreContactConsults, isPreContactConsultResume, MAX_OPEN_PRE_CONTACT_CONSULTS_PER_INTENT, negotiationConsultationPolicyMode, PRE_CONTACT_CONSULT_MARKER } from "./negotiation.consultation-policy.js";
7
7
  import { blocksNegotiationBeforeFirstTurn } from "./negotiation.screen.js";
8
8
  import { assessDeadlock, configuredDeadlockShiftEnabled, configuredDeadlockThreshold } from "./negotiation.deadlock.js";
9
9
  import { NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY, NEGOTIATION_QUESTION_GENERIC_NETWORK, isSafeAuthoredNegotiationQuestion, negotiationQuestionSettlementId } from './negotiation.question-safety.js';
10
10
  import { isNegotiationTurnCapReached } from "./negotiation.turn-cap.js";
11
11
  import { buildAttributedDialogue, countNegotiationAskRounds, hasPriorAskUser, memoryQueryText, retrieveClientDm, retrieveMemory, turnLog, turnsFromMessages } from "./negotiation.graph.shared.js";
12
+ /**
13
+ * Whether this signal may open another pre-contact consultation.
14
+ *
15
+ * The count comes from the user's own parked negotiation tasks — the durable
16
+ * parks themselves — rather than a stored counter, so an answered, expired, or
17
+ * resumed park frees its slot with nothing to keep in step. The acting task is
18
+ * excluded: it is `working` at this point, but a retried turn on a task that
19
+ * already parked must not count itself out.
20
+ *
21
+ * Fails OPEN. This bound defends against an agent that would re-ask the same
22
+ * signal-level question candidate by candidate; it is not the safety gate. The
23
+ * per-negotiation ration and the ask-rounds cap already hold that line from
24
+ * the message record, and neither depends on this query — so a database blip
25
+ * must not silently retire the turn-0 verdict.
26
+ */
27
+ async function preContactConsultsUnderCap(deps, userId, intentId, actingTaskId) {
28
+ try {
29
+ const parked = await deps.database.getTasksForUser(userId, { state: 'input_required' });
30
+ const open = countOpenPreContactConsults(parked, { userId, intentId, excludeTaskId: actingTaskId });
31
+ if (open >= MAX_OPEN_PRE_CONTACT_CONSULTS_PER_INTENT) {
32
+ turnLog.info('negotiation_pre_contact_consult_capped', {
33
+ userId, intentId, open, cap: MAX_OPEN_PRE_CONTACT_CONSULTS_PER_INTENT,
34
+ });
35
+ return false;
36
+ }
37
+ return true;
38
+ }
39
+ catch (err) {
40
+ turnLog.warn('Pre-contact consult cap check failed; proceeding without the per-signal bound', {
41
+ userId,
42
+ intentId,
43
+ error: err instanceof Error ? err.message : String(err),
44
+ });
45
+ return true;
46
+ }
47
+ }
12
48
  export async function turnNode(state, deps) {
13
49
  const traceEmitter = requestContext.getStore()?.traceEmitter;
14
50
  // Local helper to emit events whose shape is wider than the declared
@@ -45,7 +81,24 @@ export async function turnNode(state, deps) {
45
81
  // also persist `ask_user` messages — count against the same budget,
46
82
  // and a negotiation near its cap cannot spend a further round here.
47
83
  const policyMode = negotiationConsultationPolicyMode();
48
- const askUserAvailable = version === 'v2'
84
+ // The opening turn, before anything is sent. `outreachOpened` is per-run
85
+ // and history is this negotiation's own record, so this is true exactly
86
+ // once per negotiation — and stays true across a pre-contact park, whose
87
+ // resume re-enters holding nothing but its own `ask_user` turn.
88
+ const isFreshOpeningTurn = state.turnCount === 0 && !state.isContinuation;
89
+ const isPreContactResume = state.turnCount === 0 && isPreContactConsultResume(history);
90
+ // Pre-contact consultation (the turn-0 third verdict). The initiator may
91
+ // consult its client BEFORE deciding whether to reach out, so the seat's
92
+ // opening vocabulary stops being binary. Everything downstream is the
93
+ // shipped consult loop unchanged: same park, same binding, same question
94
+ // routing, same expiry. Only admission moves.
95
+ //
96
+ // Bounded twice over. Per negotiation by the same ration and ask-rounds
97
+ // cap the mid-flight consult reads (this consult IS round 1). Per signal
98
+ // by the open-park count below, so one vague intent cannot interrogate
99
+ // its client candidate-by-candidate.
100
+ const preContactConsultShapeAvailable = isFreshOpeningTurn && seat === 'initiator';
101
+ const askUserWiringAvailable = version === 'v2'
49
102
  && !isFinalTurn
50
103
  && configuredAskUserEnabled()
51
104
  && !!deps.questionerEnqueue
@@ -53,9 +106,12 @@ export async function turnNode(state, deps) {
53
106
  && !!state.opportunityId
54
107
  && !!ownIntentId
55
108
  && !!state.indexContext.networkId
56
- && !(state.turnCount === 0 && !state.isContinuation)
109
+ && (!isFreshOpeningTurn || preContactConsultShapeAvailable)
57
110
  && !hasPriorAskUser(state.messages, ownUser.id)
58
111
  && countNegotiationAskRounds(state.messages) < negotiationAskRoundsCap();
112
+ const askUserAvailable = askUserWiringAvailable
113
+ && (!preContactConsultShapeAvailable
114
+ || await preContactConsultsUnderCap(deps, ownUser.id, ownIntentId, state.taskId));
59
115
  // ─── Deadlock detection → persuasion→bargaining stance (IND-428) ──────
60
116
  // Deterministic trailing-run inspection of the persisted history — no
61
117
  // LLM in the decision. Gated on the strict default-off flag AND v2,
@@ -215,7 +271,17 @@ export async function turnNode(state, deps) {
215
271
  // Exact ask_user resumes are exempt: the successor is the SAME logical
216
272
  // negotiation resumed after the client answered, so post-consultation
217
273
  // withdraw is legitimate.
218
- if (turn.action === 'withdraw' && !state.outreachOpened && !state.continuationExecution) {
274
+ //
275
+ // A PRE-CONTACT resume is exempt from that exemption. The exemption exists
276
+ // because a mid-flight consult has an outreach behind it — there is a
277
+ // message on the table, and walking away from it is a real move. A
278
+ // pre-contact park has nothing behind it: the counterparty was never
279
+ // contacted, so the post-consult refusal is still the opening refusal and
280
+ // must land on the same quiet `screened_out` outcome the unconsulted pass
281
+ // lands on. This is also what makes an UNANSWERED pre-contact consult
282
+ // resolve to today's behavior — the expiry worker resumes through exactly
283
+ // this path.
284
+ if (turn.action === 'withdraw' && !state.outreachOpened && (!state.continuationExecution || isPreContactResume)) {
219
285
  turnLog.info('negotiation_opening_withdraw_screened_out', {
220
286
  taskId: state.taskId,
221
287
  opportunityId: state.opportunityId || undefined,
@@ -234,11 +300,21 @@ export async function turnNode(state, deps) {
234
300
  // A legitimate turn-0 refusal never reaches here: the opening-withdraw
235
301
  // guard above already returned. What remains are genuinely malformed
236
302
  // openings (a turn-0 `counter`/`question`), which are still coerced.
237
- if (state.turnCount === 0 && !state.isContinuation) {
303
+ //
304
+ // A pre-contact resume is coerced by the same rule: nothing was ever sent,
305
+ // so the negotiation still has to OPEN, and a `counter`/`question` there
306
+ // would make the counterparty's first sight of this match a mid-exchange
307
+ // reply. An admissible `ask_user` is the one non-opening action left to
308
+ // stand — it is the turn-0 third verdict, not a malformed opening.
309
+ if (isFreshOpeningTurn || isPreContactResume) {
238
310
  const openingAction = version === 'v2' ? 'outreach' : 'propose';
239
- if ((version !== 'v2' || seat === 'initiator') && turn.action !== openingAction) {
311
+ const consultingInstead = turn.action === 'ask_user' && askUserAvailable;
312
+ if ((version !== 'v2' || seat === 'initiator') && turn.action !== openingAction && !consultingInstead) {
240
313
  turnLog.warn(`Agent returned unexpected action on turn 0, forcing to ${openingAction}`, { action: turn.action });
241
- turn.action = openingAction;
314
+ // Rebind rather than mutate: `turn` may be the very object a dispatched
315
+ // personal agent returned, and every other rewrite in this function
316
+ // already replaces it instead of editing it in place.
317
+ turn = { ...turn, action: openingAction };
242
318
  }
243
319
  }
244
320
  // IND-508 deterministic admission is evaluated only after the opening
@@ -249,7 +325,7 @@ export async function turnNode(state, deps) {
249
325
  const policyEligibility = policyMode === 'off' ? { eligible: false } : assessConsultationEligibility({
250
326
  protocolVersion: version,
251
327
  seat,
252
- isOpeningTurn: state.turnCount === 0 && !state.isContinuation,
328
+ isOpeningTurn: isFreshOpeningTurn,
253
329
  isFinalTurn,
254
330
  screenedOut: blocksNegotiationBeforeFirstTurn(state.screenDecision, state.turnCount),
255
331
  action: turn.action,
@@ -272,17 +348,36 @@ export async function turnNode(state, deps) {
272
348
  if (policyEligibility.eligible && policyEligibility.reason) {
273
349
  emitConsultationTelemetry('eligible', policyEligibility.reason);
274
350
  if (policyMode === 'on') {
275
- consultationPolicyReason = policyEligibility.reason;
276
- turn = {
277
- ...turn,
278
- action: 'ask_user',
279
- message: null,
280
- assessment: {
281
- reasoning: 'Client consultation required.',
282
- suggestedRoles: turn.assessment.suggestedRoles,
283
- },
284
- askUser: { reason: consultationPolicyReason },
285
- };
351
+ // Two shapes reach here and the policy owes them different things.
352
+ //
353
+ // A draft that asked for something ELSE is REPLACED: the policy
354
+ // inferred the consultation, so nothing in that draft was written to
355
+ // be read by the client, and its reasoning/message may carry material
356
+ // the client's question must not (the disclosure and authority
357
+ // categories are inferred from exactly such drafts).
358
+ //
359
+ // A draft that already IS `ask_user` is only ADMITTED. Here the policy
360
+ // is the gate, not the author: the agent volunteered the pause and
361
+ // wrote the question its client reads, and it is the only party that
362
+ // has read this negotiation. Overwriting `askUser` would discard that
363
+ // question and park on a server template — which is what the
364
+ // pre-contact verdict has nothing to fall back on, since a turn-0
365
+ // park has no transcript for the client to read instead. The authored
366
+ // payload still faces the identifier-aware safety gate below.
367
+ const draftedOwnConsultation = turn.action === 'ask_user' && !!turn.askUser;
368
+ consultationPolicyReason = draftedOwnConsultation ? turn.askUser.reason : policyEligibility.reason;
369
+ if (!draftedOwnConsultation) {
370
+ turn = {
371
+ ...turn,
372
+ action: 'ask_user',
373
+ message: null,
374
+ assessment: {
375
+ reasoning: 'Client consultation required.',
376
+ suggestedRoles: turn.assessment.suggestedRoles,
377
+ },
378
+ askUser: { reason: consultationPolicyReason },
379
+ };
380
+ }
286
381
  emitConsultationTelemetry('asked', consultationPolicyReason);
287
382
  }
288
383
  }
@@ -412,6 +507,11 @@ export async function turnNode(state, deps) {
412
507
  seedAssessment: state.seedAssessment,
413
508
  ...(isSource && state.discoveryQuery && { discoveryQuery: state.discoveryQuery }),
414
509
  ...(consultationReason && { consultationPolicyReason: consultationReason }),
510
+ // Marks a park the counterparty has never been contacted about, so
511
+ // the per-signal open-consult cap can count these without counting
512
+ // mid-flight consults. Read back by `countOpenPreContactConsults`;
513
+ // the park row is the only durable record, so the stamp lives on it.
514
+ ...(isFreshOpeningTurn ? { [PRE_CONTACT_CONSULT_MARKER]: true } : {}),
415
515
  },
416
516
  ...(state.continuationExecution ? { continuationExecution: state.continuationExecution } : {}),
417
517
  });
@@ -70,6 +70,13 @@ export declare function stanceJobFraming(stance: NegotiatorStance): string;
70
70
  * own rules. Empty under `advocate` → byte-identical.
71
71
  */
72
72
  export declare function stanceActionRules(stance: NegotiatorStance): string;
73
+ /**
74
+ * Stance contribution to the pre-contact consultation rule. Empty under
75
+ * `advocate` and `evaluator` — the base seat-level rule already states when
76
+ * the verdict applies, and only the skeptic's not-worth-making prior changes
77
+ * which way a close call should fall.
78
+ */
79
+ export declare function stancePreContactConsultRule(stance: NegotiatorStance): string;
73
80
  /**
74
81
  * The discovery-query satisfaction rule.
75
82
  *
@@ -153,6 +153,31 @@ export function stanceActionRules(stance) {
153
153
  : CONSULT_PROPENSITY_RULE;
154
154
  return VALUE_BAR_RULE + consultRule;
155
155
  }
156
+ /**
157
+ * `skeptic` sharpening of the pre-contact consultation rule (the turn-0 third
158
+ * verdict). Appended to the base rule in `negotiation.agent.ts`, which renders
159
+ * it only on an opening initiator turn that actually holds the grant — so
160
+ * unlike every other fragment here this one is not seat- and version-blind,
161
+ * and does not need to be: it can never reach a seat without the vocabulary.
162
+ *
163
+ * The base rule sets when consulting is warranted. This sets which way to lean
164
+ * when it is genuinely a toss-up, and the skeptic's prior is what makes the
165
+ * lean asymmetric: under "most matches are not worth making" a pass is the
166
+ * cheap default, which is exactly why an unverified pass deserves the same
167
+ * suspicion as an unverified acceptance. Both decide for the client.
168
+ *
169
+ * Names no action and no mechanism, like the rest of this module.
170
+ */
171
+ const SKEPTIC_PRE_CONTACT_LEAN = ` When it is genuinely close, lean toward asking rather than passing. Your prior is that most matches are not worth making — which makes passing the cheap answer, and a pass you reached by GUESSING at {userName}'s own criteria decides for them just as much as a connection you made by guessing. Nothing has been spent yet: the pause costs the counterparty nothing and costs {userName} one question.`;
172
+ /**
173
+ * Stance contribution to the pre-contact consultation rule. Empty under
174
+ * `advocate` and `evaluator` — the base seat-level rule already states when
175
+ * the verdict applies, and only the skeptic's not-worth-making prior changes
176
+ * which way a close call should fall.
177
+ */
178
+ export function stancePreContactConsultRule(stance) {
179
+ return stance === "skeptic" ? SKEPTIC_PRE_CONTACT_LEAN : "";
180
+ }
156
181
  /**
157
182
  * The discovery-query satisfaction rule.
158
183
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indexnetwork/protocol",
3
- "version": "22.0.0-rc.495.1",
3
+ "version": "22.0.0-rc.496.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",