@indexnetwork/protocol 22.0.0-rc.495.1 → 22.0.0-rc.497.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,10 +179,28 @@ 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
161
197
  // the rendered prompt is byte-identical to the pre-IND-611 build.
198
+ //
199
+ // `stanceActionRules` also takes the resolved `seat`: the responder
200
+ // verification rules are a duty of the seat that did NOT open, so they
201
+ // render only there. The resolved seat, not `input.seat`, so the v1
202
+ // `isDiscoverer` fallback decides it there too — under v1 the discoverer
203
+ // is likewise the side that opens.
162
204
  const stance = configuredNegotiatorStance();
163
205
  const schema = turnSchemaFor(version, seat, isFinalTurn, {
164
206
  system: SystemNegotiationTurnSchema,
@@ -170,8 +212,12 @@ export class IndexNegotiator {
170
212
  const networkContext = input.indexContext.prompt || "General discovery";
171
213
  const actionRules = (version === "v2"
172
214
  ? (seat === "initiator" ? V2_INITIATOR_RULES : V2_COUNTERPARTY_RULES)
173
- : V1_ACTION_RULES) + stanceActionRules(stance)
174
- + (canAskUser ? ASK_USER_RULE + (clientDm.length > 0 ? ASK_USER_DM_GROUNDING_RULE : "") : "");
215
+ : V1_ACTION_RULES) + stanceActionRules(stance, seat)
216
+ + (canAskUser
217
+ ? ASK_USER_RULE
218
+ + (preContactConsult ? PRE_CONTACT_ASK_USER_RULE + stancePreContactConsultRule(stance) : "")
219
+ + (clientDm.length > 0 ? ASK_USER_DM_GROUNDING_RULE : "")
220
+ : "");
175
221
  const finalTurnInstruction = input.isFinalTurn
176
222
  ? (version === "v2"
177
223
  ? (seat === "initiator"
@@ -245,11 +291,20 @@ ${stanceQuerySatisfiedRule(stance, otherName, userName)}`
245
291
  // "make your own case for it" — which, for the initiator seat, reads as
246
292
  // an instruction to re-open, and produced a fresh outreach on every one
247
293
  // 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.';
294
+ //
295
+ // A pre-contact resume is checked FIRST. It reads as a continuation to
296
+ // every existing test here (`isContinuation` is true — the negotiation has
297
+ // spoken), but the only thing it said was its own pause, and both the
298
+ // continuation policy ("you may resolve quickly") and the mid-exchange
299
+ // policy ("respond to the counterparty's latest turn") describe an
300
+ // exchange that has not happened.
301
+ const priorDialoguePolicy = preContactResume
302
+ ? `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.`
303
+ : input.isContinuation
304
+ ? '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.'
305
+ : input.history.length > 0
306
+ ? '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.'
307
+ : '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
308
  const priorDialogueContext = hasPriorDialogue
254
309
  ? `\n\n--- Prior dialogue with this counterparty ---\n${attributionPreamble}${priorDialogueBody}\n\n--- New signal under evaluation ---\n${input.discoveryQuery
255
310
  ? `Discovery query: "${input.discoveryQuery}"`
@@ -291,7 +346,11 @@ ${input.otherUser.intents.map((i) => `- ${i.title}: ${i.description}`).join("\n"
291
346
 
292
347
  Why this match was suggested: ${input.seedAssessment.reasoning}${hasPriorDialogue ? priorDialogueContext : historyText}${clientDmContext}${userAnswersContext}${privateConsultationContext}
293
348
  ${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."}`;
349
+ ${preContactResume
350
+ ? `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}.`
351
+ : input.history.length === 0 && !input.isContinuation
352
+ ? (version === "v2" && seat === "initiator" ? "This is the opening turn. Make the outreach case." : "This is the opening turn. Propose the connection case.")
353
+ : "Evaluate the latest arguments and respond."}`;
295
354
  const chatMessages = [
296
355
  { role: "system", content: systemPrompt },
297
356
  { 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
  });
@@ -9,11 +9,11 @@
9
9
  *
10
10
  * `NEGOTIATOR_STANCE` makes that stance configurable instead of hard-coded:
11
11
  *
12
- * | stance | framing | value bar | query rule | consult propensity | deadlock |
13
- * |-------------|--------------------------------------|------------------|-------------------------|-------------------------|-----------|
14
- * | `advocate` | argue the case (today) | none | mandate (today) | none (today) | bargain |
15
- * | `evaluator` | assess first, advocate if it survives | opportunity-cost | necessary-not-sufficient| prefer over assumption | bargain |
16
- * | `skeptic` | + "most matches are not worth making" | opportunity-cost | necessary-not-sufficient| + unverified = don't proceed | stalemate |
12
+ * | stance | framing | value bar | query rule | consult propensity | responder check | deadlock |
13
+ * |-------------|--------------------------------------|------------------|-------------------------|-------------------------|--------------------------|-----------|
14
+ * | `advocate` | argue the case (today) | none | mandate (today) | none (today) | none (today) | bargain |
15
+ * | `evaluator` | assess first, advocate if it survives | opportunity-cost | necessary-not-sufficient| prefer over assumption | verify the opening | bargain |
16
+ * | `skeptic` | + "most matches are not worth making" | opportunity-cost | necessary-not-sufficient| + unverified = don't proceed | + probe before accepting | stalemate |
17
17
  *
18
18
  * Design constraints (hard):
19
19
  * - **`advocate` is byte-identical.** Every fragment below is additive and
@@ -35,7 +35,16 @@
35
35
  * Fragments deliberately never contain the literal `ask_user` or a quoted
36
36
  * `"withdraw"`: they render into every seat and protocol version, and the seat
37
37
  * specs pin that those tokens appear only where the seat legally holds them.
38
+ *
39
+ * One family of fragments is the exception to that seat-blindness by
40
+ * construction rather than by accident: the responder verification rules
41
+ * (`stanceVerifiesResponderFit`) address a duty only the RESPONDING seat has —
42
+ * reading someone else's opening — so `stanceActionRules` takes the seat and
43
+ * renders them only there. They still name no action and no mechanism, so the
44
+ * seat's own rules and the graph's grants stay the sole authority on what this
45
+ * turn may actually do.
38
46
  */
47
+ import type { NegotiationSeat } from "../shared/schemas/negotiation-state.schema.js";
39
48
  export declare const NEGOTIATOR_STANCES: readonly ["advocate", "evaluator", "skeptic"];
40
49
  export type NegotiatorStance = (typeof NEGOTIATOR_STANCES)[number];
41
50
  export declare const DEFAULT_NEGOTIATOR_STANCE: NegotiatorStance;
@@ -55,6 +64,11 @@ export declare function stanceAppliesValueBar(stance: NegotiatorStance): boolean
55
64
  * continuing to evaluate rather than as a mandate to connect.
56
65
  */
57
66
  export declare function stanceQueryMatchIsNecessaryNotSufficient(stance: NegotiatorStance): boolean;
67
+ /**
68
+ * Whether this stance asks the RESPONDING seat to verify the opening's account
69
+ * of the fit before accepting it, rather than reading that account as evidence.
70
+ */
71
+ export declare function stanceVerifiesResponderFit(stance: NegotiatorStance): boolean;
58
72
  /** Whether a detected deadlock resolves by stalemate rather than bargaining. */
59
73
  export declare function stanceResolvesDeadlockByStalemate(stance: NegotiatorStance): boolean;
60
74
  /**
@@ -68,8 +82,20 @@ export declare function stanceJobFraming(stance: NegotiatorStance): string;
68
82
  /**
69
83
  * Extra action-rule lines contributed by the stance, appended after the seat's
70
84
  * own rules. Empty under `advocate` → byte-identical.
85
+ *
86
+ * `seat` scopes the responder verification rules to the seat that did NOT
87
+ * open. Everything else here is seat-blind: the value bar and the consult
88
+ * propensity are duties of both seats, and the seat parameter must not become
89
+ * a reason to fork them.
90
+ */
91
+ export declare function stanceActionRules(stance: NegotiatorStance, seat: NegotiationSeat): string;
92
+ /**
93
+ * Stance contribution to the pre-contact consultation rule. Empty under
94
+ * `advocate` and `evaluator` — the base seat-level rule already states when
95
+ * the verdict applies, and only the skeptic's not-worth-making prior changes
96
+ * which way a close call should fall.
71
97
  */
72
- export declare function stanceActionRules(stance: NegotiatorStance): string;
98
+ export declare function stancePreContactConsultRule(stance: NegotiatorStance): string;
73
99
  /**
74
100
  * The discovery-query satisfaction rule.
75
101
  *
@@ -9,11 +9,11 @@
9
9
  *
10
10
  * `NEGOTIATOR_STANCE` makes that stance configurable instead of hard-coded:
11
11
  *
12
- * | stance | framing | value bar | query rule | consult propensity | deadlock |
13
- * |-------------|--------------------------------------|------------------|-------------------------|-------------------------|-----------|
14
- * | `advocate` | argue the case (today) | none | mandate (today) | none (today) | bargain |
15
- * | `evaluator` | assess first, advocate if it survives | opportunity-cost | necessary-not-sufficient| prefer over assumption | bargain |
16
- * | `skeptic` | + "most matches are not worth making" | opportunity-cost | necessary-not-sufficient| + unverified = don't proceed | stalemate |
12
+ * | stance | framing | value bar | query rule | consult propensity | responder check | deadlock |
13
+ * |-------------|--------------------------------------|------------------|-------------------------|-------------------------|--------------------------|-----------|
14
+ * | `advocate` | argue the case (today) | none | mandate (today) | none (today) | none (today) | bargain |
15
+ * | `evaluator` | assess first, advocate if it survives | opportunity-cost | necessary-not-sufficient| prefer over assumption | verify the opening | bargain |
16
+ * | `skeptic` | + "most matches are not worth making" | opportunity-cost | necessary-not-sufficient| + unverified = don't proceed | + probe before accepting | stalemate |
17
17
  *
18
18
  * Design constraints (hard):
19
19
  * - **`advocate` is byte-identical.** Every fragment below is additive and
@@ -35,6 +35,14 @@
35
35
  * Fragments deliberately never contain the literal `ask_user` or a quoted
36
36
  * `"withdraw"`: they render into every seat and protocol version, and the seat
37
37
  * specs pin that those tokens appear only where the seat legally holds them.
38
+ *
39
+ * One family of fragments is the exception to that seat-blindness by
40
+ * construction rather than by accident: the responder verification rules
41
+ * (`stanceVerifiesResponderFit`) address a duty only the RESPONDING seat has —
42
+ * reading someone else's opening — so `stanceActionRules` takes the seat and
43
+ * renders them only there. They still name no action and no mechanism, so the
44
+ * seat's own rules and the graph's grants stay the sole authority on what this
45
+ * turn may actually do.
38
46
  */
39
47
  export const NEGOTIATOR_STANCES = ["advocate", "evaluator", "skeptic"];
40
48
  export const DEFAULT_NEGOTIATOR_STANCE = "advocate";
@@ -63,6 +71,13 @@ export function stanceAppliesValueBar(stance) {
63
71
  export function stanceQueryMatchIsNecessaryNotSufficient(stance) {
64
72
  return stance !== "advocate";
65
73
  }
74
+ /**
75
+ * Whether this stance asks the RESPONDING seat to verify the opening's account
76
+ * of the fit before accepting it, rather than reading that account as evidence.
77
+ */
78
+ export function stanceVerifiesResponderFit(stance) {
79
+ return stance !== "advocate";
80
+ }
66
81
  /** Whether a detected deadlock resolves by stalemate rather than bargaining. */
67
82
  export function stanceResolvesDeadlockByStalemate(stance) {
68
83
  return stance === "skeptic";
@@ -141,17 +156,91 @@ const CONSULT_PROPENSITY_RULE = `
141
156
  * reason not to proceed, and consulting the client is how it gets verified.
142
157
  */
143
158
  const SKEPTIC_CONSULT_SHARPENING = ` For you this is a gate, not a preference: an UNVERIFIED assumption that the two sides' intents actually align is a reason NOT to proceed, and consulting {userName} is how that assumption gets verified.`;
159
+ /**
160
+ * Responder verification — assessing stances, RESPONDING seat only.
161
+ *
162
+ * Two structural gaps this closes, both visible in the failure it was written
163
+ * for: a first-contact outreach accepted in one exchange, on reasoning that
164
+ * restated the opening's own fit claim back as the reason for accepting.
165
+ *
166
+ * 1. The opening enters the prompt as if it were evidence. It is not: it is
167
+ * advocacy authored by the counterparty's agent, and its most load-bearing
168
+ * move is characterizing what THIS client wants. Nothing else in the prompt
169
+ * tells the responding seat to treat that characterization as a claim.
170
+ * 2. `VALUE_BAR_RULE` has no bite in this seat. "Most matches are not worth
171
+ * making" reads as being about MAKING matches, and a responder frames its
172
+ * decision as "would my client be open to connecting?" — nearly costless,
173
+ * nearly certain to be yes. So the same opportunity-cost currency is
174
+ * restated in the terms this seat actually spends it: accepting puts a
175
+ * connection in front of the client for approval.
176
+ *
177
+ * Conditional by construction: the steer applies where the fit case RESTS on
178
+ * the initiator's interpretation. A match the client's own criteria and the
179
+ * counterparty's own evidence support independently may still be accepted on
180
+ * first contact — which is why no "always"/"never accept" wording appears here
181
+ * and a spec pins its absence.
182
+ *
183
+ * Names no action and no mechanism, like every other fragment in this module:
184
+ * "one more exchange" and "consulting {userName}" describe the move, and the
185
+ * seat's own rules decide which token carries it (and whether the grant for it
186
+ * is even live this turn).
187
+ */
188
+ const RESPONDER_VERIFICATION_RULE = `
189
+ - THE OPENING IS ADVOCACY, NOT EVIDENCE: what reached {userName} was written by the other side's agent to make this match sound worth taking, and its account of the fit — what {userName} is looking for, why the two sides line up — is that agent's CLAIM about {userName}, not a fact you have checked. Test it against {userName}'s OWN intent and against what the counterparty's own profile and intents actually show. Restating the opening's fit claim back as your reason is agreement, not verification.
190
+ - WHAT ACCEPTING SPENDS: accepting is not the free or agreeable option — it puts a connection in front of {userName} for approval and spends the same finite attention the bar above governs. "Would {userName} be open to connecting?" is a bar almost anything clears, and it is not the bar. An accept on the first exchange has to be grounded in what {userName} themselves stated they were looking for, met by evidence about the counterparty that stands up without the opening's reading of it. Where the case for fit still rests on how the other agent characterized {userName}'s needs, the cheap move is one more exchange — put the specific gap to them, or counter with what would have to be true — and where the doubt is about {userName}'s own criteria rather than the counterparty's evidence, consulting {userName} settles it instead.`;
191
+ /**
192
+ * `skeptic` sharpening of the responder rule, appended to the same bullet (the
193
+ * same additive pattern as `SKEPTIC_CONSULT_SHARPENING`): under the
194
+ * not-worth-making prior, closing on the opening alone is the exception rather
195
+ * than the default. The escape hatch is restated explicitly here because this
196
+ * is where the pressure is highest and an over-read would turn a lean into a
197
+ * ban on first-contact accepts.
198
+ */
199
+ const SKEPTIC_RESPONDER_SHARPENING = ` For you an accept on the first exchange is the exception, not the default: where the fit case still rests on the opening's own characterization, probe once before accepting — one exchange costs the counterparty nothing and {userName} very little, while an accept you cannot ground spends their attention on a match no one has checked. Where {userName}'s stated criteria and the counterparty's own evidence carry the fit without that characterization, accepting straight away is still the right call.`;
144
200
  /**
145
201
  * Extra action-rule lines contributed by the stance, appended after the seat's
146
202
  * own rules. Empty under `advocate` → byte-identical.
203
+ *
204
+ * `seat` scopes the responder verification rules to the seat that did NOT
205
+ * open. Everything else here is seat-blind: the value bar and the consult
206
+ * propensity are duties of both seats, and the seat parameter must not become
207
+ * a reason to fork them.
147
208
  */
148
- export function stanceActionRules(stance) {
209
+ export function stanceActionRules(stance, seat) {
149
210
  if (!stanceAppliesValueBar(stance))
150
211
  return "";
151
212
  const consultRule = stance === "skeptic"
152
213
  ? CONSULT_PROPENSITY_RULE + SKEPTIC_CONSULT_SHARPENING
153
214
  : CONSULT_PROPENSITY_RULE;
154
- return VALUE_BAR_RULE + consultRule;
215
+ const responderRule = seat === "counterparty" && stanceVerifiesResponderFit(stance)
216
+ ? RESPONDER_VERIFICATION_RULE + (stance === "skeptic" ? SKEPTIC_RESPONDER_SHARPENING : "")
217
+ : "";
218
+ return VALUE_BAR_RULE + consultRule + responderRule;
219
+ }
220
+ /**
221
+ * `skeptic` sharpening of the pre-contact consultation rule (the turn-0 third
222
+ * verdict). Appended to the base rule in `negotiation.agent.ts`, which renders
223
+ * it only on an opening initiator turn that actually holds the grant — so
224
+ * unlike every other fragment here this one is not seat- and version-blind,
225
+ * and does not need to be: it can never reach a seat without the vocabulary.
226
+ *
227
+ * The base rule sets when consulting is warranted. This sets which way to lean
228
+ * when it is genuinely a toss-up, and the skeptic's prior is what makes the
229
+ * lean asymmetric: under "most matches are not worth making" a pass is the
230
+ * cheap default, which is exactly why an unverified pass deserves the same
231
+ * suspicion as an unverified acceptance. Both decide for the client.
232
+ *
233
+ * Names no action and no mechanism, like the rest of this module.
234
+ */
235
+ 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.`;
236
+ /**
237
+ * Stance contribution to the pre-contact consultation rule. Empty under
238
+ * `advocate` and `evaluator` — the base seat-level rule already states when
239
+ * the verdict applies, and only the skeptic's not-worth-making prior changes
240
+ * which way a close call should fall.
241
+ */
242
+ export function stancePreContactConsultRule(stance) {
243
+ return stance === "skeptic" ? SKEPTIC_PRE_CONTACT_LEAN : "";
155
244
  }
156
245
  /**
157
246
  * The discovery-query satisfaction rule.
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.497.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",