@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.
- package/dist/negotiations/negotiation.agent.js +68 -9
- package/dist/negotiations/negotiation.consultation-policy.d.ts +57 -0
- package/dist/negotiations/negotiation.consultation-policy.js +92 -1
- package/dist/negotiations/negotiation.graph.turn.js +119 -19
- package/dist/negotiations/negotiation.stance.contracts.d.ts +32 -6
- package/dist/negotiations/negotiation.stance.contracts.js +96 -7
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
${
|
|
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
|
-
|
|
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
|
-
&& !
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
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
|
-
|
|
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.
|