@indexnetwork/protocol 21.1.0-rc.491.1 → 21.1.0-rc.493.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 +2 -0
- package/dist/index.js +2 -0
- package/dist/negotiations/negotiation.agent.d.ts +1 -0
- package/dist/negotiations/negotiation.agent.js +1 -1
- package/dist/negotiations/negotiation.answer-consumption.d.ts +223 -0
- package/dist/negotiations/negotiation.answer-consumption.js +275 -0
- package/dist/negotiations/negotiation.graph.finalize.js +132 -9
- package/dist/negotiations/negotiation.graph.js +2 -0
- package/dist/negotiations/negotiation.graph.shared.d.ts +17 -0
- package/dist/negotiations/negotiation.graph.shared.js +23 -7
- package/dist/negotiations/negotiation.graph.turn.js +10 -3
- package/dist/negotiations/negotiation.module.d.ts +6 -1
- package/dist/negotiations/negotiation.module.js +4 -1
- package/dist/negotiations/negotiation.protocol.d.ts +14 -0
- package/dist/negotiations/negotiation.protocol.js +22 -0
- package/dist/negotiations/negotiation.stall-gap.d.ts +83 -0
- package/dist/negotiations/negotiation.stall-gap.js +152 -0
- package/dist/negotiations/negotiation.stance.contracts.d.ts +5 -5
- package/dist/negotiations/negotiation.stance.contracts.js +39 -6
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -132,3 +132,5 @@ export type { NegotiationSpeakerParticipants, NegotiationSpeakerMessage, Negotia
|
|
|
132
132
|
export { assessConsultationEligibility, consultationPromptFor, negotiationConsultationPolicyMode } from "./negotiations/negotiation.module.js";
|
|
133
133
|
export type { ConsultationEligibility, ConsultationEligibilityInput, NegotiationConsultationPolicyMode } from "./negotiations/negotiation.module.js";
|
|
134
134
|
export { NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY, NEGOTIATION_QUESTION_GENERIC_NETWORK, NEGOTIATION_QUESTION_GENERIC_UPTAKE_ACTIVITY, negotiationQuestionSettlementId, } from "./negotiations/negotiation.module.js";
|
|
135
|
+
export { classifyParkedNegotiation, consumeQuestionBlockAnswers, negotiationParkAnswerId, resumeParkedNegotiation, routeAnswerRef, } from "./negotiations/negotiation.module.js";
|
|
136
|
+
export type { AnswerRoute, InflightAnswerSettlementInput, InflightAnswerSettlementResult, NegotiationAnswerConsumptionPorts, NegotiationAnswerInput, NegotiationAnswerResumeOutcome, ParkClassification, QuestionBlockAnswerConsumptionInput, QuestionBlockAnswerConsumptionResult, RoutedAnswer, } from "./negotiations/negotiation.module.js";
|
package/dist/index.js
CHANGED
|
@@ -116,3 +116,5 @@ export { HERMES_OWNER_DIRECTIVE, HermesNegotiationResponseSchema, allowedHermesA
|
|
|
116
116
|
export { isNegotiationTurnCapReached, expectedNegotiationSpeaker, negotiationScopeKey, readNegotiationMessages, allowedActionsFor, askUserAnswerWindowMs, configuredAskUserEnabled, isTerminalAction, isRejectLikeAction, readProtocolVersion, resolveSeat, seatViolationMessage } from "./negotiations/negotiation.module.js";
|
|
117
117
|
export { assessConsultationEligibility, consultationPromptFor, negotiationConsultationPolicyMode } from "./negotiations/negotiation.module.js";
|
|
118
118
|
export { NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY, NEGOTIATION_QUESTION_GENERIC_NETWORK, NEGOTIATION_QUESTION_GENERIC_UPTAKE_ACTIVITY, negotiationQuestionSettlementId, } from "./negotiations/negotiation.module.js";
|
|
119
|
+
// ─── Negotiation answer consumption (conversational questions) ──────────────
|
|
120
|
+
export { classifyParkedNegotiation, consumeQuestionBlockAnswers, negotiationParkAnswerId, resumeParkedNegotiation, routeAnswerRef, } from "./negotiations/negotiation.module.js";
|
|
@@ -103,6 +103,7 @@ export interface IndexNegotiatorConfig {
|
|
|
103
103
|
*/
|
|
104
104
|
turnTimeoutMs?: number;
|
|
105
105
|
}
|
|
106
|
+
export declare function resolveTurnTimeoutMs(override?: number): number;
|
|
106
107
|
/**
|
|
107
108
|
* Unified system negotiation agent that advocates for its user.
|
|
108
109
|
* Adapts behavior based on turn position (first turn = propose, subsequent = respond).
|
|
@@ -113,7 +113,7 @@ const DEFAULT_TURN_TIMEOUT_MS = 15000;
|
|
|
113
113
|
function isValidTimeoutMs(n) {
|
|
114
114
|
return Number.isFinite(n) && n > 0 && n <= Number.MAX_SAFE_INTEGER;
|
|
115
115
|
}
|
|
116
|
-
function resolveTurnTimeoutMs(override) {
|
|
116
|
+
export function resolveTurnTimeoutMs(override) {
|
|
117
117
|
if (typeof override === "number" && isValidTimeoutMs(override))
|
|
118
118
|
return override;
|
|
119
119
|
const envValue = process.env.NEGOTIATOR_TURN_TIMEOUT_MS;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Answer consumption: route a client's DM reply to its parked negotiations and
|
|
3
|
+
* resume them (conversational-questions plan, "Answers").
|
|
4
|
+
*
|
|
5
|
+
* The delivery surface decides that a chat reply answers a question-message
|
|
6
|
+
* and maps the reply's content onto block refs; this module owns everything
|
|
7
|
+
* after that decision — the RESUME SEAM. Given a routed answer and the block
|
|
8
|
+
* it answers, it re-resolves each referenced negotiation to its current park
|
|
9
|
+
* and resumes it exactly once:
|
|
10
|
+
*
|
|
11
|
+
* - A mid-flight consult park (`input_required` task with a captured ask-user
|
|
12
|
+
* binding) resumes through the same durable settlement → exact continuation
|
|
13
|
+
* path the card answer used: settle the exact task, then enqueue the
|
|
14
|
+
* settlement-keyed resume. The settle is a CAS on the parked task, so a
|
|
15
|
+
* second delivery finds it already settled and only re-enqueues the
|
|
16
|
+
* idempotent continuation — never a double resume.
|
|
17
|
+
* - A post-stall park (completed task on a stalled opportunity whose trailing
|
|
18
|
+
* turn is the authored `ask_user` gap, `NEGOTIATION_PARK_REASONING`) resumes
|
|
19
|
+
* as a fresh negotiation attempt: the answer is recorded on the opportunity
|
|
20
|
+
* (where continuation prompts already read it via
|
|
21
|
+
* `getOpportunityUserAnswers`) and a retry is enqueued. Exactly-once is the
|
|
22
|
+
* attempt claim: concurrent retries race `createNegotiationTaskForAttempt`
|
|
23
|
+
* and all but one lose.
|
|
24
|
+
*
|
|
25
|
+
* Idempotency is a property of the negotiation, not of any settlement record
|
|
26
|
+
* kept here: re-resolution + the `input_required` admission gate (mid-flight)
|
|
27
|
+
* and the atomic attempt claim (post-stall) make a repeated delivery a no-op.
|
|
28
|
+
* A ref that matches nothing in the block resumes NOTHING — misrouting resumes
|
|
29
|
+
* the wrong negotiation with the wrong fact, which is worse than asking again —
|
|
30
|
+
* and is reported back so the delivery surface can ask a clarifying follow-up.
|
|
31
|
+
*
|
|
32
|
+
* A resumed negotiation may park again; the ask-rounds cap (#1430) bounds that
|
|
33
|
+
* loop at park time. This module deliberately adds no second counter.
|
|
34
|
+
*/
|
|
35
|
+
import type { NegotiationGraphDatabase, NegotiationUserAnswer } from "../shared/interfaces/database.interface.js";
|
|
36
|
+
import type { QuestionBlock, QuestionBlockQuestion } from "../shared/schemas/question-block.schema.js";
|
|
37
|
+
/** One answer the delivery agent routed onto a block ref. */
|
|
38
|
+
export interface RoutedAnswer {
|
|
39
|
+
/** The block ref the reply was matched to — primary or alsoUnblocks alike. */
|
|
40
|
+
ref: string;
|
|
41
|
+
/** The client's answer for that question, as free text. */
|
|
42
|
+
answerText: string;
|
|
43
|
+
}
|
|
44
|
+
/** A resolved route: the owning question and every negotiation its answer resumes. */
|
|
45
|
+
export interface AnswerRoute {
|
|
46
|
+
question: QuestionBlockQuestion;
|
|
47
|
+
/** Primary first, then `alsoUnblocks` — one answer resumes them all. */
|
|
48
|
+
opportunityIds: string[];
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Resolve a matched ref to its question and full resume set. The block schema
|
|
52
|
+
* guarantees every ref appears exactly once across the whole block, so this is
|
|
53
|
+
* a lookup — ambiguity is a producer-side error that cannot reach here through
|
|
54
|
+
* a parsed block. Returns null for a ref the block does not carry: the caller
|
|
55
|
+
* must ask a clarifying follow-up, never resume speculatively.
|
|
56
|
+
*/
|
|
57
|
+
export declare function routeAnswerRef(block: QuestionBlock, ref: string): AnswerRoute | null;
|
|
58
|
+
/** The minimal ask-user binding a mid-flight resume needs, read off task metadata. */
|
|
59
|
+
interface AskUserResumeBinding {
|
|
60
|
+
settlementId: string;
|
|
61
|
+
recipientUserId: string;
|
|
62
|
+
recipientIntentId: string;
|
|
63
|
+
networkId: string;
|
|
64
|
+
opportunityId: string;
|
|
65
|
+
}
|
|
66
|
+
/** What answering a given ref would resume, as re-resolved right now. */
|
|
67
|
+
export type ParkClassification = {
|
|
68
|
+
kind: "inflight";
|
|
69
|
+
taskId: string;
|
|
70
|
+
binding: AskUserResumeBinding;
|
|
71
|
+
} | {
|
|
72
|
+
kind: "post_stall";
|
|
73
|
+
taskId: string;
|
|
74
|
+
}
|
|
75
|
+
/** No negotiation task exists for the ref at all. */
|
|
76
|
+
| {
|
|
77
|
+
kind: "no_negotiation";
|
|
78
|
+
}
|
|
79
|
+
/** A negotiation exists but holds no live park — already resumed, expired, or terminal. */
|
|
80
|
+
| {
|
|
81
|
+
kind: "not_parked";
|
|
82
|
+
}
|
|
83
|
+
/** Parked, but awaiting the OTHER side's client — this user's answer must not resume it. */
|
|
84
|
+
| {
|
|
85
|
+
kind: "wrong_recipient";
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Re-resolve a negotiation ref to its current park. This is the exact task
|
|
89
|
+
* re-resolution the graph itself uses (`getNegotiationTaskForOpportunity`),
|
|
90
|
+
* never a snapshot: answer routing branches on what the negotiation is NOW,
|
|
91
|
+
* so a park that was answered, expired, or superseded since the block was
|
|
92
|
+
* authored classifies as `not_parked` and the answer no-ops.
|
|
93
|
+
*/
|
|
94
|
+
export declare function classifyParkedNegotiation(database: Pick<NegotiationGraphDatabase, "getNegotiationTaskForOpportunity" | "getNegotiationMessages">, input: {
|
|
95
|
+
opportunityId: string;
|
|
96
|
+
userId: string;
|
|
97
|
+
}): Promise<ParkClassification>;
|
|
98
|
+
/** Everything a mid-flight settle needs; all fields come from the re-resolved binding. */
|
|
99
|
+
export interface InflightAnswerSettlementInput {
|
|
100
|
+
taskId: string;
|
|
101
|
+
settlementId: string;
|
|
102
|
+
opportunityId: string;
|
|
103
|
+
recipientUserId: string;
|
|
104
|
+
recipientIntentId: string;
|
|
105
|
+
networkId: string;
|
|
106
|
+
answer: {
|
|
107
|
+
selectedOptions: string[];
|
|
108
|
+
freeText?: string;
|
|
109
|
+
answeredAt: string;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* - `settled`: this call closed the exact `input_required` task and durably
|
|
114
|
+
* stored the answer for the continuation claim to read.
|
|
115
|
+
* - `already_settled`: an earlier delivery settled it; the stored settlement
|
|
116
|
+
* stands. Resuming is still correct — the continuation enqueue and claim are
|
|
117
|
+
* settlement-keyed and idempotent, so re-enqueueing recovers a lost job
|
|
118
|
+
* without a double resume.
|
|
119
|
+
* - `lost`: the admission gate refused — the task is no longer
|
|
120
|
+
* `input_required` (answer-window expiry or another path won). No resume.
|
|
121
|
+
*/
|
|
122
|
+
export type InflightAnswerSettlementResult = "settled" | "already_settled" | "lost";
|
|
123
|
+
export interface NegotiationAnswerConsumptionPorts {
|
|
124
|
+
/** The same reads the negotiation graph resolves parks with. */
|
|
125
|
+
database: Pick<NegotiationGraphDatabase, "getNegotiationTaskForOpportunity" | "getNegotiationMessages">;
|
|
126
|
+
/**
|
|
127
|
+
* Settle the exact mid-flight consult: CAS the `input_required` task closed
|
|
128
|
+
* under the deterministic settlement lock and durably store the answer where
|
|
129
|
+
* the continuation claim reads its private consultation. Implementations
|
|
130
|
+
* must be answer-vs-timeout safe (the expiry worker races this) and must
|
|
131
|
+
* report a repeat delivery as `already_settled`, never settle twice.
|
|
132
|
+
*/
|
|
133
|
+
settleInflightAnswer(input: InflightAnswerSettlementInput): Promise<InflightAnswerSettlementResult>;
|
|
134
|
+
/**
|
|
135
|
+
* Enqueue the exact durable continuation — the same settlement-keyed resume
|
|
136
|
+
* the card answer path enqueues. Must be idempotent per settlementId.
|
|
137
|
+
*/
|
|
138
|
+
enqueueInflightResume(input: {
|
|
139
|
+
opportunityId: string;
|
|
140
|
+
userId: string;
|
|
141
|
+
taskId: string;
|
|
142
|
+
settlementId: string;
|
|
143
|
+
recipientIntentId: string;
|
|
144
|
+
networkId: string;
|
|
145
|
+
}): Promise<void>;
|
|
146
|
+
/**
|
|
147
|
+
* Append the routed answer to the opportunity's `userAnswers`, where
|
|
148
|
+
* continuation prompts already read between-session context. Implementations
|
|
149
|
+
* MUST ignore an append whose `questionId` is already present — that key is
|
|
150
|
+
* deterministic per park, so a repeated delivery records nothing twice.
|
|
151
|
+
*/
|
|
152
|
+
recordOpportunityAnswer(input: {
|
|
153
|
+
opportunityId: string;
|
|
154
|
+
answer: NegotiationUserAnswer;
|
|
155
|
+
}): Promise<void>;
|
|
156
|
+
/**
|
|
157
|
+
* Enqueue a fresh negotiate-existing retry of the stalled opportunity.
|
|
158
|
+
* `parkTaskId` identifies the answered park; implementations should dedupe
|
|
159
|
+
* on it. Over-enqueueing is safe regardless: every retry races the atomic
|
|
160
|
+
* attempt claim and all but one lose.
|
|
161
|
+
*/
|
|
162
|
+
enqueueStalledRetry(input: {
|
|
163
|
+
opportunityId: string;
|
|
164
|
+
userId: string;
|
|
165
|
+
parkTaskId: string;
|
|
166
|
+
}): Promise<void>;
|
|
167
|
+
}
|
|
168
|
+
/** Deterministic identity of a post-stall park's recorded answer (dedup key, never rendered). */
|
|
169
|
+
export declare function negotiationParkAnswerId(parkTaskId: string): string;
|
|
170
|
+
export type NegotiationAnswerResumeOutcome = "resumed_inflight" | "resumed_retry" | "not_parked" | "no_negotiation" | "wrong_recipient";
|
|
171
|
+
export interface NegotiationAnswerInput {
|
|
172
|
+
/** The negotiation to resume: one ref out of a routed answer's resume set. */
|
|
173
|
+
opportunityId: string;
|
|
174
|
+
/** The answering client — the park must be awaiting THIS user's side. */
|
|
175
|
+
userId: string;
|
|
176
|
+
answerText: string;
|
|
177
|
+
/** ISO timestamp of the reply; defaults to now. */
|
|
178
|
+
answeredAt?: string;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Resume one parked negotiation with a routed answer, exactly once. Both
|
|
182
|
+
* outcomes that resume enqueue asynchronously — the negotiation continues on
|
|
183
|
+
* its queue, not inline. Every other outcome resumes nothing. Throws only on
|
|
184
|
+
* port failure; every step is safe to repeat, so the caller may simply
|
|
185
|
+
* redeliver.
|
|
186
|
+
*/
|
|
187
|
+
export declare function resumeParkedNegotiation(ports: NegotiationAnswerConsumptionPorts, input: NegotiationAnswerInput): Promise<NegotiationAnswerResumeOutcome>;
|
|
188
|
+
export interface QuestionBlockAnswerConsumptionInput {
|
|
189
|
+
/** The parsed block the reply answers (see `parseQuestionMessage`). */
|
|
190
|
+
block: QuestionBlock;
|
|
191
|
+
/** The replying client — the DM's owner. */
|
|
192
|
+
userId: string;
|
|
193
|
+
/** The reply's content routed onto block refs; empty when nothing matched. */
|
|
194
|
+
answers: RoutedAnswer[];
|
|
195
|
+
/** ISO timestamp of the reply; defaults to now. */
|
|
196
|
+
answeredAt?: string;
|
|
197
|
+
}
|
|
198
|
+
export interface QuestionBlockAnswerConsumptionResult {
|
|
199
|
+
resumed: Array<{
|
|
200
|
+
opportunityId: string;
|
|
201
|
+
outcome: "resumed_inflight" | "resumed_retry";
|
|
202
|
+
}>;
|
|
203
|
+
skipped: Array<{
|
|
204
|
+
opportunityId: string;
|
|
205
|
+
outcome: "not_parked" | "no_negotiation" | "wrong_recipient" | "duplicate_route" | "failed";
|
|
206
|
+
}>;
|
|
207
|
+
/** Routed answers whose ref the block does not carry — resume nothing, ask again. */
|
|
208
|
+
unmatched: RoutedAnswer[];
|
|
209
|
+
/**
|
|
210
|
+
* True when the reply left something unresolved on the routing side: no
|
|
211
|
+
* answer matched at all, or a ref matched nothing in the block. The caller
|
|
212
|
+
* owns the clarifying follow-up; this seam only ever refuses to guess.
|
|
213
|
+
*/
|
|
214
|
+
needsClarification: boolean;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Consume a client reply against the block it answers: resolve each routed
|
|
218
|
+
* answer to its question, then resume the primary negotiation and every
|
|
219
|
+
* `alsoUnblocks` ref with that answer, exactly once each. Failures are
|
|
220
|
+
* per-negotiation — one broken target never blocks the rest of the reply.
|
|
221
|
+
*/
|
|
222
|
+
export declare function consumeQuestionBlockAnswers(ports: NegotiationAnswerConsumptionPorts, input: QuestionBlockAnswerConsumptionInput): Promise<QuestionBlockAnswerConsumptionResult>;
|
|
223
|
+
export {};
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Answer consumption: route a client's DM reply to its parked negotiations and
|
|
3
|
+
* resume them (conversational-questions plan, "Answers").
|
|
4
|
+
*
|
|
5
|
+
* The delivery surface decides that a chat reply answers a question-message
|
|
6
|
+
* and maps the reply's content onto block refs; this module owns everything
|
|
7
|
+
* after that decision — the RESUME SEAM. Given a routed answer and the block
|
|
8
|
+
* it answers, it re-resolves each referenced negotiation to its current park
|
|
9
|
+
* and resumes it exactly once:
|
|
10
|
+
*
|
|
11
|
+
* - A mid-flight consult park (`input_required` task with a captured ask-user
|
|
12
|
+
* binding) resumes through the same durable settlement → exact continuation
|
|
13
|
+
* path the card answer used: settle the exact task, then enqueue the
|
|
14
|
+
* settlement-keyed resume. The settle is a CAS on the parked task, so a
|
|
15
|
+
* second delivery finds it already settled and only re-enqueues the
|
|
16
|
+
* idempotent continuation — never a double resume.
|
|
17
|
+
* - A post-stall park (completed task on a stalled opportunity whose trailing
|
|
18
|
+
* turn is the authored `ask_user` gap, `NEGOTIATION_PARK_REASONING`) resumes
|
|
19
|
+
* as a fresh negotiation attempt: the answer is recorded on the opportunity
|
|
20
|
+
* (where continuation prompts already read it via
|
|
21
|
+
* `getOpportunityUserAnswers`) and a retry is enqueued. Exactly-once is the
|
|
22
|
+
* attempt claim: concurrent retries race `createNegotiationTaskForAttempt`
|
|
23
|
+
* and all but one lose.
|
|
24
|
+
*
|
|
25
|
+
* Idempotency is a property of the negotiation, not of any settlement record
|
|
26
|
+
* kept here: re-resolution + the `input_required` admission gate (mid-flight)
|
|
27
|
+
* and the atomic attempt claim (post-stall) make a repeated delivery a no-op.
|
|
28
|
+
* A ref that matches nothing in the block resumes NOTHING — misrouting resumes
|
|
29
|
+
* the wrong negotiation with the wrong fact, which is worse than asking again —
|
|
30
|
+
* and is reported back so the delivery surface can ask a clarifying follow-up.
|
|
31
|
+
*
|
|
32
|
+
* A resumed negotiation may park again; the ask-rounds cap (#1430) bounds that
|
|
33
|
+
* loop at park time. This module deliberately adds no second counter.
|
|
34
|
+
*/
|
|
35
|
+
import { NEGOTIATION_PARK_REASONING } from "./negotiation.stall-gap.js";
|
|
36
|
+
import { negotiationQuestionSettlementId } from "./negotiation.question-safety.js";
|
|
37
|
+
import { turnsFromMessages } from "./negotiation.graph.shared.js";
|
|
38
|
+
import { protocolLogger } from "../shared/observability/protocol.logger.js";
|
|
39
|
+
const answerLog = protocolLogger("NegotiationAnswerConsumption");
|
|
40
|
+
/**
|
|
41
|
+
* Resolve a matched ref to its question and full resume set. The block schema
|
|
42
|
+
* guarantees every ref appears exactly once across the whole block, so this is
|
|
43
|
+
* a lookup — ambiguity is a producer-side error that cannot reach here through
|
|
44
|
+
* a parsed block. Returns null for a ref the block does not carry: the caller
|
|
45
|
+
* must ask a clarifying follow-up, never resume speculatively.
|
|
46
|
+
*/
|
|
47
|
+
export function routeAnswerRef(block, ref) {
|
|
48
|
+
for (const question of block.questions) {
|
|
49
|
+
const opportunityIds = [question.opportunityId, ...(question.alsoUnblocks ?? [])];
|
|
50
|
+
if (opportunityIds.includes(ref))
|
|
51
|
+
return { question, opportunityIds };
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
function nonEmptyString(value) {
|
|
56
|
+
return typeof value === "string" && value.length > 0;
|
|
57
|
+
}
|
|
58
|
+
function readAskUserResumeBinding(metadata) {
|
|
59
|
+
const turnContext = metadata?.turnContext;
|
|
60
|
+
const binding = turnContext?.askUserBinding;
|
|
61
|
+
if (!binding
|
|
62
|
+
|| !nonEmptyString(binding.settlementId)
|
|
63
|
+
|| !nonEmptyString(binding.recipientUserId)
|
|
64
|
+
|| !nonEmptyString(binding.recipientIntentId)
|
|
65
|
+
|| !nonEmptyString(binding.networkId)
|
|
66
|
+
|| !nonEmptyString(binding.opportunityId))
|
|
67
|
+
return null;
|
|
68
|
+
return {
|
|
69
|
+
settlementId: binding.settlementId,
|
|
70
|
+
recipientUserId: binding.recipientUserId,
|
|
71
|
+
recipientIntentId: binding.recipientIntentId,
|
|
72
|
+
networkId: binding.networkId,
|
|
73
|
+
opportunityId: binding.opportunityId,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The trailing canonical turn of a negotiation's own messages, when it is the
|
|
78
|
+
* authored post-stall gap. Non-turn messages are skipped, mirroring
|
|
79
|
+
* `turnsFromMessages`; any other trailing turn means the negotiation is not
|
|
80
|
+
* parked post-stall.
|
|
81
|
+
*/
|
|
82
|
+
function trailingParkMessage(messages) {
|
|
83
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
84
|
+
const message = messages[index];
|
|
85
|
+
const turn = turnsFromMessages([message])[0];
|
|
86
|
+
if (!turn)
|
|
87
|
+
continue;
|
|
88
|
+
return turn.action === "ask_user" && turn.assessment?.reasoning === NEGOTIATION_PARK_REASONING
|
|
89
|
+
? { senderId: message.senderId, taskId: message.taskId, turn }
|
|
90
|
+
: null;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Re-resolve a negotiation ref to its current park. This is the exact task
|
|
96
|
+
* re-resolution the graph itself uses (`getNegotiationTaskForOpportunity`),
|
|
97
|
+
* never a snapshot: answer routing branches on what the negotiation is NOW,
|
|
98
|
+
* so a park that was answered, expired, or superseded since the block was
|
|
99
|
+
* authored classifies as `not_parked` and the answer no-ops.
|
|
100
|
+
*/
|
|
101
|
+
export async function classifyParkedNegotiation(database, input) {
|
|
102
|
+
const task = await database.getNegotiationTaskForOpportunity(input.opportunityId);
|
|
103
|
+
if (!task)
|
|
104
|
+
return { kind: "no_negotiation" };
|
|
105
|
+
if (task.state === "input_required") {
|
|
106
|
+
const binding = readAskUserResumeBinding(task.metadata);
|
|
107
|
+
if (!binding
|
|
108
|
+
|| binding.opportunityId !== input.opportunityId
|
|
109
|
+
|| binding.settlementId !== negotiationQuestionSettlementId(task.id)) {
|
|
110
|
+
answerLog.warn("input_required negotiation task carries no coherent ask-user binding; answer cannot resume it", {
|
|
111
|
+
taskId: task.id,
|
|
112
|
+
opportunityId: input.opportunityId,
|
|
113
|
+
});
|
|
114
|
+
return { kind: "not_parked" };
|
|
115
|
+
}
|
|
116
|
+
if (binding.recipientUserId !== input.userId)
|
|
117
|
+
return { kind: "wrong_recipient" };
|
|
118
|
+
return { kind: "inflight", taskId: task.id, binding };
|
|
119
|
+
}
|
|
120
|
+
if (task.state === "completed") {
|
|
121
|
+
const messages = await database.getNegotiationMessages(input.opportunityId);
|
|
122
|
+
const park = trailingParkMessage(messages);
|
|
123
|
+
if (!park)
|
|
124
|
+
return { kind: "not_parked" };
|
|
125
|
+
// The gap was written by the finalizing session's task — the most recent
|
|
126
|
+
// one. A trailing park from an older task means state has moved on.
|
|
127
|
+
if (park.taskId != null && park.taskId !== task.id)
|
|
128
|
+
return { kind: "not_parked" };
|
|
129
|
+
if (park.senderId !== `agent:${input.userId}`)
|
|
130
|
+
return { kind: "wrong_recipient" };
|
|
131
|
+
return { kind: "post_stall", taskId: task.id };
|
|
132
|
+
}
|
|
133
|
+
// submitted/working/waiting_for_agent: a session is live (possibly the very
|
|
134
|
+
// resume a first delivery triggered); canceled: a settled consult awaiting
|
|
135
|
+
// its successor; failed/rejected: terminal. None hold an answerable park.
|
|
136
|
+
return { kind: "not_parked" };
|
|
137
|
+
}
|
|
138
|
+
/** Deterministic identity of a post-stall park's recorded answer (dedup key, never rendered). */
|
|
139
|
+
export function negotiationParkAnswerId(parkTaskId) {
|
|
140
|
+
return `negotiation-park-answer-v1-${parkTaskId}`;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Resume one parked negotiation with a routed answer, exactly once. Both
|
|
144
|
+
* outcomes that resume enqueue asynchronously — the negotiation continues on
|
|
145
|
+
* its queue, not inline. Every other outcome resumes nothing. Throws only on
|
|
146
|
+
* port failure; every step is safe to repeat, so the caller may simply
|
|
147
|
+
* redeliver.
|
|
148
|
+
*/
|
|
149
|
+
export async function resumeParkedNegotiation(ports, input) {
|
|
150
|
+
const classification = await classifyParkedNegotiation(ports.database, {
|
|
151
|
+
opportunityId: input.opportunityId,
|
|
152
|
+
userId: input.userId,
|
|
153
|
+
});
|
|
154
|
+
const answeredAt = input.answeredAt ?? new Date().toISOString();
|
|
155
|
+
if (classification.kind === "inflight") {
|
|
156
|
+
const settlement = await ports.settleInflightAnswer({
|
|
157
|
+
taskId: classification.taskId,
|
|
158
|
+
settlementId: classification.binding.settlementId,
|
|
159
|
+
opportunityId: input.opportunityId,
|
|
160
|
+
recipientUserId: classification.binding.recipientUserId,
|
|
161
|
+
recipientIntentId: classification.binding.recipientIntentId,
|
|
162
|
+
networkId: classification.binding.networkId,
|
|
163
|
+
answer: { selectedOptions: [], freeText: input.answerText, answeredAt },
|
|
164
|
+
});
|
|
165
|
+
if (settlement === "lost") {
|
|
166
|
+
answerLog.info("negotiation_answer_settlement_lost", {
|
|
167
|
+
taskId: classification.taskId,
|
|
168
|
+
opportunityId: input.opportunityId,
|
|
169
|
+
});
|
|
170
|
+
return "not_parked";
|
|
171
|
+
}
|
|
172
|
+
// Settlement is durable; enqueue after it, never before — a crash between
|
|
173
|
+
// the two is recovered by redelivery (`already_settled` → enqueue again).
|
|
174
|
+
await ports.enqueueInflightResume({
|
|
175
|
+
opportunityId: input.opportunityId,
|
|
176
|
+
userId: classification.binding.recipientUserId,
|
|
177
|
+
taskId: classification.taskId,
|
|
178
|
+
settlementId: classification.binding.settlementId,
|
|
179
|
+
recipientIntentId: classification.binding.recipientIntentId,
|
|
180
|
+
networkId: classification.binding.networkId,
|
|
181
|
+
});
|
|
182
|
+
answerLog.info("negotiation_answer_resumed_inflight", {
|
|
183
|
+
taskId: classification.taskId,
|
|
184
|
+
opportunityId: input.opportunityId,
|
|
185
|
+
settlement,
|
|
186
|
+
});
|
|
187
|
+
return "resumed_inflight";
|
|
188
|
+
}
|
|
189
|
+
if (classification.kind === "post_stall") {
|
|
190
|
+
// Record before enqueueing: the retry's continuation prompt must see the
|
|
191
|
+
// answer. The deterministic id makes the append idempotent, so the
|
|
192
|
+
// crash-recovery order (record, then enqueue, redeliver on failure) holds.
|
|
193
|
+
await ports.recordOpportunityAnswer({
|
|
194
|
+
opportunityId: input.opportunityId,
|
|
195
|
+
answer: {
|
|
196
|
+
questionId: negotiationParkAnswerId(classification.taskId),
|
|
197
|
+
selectedOptions: [],
|
|
198
|
+
freeText: input.answerText,
|
|
199
|
+
answeredAt,
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
await ports.enqueueStalledRetry({
|
|
203
|
+
opportunityId: input.opportunityId,
|
|
204
|
+
userId: input.userId,
|
|
205
|
+
parkTaskId: classification.taskId,
|
|
206
|
+
});
|
|
207
|
+
answerLog.info("negotiation_answer_resumed_retry", {
|
|
208
|
+
taskId: classification.taskId,
|
|
209
|
+
opportunityId: input.opportunityId,
|
|
210
|
+
});
|
|
211
|
+
return "resumed_retry";
|
|
212
|
+
}
|
|
213
|
+
if (classification.kind === "wrong_recipient") {
|
|
214
|
+
answerLog.warn("Answer routed to a negotiation parked on the counterparty's side; not resuming", {
|
|
215
|
+
opportunityId: input.opportunityId,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
return classification.kind;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Consume a client reply against the block it answers: resolve each routed
|
|
222
|
+
* answer to its question, then resume the primary negotiation and every
|
|
223
|
+
* `alsoUnblocks` ref with that answer, exactly once each. Failures are
|
|
224
|
+
* per-negotiation — one broken target never blocks the rest of the reply.
|
|
225
|
+
*/
|
|
226
|
+
export async function consumeQuestionBlockAnswers(ports, input) {
|
|
227
|
+
const answeredAt = input.answeredAt ?? new Date().toISOString();
|
|
228
|
+
const result = {
|
|
229
|
+
resumed: [],
|
|
230
|
+
skipped: [],
|
|
231
|
+
unmatched: [],
|
|
232
|
+
needsClarification: false,
|
|
233
|
+
};
|
|
234
|
+
const consumedQuestions = new Set();
|
|
235
|
+
for (const answer of input.answers) {
|
|
236
|
+
const route = routeAnswerRef(input.block, answer.ref);
|
|
237
|
+
if (!route) {
|
|
238
|
+
result.unmatched.push(answer);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
// Two routed answers can name refs of the same question; the first wins.
|
|
242
|
+
// The later one is surfaced as a duplicate rather than silently merged —
|
|
243
|
+
// choosing between conflicting phrasings is the delivery agent's job.
|
|
244
|
+
if (consumedQuestions.has(route.question.opportunityId)) {
|
|
245
|
+
result.skipped.push({ opportunityId: route.question.opportunityId, outcome: "duplicate_route" });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
consumedQuestions.add(route.question.opportunityId);
|
|
249
|
+
for (const opportunityId of route.opportunityIds) {
|
|
250
|
+
try {
|
|
251
|
+
const outcome = await resumeParkedNegotiation(ports, {
|
|
252
|
+
opportunityId,
|
|
253
|
+
userId: input.userId,
|
|
254
|
+
answerText: answer.answerText,
|
|
255
|
+
answeredAt,
|
|
256
|
+
});
|
|
257
|
+
if (outcome === "resumed_inflight" || outcome === "resumed_retry") {
|
|
258
|
+
result.resumed.push({ opportunityId, outcome });
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
result.skipped.push({ opportunityId, outcome });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
answerLog.error("Failed to resume an answered negotiation; continuing with the rest of the reply", {
|
|
266
|
+
opportunityId,
|
|
267
|
+
error: err instanceof Error ? err.message : String(err),
|
|
268
|
+
});
|
|
269
|
+
result.skipped.push({ opportunityId, outcome: "failed" });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
result.needsClarification = input.answers.length === 0 || result.unmatched.length > 0;
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
* Negotiation graph, stage 4: persist the outcome and fan out follow-ups.
|
|
3
3
|
*/
|
|
4
4
|
import { requestContext } from "../shared/observability/request-context.js";
|
|
5
|
-
import { isRejectLikeAction, isTerminalAction } from "./negotiation.protocol.js";
|
|
5
|
+
import { isRejectLikeAction, isTerminalAction, negotiationAskRoundsCap } from "./negotiation.protocol.js";
|
|
6
6
|
import { blocksNegotiationBeforeFirstTurn } from "./negotiation.screen.js";
|
|
7
|
-
import { NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY, NEGOTIATION_QUESTION_GENERIC_NETWORK } from './negotiation.question-safety.js';
|
|
7
|
+
import { NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY, NEGOTIATION_QUESTION_GENERIC_NETWORK, isSafeAuthoredNegotiationQuestion } from './negotiation.question-safety.js';
|
|
8
|
+
import { NEGOTIATION_PARK_REASONING } from './negotiation.stall-gap.js';
|
|
8
9
|
import { isNegotiationTurnCapReached } from "./negotiation.turn-cap.js";
|
|
9
|
-
import { finalizeLog, turnsFromMessages } from "./negotiation.graph.shared.js";
|
|
10
|
+
import { countNegotiationAskRounds, finalizeLog, retrieveClientDm, turnsFromMessages } from "./negotiation.graph.shared.js";
|
|
10
11
|
export async function finalizeNode(state, deps) {
|
|
11
12
|
const traceEmitter = requestContext.getStore()?.traceEmitter;
|
|
12
13
|
const emitWide = (event) => traceEmitter?.(event);
|
|
@@ -113,6 +114,131 @@ export async function finalizeNode(state, deps) {
|
|
|
113
114
|
? { reason: "turn_cap" }
|
|
114
115
|
: {}),
|
|
115
116
|
};
|
|
117
|
+
// Unconcluded end: no opportunity, no explicit reject, and turns actually
|
|
118
|
+
// happened — turn cap, timeout, or a plain stall. Feeds both the post-stall
|
|
119
|
+
// park below and the legacy questioner enqueue further down.
|
|
120
|
+
const endedUnconcluded = !hasOpportunity && !screenedOut && !isRejectLikeAction(lastTurn?.action) && state.turnCount > 0;
|
|
121
|
+
const stallReason = atCap
|
|
122
|
+
? 'turn_cap'
|
|
123
|
+
: (state.error && /timeout/i.test(state.error))
|
|
124
|
+
? 'timeout'
|
|
125
|
+
: 'stalled';
|
|
126
|
+
// ─── Post-stall park (conversational-questions plan) ──────────────────
|
|
127
|
+
// Instead of ending silently, an unconcluded negotiation parks carrying
|
|
128
|
+
// the ONE question that would let a retry conclude — authored by the
|
|
129
|
+
// negotiator from this negotiation's transcript and the signal's client
|
|
130
|
+
// DM, exactly the grounding the mid-flight consult uses. The gap is
|
|
131
|
+
// persisted as an `ask_user` message in the negotiation's own record: the
|
|
132
|
+
// parked negotiation is the only durable record of the information need,
|
|
133
|
+
// the same substrate the per-side ration reads, and an `ask_user` last
|
|
134
|
+
// message keeps the floor with the asking side on retry.
|
|
135
|
+
//
|
|
136
|
+
// Bounded per negotiation: past the ask-rounds cap the negotiation stalls
|
|
137
|
+
// TERMINALLY — no authoring call, no park — so two agents cannot
|
|
138
|
+
// ping-pong their humans indefinitely. Runs on continuations too (a
|
|
139
|
+
// resumed negotiation may park again); the cap is what bounds the loop.
|
|
140
|
+
//
|
|
141
|
+
// Every failure — authoring, safety gate, persistence — degrades to
|
|
142
|
+
// today's terminal stall. A park is additive state, never a new way for
|
|
143
|
+
// finalize to fail.
|
|
144
|
+
if (endedUnconcluded
|
|
145
|
+
&& deps.stallGapAuthor
|
|
146
|
+
&& state.opportunityId
|
|
147
|
+
&& state.sourceIntentId
|
|
148
|
+
&& state.indexContext.networkId) {
|
|
149
|
+
const askRounds = countNegotiationAskRounds(state.messages);
|
|
150
|
+
const askRoundsCap = negotiationAskRoundsCap();
|
|
151
|
+
if (askRounds >= askRoundsCap) {
|
|
152
|
+
finalizeLog.info('negotiation_ask_cap_terminal', {
|
|
153
|
+
taskId: state.taskId,
|
|
154
|
+
opportunityId: state.opportunityId,
|
|
155
|
+
askRounds,
|
|
156
|
+
askRoundsCap,
|
|
157
|
+
stallReason,
|
|
158
|
+
});
|
|
159
|
+
emitWide({
|
|
160
|
+
type: 'negotiation_ask_cap_terminal',
|
|
161
|
+
opportunityId: state.opportunityId,
|
|
162
|
+
askRounds,
|
|
163
|
+
askRoundsCap,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
try {
|
|
168
|
+
const clientDm = await retrieveClientDm(deps, state.sourceUser.id, state.sourceIntentId);
|
|
169
|
+
const parkIntent = state.sourceUser.intents.find((intent) => intent.id === state.sourceIntentId);
|
|
170
|
+
const gap = await deps.stallGapAuthor.author({
|
|
171
|
+
userName: state.sourceUser.profile.name ?? 'your user',
|
|
172
|
+
signal: parkIntent
|
|
173
|
+
? { title: parkIntent.title, description: parkIntent.description }
|
|
174
|
+
: { title: 'Signal', description: 'the signal attached to this match' },
|
|
175
|
+
seedReasoning: state.seedAssessment.reasoning,
|
|
176
|
+
history,
|
|
177
|
+
stallReason,
|
|
178
|
+
...(clientDm.length > 0 && { clientDm }),
|
|
179
|
+
});
|
|
180
|
+
// Same identifier-aware gate as the mid-flight authored question, with
|
|
181
|
+
// the same inputs in hand: the counterparty's name and the evaluator's
|
|
182
|
+
// reasoning. An unsafe question never parks — there is no enum-only
|
|
183
|
+
// downgrade here because a park without its gap records nothing.
|
|
184
|
+
const counterpartyName = state.candidateUser.profile?.name?.trim();
|
|
185
|
+
const seedReasoning = state.seedAssessment?.reasoning?.trim();
|
|
186
|
+
const safeGap = gap && isSafeAuthoredNegotiationQuestion(gap.question, {
|
|
187
|
+
...(counterpartyName ? { forbiddenIdentifiers: [counterpartyName] } : {}),
|
|
188
|
+
...(seedReasoning ? { forbiddenSourceText: [seedReasoning] } : {}),
|
|
189
|
+
});
|
|
190
|
+
if (safeGap) {
|
|
191
|
+
const parkTurn = {
|
|
192
|
+
action: 'ask_user',
|
|
193
|
+
assessment: {
|
|
194
|
+
reasoning: NEGOTIATION_PARK_REASONING,
|
|
195
|
+
suggestedRoles: lastTurn?.assessment.suggestedRoles ?? { ownUser: 'peer', otherUser: 'peer' },
|
|
196
|
+
},
|
|
197
|
+
message: null,
|
|
198
|
+
askUser: { reason: gap.reason, question: gap.question },
|
|
199
|
+
};
|
|
200
|
+
await deps.database.createMessage({
|
|
201
|
+
conversationId: state.conversationId,
|
|
202
|
+
senderId: `agent:${state.sourceUser.id}`,
|
|
203
|
+
role: 'agent',
|
|
204
|
+
parts: [{ kind: 'data', data: parkTurn }],
|
|
205
|
+
taskId: state.taskId,
|
|
206
|
+
...(state.continuationExecution ? { continuationExecution: state.continuationExecution } : {}),
|
|
207
|
+
});
|
|
208
|
+
finalizeLog.info('negotiation_parked', {
|
|
209
|
+
taskId: state.taskId,
|
|
210
|
+
opportunityId: state.opportunityId,
|
|
211
|
+
recipientUserId: state.sourceUser.id,
|
|
212
|
+
recipientIntentId: state.sourceIntentId,
|
|
213
|
+
askRounds: askRounds + 1,
|
|
214
|
+
askRoundsCap,
|
|
215
|
+
stallReason,
|
|
216
|
+
});
|
|
217
|
+
emitWide({
|
|
218
|
+
type: 'negotiation_parked',
|
|
219
|
+
opportunityId: state.opportunityId,
|
|
220
|
+
negotiationConversationId: state.conversationId,
|
|
221
|
+
askRounds: askRounds + 1,
|
|
222
|
+
askRoundsCap,
|
|
223
|
+
stallReason,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
else if (gap) {
|
|
227
|
+
finalizeLog.warn('Dropping unsafe post-stall gap question; negotiation stalls without a park', {
|
|
228
|
+
taskId: state.taskId,
|
|
229
|
+
opportunityId: state.opportunityId,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
finalizeLog.error('Failed to park stalled negotiation with its gap', {
|
|
235
|
+
taskId: state.taskId,
|
|
236
|
+
opportunityId: state.opportunityId,
|
|
237
|
+
error: err,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
116
242
|
try {
|
|
117
243
|
await deps.database.updateTaskState(state.taskId, "completed", undefined, state.continuationExecution);
|
|
118
244
|
await deps.database.createArtifact({
|
|
@@ -226,12 +352,9 @@ export async function finalizeNode(state, deps) {
|
|
|
226
352
|
}
|
|
227
353
|
// Enqueue question generation for stalled/capped negotiations (not accepted or explicitly rejected).
|
|
228
354
|
// Require turnCount > 0 so early init/turn errors don't enqueue with empty context.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
: (state.error && /timeout/i.test(state.error))
|
|
233
|
-
? 'timeout'
|
|
234
|
-
: 'stalled';
|
|
355
|
+
// Kept alongside the post-stall park above until the conversational-questions
|
|
356
|
+
// delivery lane retires the blind questioner path.
|
|
357
|
+
if (endedUnconcluded && state.opportunityId && state.sourceIntentId && state.indexContext.networkId && deps.questionerEnqueue && !state.continuationExecution) {
|
|
235
358
|
const userContext = (await deps.database.getUserContext(state.sourceUser.id, null))?.text ?? '';
|
|
236
359
|
const sourceIntent = state.sourceUser.intents.find((intent) => intent.id === state.sourceIntentId);
|
|
237
360
|
deps.questionerEnqueue({
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { StateGraph } from "@langchain/langgraph";
|
|
9
9
|
import { NegotiationGraphState } from "./negotiation.state.js";
|
|
10
10
|
import { IndexNegotiator } from "./negotiation.agent.js";
|
|
11
|
+
import { NegotiationStallGapAuthor } from "./negotiation.stall-gap.js";
|
|
11
12
|
import { blocksNegotiationBeforeFirstTurn, NegotiationScreener } from "./negotiation.screen.js";
|
|
12
13
|
import { configuredScreenMode } from "./negotiation.screen.contracts.js";
|
|
13
14
|
import { isTerminalAction } from "./negotiation.protocol.js";
|
|
@@ -32,6 +33,7 @@ export class NegotiationGraphFactory {
|
|
|
32
33
|
memoryRetrieve,
|
|
33
34
|
clientDmRetrieve,
|
|
34
35
|
systemAgent: new IndexNegotiator(),
|
|
36
|
+
stallGapAuthor: new NegotiationStallGapAuthor(),
|
|
35
37
|
screener: new NegotiationScreener(),
|
|
36
38
|
};
|
|
37
39
|
}
|
|
@@ -16,6 +16,7 @@ import type { QuestionerEnqueueFn } from "../questions/question.module.js";
|
|
|
16
16
|
import type { ReflectEnqueueFn } from "./negotiation.reflect.js";
|
|
17
17
|
import type { NegotiatorMemoryEntry, NegotiatorMemoryRetrieveFn, NegotiatorMemoryScope } from "./negotiation.memory.js";
|
|
18
18
|
import type { NegotiatorClientDmMessage, NegotiatorClientDmRetrieveFn } from "./negotiation.client-dm.js";
|
|
19
|
+
import type { NegotiationStallGapAuthor } from "./negotiation.stall-gap.js";
|
|
19
20
|
import { type AttributedPriorDialogue, type TaskAttribution } from './negotiation.attribution.js';
|
|
20
21
|
/** The graph's channel state, as every node sees it. */
|
|
21
22
|
export type NegotiationState = typeof NegotiationGraphState.State;
|
|
@@ -35,6 +36,8 @@ export interface NegotiationGraphDeps {
|
|
|
35
36
|
clientDmRetrieve?: NegotiatorClientDmRetrieveFn;
|
|
36
37
|
/** In-process negotiator used when no personal agent answers. */
|
|
37
38
|
systemAgent: IndexNegotiator;
|
|
39
|
+
/** Authors the post-stall gap question at finalize (park-on-stall). */
|
|
40
|
+
stallGapAuthor?: NegotiationStallGapAuthor;
|
|
38
41
|
/** Outreach gate for fresh negotiations. */
|
|
39
42
|
screener: NegotiationScreener;
|
|
40
43
|
}
|
|
@@ -60,6 +63,20 @@ export declare function hasPriorAskUser(messages: Array<{
|
|
|
60
63
|
senderId: string;
|
|
61
64
|
parts: unknown[];
|
|
62
65
|
}>, userId: string): boolean;
|
|
66
|
+
/**
|
|
67
|
+
* How many ask rounds this negotiation has already spent, BOTH sides combined.
|
|
68
|
+
* A round is one persisted `ask_user` park — a mid-flight client consultation
|
|
69
|
+
* or a post-stall park — each of which suspends the negotiation on a human
|
|
70
|
+
* answer. Same substrate as {@link hasPriorAskUser} (the negotiation's own
|
|
71
|
+
* message record, spanning all of its sessions), read negotiation-wide rather
|
|
72
|
+
* than per side: the cap this feeds bounds the park → answer → resume loop for
|
|
73
|
+
* the negotiation as a whole, so two agents cannot ping-pong their humans
|
|
74
|
+
* indefinitely.
|
|
75
|
+
*/
|
|
76
|
+
export declare function countNegotiationAskRounds(messages: Array<{
|
|
77
|
+
senderId: string;
|
|
78
|
+
parts: unknown[];
|
|
79
|
+
}>): number;
|
|
63
80
|
/**
|
|
64
81
|
* P5.3 memory retrieval — never throws, never blocks a negotiation. The
|
|
65
82
|
* injected fn already resolves [] when NEGOTIATOR_MEMORY_INJECT is off;
|
|
@@ -23,6 +23,15 @@ export function turnsFromMessages(messages) {
|
|
|
23
23
|
})
|
|
24
24
|
.filter(Boolean);
|
|
25
25
|
}
|
|
26
|
+
/** Sender ids of every persisted `ask_user` park in this negotiation's messages. */
|
|
27
|
+
function askUserSenderIds(messages) {
|
|
28
|
+
return messages
|
|
29
|
+
.filter((m) => {
|
|
30
|
+
const dataPart = m.parts.find((p) => p.kind === "data");
|
|
31
|
+
return dataPart?.data?.action === "ask_user";
|
|
32
|
+
})
|
|
33
|
+
.map((m) => m.senderId);
|
|
34
|
+
}
|
|
26
35
|
/**
|
|
27
36
|
* Whether `userId`'s side has already spent its one `ask_user` client
|
|
28
37
|
* consultation in THIS negotiation (P3.2 rationing: max one per negotiation per
|
|
@@ -32,13 +41,20 @@ export function turnsFromMessages(messages) {
|
|
|
32
41
|
* counterparty does not.
|
|
33
42
|
*/
|
|
34
43
|
export function hasPriorAskUser(messages, userId) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
return askUserSenderIds(messages).includes(`agent:${userId}`);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* How many ask rounds this negotiation has already spent, BOTH sides combined.
|
|
48
|
+
* A round is one persisted `ask_user` park — a mid-flight client consultation
|
|
49
|
+
* or a post-stall park — each of which suspends the negotiation on a human
|
|
50
|
+
* answer. Same substrate as {@link hasPriorAskUser} (the negotiation's own
|
|
51
|
+
* message record, spanning all of its sessions), read negotiation-wide rather
|
|
52
|
+
* than per side: the cap this feeds bounds the park → answer → resume loop for
|
|
53
|
+
* the negotiation as a whole, so two agents cannot ping-pong their humans
|
|
54
|
+
* indefinitely.
|
|
55
|
+
*/
|
|
56
|
+
export function countNegotiationAskRounds(messages) {
|
|
57
|
+
return askUserSenderIds(messages).length;
|
|
42
58
|
}
|
|
43
59
|
/**
|
|
44
60
|
* P5.3 memory retrieval — never throws, never blocks a negotiation. The
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
* Negotiation graph, stage 3: one negotiator turn.
|
|
3
3
|
*/
|
|
4
4
|
import { requestContext } from "../shared/observability/request-context.js";
|
|
5
|
-
import { allowedActionsFor, askUserAnswerWindowMs, configuredAskUserEnabled, fallbackActionFor, rejectActionFor } from "./negotiation.protocol.js";
|
|
5
|
+
import { allowedActionsFor, askUserAnswerWindowMs, configuredAskUserEnabled, fallbackActionFor, negotiationAskRoundsCap, rejectActionFor } from "./negotiation.protocol.js";
|
|
6
6
|
import { assessConsultationEligibility, consultationPromptFor, negotiationConsultationPolicyMode } 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
|
-
import { buildAttributedDialogue, hasPriorAskUser, memoryQueryText, retrieveClientDm, retrieveMemory, turnLog, turnsFromMessages } from "./negotiation.graph.shared.js";
|
|
11
|
+
import { buildAttributedDialogue, countNegotiationAskRounds, hasPriorAskUser, memoryQueryText, retrieveClientDm, retrieveMemory, turnLog, turnsFromMessages } from "./negotiation.graph.shared.js";
|
|
12
12
|
export async function turnNode(state, deps) {
|
|
13
13
|
const traceEmitter = requestContext.getStore()?.traceEmitter;
|
|
14
14
|
// Local helper to emit events whose shape is wider than the declared
|
|
@@ -38,6 +38,12 @@ export async function turnNode(state, deps) {
|
|
|
38
38
|
// against), v2 non-final non-opening turn, and this side's one client
|
|
39
39
|
// consultation not yet spent (rationing). Shadow is observational and
|
|
40
40
|
// must preserve this legacy path byte-for-byte except for telemetry.
|
|
41
|
+
//
|
|
42
|
+
// The negotiation-wide ask-rounds cap reads the same message substrate
|
|
43
|
+
// as the per-side ration. It cannot bind on mid-flight consults alone
|
|
44
|
+
// (one per side < default cap); it exists so post-stall parks — which
|
|
45
|
+
// also persist `ask_user` messages — count against the same budget,
|
|
46
|
+
// and a negotiation near its cap cannot spend a further round here.
|
|
41
47
|
const policyMode = negotiationConsultationPolicyMode();
|
|
42
48
|
const askUserAvailable = version === 'v2'
|
|
43
49
|
&& !isFinalTurn
|
|
@@ -48,7 +54,8 @@ export async function turnNode(state, deps) {
|
|
|
48
54
|
&& !!ownIntentId
|
|
49
55
|
&& !!state.indexContext.networkId
|
|
50
56
|
&& !(state.turnCount === 0 && !state.isContinuation)
|
|
51
|
-
&& !hasPriorAskUser(state.messages, ownUser.id)
|
|
57
|
+
&& !hasPriorAskUser(state.messages, ownUser.id)
|
|
58
|
+
&& countNegotiationAskRounds(state.messages) < negotiationAskRoundsCap();
|
|
52
59
|
// ─── Deadlock detection → persuasion→bargaining stance (IND-428) ──────
|
|
53
60
|
// Deterministic trailing-run inspection of the persisted history — no
|
|
54
61
|
// LLM in the decision. Gated on the strict default-off flag AND v2,
|
|
@@ -13,7 +13,12 @@ export { NegotiationScreener } from "./negotiation.screen.js";
|
|
|
13
13
|
export type { ChatReflectionInput, DistilledMemory, NegotiationReflectionInput, NegotiationReflectJobData, ReflectEnqueueFn, ReflectionTranscriptEntry, } from "./negotiation.reflect.js";
|
|
14
14
|
export type { NegotiationCandidate, OnNegotiationResolved } from "./negotiation.graph.js";
|
|
15
15
|
export type { NegotiationDigest } from "./insight.generator.js";
|
|
16
|
-
export { ASK_USER_LOCK_SLACK_MS, allowedActionsFor, askUserAnswerWindowMs, configuredAskUserEnabled, isRejectLikeAction, isTerminalAction, readProtocolVersion, resolveSeat, seatViolationMessage, } from "./negotiation.protocol.js";
|
|
16
|
+
export { ASK_USER_LOCK_SLACK_MS, DEFAULT_NEGOTIATION_ASK_ROUNDS_CAP, allowedActionsFor, askUserAnswerWindowMs, configuredAskUserEnabled, isRejectLikeAction, isTerminalAction, negotiationAskRoundsCap, readProtocolVersion, resolveSeat, seatViolationMessage, } from "./negotiation.protocol.js";
|
|
17
|
+
export { countNegotiationAskRounds } from "./negotiation.graph.shared.js";
|
|
18
|
+
export { NEGOTIATION_PARK_REASONING, NegotiationStallGapAuthor } from "./negotiation.stall-gap.js";
|
|
19
|
+
export type { NegotiationStallGap, NegotiationStallReason, StallGapAuthorInput } from "./negotiation.stall-gap.js";
|
|
20
|
+
export { classifyParkedNegotiation, consumeQuestionBlockAnswers, negotiationParkAnswerId, resumeParkedNegotiation, routeAnswerRef, } from "./negotiation.answer-consumption.js";
|
|
21
|
+
export type { AnswerRoute, InflightAnswerSettlementInput, InflightAnswerSettlementResult, NegotiationAnswerConsumptionPorts, NegotiationAnswerInput, NegotiationAnswerResumeOutcome, ParkClassification, QuestionBlockAnswerConsumptionInput, QuestionBlockAnswerConsumptionResult, RoutedAnswer, } from "./negotiation.answer-consumption.js";
|
|
17
22
|
export { HERMES_OWNER_DIRECTIVE, HERMES_SHARED_MESSAGE_TEMPLATES, HermesNegotiationActionSchema, HermesNegotiationResponseSchema, HermesOwnerDirectiveSchema, HermesRoleAlignmentSchema, allowedHermesActionsFor, buildHermesNegotiationTurn, } from "./negotiation.hermes-contract.js";
|
|
18
23
|
export { DEFAULT_NEGOTIATION_MAX_TURNS, isNegotiationTurnCapReached } from "./negotiation.turn-cap.js";
|
|
19
24
|
export { expectedNegotiationSpeaker } from "./negotiation.expected-speaker.js";
|
|
@@ -10,7 +10,10 @@ export { negotiateCandidates, NegotiationGraphFactory } from "./negotiation.grap
|
|
|
10
10
|
export { NegotiationInsightsGenerator } from "./insight.generator.js";
|
|
11
11
|
export { NegotiationReflector } from "./negotiation.reflect.js";
|
|
12
12
|
export { NegotiationScreener } from "./negotiation.screen.js";
|
|
13
|
-
export { ASK_USER_LOCK_SLACK_MS, allowedActionsFor, askUserAnswerWindowMs, configuredAskUserEnabled, isRejectLikeAction, isTerminalAction, readProtocolVersion, resolveSeat, seatViolationMessage, } from "./negotiation.protocol.js";
|
|
13
|
+
export { ASK_USER_LOCK_SLACK_MS, DEFAULT_NEGOTIATION_ASK_ROUNDS_CAP, allowedActionsFor, askUserAnswerWindowMs, configuredAskUserEnabled, isRejectLikeAction, isTerminalAction, negotiationAskRoundsCap, readProtocolVersion, resolveSeat, seatViolationMessage, } from "./negotiation.protocol.js";
|
|
14
|
+
export { countNegotiationAskRounds } from "./negotiation.graph.shared.js";
|
|
15
|
+
export { NEGOTIATION_PARK_REASONING, NegotiationStallGapAuthor } from "./negotiation.stall-gap.js";
|
|
16
|
+
export { classifyParkedNegotiation, consumeQuestionBlockAnswers, negotiationParkAnswerId, resumeParkedNegotiation, routeAnswerRef, } from "./negotiation.answer-consumption.js";
|
|
14
17
|
export { HERMES_OWNER_DIRECTIVE, HERMES_SHARED_MESSAGE_TEMPLATES, HermesNegotiationActionSchema, HermesNegotiationResponseSchema, HermesOwnerDirectiveSchema, HermesRoleAlignmentSchema, allowedHermesActionsFor, buildHermesNegotiationTurn, } from "./negotiation.hermes-contract.js";
|
|
15
18
|
export { DEFAULT_NEGOTIATION_MAX_TURNS, isNegotiationTurnCapReached } from "./negotiation.turn-cap.js";
|
|
16
19
|
export { expectedNegotiationSpeaker } from "./negotiation.expected-speaker.js";
|
|
@@ -972,6 +972,20 @@ export declare function configuredProtocolVersion(): NegotiationProtocolVersion;
|
|
|
972
972
|
* the same single switch.
|
|
973
973
|
*/
|
|
974
974
|
export declare function configuredAskUserEnabled(): boolean;
|
|
975
|
+
/**
|
|
976
|
+
* Default per-negotiation ask cap: total client-consultation rounds (mid-flight
|
|
977
|
+
* `ask_user` pauses and post-stall parks, both sides combined) before the
|
|
978
|
+
* negotiation stalls terminally instead of parking again. Three admits one
|
|
979
|
+
* post-stall park even after each side has spent its one mid-flight consult.
|
|
980
|
+
*/
|
|
981
|
+
export declare const DEFAULT_NEGOTIATION_ASK_ROUNDS_CAP = 3;
|
|
982
|
+
/**
|
|
983
|
+
* Per-negotiation ask cap, overridable via `NEGOTIATION_ASK_ROUNDS_CAP`.
|
|
984
|
+
* Invalid or non-positive values fall back to the default — zero is not an
|
|
985
|
+
* off switch here; the cap exists so two agents cannot ping-pong their humans
|
|
986
|
+
* indefinitely. It tunes the bound, it does not gate the behaviour.
|
|
987
|
+
*/
|
|
988
|
+
export declare function negotiationAskRoundsCap(): number;
|
|
975
989
|
/** Default answer window for a paused `ask_user` negotiation: 24 hours. */
|
|
976
990
|
export declare const DEFAULT_ASK_USER_WINDOW_MS: number;
|
|
977
991
|
/**
|
|
@@ -155,6 +155,28 @@ export function configuredProtocolVersion() {
|
|
|
155
155
|
export function configuredAskUserEnabled() {
|
|
156
156
|
return process.env.NEGOTIATION_ASK_USER_ENABLED === "true";
|
|
157
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* Default per-negotiation ask cap: total client-consultation rounds (mid-flight
|
|
160
|
+
* `ask_user` pauses and post-stall parks, both sides combined) before the
|
|
161
|
+
* negotiation stalls terminally instead of parking again. Three admits one
|
|
162
|
+
* post-stall park even after each side has spent its one mid-flight consult.
|
|
163
|
+
*/
|
|
164
|
+
export const DEFAULT_NEGOTIATION_ASK_ROUNDS_CAP = 3;
|
|
165
|
+
/**
|
|
166
|
+
* Per-negotiation ask cap, overridable via `NEGOTIATION_ASK_ROUNDS_CAP`.
|
|
167
|
+
* Invalid or non-positive values fall back to the default — zero is not an
|
|
168
|
+
* off switch here; the cap exists so two agents cannot ping-pong their humans
|
|
169
|
+
* indefinitely. It tunes the bound, it does not gate the behaviour.
|
|
170
|
+
*/
|
|
171
|
+
export function negotiationAskRoundsCap() {
|
|
172
|
+
const raw = process.env.NEGOTIATION_ASK_ROUNDS_CAP;
|
|
173
|
+
if (raw) {
|
|
174
|
+
const parsed = Number(raw);
|
|
175
|
+
if (Number.isInteger(parsed) && parsed > 0)
|
|
176
|
+
return parsed;
|
|
177
|
+
}
|
|
178
|
+
return DEFAULT_NEGOTIATION_ASK_ROUNDS_CAP;
|
|
179
|
+
}
|
|
158
180
|
/** Default answer window for a paused `ask_user` negotiation: 24 hours. */
|
|
159
181
|
export const DEFAULT_ASK_USER_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
160
182
|
/**
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-stall gap authoring (conversational-questions plan).
|
|
3
|
+
*
|
|
4
|
+
* When a negotiation ends unconcluded — no opportunity, no explicit reject:
|
|
5
|
+
* turn cap, timeout, or stall — the finalize node asks the negotiator for the
|
|
6
|
+
* ONE question whose answer would let a retry conclude, and parks the
|
|
7
|
+
* negotiation carrying that gap as an `ask_user` message in its own record.
|
|
8
|
+
* This module owns that single extra model call.
|
|
9
|
+
*
|
|
10
|
+
* Grounding is exactly what mid-flight authoring (P3.2 / IND-401 A2H) uses:
|
|
11
|
+
* this negotiation's transcript, plus the client's own negotiator DM for the
|
|
12
|
+
* signal when the caller retrieved one. Same non-naming and non-echo rules;
|
|
13
|
+
* the caller re-checks the output with `isSafeAuthoredNegotiationQuestion`
|
|
14
|
+
* (identifiers in hand) before persisting anything.
|
|
15
|
+
*
|
|
16
|
+
* Fail-open contract: any model failure, timeout, or invalid output resolves
|
|
17
|
+
* to null — the negotiation then stalls exactly as it did before this feature,
|
|
18
|
+
* never half-parks.
|
|
19
|
+
*/
|
|
20
|
+
import { createStructuredModel } from "../shared/agent/model.config.js";
|
|
21
|
+
import { type StructuredQuestion } from "../shared/schemas/structured-question.schema.js";
|
|
22
|
+
import { type NegotiationConsultationReason } from "../shared/schemas/negotiation-state.schema.js";
|
|
23
|
+
import { type NegotiatorClientDmMessage } from "./negotiation.client-dm.js";
|
|
24
|
+
import type { NegotiationTurn } from "./negotiation.state.js";
|
|
25
|
+
/**
|
|
26
|
+
* Fixed transcript reasoning for a post-stall park turn. Deliberately not
|
|
27
|
+
* model-authored: assessment reasoning enters the shared A2A record, and the
|
|
28
|
+
* park's "why" already lives in the guarded question itself — a second,
|
|
29
|
+
* unguarded free-text channel would reopen the leak surface the question gate
|
|
30
|
+
* closes.
|
|
31
|
+
*/
|
|
32
|
+
export declare const NEGOTIATION_PARK_REASONING = "Negotiation parked pending the client's answer.";
|
|
33
|
+
/** Why the negotiation failed to conclude, as finalize classified it. */
|
|
34
|
+
export type NegotiationStallReason = "turn_cap" | "timeout" | "stalled";
|
|
35
|
+
/** The authored gap: what a retry needs from the client, and why the pause is warranted. */
|
|
36
|
+
export interface NegotiationStallGap {
|
|
37
|
+
reason: NegotiationConsultationReason;
|
|
38
|
+
question: StructuredQuestion;
|
|
39
|
+
}
|
|
40
|
+
export interface StallGapAuthorInput {
|
|
41
|
+
/** Display name of the client the question is addressed to. */
|
|
42
|
+
userName: string;
|
|
43
|
+
/** The client's signal this negotiation was about. */
|
|
44
|
+
signal: {
|
|
45
|
+
title: string;
|
|
46
|
+
description: string;
|
|
47
|
+
};
|
|
48
|
+
/** Why the match was suggested (evaluator output; context, never copy). */
|
|
49
|
+
seedReasoning: string;
|
|
50
|
+
/** This negotiation's full transcript, oldest first. */
|
|
51
|
+
history: NegotiationTurn[];
|
|
52
|
+
stallReason: NegotiationStallReason;
|
|
53
|
+
/** Recent excerpt of the client's negotiator DM for this signal, most recent last. */
|
|
54
|
+
clientDm?: NegotiatorClientDmMessage[];
|
|
55
|
+
}
|
|
56
|
+
export interface NegotiationStallGapAuthorConfig {
|
|
57
|
+
/** Hard ceiling on the model round-trip, in ms. Same resolution as the negotiator turn timeout. */
|
|
58
|
+
timeoutMs?: number;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Authors the post-stall gap. One instance lives in the graph's dependency bag
|
|
62
|
+
* beside `systemAgent`; the finalize node calls it at most once per stalled
|
|
63
|
+
* session.
|
|
64
|
+
*/
|
|
65
|
+
export declare class NegotiationStallGapAuthor {
|
|
66
|
+
private readonly timeoutMs;
|
|
67
|
+
constructor(config?: NegotiationStallGapAuthorConfig);
|
|
68
|
+
/**
|
|
69
|
+
* @returns The authored gap, or null when there is none to ask — the model
|
|
70
|
+
* said so, produced invalid output after a retry, or failed. The
|
|
71
|
+
* caller treats every null identically: terminal stall, no park.
|
|
72
|
+
*/
|
|
73
|
+
author(input: StallGapAuthorInput): Promise<NegotiationStallGap | null>;
|
|
74
|
+
/**
|
|
75
|
+
* Raw structured-model round trip. Split out as a seam so tests can drive
|
|
76
|
+
* the validate→retry→null loop without a live provider — same pattern as
|
|
77
|
+
* `IndexNegotiator.callModel`.
|
|
78
|
+
*/
|
|
79
|
+
protected callModel(model: ReturnType<typeof createStructuredModel>, chatMessages: Array<{
|
|
80
|
+
role: string;
|
|
81
|
+
content: string;
|
|
82
|
+
}>): Promise<unknown>;
|
|
83
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-stall gap authoring (conversational-questions plan).
|
|
3
|
+
*
|
|
4
|
+
* When a negotiation ends unconcluded — no opportunity, no explicit reject:
|
|
5
|
+
* turn cap, timeout, or stall — the finalize node asks the negotiator for the
|
|
6
|
+
* ONE question whose answer would let a retry conclude, and parks the
|
|
7
|
+
* negotiation carrying that gap as an `ask_user` message in its own record.
|
|
8
|
+
* This module owns that single extra model call.
|
|
9
|
+
*
|
|
10
|
+
* Grounding is exactly what mid-flight authoring (P3.2 / IND-401 A2H) uses:
|
|
11
|
+
* this negotiation's transcript, plus the client's own negotiator DM for the
|
|
12
|
+
* signal when the caller retrieved one. Same non-naming and non-echo rules;
|
|
13
|
+
* the caller re-checks the output with `isSafeAuthoredNegotiationQuestion`
|
|
14
|
+
* (identifiers in hand) before persisting anything.
|
|
15
|
+
*
|
|
16
|
+
* Fail-open contract: any model failure, timeout, or invalid output resolves
|
|
17
|
+
* to null — the negotiation then stalls exactly as it did before this feature,
|
|
18
|
+
* never half-parks.
|
|
19
|
+
*/
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { createStructuredModel } from "../shared/agent/model.config.js";
|
|
22
|
+
import { invokeWithAbortSignal } from "../shared/agent/model-signal.js";
|
|
23
|
+
import { StructuredQuestionSchema } from "../shared/schemas/structured-question.schema.js";
|
|
24
|
+
import { NegotiationConsultationReasonSchema } from "../shared/schemas/negotiation-state.schema.js";
|
|
25
|
+
import { renderNegotiatorClientDmSection } from "./negotiation.client-dm.js";
|
|
26
|
+
import { resolveTurnTimeoutMs } from "./negotiation.agent.js";
|
|
27
|
+
import { protocolLogger } from "../shared/observability/protocol.logger.js";
|
|
28
|
+
const stallGapLog = protocolLogger("NegotiationStallGapAuthor");
|
|
29
|
+
/**
|
|
30
|
+
* Fixed transcript reasoning for a post-stall park turn. Deliberately not
|
|
31
|
+
* model-authored: assessment reasoning enters the shared A2A record, and the
|
|
32
|
+
* park's "why" already lives in the guarded question itself — a second,
|
|
33
|
+
* unguarded free-text channel would reopen the leak surface the question gate
|
|
34
|
+
* closes.
|
|
35
|
+
*/
|
|
36
|
+
export const NEGOTIATION_PARK_REASONING = "Negotiation parked pending the client's answer.";
|
|
37
|
+
const STALL_REASON_LABELS = {
|
|
38
|
+
turn_cap: "the turn limit was reached without agreement",
|
|
39
|
+
timeout: "the negotiation timed out",
|
|
40
|
+
stalled: "the exchange stalled without reaching a conclusion",
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Structured output for the gap call. `hasGap: false` is a first-class answer:
|
|
44
|
+
* when no single client answer would change a retry's outcome, the negotiation
|
|
45
|
+
* must stall terminally rather than park on a filler question. Nullable
|
|
46
|
+
* declarations mirror `AskUserPayloadSchema.question` — strict structured-output
|
|
47
|
+
* conversion rejects optional-without-nullable, and a returned null reads as
|
|
48
|
+
* absent.
|
|
49
|
+
*/
|
|
50
|
+
const StallGapOutputSchema = z.object({
|
|
51
|
+
hasGap: z.boolean(),
|
|
52
|
+
reason: NegotiationConsultationReasonSchema.nullable().optional().transform((value) => value ?? undefined),
|
|
53
|
+
question: StructuredQuestionSchema.nullable().optional().transform((value) => value ?? undefined),
|
|
54
|
+
});
|
|
55
|
+
const SYSTEM_PROMPT = `You are the Index Negotiator, an AI agent acting on behalf of {userName}. A negotiation you conducted for them about a potential connection has just ended without conclusion: {stallReasonLabel}.
|
|
56
|
+
|
|
57
|
+
Your job now is a single decision: is there ONE piece of information only {userName} holds whose answer would let a retry of this negotiation reach a conclusion? Read the exchange below and judge where it actually stuck.
|
|
58
|
+
|
|
59
|
+
- If no single answer from {userName} would change a retry's outcome — the match is simply weak, the counterparty is the blocker, or the stall had nothing to do with missing input from {userName} — set hasGap to false and omit the question. Do not invent a question to have something to ask; asking costs {userName} attention and pauses nothing useful.
|
|
60
|
+
- If yes, set hasGap to true and author the question:
|
|
61
|
+
- reason: exactly one closed server category recording WHY the pause is warranted: "unresolved_owner_constraint" | "consequential_disclosure_permission" | "repeated_non_convergence" | "insufficient_commitment_authority". It is not the wording {userName} sees.
|
|
62
|
+
- title: at most 12 characters — a noun for the decision domain, e.g. "Stage", "Timing", "Budget", "Scope".
|
|
63
|
+
- prompt: at most 2 sentences and 400 characters, ending in a question mark. Ask about the specific thing that was actually stuck in this negotiation, in {userName}'s own terms, grounded in the exchange below. Never a generic template.
|
|
64
|
+
- options: 2–4 of {userName}'s real decision options. Each label at most 120 characters; each description at most 280 characters, stating the CONSEQUENCE of choosing that option — what the retry would do with it — not what it means. Never add an "Other" option; clients provide a free-text fallback automatically.
|
|
65
|
+
- multiSelect: true ONLY when the options are not mutually exclusive; false for a single either/or decision.
|
|
66
|
+
- Do not name, quote, or describe the counterparty. {userName} can read the transcript, but the question itself must stand on its own without their identity or profile in it.
|
|
67
|
+
- Do NOT reference internal system details like scores, pre-screens, or evaluator outputs.{dmGroundingRule}`;
|
|
68
|
+
/**
|
|
69
|
+
* Appended only when the call actually carries a client-DM excerpt, mirroring
|
|
70
|
+
* `ASK_USER_DM_GROUNDING_RULE`: a call with no DM must not carry a rule that
|
|
71
|
+
* dangles with nothing in the prompt to check against.
|
|
72
|
+
*/
|
|
73
|
+
const DM_GROUNDING_RULE = `
|
|
74
|
+
- Ground the question in your conversation with {userName} about this signal (shown below) as well as in the exchange. Do NOT ask what they have already answered there: if their own words settle the point, there is no gap on it. Use their terms for the thing at stake — the words, numbers, and framing they used, not your paraphrase of them.`;
|
|
75
|
+
function formatTurnLine(turn, index) {
|
|
76
|
+
const msgPart = turn.message ? ` — message: ${turn.message}` : "";
|
|
77
|
+
return `Turn ${index + 1}: ${turn.action} — reasoning: ${turn.assessment.reasoning}${msgPart}`;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Authors the post-stall gap. One instance lives in the graph's dependency bag
|
|
81
|
+
* beside `systemAgent`; the finalize node calls it at most once per stalled
|
|
82
|
+
* session.
|
|
83
|
+
*/
|
|
84
|
+
export class NegotiationStallGapAuthor {
|
|
85
|
+
constructor(config) {
|
|
86
|
+
this.timeoutMs = resolveTurnTimeoutMs(config?.timeoutMs);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* @returns The authored gap, or null when there is none to ask — the model
|
|
90
|
+
* said so, produced invalid output after a retry, or failed. The
|
|
91
|
+
* caller treats every null identically: terminal stall, no park.
|
|
92
|
+
*/
|
|
93
|
+
async author(input) {
|
|
94
|
+
const clientDm = input.clientDm ?? [];
|
|
95
|
+
const model = createStructuredModel("negotiator", StallGapOutputSchema, { name: "negotiation_stall_gap" });
|
|
96
|
+
const systemPrompt = SYSTEM_PROMPT
|
|
97
|
+
.replace("{stallReasonLabel}", STALL_REASON_LABELS[input.stallReason])
|
|
98
|
+
.replace("{dmGroundingRule}", clientDm.length > 0 ? DM_GROUNDING_RULE : "")
|
|
99
|
+
.replace(/{userName}/g, input.userName);
|
|
100
|
+
const transcript = input.history.length > 0
|
|
101
|
+
? `\n\nNegotiation transcript:\n${input.history.map(formatTurnLine).join("\n")}`
|
|
102
|
+
: "";
|
|
103
|
+
const userMessage = `{userName}'s signal under negotiation:
|
|
104
|
+
- ${input.signal.title}: ${input.signal.description}
|
|
105
|
+
|
|
106
|
+
Why this match was suggested: ${input.seedReasoning}${transcript}${renderNegotiatorClientDmSection(clientDm, input.userName)}
|
|
107
|
+
|
|
108
|
+
Decide whether one question to {userName} would let a retry conclude, and author it if so.`.replace(/{userName}/g, input.userName);
|
|
109
|
+
const chatMessages = [
|
|
110
|
+
{ role: "system", content: systemPrompt },
|
|
111
|
+
{ role: "user", content: userMessage },
|
|
112
|
+
];
|
|
113
|
+
try {
|
|
114
|
+
// Same validate → retry-once → give-up loop as the negotiator turn,
|
|
115
|
+
// except giving up resolves to null (terminal stall) instead of a
|
|
116
|
+
// fallback action — there is no conservative fallback question.
|
|
117
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
118
|
+
const result = await this.callModel(model, chatMessages);
|
|
119
|
+
const parsed = StallGapOutputSchema.safeParse(result);
|
|
120
|
+
if (!parsed.success) {
|
|
121
|
+
stallGapLog.warn("Stall-gap output failed schema validation", {
|
|
122
|
+
attempt: attempt + 1,
|
|
123
|
+
issues: parsed.error.issues.map((issue) => issue.message).slice(0, 3),
|
|
124
|
+
});
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (!parsed.data.hasGap)
|
|
128
|
+
return null;
|
|
129
|
+
if (!parsed.data.question || !parsed.data.reason) {
|
|
130
|
+
stallGapLog.warn("Stall-gap output claimed a gap without question or reason", { attempt: attempt + 1 });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
return { reason: parsed.data.reason, question: parsed.data.question };
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
stallGapLog.warn("Stall-gap authoring failed; negotiation stalls without a park", {
|
|
139
|
+
error: err instanceof Error ? err.message : String(err),
|
|
140
|
+
});
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Raw structured-model round trip. Split out as a seam so tests can drive
|
|
146
|
+
* the validate→retry→null loop without a live provider — same pattern as
|
|
147
|
+
* `IndexNegotiator.callModel`.
|
|
148
|
+
*/
|
|
149
|
+
async callModel(model, chatMessages) {
|
|
150
|
+
return invokeWithAbortSignal(model, chatMessages, AbortSignal.timeout(this.timeoutMs));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
*
|
|
10
10
|
* `NEGOTIATOR_STANCE` makes that stance configurable instead of hard-coded:
|
|
11
11
|
*
|
|
12
|
-
* | stance | framing | value bar | query rule | deadlock |
|
|
13
|
-
*
|
|
14
|
-
* | `advocate` | argue the case (today) | none | mandate (today) | bargain |
|
|
15
|
-
* | `evaluator` | assess first, advocate if it survives | opportunity-cost | necessary-not-sufficient| bargain |
|
|
16
|
-
* | `skeptic` | + "most matches are not worth making" | opportunity-cost | necessary-not-sufficient| stalemate |
|
|
12
|
+
* | stance | framing | value bar | query rule | consult propensity | deadlock |
|
|
13
|
+
* |-------------|--------------------------------------|------------------|-------------------------|-------------------------|-----------|
|
|
14
|
+
* | `advocate` | argue the case (today) | none | mandate (today) | none (today) | bargain |
|
|
15
|
+
* | `evaluator` | assess first, advocate if it survives | opportunity-cost | necessary-not-sufficient| prefer over assumption | bargain |
|
|
16
|
+
* | `skeptic` | + "most matches are not worth making" | opportunity-cost | necessary-not-sufficient| + unverified = don't proceed | stalemate |
|
|
17
17
|
*
|
|
18
18
|
* Design constraints (hard):
|
|
19
19
|
* - **`advocate` is byte-identical.** Every fragment below is additive and
|
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
*
|
|
10
10
|
* `NEGOTIATOR_STANCE` makes that stance configurable instead of hard-coded:
|
|
11
11
|
*
|
|
12
|
-
* | stance | framing | value bar | query rule | deadlock |
|
|
13
|
-
*
|
|
14
|
-
* | `advocate` | argue the case (today) | none | mandate (today) | bargain |
|
|
15
|
-
* | `evaluator` | assess first, advocate if it survives | opportunity-cost | necessary-not-sufficient| bargain |
|
|
16
|
-
* | `skeptic` | + "most matches are not worth making" | opportunity-cost | necessary-not-sufficient| stalemate |
|
|
12
|
+
* | stance | framing | value bar | query rule | consult propensity | deadlock |
|
|
13
|
+
* |-------------|--------------------------------------|------------------|-------------------------|-------------------------|-----------|
|
|
14
|
+
* | `advocate` | argue the case (today) | none | mandate (today) | none (today) | bargain |
|
|
15
|
+
* | `evaluator` | assess first, advocate if it survives | opportunity-cost | necessary-not-sufficient| prefer over assumption | bargain |
|
|
16
|
+
* | `skeptic` | + "most matches are not worth making" | opportunity-cost | necessary-not-sufficient| + unverified = don't proceed | stalemate |
|
|
17
17
|
*
|
|
18
18
|
* Design constraints (hard):
|
|
19
19
|
* - **`advocate` is byte-identical.** Every fragment below is additive and
|
|
@@ -113,12 +113,45 @@ export function stanceJobFraming(stance) {
|
|
|
113
113
|
*/
|
|
114
114
|
const VALUE_BAR_RULE = `
|
|
115
115
|
- OPPORTUNITY COST: {userName}'s attention is finite and their name is spent on every connection made on their behalf. The bar is "worth that spend", not "does no harm" — absence of a downside is NOT a reason to proceed. Ask what {userName} gives up by spending this attention here instead of on a better match, and say no when the answer is "too much".`;
|
|
116
|
+
/**
|
|
117
|
+
* Consult propensity — assessing stances only.
|
|
118
|
+
*
|
|
119
|
+
* The assessing stances demand a judgment ("is this actually worth making for
|
|
120
|
+
* {userName}?") that sometimes turns on a fact only the client holds: their
|
|
121
|
+
* current priorities, their real constraints, what "alignment" would mean to
|
|
122
|
+
* them. The legacy failure mode is resolving that gap by assumption —
|
|
123
|
+
* guessing, conceding, or accepting on vibes. This rule names the resolution
|
|
124
|
+
* path instead: consult the client.
|
|
125
|
+
*
|
|
126
|
+
* Deliberately names no action and no mechanism: like every fragment here it
|
|
127
|
+
* renders into all seats and protocol versions, including seats with no
|
|
128
|
+
* consultation vocabulary, so the seat's own rules decide HOW a consultation
|
|
129
|
+
* happens and whether one is still available this turn. This only sets when
|
|
130
|
+
* the agent should WANT one — which is also why it stays conditional on the
|
|
131
|
+
* uncertainty being client-resolvable: no "always"/"regardless" wording that
|
|
132
|
+
* would fight the per-negotiation consultation cap
|
|
133
|
+
* (`negotiationAskRoundsCap`).
|
|
134
|
+
*/
|
|
135
|
+
const CONSULT_PROPENSITY_RULE = `
|
|
136
|
+
- CONSULT, DON'T ASSUME: when your judgment turns on a fact about {userName}'s OWN intent that you do not hold — their current priorities, their real constraints, what "alignment" would actually mean to them — prefer consulting {userName} over resolving that uncertainty by assumption. Guessing their answer, conceding to keep things moving, or proceeding because nothing contradicts the match are all ways of deciding for them what only they can decide.`;
|
|
137
|
+
/**
|
|
138
|
+
* `skeptic` sharpening of the consult rule, appended to the same bullet (the
|
|
139
|
+
* same additive pattern as `SKEPTIC_FRAMING` over `EVALUATOR_FRAMING`): under
|
|
140
|
+
* the not-worth-making prior an unverified alignment assumption is itself a
|
|
141
|
+
* reason not to proceed, and consulting the client is how it gets verified.
|
|
142
|
+
*/
|
|
143
|
+
const SKEPTIC_CONSULT_SHARPENING = ` For you this is a gate, not a preference: an UNVERIFIED assumption that the two sides' intents actually align is a reason NOT to proceed, and consulting {userName} is how that assumption gets verified.`;
|
|
116
144
|
/**
|
|
117
145
|
* Extra action-rule lines contributed by the stance, appended after the seat's
|
|
118
146
|
* own rules. Empty under `advocate` → byte-identical.
|
|
119
147
|
*/
|
|
120
148
|
export function stanceActionRules(stance) {
|
|
121
|
-
|
|
149
|
+
if (!stanceAppliesValueBar(stance))
|
|
150
|
+
return "";
|
|
151
|
+
const consultRule = stance === "skeptic"
|
|
152
|
+
? CONSULT_PROPENSITY_RULE + SKEPTIC_CONSULT_SHARPENING
|
|
153
|
+
: CONSULT_PROPENSITY_RULE;
|
|
154
|
+
return VALUE_BAR_RULE + consultRule;
|
|
122
155
|
}
|
|
123
156
|
/**
|
|
124
157
|
* The discovery-query satisfaction rule.
|