@indexnetwork/protocol 21.0.0-rc.490.1 → 21.1.0-rc.492.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/CHANGELOG.md CHANGED
@@ -20,6 +20,27 @@ went 6.7.1 → 8.0.2 with no 7.x in between because the whole 7.x line shipped a
20
20
  prereleases between the two promotions. To track every change, read `rc`; to
21
21
  pin a supported release, use `latest`.
22
22
 
23
+ ## 21.1.0 - 2026-08-18
24
+
25
+ ### Added
26
+
27
+ - The **question block contract** for conversational questions
28
+ (`docs/plans/2026-08-18-conversational-questions.md`):
29
+ `QuestionBlockSchema`, `QuestionBlockQuestionSchema`,
30
+ `parseQuestionMessage`, `serializeQuestionMessage`,
31
+ `QUESTION_BLOCK_MARKER`, `QUESTION_BLOCK_VERSION` and the
32
+ `QuestionBlock`/`QuestionBlockQuestion`/`ParsedQuestionMessage` types.
33
+ The block is the rendering contract embedded as a terminal
34
+ ` ```index-questions ` fenced section in the negotiator's question-message;
35
+ the parser fails closed (malformed → `null` → render as plain text), and a
36
+ question's identity is its primary `opportunityId` ref (negotiations have
37
+ no table of their own) — there are no question ids and no block-level state.
38
+ - First package **subpath exports**, `@indexnetwork/protocol/question-block`
39
+ and `.../question-block/fixture`, so the browser client can import the
40
+ contract (and its canonical fixture) without pulling the node-only package
41
+ root into a bundle. STABILITY.md now documents browser-safe subpaths as part
42
+ of the public contract.
43
+
23
44
  ## 21.0.0 - 2026-08-17
24
45
 
25
46
  ### Removed
package/STABILITY.md CHANGED
@@ -7,8 +7,16 @@ change. It is the reference behind the tier annotations in `src/index.ts`.
7
7
 
8
8
  ## The public contract
9
9
 
10
- - The **only** supported entry point is the package root:
11
- `import { ... } from "@indexnetwork/protocol"`.
10
+ - The supported entry points are the package root:
11
+ `import { ... } from "@indexnetwork/protocol"` — and the **browser-safe
12
+ subpaths** listed in `package.json` `exports` (as of 21.1.0:
13
+ `@indexnetwork/protocol/question-block` and
14
+ `@indexnetwork/protocol/question-block/fixture`). A browser-safe subpath
15
+ exposes one shared-schema module whose only runtime dependency is `zod`, for
16
+ consumers (the web client) that cannot load the node-only package root. The
17
+ schema module's symbols are also re-exported from the root (the fixture is
18
+ subpath-only, to keep test data out of the runtime barrel), and the subpaths
19
+ carry the Stable tier.
12
20
  - Deep imports (`@indexnetwork/protocol/dist/...` or `/src/...`) are **not** part
13
21
  of the contract and may change or disappear in any release — do not rely on them.
14
22
  - The contract is exactly the set of symbols re-exported from `src/index.ts`.
package/dist/index.d.ts CHANGED
@@ -29,6 +29,7 @@ export type { PendingQuestionSummary } from "./shared/schemas/pending-question.s
29
29
  export { McpApiKeyMetadataSchema } from "./shared/schemas/mcp-auth.schema.js";
30
30
  export type { McpAuthInput, McpResolvedIdentity, } from "./shared/schemas/mcp-auth.schema.js";
31
31
  export type { DiscoveryNegotiation } from "./shared/schemas/discovery-question.schema.js";
32
+ export { QUESTION_BLOCK_MARKER, QUESTION_BLOCK_VERSION, QuestionBlockSchema, QuestionBlockQuestionSchema, parseQuestionMessage, serializeQuestionMessage, type QuestionBlock, type QuestionBlockQuestion, type ParsedQuestionMessage, } from "./shared/schemas/question-block.schema.js";
32
33
  export type { NetworkAssignmentMetadata } from "./shared/schemas/network-assignment.schema.js";
33
34
  export { DEFAULT_NETWORK_ASSIGNMENT_THRESHOLD, resolveAssignmentNetworkScope, buildNetworkAssignmentDecision } from "./shared/assignment/network-assignment.policy.js";
34
35
  export { ChatGraphFactory } from "./agents/agent.module.js";
package/dist/index.js CHANGED
@@ -26,6 +26,7 @@ export { SYSTEM_AGENT_IDS } from './agents/agent.module.js';
26
26
  export { ChatContextDigestSchema } from "./shared/schemas/chat-context.schema.js";
27
27
  export { NegotiationQuestionCandidateSchema, } from "./questions/question.module.js";
28
28
  export { McpApiKeyMetadataSchema } from "./shared/schemas/mcp-auth.schema.js";
29
+ export { QUESTION_BLOCK_MARKER, QUESTION_BLOCK_VERSION, QuestionBlockSchema, QuestionBlockQuestionSchema, parseQuestionMessage, serializeQuestionMessage, } from "./shared/schemas/question-block.schema.js";
29
30
  export { DEFAULT_NETWORK_ASSIGNMENT_THRESHOLD, resolveAssignmentNetworkScope, buildNetworkAssignmentDecision } from "./shared/assignment/network-assignment.policy.js";
30
31
  // ─── Graph factories ──────────────────────────────────────────────────────────
31
32
  export { ChatGraphFactory } from "./agents/agent.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;
@@ -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
- if (!hasOpportunity && !isRejectLikeAction(lastTurn?.action) && state.turnCount > 0 && state.opportunityId && state.sourceIntentId && state.indexContext.networkId && deps.questionerEnqueue && !state.continuationExecution) {
230
- const stallReason = atCap
231
- ? 'turn_cap'
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
- const sender = `agent:${userId}`;
36
- return messages.some((m) => {
37
- if (m.senderId !== sender)
38
- return false;
39
- const dataPart = m.parts.find((p) => p.kind === "data");
40
- return dataPart?.data?.action === "ask_user";
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,10 @@ 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";
17
20
  export { HERMES_OWNER_DIRECTIVE, HERMES_SHARED_MESSAGE_TEMPLATES, HermesNegotiationActionSchema, HermesNegotiationResponseSchema, HermesOwnerDirectiveSchema, HermesRoleAlignmentSchema, allowedHermesActionsFor, buildHermesNegotiationTurn, } from "./negotiation.hermes-contract.js";
18
21
  export { DEFAULT_NEGOTIATION_MAX_TURNS, isNegotiationTurnCapReached } from "./negotiation.turn-cap.js";
19
22
  export { expectedNegotiationSpeaker } from "./negotiation.expected-speaker.js";
@@ -10,7 +10,9 @@ 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";
14
16
  export { HERMES_OWNER_DIRECTIVE, HERMES_SHARED_MESSAGE_TEMPLATES, HermesNegotiationActionSchema, HermesNegotiationResponseSchema, HermesOwnerDirectiveSchema, HermesRoleAlignmentSchema, allowedHermesActionsFor, buildHermesNegotiationTurn, } from "./negotiation.hermes-contract.js";
15
17
  export { DEFAULT_NEGOTIATION_MAX_TURNS, isNegotiationTurnCapReached } from "./negotiation.turn-cap.js";
16
18
  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
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Canonical question-message fixture for the question block contract.
3
+ *
4
+ * `questionMessageFixture` is a literal — not derived from the serializer — so
5
+ * a serializer change that would alter the wire format breaks the spec loudly.
6
+ * The web client's steps-UI tests should import this via
7
+ * `@indexnetwork/protocol/question-block/fixture` instead of minting their own.
8
+ */
9
+ import type { QuestionBlock } from "./question-block.schema.js";
10
+ export declare const questionProseFixture: string;
11
+ export declare const questionBlockFixture: QuestionBlock;
12
+ export declare const questionMessageFixture: string;
@@ -0,0 +1,39 @@
1
+ export const questionProseFixture = [
2
+ "I moved three conversations forward on your search for a technical co-founder.",
3
+ "Two of them are waiting on details only you can give — answer here and I will",
4
+ "pick the negotiations back up.",
5
+ ].join("\n");
6
+ export const questionBlockFixture = {
7
+ version: 1,
8
+ questions: [
9
+ {
10
+ prompt: "Both counterparts asked about equity: what range are you prepared to offer a founding engineer?",
11
+ opportunityId: "0b0e8a9c-6d3f-4d6a-9f2e-1c5b7a4d8e01",
12
+ alsoUnblocks: ["7f3d2c1b-8a90-4e5f-b6c7-d8e9f0a1b2c3"],
13
+ },
14
+ {
15
+ prompt: "The Berlin robotics lab wants to know whether you can be on-site one week per month.",
16
+ opportunityId: "4a5b6c7d-8e9f-4a1b-8c2d-3e4f5a6b7c8d",
17
+ },
18
+ ],
19
+ };
20
+ export const questionMessageFixture = `${questionProseFixture}
21
+
22
+ \`\`\`index-questions
23
+ {
24
+ "version": 1,
25
+ "questions": [
26
+ {
27
+ "prompt": "Both counterparts asked about equity: what range are you prepared to offer a founding engineer?",
28
+ "opportunityId": "0b0e8a9c-6d3f-4d6a-9f2e-1c5b7a4d8e01",
29
+ "alsoUnblocks": [
30
+ "7f3d2c1b-8a90-4e5f-b6c7-d8e9f0a1b2c3"
31
+ ]
32
+ },
33
+ {
34
+ "prompt": "The Berlin robotics lab wants to know whether you can be on-site one week per month.",
35
+ "opportunityId": "4a5b6c7d-8e9f-4a1b-8c2d-3e4f5a6b7c8d"
36
+ }
37
+ ]
38
+ }
39
+ \`\`\``;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The question block: the rendering contract between the negotiator's
3
+ * question-message and the web client's steps UI, and the routing contract
4
+ * for answers (docs/plans/2026-08-18-conversational-questions.md).
5
+ *
6
+ * The block is a *view* of the currently-parked negotiations for one intent
7
+ * scope, regenerated fresh on every change — it is not a persistence schema
8
+ * and must never grow state (answered flags, timestamps, question ids).
9
+ * A question's identity is its primary negotiation reference: the id of the
10
+ * opportunity row the negotiation runs on. Task re-resolution from that row
11
+ * is server-side and never encoded here — deliberately, because a snapshotted
12
+ * task id would go stale: answer routing branches on what it re-resolves
13
+ * (an `input_required` task → mid-flight consult, answered via the exact
14
+ * continuation's successor task; a completed task on a stalled opportunity
15
+ * with a trailing ask_user gap → post-stall park, answered via a retry).
16
+ *
17
+ * This module must stay browser-safe: it may import zod and nothing else.
18
+ * It is exposed to the web client via the `@indexnetwork/protocol/question-block`
19
+ * subpath export (see STABILITY.md).
20
+ */
21
+ import { z } from "zod";
22
+ /** Info string of the fenced section that carries the block in a message body. */
23
+ export declare const QUESTION_BLOCK_MARKER = "index-questions";
24
+ /** Bump when the block shape changes; parsers fail closed on unknown versions. */
25
+ export declare const QUESTION_BLOCK_VERSION = 1;
26
+ export declare const QuestionBlockQuestionSchema: z.ZodObject<{
27
+ /** Agent-authored question text, rendered as one step. */
28
+ prompt: z.ZodString;
29
+ /** Primary negotiation this question unparks; doubles as the question's identity. */
30
+ opportunityId: z.ZodString;
31
+ /** Further negotiations parked on the same gap that this answer also unparks. */
32
+ alsoUnblocks: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
33
+ }, "strict", z.ZodTypeAny, {
34
+ prompt: string;
35
+ opportunityId: string;
36
+ alsoUnblocks?: string[] | undefined;
37
+ }, {
38
+ prompt: string;
39
+ opportunityId: string;
40
+ alsoUnblocks?: string[] | undefined;
41
+ }>;
42
+ export type QuestionBlockQuestion = z.infer<typeof QuestionBlockQuestionSchema>;
43
+ export declare const QuestionBlockSchema: z.ZodEffects<z.ZodObject<{
44
+ version: z.ZodLiteral<1>;
45
+ questions: z.ZodArray<z.ZodObject<{
46
+ /** Agent-authored question text, rendered as one step. */
47
+ prompt: z.ZodString;
48
+ /** Primary negotiation this question unparks; doubles as the question's identity. */
49
+ opportunityId: z.ZodString;
50
+ /** Further negotiations parked on the same gap that this answer also unparks. */
51
+ alsoUnblocks: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
52
+ }, "strict", z.ZodTypeAny, {
53
+ prompt: string;
54
+ opportunityId: string;
55
+ alsoUnblocks?: string[] | undefined;
56
+ }, {
57
+ prompt: string;
58
+ opportunityId: string;
59
+ alsoUnblocks?: string[] | undefined;
60
+ }>, "many">;
61
+ }, "strict", z.ZodTypeAny, {
62
+ questions: {
63
+ prompt: string;
64
+ opportunityId: string;
65
+ alsoUnblocks?: string[] | undefined;
66
+ }[];
67
+ version: 1;
68
+ }, {
69
+ questions: {
70
+ prompt: string;
71
+ opportunityId: string;
72
+ alsoUnblocks?: string[] | undefined;
73
+ }[];
74
+ version: 1;
75
+ }>, {
76
+ questions: {
77
+ prompt: string;
78
+ opportunityId: string;
79
+ alsoUnblocks?: string[] | undefined;
80
+ }[];
81
+ version: 1;
82
+ }, {
83
+ questions: {
84
+ prompt: string;
85
+ opportunityId: string;
86
+ alsoUnblocks?: string[] | undefined;
87
+ }[];
88
+ version: 1;
89
+ }>;
90
+ export type QuestionBlock = z.infer<typeof QuestionBlockSchema>;
91
+ /** A parsed question-message: the agent's prose and the block that followed it. */
92
+ export interface ParsedQuestionMessage {
93
+ prose: string;
94
+ block: QuestionBlock;
95
+ }
96
+ /**
97
+ * Embed a block after the agent's prose. The block is always the terminal
98
+ * section of the body: prose, a blank line, then a fenced section whose info
99
+ * string is the marker. The fence is lengthened past any backtick run in the
100
+ * payload so the payload can never close it early.
101
+ *
102
+ * Throws on a block that fails the schema — serializing an invalid block is a
103
+ * producer bug, not a rendering condition.
104
+ */
105
+ export declare function serializeQuestionMessage(prose: string, block: QuestionBlock): string;
106
+ /**
107
+ * Extract the block from a message body. Fails closed: any body that does not
108
+ * end in exactly one well-formed, schema-valid block returns null, and the
109
+ * caller renders the whole body as plain text — never a broken steps UI.
110
+ */
111
+ export declare function parseQuestionMessage(body: string): ParsedQuestionMessage | null;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The question block: the rendering contract between the negotiator's
3
+ * question-message and the web client's steps UI, and the routing contract
4
+ * for answers (docs/plans/2026-08-18-conversational-questions.md).
5
+ *
6
+ * The block is a *view* of the currently-parked negotiations for one intent
7
+ * scope, regenerated fresh on every change — it is not a persistence schema
8
+ * and must never grow state (answered flags, timestamps, question ids).
9
+ * A question's identity is its primary negotiation reference: the id of the
10
+ * opportunity row the negotiation runs on. Task re-resolution from that row
11
+ * is server-side and never encoded here — deliberately, because a snapshotted
12
+ * task id would go stale: answer routing branches on what it re-resolves
13
+ * (an `input_required` task → mid-flight consult, answered via the exact
14
+ * continuation's successor task; a completed task on a stalled opportunity
15
+ * with a trailing ask_user gap → post-stall park, answered via a retry).
16
+ *
17
+ * This module must stay browser-safe: it may import zod and nothing else.
18
+ * It is exposed to the web client via the `@indexnetwork/protocol/question-block`
19
+ * subpath export (see STABILITY.md).
20
+ */
21
+ import { z } from "zod";
22
+ /** Info string of the fenced section that carries the block in a message body. */
23
+ export const QUESTION_BLOCK_MARKER = "index-questions";
24
+ /** Bump when the block shape changes; parsers fail closed on unknown versions. */
25
+ export const QUESTION_BLOCK_VERSION = 1;
26
+ /**
27
+ * A negotiation reference: the id of the opportunity row the negotiation runs
28
+ * on. There is no negotiations table — opportunity id is the durable identity
29
+ * used by every negotiation binding (see NegotiationQuestionProvenanceSchema).
30
+ */
31
+ const NegotiationRefSchema = z.string().uuid();
32
+ export const QuestionBlockQuestionSchema = z.object({
33
+ /** Agent-authored question text, rendered as one step. */
34
+ prompt: z.string().min(1).max(2000),
35
+ /** Primary negotiation this question unparks; doubles as the question's identity. */
36
+ opportunityId: NegotiationRefSchema,
37
+ /** Further negotiations parked on the same gap that this answer also unparks. */
38
+ alsoUnblocks: z.array(NegotiationRefSchema).max(8).optional(),
39
+ }).strict();
40
+ export const QuestionBlockSchema = z.object({
41
+ version: z.literal(QUESTION_BLOCK_VERSION),
42
+ questions: z.array(QuestionBlockQuestionSchema).min(1).max(20),
43
+ }).strict().superRefine((block, ctx) => {
44
+ // Identity is the negotiation ref, so a ref may appear exactly once in the
45
+ // whole block — a duplicate would make answer routing ambiguous.
46
+ const seen = new Set();
47
+ block.questions.forEach((question, questionIndex) => {
48
+ const refs = [question.opportunityId, ...(question.alsoUnblocks ?? [])];
49
+ refs.forEach((ref, refIndex) => {
50
+ if (seen.has(ref)) {
51
+ ctx.addIssue({
52
+ code: z.ZodIssueCode.custom,
53
+ path: refIndex === 0
54
+ ? ["questions", questionIndex, "opportunityId"]
55
+ : ["questions", questionIndex, "alsoUnblocks", refIndex - 1],
56
+ message: `negotiation ref ${ref} appears more than once in the block`,
57
+ });
58
+ }
59
+ seen.add(ref);
60
+ });
61
+ });
62
+ });
63
+ const openFencePattern = new RegExp(`^(\`{3,})${QUESTION_BLOCK_MARKER}[ \\t]*\\r?$`, "m");
64
+ function longestBacktickRun(text) {
65
+ let longest = 0;
66
+ for (const run of text.match(/`+/g) ?? [])
67
+ longest = Math.max(longest, run.length);
68
+ return longest;
69
+ }
70
+ /**
71
+ * Embed a block after the agent's prose. The block is always the terminal
72
+ * section of the body: prose, a blank line, then a fenced section whose info
73
+ * string is the marker. The fence is lengthened past any backtick run in the
74
+ * payload so the payload can never close it early.
75
+ *
76
+ * Throws on a block that fails the schema — serializing an invalid block is a
77
+ * producer bug, not a rendering condition.
78
+ */
79
+ export function serializeQuestionMessage(prose, block) {
80
+ const payload = JSON.stringify(QuestionBlockSchema.parse(block), null, 2);
81
+ const fence = "`".repeat(Math.max(3, longestBacktickRun(payload) + 1));
82
+ const trimmedProse = prose.trimEnd();
83
+ const fenced = `${fence}${QUESTION_BLOCK_MARKER}\n${payload}\n${fence}`;
84
+ return trimmedProse.length > 0 ? `${trimmedProse}\n\n${fenced}` : fenced;
85
+ }
86
+ /**
87
+ * Extract the block from a message body. Fails closed: any body that does not
88
+ * end in exactly one well-formed, schema-valid block returns null, and the
89
+ * caller renders the whole body as plain text — never a broken steps UI.
90
+ */
91
+ export function parseQuestionMessage(body) {
92
+ // Anchor on the last marker fence so prose that merely mentions the marker
93
+ // earlier in the body cannot shadow the real block.
94
+ let open = null;
95
+ const searchPattern = new RegExp(openFencePattern.source, "gm");
96
+ for (let match = searchPattern.exec(body); match; match = searchPattern.exec(body)) {
97
+ open = match;
98
+ }
99
+ if (!open)
100
+ return null;
101
+ const fenceLength = open[1].length;
102
+ const payloadStart = open.index + open[0].length + 1; // past the fence line's newline
103
+ if (payloadStart > body.length)
104
+ return null;
105
+ const closePattern = new RegExp(`^\`{${fenceLength}}[ \\t]*\\r?$`, "m");
106
+ const close = closePattern.exec(body.slice(payloadStart));
107
+ if (!close)
108
+ return null;
109
+ // The block must terminate the message: nothing but whitespace may follow.
110
+ const afterClose = body.slice(payloadStart + close.index + close[0].length);
111
+ if (afterClose.trim().length > 0)
112
+ return null;
113
+ let parsedPayload;
114
+ try {
115
+ parsedPayload = JSON.parse(body.slice(payloadStart, payloadStart + close.index));
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ const block = QuestionBlockSchema.safeParse(parsedPayload);
121
+ if (!block.success)
122
+ return null;
123
+ return { prose: body.slice(0, open.index).trimEnd(), block: block.data };
124
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indexnetwork/protocol",
3
- "version": "21.0.0-rc.490.1",
3
+ "version": "21.1.0-rc.492.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -8,6 +8,14 @@
8
8
  ".": {
9
9
  "import": "./dist/index.js",
10
10
  "types": "./dist/index.d.ts"
11
+ },
12
+ "./question-block": {
13
+ "types": "./dist/shared/schemas/question-block.schema.d.ts",
14
+ "import": "./dist/shared/schemas/question-block.schema.js"
15
+ },
16
+ "./question-block/fixture": {
17
+ "types": "./dist/shared/schemas/question-block.fixture.d.ts",
18
+ "import": "./dist/shared/schemas/question-block.fixture.js"
11
19
  }
12
20
  },
13
21
  "files": [