@indexnetwork/protocol 22.0.0-rc.495.1 → 22.0.0-rc.496.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/negotiations/negotiation.agent.js +61 -8
- 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 +7 -0
- package/dist/negotiations/negotiation.stance.contracts.js +25 -0
- 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,6 +179,18 @@ export class IndexNegotiator {
|
|
|
155
179
|
// otherwise render the client's private thread with no rule explaining
|
|
156
180
|
// what it is for.
|
|
157
181
|
const clientDm = canAskUser ? input.clientDm ?? [] : [];
|
|
182
|
+
// The opening initiator turn: nothing has been sent, so a granted
|
|
183
|
+
// consultation is the pre-contact verdict rather than a mid-exchange
|
|
184
|
+
// pause. Derived, not passed: the graph grants `canAskUser` on a turn-0
|
|
185
|
+
// initiator turn only when the pre-contact admission and its per-signal
|
|
186
|
+
// bound both hold, so the grant plus the turn's own shape is the fact.
|
|
187
|
+
const preContactConsult = canAskUser && seat === "initiator"
|
|
188
|
+
&& input.history.length === 0 && input.isContinuation !== true;
|
|
189
|
+
// The resume after such a pause. The negotiation's whole record is its own
|
|
190
|
+
// consultation park, so this is still the opening decision — the client
|
|
191
|
+
// answered, and the seat now reaches out or lets the match pass.
|
|
192
|
+
const preContactResume = version === "v2" && seat === "initiator"
|
|
193
|
+
&& isPreContactConsultResume(input.history);
|
|
158
194
|
// Negotiator stance (IND-611). Resolved from the environment once per turn
|
|
159
195
|
// via the domain contract, exactly like `configuredScreenMode()`. Under the
|
|
160
196
|
// `advocate` default every stance fragment below is the legacy string, so
|
|
@@ -171,7 +207,11 @@ export class IndexNegotiator {
|
|
|
171
207
|
const actionRules = (version === "v2"
|
|
172
208
|
? (seat === "initiator" ? V2_INITIATOR_RULES : V2_COUNTERPARTY_RULES)
|
|
173
209
|
: V1_ACTION_RULES) + stanceActionRules(stance)
|
|
174
|
-
+ (canAskUser
|
|
210
|
+
+ (canAskUser
|
|
211
|
+
? ASK_USER_RULE
|
|
212
|
+
+ (preContactConsult ? PRE_CONTACT_ASK_USER_RULE + stancePreContactConsultRule(stance) : "")
|
|
213
|
+
+ (clientDm.length > 0 ? ASK_USER_DM_GROUNDING_RULE : "")
|
|
214
|
+
: "");
|
|
175
215
|
const finalTurnInstruction = input.isFinalTurn
|
|
176
216
|
? (version === "v2"
|
|
177
217
|
? (seat === "initiator"
|
|
@@ -245,11 +285,20 @@ ${stanceQuerySatisfiedRule(stance, otherName, userName)}`
|
|
|
245
285
|
// "make your own case for it" — which, for the initiator seat, reads as
|
|
246
286
|
// an instruction to re-open, and produced a fresh outreach on every one
|
|
247
287
|
// of its turns instead of a reply.
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
288
|
+
//
|
|
289
|
+
// A pre-contact resume is checked FIRST. It reads as a continuation to
|
|
290
|
+
// every existing test here (`isContinuation` is true — the negotiation has
|
|
291
|
+
// spoken), but the only thing it said was its own pause, and both the
|
|
292
|
+
// continuation policy ("you may resolve quickly") and the mid-exchange
|
|
293
|
+
// policy ("respond to the counterparty's latest turn") describe an
|
|
294
|
+
// exchange that has not happened.
|
|
295
|
+
const priorDialoguePolicy = preContactResume
|
|
296
|
+
? `Policy: You have NOT contacted ${otherName} about this signal. The only turn above is your own pause to consult ${userName} before deciding — there is no exchange to respond to, and nothing has been sent. This is still the opening decision.`
|
|
297
|
+
: input.isContinuation
|
|
298
|
+
? 'Policy: You are continuing a prior dialogue. If this signal is materially the same as one you previously evaluated, you may resolve quickly. If materially different, evaluate on its own merits.'
|
|
299
|
+
: input.history.length > 0
|
|
300
|
+
? 'Policy: This negotiation is already under way — the turns above under the current opportunity are THIS exchange. Respond to the counterparty\'s latest turn; do not restate or re-pitch your opening.'
|
|
301
|
+
: 'Policy: This signal is NEW — you have not negotiated it before. The dialogue above concluded on other signals and is background only. Evaluate this one on its own merits and make your own case for it.';
|
|
253
302
|
const priorDialogueContext = hasPriorDialogue
|
|
254
303
|
? `\n\n--- Prior dialogue with this counterparty ---\n${attributionPreamble}${priorDialogueBody}\n\n--- New signal under evaluation ---\n${input.discoveryQuery
|
|
255
304
|
? `Discovery query: "${input.discoveryQuery}"`
|
|
@@ -291,7 +340,11 @@ ${input.otherUser.intents.map((i) => `- ${i.title}: ${i.description}`).join("\n"
|
|
|
291
340
|
|
|
292
341
|
Why this match was suggested: ${input.seedAssessment.reasoning}${hasPriorDialogue ? priorDialogueContext : historyText}${clientDmContext}${userAnswersContext}${privateConsultationContext}
|
|
293
342
|
${discoveryQueryReminder}
|
|
294
|
-
${
|
|
343
|
+
${preContactResume
|
|
344
|
+
? `You paused this opening turn to ask ${userName} the one thing you could not decide without. Their answer is above. Take the opening decision now: "outreach" to make the case, or "withdraw" to let the match pass without ever contacting ${otherName}.`
|
|
345
|
+
: input.history.length === 0 && !input.isContinuation
|
|
346
|
+
? (version === "v2" && seat === "initiator" ? "This is the opening turn. Make the outreach case." : "This is the opening turn. Propose the connection case.")
|
|
347
|
+
: "Evaluate the latest arguments and respond."}`;
|
|
295
348
|
const chatMessages = [
|
|
296
349
|
{ role: "system", content: systemPrompt },
|
|
297
350
|
{ role: "user", content: userMessage },
|
|
@@ -36,3 +36,60 @@ export declare function consultationPromptFor(reason: NegotiationConsultationRea
|
|
|
36
36
|
disclosureSubject: string;
|
|
37
37
|
draftQuestion: string;
|
|
38
38
|
};
|
|
39
|
+
/**
|
|
40
|
+
* How many pre-contact consultations one intent may hold OPEN at once.
|
|
41
|
+
*
|
|
42
|
+
* A vague signal can surface many candidates in a batch, and the doubt that
|
|
43
|
+
* blocks the first ("does 'academic linguistics' strictly bound this, or is
|
|
44
|
+
* adjacent depth in scope?") is usually the same doubt that blocks all of
|
|
45
|
+
* them. One answer generalizes: it lands in the signal's DM, and the DM is
|
|
46
|
+
* injected into every later turn-0 decision on that signal. The cap exists
|
|
47
|
+
* for the agent that does not internalize that and would interrogate its
|
|
48
|
+
* client candidate-by-candidate.
|
|
49
|
+
*
|
|
50
|
+
* Two, not one: a second genuinely different question about the same signal
|
|
51
|
+
* is plausible, a third is a pattern. Past the cap the action is simply not
|
|
52
|
+
* offered and the seat falls back to today's binary reach-out-or-pass.
|
|
53
|
+
*/
|
|
54
|
+
export declare const MAX_OPEN_PRE_CONTACT_CONSULTS_PER_INTENT = 2;
|
|
55
|
+
/**
|
|
56
|
+
* Whether a negotiation's own turns show it has never contacted the
|
|
57
|
+
* counterparty — every turn it holds is a client-consultation park.
|
|
58
|
+
*
|
|
59
|
+
* This is what makes a post-consult resume still-the-opening. A mid-flight
|
|
60
|
+
* consult always has an `outreach` behind it (the counterparty seat cannot
|
|
61
|
+
* even speak before one), so this is false for every park the pre-contact
|
|
62
|
+
* verdict did not create, and the resume rules it gates stay off the
|
|
63
|
+
* mid-flight path by construction rather than by a flag.
|
|
64
|
+
*/
|
|
65
|
+
export declare function isPreContactConsultResume(turns: readonly {
|
|
66
|
+
action: NegotiationAction;
|
|
67
|
+
}[]): boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Turn-context key stamped on a pre-contact park. Written at park time beside
|
|
70
|
+
* the ask-user binding, read back by {@link countOpenPreContactConsults} — the
|
|
71
|
+
* park row is the only durable record of the consultation, so the marker lives
|
|
72
|
+
* with it rather than in a separate counter that could drift.
|
|
73
|
+
*/
|
|
74
|
+
export declare const PRE_CONTACT_CONSULT_MARKER = "preContactConsult";
|
|
75
|
+
/** Minimal task shape the open-consult count reads; matches `getTasksForUser`. */
|
|
76
|
+
export interface PreContactConsultTaskRow {
|
|
77
|
+
id: string;
|
|
78
|
+
state: string;
|
|
79
|
+
metadata: Record<string, unknown> | null;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Count the pre-contact consultations currently open for one `(user, intent)`
|
|
83
|
+
* scope, from the user's own negotiation tasks.
|
|
84
|
+
*
|
|
85
|
+
* "Open" is derived from the durable park itself — an `input_required` task
|
|
86
|
+
* whose captured ask-user binding names this recipient pair and whose turn
|
|
87
|
+
* context carries the pre-contact stamp — so answering, expiry, and resume all
|
|
88
|
+
* retire a park from the count with no counter to keep in step. Mid-flight
|
|
89
|
+
* consults carry no stamp and never count.
|
|
90
|
+
*/
|
|
91
|
+
export declare function countOpenPreContactConsults(tasks: readonly PreContactConsultTaskRow[], scope: {
|
|
92
|
+
userId: string;
|
|
93
|
+
intentId: string;
|
|
94
|
+
excludeTaskId?: string;
|
|
95
|
+
}): number;
|
|
@@ -13,7 +13,6 @@ export function negotiationConsultationPolicyMode() {
|
|
|
13
13
|
*/
|
|
14
14
|
export function assessConsultationEligibility(input) {
|
|
15
15
|
if (input.protocolVersion !== "v2"
|
|
16
|
-
|| input.isOpeningTurn
|
|
17
16
|
|| input.isFinalTurn
|
|
18
17
|
|| input.screenedOut
|
|
19
18
|
|| input.previouslyConsulted
|
|
@@ -21,6 +20,40 @@ export function assessConsultationEligibility(input) {
|
|
|
21
20
|
|| !input.lifecycleValid
|
|
22
21
|
|| isObviousTerminal(input.action))
|
|
23
22
|
return { eligible: false };
|
|
23
|
+
// ─── Pre-contact consultation (the opening turn) ──────────────────────────
|
|
24
|
+
// Before this branch the opening turn was a blanket exclusion, so the
|
|
25
|
+
// initiator's turn-0 vocabulary was binary: reach out, or pass with the
|
|
26
|
+
// counterparty never contacted. A third verdict is admissible when the
|
|
27
|
+
// blocking doubt is one only the client can settle — how their own criteria
|
|
28
|
+
// bound the search — and the pause costs the counterparty nothing, because
|
|
29
|
+
// nothing has been sent.
|
|
30
|
+
//
|
|
31
|
+
// The admission is deliberately NARROW, and narrower than the mid-flight
|
|
32
|
+
// rules below:
|
|
33
|
+
//
|
|
34
|
+
// - INITIATOR ONLY. A turn-0 counterparty seat (tie-break inheritance) is
|
|
35
|
+
// responding to an outreach, so its client's criteria are not what is
|
|
36
|
+
// blocking; there is no pre-contact position to protect either.
|
|
37
|
+
// - A MODEL-AUTHORED `ask_user` ONLY. Every rule below infers a
|
|
38
|
+
// consultation from a draft that asked for something else, which needs
|
|
39
|
+
// history to be a safe inference — and at turn 0 there is none. The
|
|
40
|
+
// acting agent is the only party that has read the client's own signal,
|
|
41
|
+
// so the pre-contact case is the one where volunteering the pause is the
|
|
42
|
+
// evidence, not a substitute for it. This also keeps the distinction the
|
|
43
|
+
// graph prompt draws — client-resolvable scope doubt consults;
|
|
44
|
+
// counterparty evidence that contradicts the match passes silently —
|
|
45
|
+
// resolvable HERE only as "the agent did not ask", which is exactly what
|
|
46
|
+
// a contradiction-shaped doubt produces.
|
|
47
|
+
//
|
|
48
|
+
// The category is fixed rather than read off the draft: a pre-contact pause
|
|
49
|
+
// is by construction an unresolved constraint the OWNER controls. The
|
|
50
|
+
// consult counts as an ordinary ask round (the graph reads the same
|
|
51
|
+
// `negotiationAskRoundsCap` substrate before granting the action at all).
|
|
52
|
+
if (input.isOpeningTurn) {
|
|
53
|
+
return input.seat === "initiator" && input.action === "ask_user"
|
|
54
|
+
? { eligible: true, reason: "unresolved_owner_constraint" }
|
|
55
|
+
: { eligible: false };
|
|
56
|
+
}
|
|
24
57
|
// A patient-side counter is a schema-constrained, source-safe signal that
|
|
25
58
|
// the owner must decide whether a consequential disclosure or permission is
|
|
26
59
|
// acceptable. This is reachable under the normal v2 action vocabulary; it
|
|
@@ -66,3 +99,61 @@ export function consultationPromptFor(reason) {
|
|
|
66
99
|
function isObviousTerminal(action) {
|
|
67
100
|
return action === "accept" || action === "reject" || action === "withdraw" || action === "decline";
|
|
68
101
|
}
|
|
102
|
+
// ─── Pre-contact consultation: bounds and recognition ────────────────────────
|
|
103
|
+
/**
|
|
104
|
+
* How many pre-contact consultations one intent may hold OPEN at once.
|
|
105
|
+
*
|
|
106
|
+
* A vague signal can surface many candidates in a batch, and the doubt that
|
|
107
|
+
* blocks the first ("does 'academic linguistics' strictly bound this, or is
|
|
108
|
+
* adjacent depth in scope?") is usually the same doubt that blocks all of
|
|
109
|
+
* them. One answer generalizes: it lands in the signal's DM, and the DM is
|
|
110
|
+
* injected into every later turn-0 decision on that signal. The cap exists
|
|
111
|
+
* for the agent that does not internalize that and would interrogate its
|
|
112
|
+
* client candidate-by-candidate.
|
|
113
|
+
*
|
|
114
|
+
* Two, not one: a second genuinely different question about the same signal
|
|
115
|
+
* is plausible, a third is a pattern. Past the cap the action is simply not
|
|
116
|
+
* offered and the seat falls back to today's binary reach-out-or-pass.
|
|
117
|
+
*/
|
|
118
|
+
export const MAX_OPEN_PRE_CONTACT_CONSULTS_PER_INTENT = 2;
|
|
119
|
+
/**
|
|
120
|
+
* Whether a negotiation's own turns show it has never contacted the
|
|
121
|
+
* counterparty — every turn it holds is a client-consultation park.
|
|
122
|
+
*
|
|
123
|
+
* This is what makes a post-consult resume still-the-opening. A mid-flight
|
|
124
|
+
* consult always has an `outreach` behind it (the counterparty seat cannot
|
|
125
|
+
* even speak before one), so this is false for every park the pre-contact
|
|
126
|
+
* verdict did not create, and the resume rules it gates stay off the
|
|
127
|
+
* mid-flight path by construction rather than by a flag.
|
|
128
|
+
*/
|
|
129
|
+
export function isPreContactConsultResume(turns) {
|
|
130
|
+
return turns.length > 0 && turns.every((turn) => turn.action === "ask_user");
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Turn-context key stamped on a pre-contact park. Written at park time beside
|
|
134
|
+
* the ask-user binding, read back by {@link countOpenPreContactConsults} — the
|
|
135
|
+
* park row is the only durable record of the consultation, so the marker lives
|
|
136
|
+
* with it rather than in a separate counter that could drift.
|
|
137
|
+
*/
|
|
138
|
+
export const PRE_CONTACT_CONSULT_MARKER = "preContactConsult";
|
|
139
|
+
/**
|
|
140
|
+
* Count the pre-contact consultations currently open for one `(user, intent)`
|
|
141
|
+
* scope, from the user's own negotiation tasks.
|
|
142
|
+
*
|
|
143
|
+
* "Open" is derived from the durable park itself — an `input_required` task
|
|
144
|
+
* whose captured ask-user binding names this recipient pair and whose turn
|
|
145
|
+
* context carries the pre-contact stamp — so answering, expiry, and resume all
|
|
146
|
+
* retire a park from the count with no counter to keep in step. Mid-flight
|
|
147
|
+
* consults carry no stamp and never count.
|
|
148
|
+
*/
|
|
149
|
+
export function countOpenPreContactConsults(tasks, scope) {
|
|
150
|
+
return tasks.filter((task) => {
|
|
151
|
+
if (task.state !== "input_required" || task.id === scope.excludeTaskId)
|
|
152
|
+
return false;
|
|
153
|
+
const turnContext = task.metadata?.turnContext;
|
|
154
|
+
if (turnContext?.[PRE_CONTACT_CONSULT_MARKER] !== true)
|
|
155
|
+
return false;
|
|
156
|
+
const binding = turnContext.askUserBinding;
|
|
157
|
+
return binding?.recipientUserId === scope.userId && binding.recipientIntentId === scope.intentId;
|
|
158
|
+
}).length;
|
|
159
|
+
}
|
|
@@ -3,12 +3,48 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { requestContext } from "../shared/observability/request-context.js";
|
|
5
5
|
import { allowedActionsFor, askUserAnswerWindowMs, configuredAskUserEnabled, fallbackActionFor, negotiationAskRoundsCap, rejectActionFor } from "./negotiation.protocol.js";
|
|
6
|
-
import { assessConsultationEligibility, consultationPromptFor, negotiationConsultationPolicyMode } from "./negotiation.consultation-policy.js";
|
|
6
|
+
import { assessConsultationEligibility, consultationPromptFor, countOpenPreContactConsults, isPreContactConsultResume, MAX_OPEN_PRE_CONTACT_CONSULTS_PER_INTENT, negotiationConsultationPolicyMode, PRE_CONTACT_CONSULT_MARKER } from "./negotiation.consultation-policy.js";
|
|
7
7
|
import { blocksNegotiationBeforeFirstTurn } from "./negotiation.screen.js";
|
|
8
8
|
import { assessDeadlock, configuredDeadlockShiftEnabled, configuredDeadlockThreshold } from "./negotiation.deadlock.js";
|
|
9
9
|
import { NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY, NEGOTIATION_QUESTION_GENERIC_NETWORK, isSafeAuthoredNegotiationQuestion, negotiationQuestionSettlementId } from './negotiation.question-safety.js';
|
|
10
10
|
import { isNegotiationTurnCapReached } from "./negotiation.turn-cap.js";
|
|
11
11
|
import { buildAttributedDialogue, countNegotiationAskRounds, hasPriorAskUser, memoryQueryText, retrieveClientDm, retrieveMemory, turnLog, turnsFromMessages } from "./negotiation.graph.shared.js";
|
|
12
|
+
/**
|
|
13
|
+
* Whether this signal may open another pre-contact consultation.
|
|
14
|
+
*
|
|
15
|
+
* The count comes from the user's own parked negotiation tasks — the durable
|
|
16
|
+
* parks themselves — rather than a stored counter, so an answered, expired, or
|
|
17
|
+
* resumed park frees its slot with nothing to keep in step. The acting task is
|
|
18
|
+
* excluded: it is `working` at this point, but a retried turn on a task that
|
|
19
|
+
* already parked must not count itself out.
|
|
20
|
+
*
|
|
21
|
+
* Fails OPEN. This bound defends against an agent that would re-ask the same
|
|
22
|
+
* signal-level question candidate by candidate; it is not the safety gate. The
|
|
23
|
+
* per-negotiation ration and the ask-rounds cap already hold that line from
|
|
24
|
+
* the message record, and neither depends on this query — so a database blip
|
|
25
|
+
* must not silently retire the turn-0 verdict.
|
|
26
|
+
*/
|
|
27
|
+
async function preContactConsultsUnderCap(deps, userId, intentId, actingTaskId) {
|
|
28
|
+
try {
|
|
29
|
+
const parked = await deps.database.getTasksForUser(userId, { state: 'input_required' });
|
|
30
|
+
const open = countOpenPreContactConsults(parked, { userId, intentId, excludeTaskId: actingTaskId });
|
|
31
|
+
if (open >= MAX_OPEN_PRE_CONTACT_CONSULTS_PER_INTENT) {
|
|
32
|
+
turnLog.info('negotiation_pre_contact_consult_capped', {
|
|
33
|
+
userId, intentId, open, cap: MAX_OPEN_PRE_CONTACT_CONSULTS_PER_INTENT,
|
|
34
|
+
});
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
turnLog.warn('Pre-contact consult cap check failed; proceeding without the per-signal bound', {
|
|
41
|
+
userId,
|
|
42
|
+
intentId,
|
|
43
|
+
error: err instanceof Error ? err.message : String(err),
|
|
44
|
+
});
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
12
48
|
export async function turnNode(state, deps) {
|
|
13
49
|
const traceEmitter = requestContext.getStore()?.traceEmitter;
|
|
14
50
|
// Local helper to emit events whose shape is wider than the declared
|
|
@@ -45,7 +81,24 @@ export async function turnNode(state, deps) {
|
|
|
45
81
|
// also persist `ask_user` messages — count against the same budget,
|
|
46
82
|
// and a negotiation near its cap cannot spend a further round here.
|
|
47
83
|
const policyMode = negotiationConsultationPolicyMode();
|
|
48
|
-
|
|
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
|
});
|
|
@@ -70,6 +70,13 @@ export declare function stanceJobFraming(stance: NegotiatorStance): string;
|
|
|
70
70
|
* own rules. Empty under `advocate` → byte-identical.
|
|
71
71
|
*/
|
|
72
72
|
export declare function stanceActionRules(stance: NegotiatorStance): string;
|
|
73
|
+
/**
|
|
74
|
+
* Stance contribution to the pre-contact consultation rule. Empty under
|
|
75
|
+
* `advocate` and `evaluator` — the base seat-level rule already states when
|
|
76
|
+
* the verdict applies, and only the skeptic's not-worth-making prior changes
|
|
77
|
+
* which way a close call should fall.
|
|
78
|
+
*/
|
|
79
|
+
export declare function stancePreContactConsultRule(stance: NegotiatorStance): string;
|
|
73
80
|
/**
|
|
74
81
|
* The discovery-query satisfaction rule.
|
|
75
82
|
*
|
|
@@ -153,6 +153,31 @@ export function stanceActionRules(stance) {
|
|
|
153
153
|
: CONSULT_PROPENSITY_RULE;
|
|
154
154
|
return VALUE_BAR_RULE + consultRule;
|
|
155
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* `skeptic` sharpening of the pre-contact consultation rule (the turn-0 third
|
|
158
|
+
* verdict). Appended to the base rule in `negotiation.agent.ts`, which renders
|
|
159
|
+
* it only on an opening initiator turn that actually holds the grant — so
|
|
160
|
+
* unlike every other fragment here this one is not seat- and version-blind,
|
|
161
|
+
* and does not need to be: it can never reach a seat without the vocabulary.
|
|
162
|
+
*
|
|
163
|
+
* The base rule sets when consulting is warranted. This sets which way to lean
|
|
164
|
+
* when it is genuinely a toss-up, and the skeptic's prior is what makes the
|
|
165
|
+
* lean asymmetric: under "most matches are not worth making" a pass is the
|
|
166
|
+
* cheap default, which is exactly why an unverified pass deserves the same
|
|
167
|
+
* suspicion as an unverified acceptance. Both decide for the client.
|
|
168
|
+
*
|
|
169
|
+
* Names no action and no mechanism, like the rest of this module.
|
|
170
|
+
*/
|
|
171
|
+
const SKEPTIC_PRE_CONTACT_LEAN = ` When it is genuinely close, lean toward asking rather than passing. Your prior is that most matches are not worth making — which makes passing the cheap answer, and a pass you reached by GUESSING at {userName}'s own criteria decides for them just as much as a connection you made by guessing. Nothing has been spent yet: the pause costs the counterparty nothing and costs {userName} one question.`;
|
|
172
|
+
/**
|
|
173
|
+
* Stance contribution to the pre-contact consultation rule. Empty under
|
|
174
|
+
* `advocate` and `evaluator` — the base seat-level rule already states when
|
|
175
|
+
* the verdict applies, and only the skeptic's not-worth-making prior changes
|
|
176
|
+
* which way a close call should fall.
|
|
177
|
+
*/
|
|
178
|
+
export function stancePreContactConsultRule(stance) {
|
|
179
|
+
return stance === "skeptic" ? SKEPTIC_PRE_CONTACT_LEAN : "";
|
|
180
|
+
}
|
|
156
181
|
/**
|
|
157
182
|
* The discovery-query satisfaction rule.
|
|
158
183
|
*
|