@indexnetwork/protocol 22.0.0-rc.494.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/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/negotiations/negotiation.agent.js +68 -6
- 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.d.ts +2 -0
- package/dist/negotiations/negotiation.graph.turn.js +128 -19
- package/dist/negotiations/negotiation.module.d.ts +1 -1
- package/dist/negotiations/negotiation.module.js +1 -1
- package/dist/negotiations/negotiation.question-safety.d.ts +0 -1
- package/dist/negotiations/negotiation.question-safety.js +0 -1
- package/dist/negotiations/negotiation.stance.contracts.d.ts +7 -0
- package/dist/negotiations/negotiation.stance.contracts.js +25 -0
- package/dist/negotiations/negotiation.tools.js +83 -31
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -127,6 +127,6 @@ export { isNegotiationTurnCapReached, expectedNegotiationSpeaker, negotiationSco
|
|
|
127
127
|
export type { NegotiationSpeakerParticipants, NegotiationSpeakerMessage, NegotiationScopeMetadata } from "./negotiations/negotiation.module.js";
|
|
128
128
|
export { assessConsultationEligibility, consultationPromptFor, negotiationConsultationPolicyMode } from "./negotiations/negotiation.module.js";
|
|
129
129
|
export type { ConsultationEligibility, ConsultationEligibilityInput, NegotiationConsultationPolicyMode } from "./negotiations/negotiation.module.js";
|
|
130
|
-
export { NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY, NEGOTIATION_QUESTION_GENERIC_NETWORK,
|
|
130
|
+
export { NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY, NEGOTIATION_QUESTION_GENERIC_NETWORK, negotiationQuestionSettlementId, } from "./negotiations/negotiation.module.js";
|
|
131
131
|
export { classifyParkedNegotiation, consumeQuestionBlockAnswers, negotiationParkAnswerId, resumeParkedNegotiation, routeAnswerRef, } from "./negotiations/negotiation.module.js";
|
|
132
132
|
export type { AnswerRoute, InflightAnswerSettlementInput, InflightAnswerSettlementResult, NegotiationAnswerConsumptionPorts, NegotiationAnswerInput, NegotiationAnswerResumeOutcome, ParkClassification, QuestionBlockAnswerConsumptionInput, QuestionBlockAnswerConsumptionResult, RoutedAnswer, } from "./negotiations/negotiation.module.js";
|
package/dist/index.js
CHANGED
|
@@ -113,6 +113,6 @@ export { HERMES_OWNER_DIRECTIVE, HermesNegotiationResponseSchema, allowedHermesA
|
|
|
113
113
|
// ─── Negotiation seat rules (v2 client-advocate protocol) ───────────────────
|
|
114
114
|
export { isNegotiationTurnCapReached, expectedNegotiationSpeaker, negotiationScopeKey, readNegotiationMessages, allowedActionsFor, askUserAnswerWindowMs, configuredAskUserEnabled, isTerminalAction, isRejectLikeAction, readProtocolVersion, resolveSeat, seatViolationMessage } from "./negotiations/negotiation.module.js";
|
|
115
115
|
export { assessConsultationEligibility, consultationPromptFor, negotiationConsultationPolicyMode } from "./negotiations/negotiation.module.js";
|
|
116
|
-
export { NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY, NEGOTIATION_QUESTION_GENERIC_NETWORK,
|
|
116
|
+
export { NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY, NEGOTIATION_QUESTION_GENERIC_NETWORK, negotiationQuestionSettlementId, } from "./negotiations/negotiation.module.js";
|
|
117
117
|
// ─── Negotiation answer consumption (conversational questions) ──────────────
|
|
118
118
|
export { classifyParkedNegotiation, consumeQuestionBlockAnswers, negotiationParkAnswerId, resumeParkedNegotiation, routeAnswerRef, } from "./negotiations/negotiation.module.js";
|
|
@@ -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"
|
|
@@ -238,9 +278,27 @@ ${stanceQuerySatisfiedRule(stance, otherName, userName)}`
|
|
|
238
278
|
// worth having. What it may not do is stand in for this negotiation's own
|
|
239
279
|
// exchange, so the policy line differs by whether this one has opened.
|
|
240
280
|
const hasPriorDialogue = hasAttributedDialogue || input.history.length > 0;
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
281
|
+
// Three states, not two. `isContinuation` only says whether this
|
|
282
|
+
// negotiation spoke in an EARLIER session; on any turn after the opening
|
|
283
|
+
// it is still false while this negotiation is visibly mid-exchange. Under
|
|
284
|
+
// the old two-way split such a turn was told the signal was new and to
|
|
285
|
+
// "make your own case for it" — which, for the initiator seat, reads as
|
|
286
|
+
// an instruction to re-open, and produced a fresh outreach on every one
|
|
287
|
+
// of its turns instead of a reply.
|
|
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.';
|
|
244
302
|
const priorDialogueContext = hasPriorDialogue
|
|
245
303
|
? `\n\n--- Prior dialogue with this counterparty ---\n${attributionPreamble}${priorDialogueBody}\n\n--- New signal under evaluation ---\n${input.discoveryQuery
|
|
246
304
|
? `Discovery query: "${input.discoveryQuery}"`
|
|
@@ -282,7 +340,11 @@ ${input.otherUser.intents.map((i) => `- ${i.title}: ${i.description}`).join("\n"
|
|
|
282
340
|
|
|
283
341
|
Why this match was suggested: ${input.seedAssessment.reasoning}${hasPriorDialogue ? priorDialogueContext : historyText}${clientDmContext}${userAnswersContext}${privateConsultationContext}
|
|
284
342
|
${discoveryQueryReminder}
|
|
285
|
-
${
|
|
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."}`;
|
|
286
348
|
const chatMessages = [
|
|
287
349
|
{ role: "system", content: systemPrompt },
|
|
288
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
|
+
}
|
|
@@ -45,6 +45,7 @@ export declare function turnNode(state: NegotiationState, deps: NegotiationGraph
|
|
|
45
45
|
role: "agent";
|
|
46
46
|
parts: unknown;
|
|
47
47
|
createdAt: Date;
|
|
48
|
+
taskId: string;
|
|
48
49
|
}[];
|
|
49
50
|
turnCount: number;
|
|
50
51
|
lastTurn: {
|
|
@@ -82,6 +83,7 @@ export declare function turnNode(state: NegotiationState, deps: NegotiationGraph
|
|
|
82
83
|
role: "agent";
|
|
83
84
|
parts: unknown;
|
|
84
85
|
createdAt: Date;
|
|
86
|
+
taskId: string;
|
|
85
87
|
}[];
|
|
86
88
|
turnCount: number;
|
|
87
89
|
currentSpeaker: "source" | "candidate";
|
|
@@ -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
|
});
|
|
@@ -495,6 +595,7 @@ export async function turnNode(state, deps) {
|
|
|
495
595
|
role: "agent",
|
|
496
596
|
parts: message.parts,
|
|
497
597
|
createdAt: message.createdAt,
|
|
598
|
+
taskId: state.taskId,
|
|
498
599
|
}],
|
|
499
600
|
turnCount: state.turnCount + 1,
|
|
500
601
|
lastTurn: turn,
|
|
@@ -518,12 +619,20 @@ export async function turnNode(state, deps) {
|
|
|
518
619
|
});
|
|
519
620
|
}
|
|
520
621
|
return {
|
|
622
|
+
// `taskId` is what makes this turn part of THIS session's block in the
|
|
623
|
+
// attributed prior dialogue (IND-569). Persisted with the current task
|
|
624
|
+
// id above; carried onto the state message so `buildAttributedDialogue`
|
|
625
|
+
// can match it. Dropping it silently emptied the "[Current opportunity —
|
|
626
|
+
// under negotiation now]" block, which — because the attributed
|
|
627
|
+
// rendering replaces the flat history in the prompt — left every turn
|
|
628
|
+
// after the opening blind to the exchange it was answering.
|
|
521
629
|
messages: [{
|
|
522
630
|
id: message.id,
|
|
523
631
|
senderId: message.senderId,
|
|
524
632
|
role: "agent",
|
|
525
633
|
parts: message.parts,
|
|
526
634
|
createdAt: message.createdAt,
|
|
635
|
+
taskId: state.taskId,
|
|
527
636
|
}],
|
|
528
637
|
turnCount: state.turnCount + 1,
|
|
529
638
|
currentSpeaker: (isSource ? "candidate" : "source"),
|
|
@@ -24,7 +24,7 @@ export { DEFAULT_NEGOTIATION_MAX_TURNS, isNegotiationTurnCapReached } from "./ne
|
|
|
24
24
|
export { expectedNegotiationSpeaker } from "./negotiation.expected-speaker.js";
|
|
25
25
|
export { negotiationScopeKey, readNegotiationMessages } from "./negotiation.scope.js";
|
|
26
26
|
export { assessConsultationEligibility, consultationPromptFor, negotiationConsultationPolicyMode, } from "./negotiation.consultation-policy.js";
|
|
27
|
-
export { NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY, NEGOTIATION_QUESTION_GENERIC_NETWORK,
|
|
27
|
+
export { NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY, NEGOTIATION_QUESTION_GENERIC_NETWORK, isSafeAuthoredNegotiationQuestion, isSafeNegotiationQuestionText, negotiationQuestionSettlementId, validateInflightAskUserFields, } from "./negotiation.question-safety.js";
|
|
28
28
|
export { renderNegotiatorChatMemorySection } from "./negotiation.memory.js";
|
|
29
29
|
export type { ConsultationEligibility, ConsultationEligibilityInput, NegotiationConsultationPolicyMode, NegotiationConsultationReason, } from "./negotiation.consultation-policy.js";
|
|
30
30
|
export type { HermesNegotiationAction, HermesNegotiationResponse, HermesOwnerDirective, HermesRoleAlignment, } from "./negotiation.hermes-contract.js";
|
|
@@ -19,5 +19,5 @@ export { DEFAULT_NEGOTIATION_MAX_TURNS, isNegotiationTurnCapReached } from "./ne
|
|
|
19
19
|
export { expectedNegotiationSpeaker } from "./negotiation.expected-speaker.js";
|
|
20
20
|
export { negotiationScopeKey, readNegotiationMessages } from "./negotiation.scope.js";
|
|
21
21
|
export { assessConsultationEligibility, consultationPromptFor, negotiationConsultationPolicyMode, } from "./negotiation.consultation-policy.js";
|
|
22
|
-
export { NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY, NEGOTIATION_QUESTION_GENERIC_NETWORK,
|
|
22
|
+
export { NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY, NEGOTIATION_QUESTION_GENERIC_NETWORK, isSafeAuthoredNegotiationQuestion, isSafeNegotiationQuestionText, negotiationQuestionSettlementId, validateInflightAskUserFields, } from "./negotiation.question-safety.js";
|
|
23
23
|
export { renderNegotiatorChatMemorySection } from "./negotiation.memory.js";
|
|
@@ -2,7 +2,6 @@ import type { StructuredQuestion } from '../shared/schemas/structured-question.s
|
|
|
2
2
|
/** Fixed prompt-safe labels; producers must not replace them with raw network/counterparty text. */
|
|
3
3
|
export declare const NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY = "the other participant";
|
|
4
4
|
export declare const NEGOTIATION_QUESTION_GENERIC_NETWORK = "the selected network";
|
|
5
|
-
export declare const NEGOTIATION_QUESTION_GENERIC_UPTAKE_ACTIVITY = "a potential collaboration that may require clarification before you decide";
|
|
6
5
|
/**
|
|
7
6
|
* Deterministically reject question context copied from private negotiation inputs.
|
|
8
7
|
* This guard never rewrites content: unsafe or ambiguous text yields no card while
|
|
@@ -2,7 +2,6 @@ import { hasUnsupportedOpportunityClaim } from '../shared/utils/claim-safety.js'
|
|
|
2
2
|
/** Fixed prompt-safe labels; producers must not replace them with raw network/counterparty text. */
|
|
3
3
|
export const NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY = 'the other participant';
|
|
4
4
|
export const NEGOTIATION_QUESTION_GENERIC_NETWORK = 'the selected network';
|
|
5
|
-
export const NEGOTIATION_QUESTION_GENERIC_UPTAKE_ACTIVITY = 'a potential collaboration that may require clarification before you decide';
|
|
6
5
|
const INTERNAL_ID_PATTERN = /\b(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|(?:task|intent|network|opportunity|user|match)[_-]?id)\b/i;
|
|
7
6
|
const PRIVATE_SOURCE_PATTERN = /\b(?:private transcript|raw transcript|assessment(?:\.reasoning)?|seed assessment|evaluator reasoning|match reason|matchReason|internal metadata|counterparty profile)\b/i;
|
|
8
7
|
function normalize(value) {
|
|
@@ -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
|
*
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { success, error } from '../shared/agent/tool.helpers.js';
|
|
3
3
|
import { IndexNegotiator } from './negotiation.agent.js';
|
|
4
|
-
import { allowedActionsFor, isTerminalAction, readProtocolVersion, rejectActionFor, resolveSeat, seatViolationMessage } from './negotiation.protocol.js';
|
|
4
|
+
import { allowedActionsFor, isRejectLikeAction, isTerminalAction, readProtocolVersion, rejectActionFor, resolveSeat, seatViolationMessage } from './negotiation.protocol.js';
|
|
5
5
|
import { NEGOTIATION_ACTIONS } from '../shared/schemas/negotiation-state.schema.js';
|
|
6
6
|
import { protocolLogger } from '../shared/observability/protocol.logger.js';
|
|
7
7
|
import { focusedIntentId, focusedNetworkId } from '../shared/agent/tool.scope.js';
|
|
@@ -53,6 +53,58 @@ async function readOpportunityLifecycles(database, opportunityIds, ownerUserId)
|
|
|
53
53
|
return {};
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Terminal opportunity status for a negotiation concluded through this tool.
|
|
58
|
+
* Version-independent mapping, identical to the graph's finalize node:
|
|
59
|
+
* `accept` → `pending` (the owner gate is still ahead), reject-like →
|
|
60
|
+
* `rejected`, anything else (the turn cap) → `stalled`.
|
|
61
|
+
*/
|
|
62
|
+
function terminalOpportunityStatus(lastAction) {
|
|
63
|
+
if (lastAction === 'accept')
|
|
64
|
+
return 'pending';
|
|
65
|
+
return isRejectLikeAction(lastAction) ? 'rejected' : 'stalled';
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Conclude a negotiation reached through `respond_to_negotiation`: complete
|
|
69
|
+
* the task, write the outcome artifact, and advance the opportunity out of
|
|
70
|
+
* `negotiating`.
|
|
71
|
+
*
|
|
72
|
+
* The status write goes through `updateOpportunityStatus` — the same waist the
|
|
73
|
+
* graph's finalize node uses — because that method is what carries the
|
|
74
|
+
* post-commit opportunity-transition emit. Writing the column any other way
|
|
75
|
+
* (or, as this path did before, not at all) leaves the transition hook blind:
|
|
76
|
+
* neither side's question-message is regenerated or pruned, radar buckets keep
|
|
77
|
+
* counting a finished negotiation, and the expiry sweep still sees a phantom
|
|
78
|
+
* active opportunity.
|
|
79
|
+
*
|
|
80
|
+
* The status write is best-effort, mirroring finalize: the task is already
|
|
81
|
+
* completed and the artifact written, so a failed status flip is logged rather
|
|
82
|
+
* than turned into a tool error the caller would read as "the conclude did not
|
|
83
|
+
* happen".
|
|
84
|
+
*/
|
|
85
|
+
async function concludeNegotiation(database, input) {
|
|
86
|
+
await database.updateTaskState(input.taskId, 'completed');
|
|
87
|
+
await database.createArtifact({
|
|
88
|
+
taskId: input.taskId,
|
|
89
|
+
name: 'negotiation-outcome',
|
|
90
|
+
parts: [{ kind: 'data', data: input.outcome }],
|
|
91
|
+
metadata: { hasOpportunity: input.outcome.hasOpportunity, turnCount: input.turnCount },
|
|
92
|
+
});
|
|
93
|
+
if (!input.opportunityId)
|
|
94
|
+
return;
|
|
95
|
+
const status = terminalOpportunityStatus(input.lastAction);
|
|
96
|
+
try {
|
|
97
|
+
await database.updateOpportunityStatus(input.opportunityId, status);
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
logger.error('Failed to update opportunity status on MCP conclude', {
|
|
101
|
+
taskId: input.taskId,
|
|
102
|
+
opportunityId: input.opportunityId,
|
|
103
|
+
status,
|
|
104
|
+
err,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
56
108
|
/** Extracts the ordered NegotiationTurn list from A2A message data parts. */
|
|
57
109
|
function turnsFromMessages(messages) {
|
|
58
110
|
return messages
|
|
@@ -437,12 +489,12 @@ export function createNegotiationTools(defineTool, deps) {
|
|
|
437
489
|
const history = turnsFromMessages(allMessages);
|
|
438
490
|
const nextSpeaker = currentSpeaker === 'source' ? 'candidate' : 'source';
|
|
439
491
|
const outcome = buildNegotiationOutcome(history, newTurnCount, query.action, meta.sourceUserId, meta.candidateUserId, nextSpeaker);
|
|
440
|
-
await negotiationDatabase
|
|
441
|
-
await negotiationDatabase.createArtifact({
|
|
492
|
+
await concludeNegotiation(negotiationDatabase, {
|
|
442
493
|
taskId: task.id,
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
494
|
+
...(meta.opportunityId ? { opportunityId: meta.opportunityId } : {}),
|
|
495
|
+
lastAction: query.action,
|
|
496
|
+
outcome,
|
|
497
|
+
turnCount: newTurnCount,
|
|
446
498
|
});
|
|
447
499
|
return success({
|
|
448
500
|
message: query.action === 'accept'
|
|
@@ -465,12 +517,12 @@ export function createNegotiationTools(defineTool, deps) {
|
|
|
465
517
|
const history = turnsFromMessages(allMessages);
|
|
466
518
|
const nextSpeakerForCap = currentSpeaker === 'source' ? 'candidate' : 'source';
|
|
467
519
|
const outcome = buildNegotiationOutcome(history, newTurnCount, 'counter', meta.sourceUserId, meta.candidateUserId, nextSpeakerForCap);
|
|
468
|
-
await negotiationDatabase
|
|
469
|
-
await negotiationDatabase.createArtifact({
|
|
520
|
+
await concludeNegotiation(negotiationDatabase, {
|
|
470
521
|
taskId: task.id,
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
522
|
+
...(meta.opportunityId ? { opportunityId: meta.opportunityId } : {}),
|
|
523
|
+
lastAction: 'counter',
|
|
524
|
+
outcome,
|
|
525
|
+
turnCount: newTurnCount,
|
|
474
526
|
});
|
|
475
527
|
return success({
|
|
476
528
|
message: 'Maximum turns reached. Negotiation finalized without opportunity.',
|
|
@@ -586,12 +638,12 @@ export function createNegotiationTools(defineTool, deps) {
|
|
|
586
638
|
if (isTerminalAction(aiTurn.action)) {
|
|
587
639
|
const fullHistory = [...historyForDispatch, aiTurn];
|
|
588
640
|
const outcome = buildNegotiationOutcome(fullHistory, finalTurnCount, aiTurn.action, meta.sourceUserId, meta.candidateUserId, counterpartySpeaker === 'source' ? 'candidate' : 'source');
|
|
589
|
-
await negotiationDatabase
|
|
590
|
-
await negotiationDatabase.createArtifact({
|
|
641
|
+
await concludeNegotiation(negotiationDatabase, {
|
|
591
642
|
taskId: task.id,
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
643
|
+
...(meta.opportunityId ? { opportunityId: meta.opportunityId } : {}),
|
|
644
|
+
lastAction: aiTurn.action,
|
|
645
|
+
outcome,
|
|
646
|
+
turnCount: finalTurnCount,
|
|
595
647
|
});
|
|
596
648
|
return success({
|
|
597
649
|
message: `${query.action === 'question' ? 'Question' : query.action === 'propose' ? 'Proposal' : 'Counter'} submitted. Counterparty responded with ${aiTurn.action}.`,
|
|
@@ -606,12 +658,12 @@ export function createNegotiationTools(defineTool, deps) {
|
|
|
606
658
|
if (isNegotiationTurnCapReached(finalTurnCount, meta.maxTurns)) {
|
|
607
659
|
const fullHistory = [...historyForDispatch, aiTurn];
|
|
608
660
|
const outcome = buildNegotiationOutcome(fullHistory, finalTurnCount, 'counter', meta.sourceUserId, meta.candidateUserId, counterpartySpeaker === 'source' ? 'candidate' : 'source');
|
|
609
|
-
await negotiationDatabase
|
|
610
|
-
await negotiationDatabase.createArtifact({
|
|
661
|
+
await concludeNegotiation(negotiationDatabase, {
|
|
611
662
|
taskId: task.id,
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
663
|
+
...(meta.opportunityId ? { opportunityId: meta.opportunityId } : {}),
|
|
664
|
+
lastAction: 'counter',
|
|
665
|
+
outcome,
|
|
666
|
+
turnCount: finalTurnCount,
|
|
615
667
|
});
|
|
616
668
|
return success({
|
|
617
669
|
message: 'Counterparty responded but max turns reached. Negotiation finalized.',
|
|
@@ -683,12 +735,12 @@ export function createNegotiationTools(defineTool, deps) {
|
|
|
683
735
|
const fullHistory = [...historyForDispatch, aiTurn, userAgentTurn];
|
|
684
736
|
const userSpeaker = isSource ? 'source' : 'candidate';
|
|
685
737
|
const outcome = buildNegotiationOutcome(fullHistory, userTurnCount, userAgentTurn.action, meta.sourceUserId, meta.candidateUserId, userSpeaker === 'source' ? 'candidate' : 'source');
|
|
686
|
-
await negotiationDatabase
|
|
687
|
-
await negotiationDatabase.createArtifact({
|
|
738
|
+
await concludeNegotiation(negotiationDatabase, {
|
|
688
739
|
taskId: task.id,
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
740
|
+
...(meta.opportunityId ? { opportunityId: meta.opportunityId } : {}),
|
|
741
|
+
lastAction: userAgentTurn.action,
|
|
742
|
+
outcome,
|
|
743
|
+
turnCount: userTurnCount,
|
|
692
744
|
});
|
|
693
745
|
return success({
|
|
694
746
|
message: `Your agent ${userAgentTurn.action}ed the counterparty's response.`,
|
|
@@ -702,12 +754,12 @@ export function createNegotiationTools(defineTool, deps) {
|
|
|
702
754
|
if (isNegotiationTurnCapReached(userTurnCount, meta.maxTurns)) {
|
|
703
755
|
const fullHistory = [...historyForDispatch, aiTurn, userAgentTurn];
|
|
704
756
|
const outcome = buildNegotiationOutcome(fullHistory, userTurnCount, 'counter', meta.sourceUserId, meta.candidateUserId, isSource ? 'candidate' : 'source');
|
|
705
|
-
await negotiationDatabase
|
|
706
|
-
await negotiationDatabase.createArtifact({
|
|
757
|
+
await concludeNegotiation(negotiationDatabase, {
|
|
707
758
|
taskId: task.id,
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
759
|
+
...(meta.opportunityId ? { opportunityId: meta.opportunityId } : {}),
|
|
760
|
+
lastAction: 'counter',
|
|
761
|
+
outcome,
|
|
762
|
+
turnCount: userTurnCount,
|
|
711
763
|
});
|
|
712
764
|
return success({
|
|
713
765
|
message: 'Your agent responded but max turns reached. Negotiation finalized.',
|