@indexnetwork/protocol 21.1.0-rc.492.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 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";
@@ -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
+ }
@@ -17,6 +17,8 @@ export { ASK_USER_LOCK_SLACK_MS, DEFAULT_NEGOTIATION_ASK_ROUNDS_CAP, allowedActi
17
17
  export { countNegotiationAskRounds } from "./negotiation.graph.shared.js";
18
18
  export { NEGOTIATION_PARK_REASONING, NegotiationStallGapAuthor } from "./negotiation.stall-gap.js";
19
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";
20
22
  export { HERMES_OWNER_DIRECTIVE, HERMES_SHARED_MESSAGE_TEMPLATES, HermesNegotiationActionSchema, HermesNegotiationResponseSchema, HermesOwnerDirectiveSchema, HermesRoleAlignmentSchema, allowedHermesActionsFor, buildHermesNegotiationTurn, } from "./negotiation.hermes-contract.js";
21
23
  export { DEFAULT_NEGOTIATION_MAX_TURNS, isNegotiationTurnCapReached } from "./negotiation.turn-cap.js";
22
24
  export { expectedNegotiationSpeaker } from "./negotiation.expected-speaker.js";
@@ -13,6 +13,7 @@ export { NegotiationScreener } from "./negotiation.screen.js";
13
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
14
  export { countNegotiationAskRounds } from "./negotiation.graph.shared.js";
15
15
  export { NEGOTIATION_PARK_REASONING, NegotiationStallGapAuthor } from "./negotiation.stall-gap.js";
16
+ export { classifyParkedNegotiation, consumeQuestionBlockAnswers, negotiationParkAnswerId, resumeParkedNegotiation, routeAnswerRef, } from "./negotiation.answer-consumption.js";
16
17
  export { HERMES_OWNER_DIRECTIVE, HERMES_SHARED_MESSAGE_TEMPLATES, HermesNegotiationActionSchema, HermesNegotiationResponseSchema, HermesOwnerDirectiveSchema, HermesRoleAlignmentSchema, allowedHermesActionsFor, buildHermesNegotiationTurn, } from "./negotiation.hermes-contract.js";
17
18
  export { DEFAULT_NEGOTIATION_MAX_TURNS, isNegotiationTurnCapReached } from "./negotiation.turn-cap.js";
18
19
  export { expectedNegotiationSpeaker } from "./negotiation.expected-speaker.js";
@@ -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
- return stanceAppliesValueBar(stance) ? VALUE_BAR_RULE : "";
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indexnetwork/protocol",
3
- "version": "21.1.0-rc.492.1",
3
+ "version": "21.1.0-rc.493.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",