@indexnetwork/protocol 4.5.0-rc.332.1 → 4.5.0-rc.334.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.
Files changed (54) hide show
  1. package/dist/chat/chat-streaming.types.d.ts +1 -1
  2. package/dist/chat/chat-streaming.types.d.ts.map +1 -1
  3. package/dist/chat/chat-streaming.types.js.map +1 -1
  4. package/dist/chat/chat.agent.d.ts +1 -1
  5. package/dist/chat/chat.agent.d.ts.map +1 -1
  6. package/dist/chat/chat.agent.js.map +1 -1
  7. package/dist/index.d.ts +5 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +4 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/negotiation/negotiation.agent.d.ts +21 -0
  12. package/dist/negotiation/negotiation.agent.d.ts.map +1 -1
  13. package/dist/negotiation/negotiation.agent.js +79 -11
  14. package/dist/negotiation/negotiation.agent.js.map +1 -1
  15. package/dist/negotiation/negotiation.graph.d.ts +56 -26
  16. package/dist/negotiation/negotiation.graph.d.ts.map +1 -1
  17. package/dist/negotiation/negotiation.graph.js +174 -19
  18. package/dist/negotiation/negotiation.graph.js.map +1 -1
  19. package/dist/negotiation/negotiation.protocol.d.ts +287 -0
  20. package/dist/negotiation/negotiation.protocol.d.ts.map +1 -0
  21. package/dist/negotiation/negotiation.protocol.js +152 -0
  22. package/dist/negotiation/negotiation.protocol.js.map +1 -0
  23. package/dist/negotiation/negotiation.screen.d.ts +128 -0
  24. package/dist/negotiation/negotiation.screen.d.ts.map +1 -0
  25. package/dist/negotiation/negotiation.screen.js +131 -0
  26. package/dist/negotiation/negotiation.screen.js.map +1 -0
  27. package/dist/negotiation/negotiation.state.d.ts +28 -9
  28. package/dist/negotiation/negotiation.state.d.ts.map +1 -1
  29. package/dist/negotiation/negotiation.state.js +28 -4
  30. package/dist/negotiation/negotiation.state.js.map +1 -1
  31. package/dist/negotiation/negotiation.tools.d.ts.map +1 -1
  32. package/dist/negotiation/negotiation.tools.js +86 -31
  33. package/dist/negotiation/negotiation.tools.js.map +1 -1
  34. package/dist/opportunity/question.prompt.d.ts +1 -1
  35. package/dist/opportunity/question.prompt.d.ts.map +1 -1
  36. package/dist/opportunity/question.prompt.js.map +1 -1
  37. package/dist/shared/agent/model.config.d.ts +5 -0
  38. package/dist/shared/agent/model.config.d.ts.map +1 -1
  39. package/dist/shared/agent/model.config.js +1 -0
  40. package/dist/shared/agent/model.config.js.map +1 -1
  41. package/dist/shared/interfaces/agent-dispatcher.interface.d.ts +6 -0
  42. package/dist/shared/interfaces/agent-dispatcher.interface.d.ts.map +1 -1
  43. package/dist/shared/interfaces/agent-dispatcher.interface.js.map +1 -1
  44. package/dist/shared/interfaces/database.interface.d.ts +9 -0
  45. package/dist/shared/interfaces/database.interface.d.ts.map +1 -1
  46. package/dist/shared/interfaces/database.interface.js.map +1 -1
  47. package/dist/shared/schemas/discovery-question.schema.d.ts +8 -8
  48. package/dist/shared/schemas/discovery-question.schema.js +1 -1
  49. package/dist/shared/schemas/discovery-question.schema.js.map +1 -1
  50. package/dist/shared/schemas/negotiation-state.schema.d.ts +19 -3
  51. package/dist/shared/schemas/negotiation-state.schema.d.ts.map +1 -1
  52. package/dist/shared/schemas/negotiation-state.schema.js +15 -1
  53. package/dist/shared/schemas/negotiation-state.schema.js.map +1 -1
  54. package/package.json +1 -1
@@ -3,9 +3,12 @@ import { invokeWithAbortSignal } from "../shared/agent/model-signal.js";
3
3
  import { requestContext } from "../shared/observability/request-context.js";
4
4
  import { NegotiationGraphState } from "./negotiation.state.js";
5
5
  import { IndexNegotiator } from "./negotiation.agent.js";
6
+ import { allowedActionsFor, configuredProtocolVersion, fallbackActionFor, isRejectLikeAction, isTerminalAction, readProtocolVersion, rejectActionFor } from "./negotiation.protocol.js";
7
+ import { NegotiationScreener, configuredScreenMode } from "./negotiation.screen.js";
6
8
  import { protocolLogger } from "../shared/observability/protocol.logger.js";
7
9
  const logger = protocolLogger("NegotiationGraph");
8
10
  const initLog = protocolLogger("NegotiationGraph:Init");
11
+ const screenNodeLog = protocolLogger("NegotiationGraph:Screen");
9
12
  const turnLog = protocolLogger("NegotiationGraph:Turn");
10
13
  const finalizeLog = protocolLogger("NegotiationGraph:Finalize");
11
14
  const negotiateCandidatesLog = protocolLogger("NegotiationGraph:negotiateCandidates");
@@ -32,6 +35,7 @@ export class NegotiationGraphFactory {
32
35
  createGraph() {
33
36
  const { database, dispatcher, timeoutQueue, questionerEnqueue } = this;
34
37
  const systemAgent = new IndexNegotiator();
38
+ const screener = new NegotiationScreener();
35
39
  const initNode = async (state) => {
36
40
  try {
37
41
  // Find-or-create the DM conversation for this agent pair (same as user DMs)
@@ -87,9 +91,13 @@ export class NegotiationGraphFactory {
87
91
  return typeof v === 'string' && v.length > 0 ? v : null;
88
92
  };
89
93
  let initiatorUserId = readInitiator(priorTask?.metadata) ?? state.initiatorUserId ?? state.sourceUser.id;
94
+ // Conversation-scoped prior task: reused for both the initiator tie-break
95
+ // (only when active+fresh) and protocol-version inheritance (any prior
96
+ // task on the conversation pins the version).
97
+ const convTask = (!readInitiator(priorTask?.metadata) || !readProtocolVersion(priorTask?.metadata))
98
+ ? await database.getLatestNegotiationTaskForConversation?.(conversation.id).catch(() => null)
99
+ : null;
90
100
  if (!readInitiator(priorTask?.metadata)) {
91
- const convTask = await database.getLatestNegotiationTaskForConversation?.(conversation.id)
92
- .catch(() => null);
93
101
  if (convTask && convTask.id !== priorTask?.id && isActiveAndFresh(convTask)) {
94
102
  const convInitiator = readInitiator(convTask.metadata);
95
103
  if (convInitiator) {
@@ -102,10 +110,29 @@ export class NegotiationGraphFactory {
102
110
  }
103
111
  }
104
112
  }
113
+ // --- Protocol version: inherited, never re-stamped ---
114
+ // Every session (including continuations) creates a new task row, so a
115
+ // naïve "stamp from env at init" would flip a v1 conversation to v2
116
+ // mid-flight. Rule: any prior negotiation task on this conversation pins
117
+ // the version (absent field on a genuine prior = pre-v2 task = v1);
118
+ // prior turns without a readable task also grandfather to v1; only
119
+ // genuinely fresh negotiations stamp from NEGOTIATION_PROTOCOL_VERSION.
120
+ let protocolVersion;
121
+ const priorVersionSource = priorTask ?? convTask;
122
+ if (priorVersionSource) {
123
+ protocolVersion = readProtocolVersion(priorVersionSource.metadata) ?? 'v1';
124
+ }
125
+ else if (isContinuation) {
126
+ protocolVersion = 'v1';
127
+ }
128
+ else {
129
+ protocolVersion = configuredProtocolVersion();
130
+ }
105
131
  const task = await database.createTask(conversation.id, {
106
132
  type: 'negotiation',
107
133
  sourceUserId: state.sourceUser.id,
108
134
  initiatorUserId,
135
+ protocolVersion,
109
136
  candidateUserId: state.candidateUser.id,
110
137
  networkId: state.indexContext.networkId,
111
138
  ...(state.opportunityId && { opportunityId: state.opportunityId }),
@@ -141,6 +168,7 @@ export class NegotiationGraphFactory {
141
168
  maxTurns,
142
169
  isContinuation,
143
170
  initiatorUserId,
171
+ protocolVersion,
144
172
  priorTurnCount: priorTurns.length,
145
173
  ...(userAnswers.length > 0 && { userAnswers }),
146
174
  ...(seedMessages.length > 0 && { messages: seedMessages }),
@@ -150,6 +178,99 @@ export class NegotiationGraphFactory {
150
178
  return { error: `Init failed: ${err instanceof Error ? err.message : String(err)}` };
151
179
  }
152
180
  };
181
+ /**
182
+ * Screen node (P2.1) — the outreach gate. Runs between init and the first
183
+ * turn on FRESH negotiations only (routing skips it on continuations and
184
+ * when NEGOTIATION_SCREEN_MODE=off). The reaching client's negotiator
185
+ * decides whether the match is worth its client's name; in shadow mode the
186
+ * decision is recorded (task metadata + trace event + log line) but never
187
+ * blocks — the negotiation always proceeds to the first turn. `enforce`
188
+ * runs identically until P2.2 lands enforcement (by the time this node
189
+ * runs, init has already created the task and flipped the opportunity to
190
+ * `negotiating` — P2.2 owns making a blocking `pass` quiet/correct).
191
+ */
192
+ const screenNode = async (state) => {
193
+ const traceEmitter = requestContext.getStore()?.traceEmitter;
194
+ const emitWide = (event) => traceEmitter?.(event);
195
+ const mode = configuredScreenMode();
196
+ if (mode === "enforce") {
197
+ screenNodeLog.warn("NEGOTIATION_SCREEN_MODE=enforce requested but enforcement lands in P2.2; running shadow", {
198
+ taskId: state.taskId,
199
+ });
200
+ }
201
+ const start = Date.now();
202
+ // The client is the initiator seat's user — the side whose negotiator is
203
+ // reaching out. Fresh runs stamp initiatorUserId in init; fall back to
204
+ // sourceUser (what the stamp defaults to anyway).
205
+ const initiatorId = state.initiatorUserId ?? state.sourceUser.id;
206
+ const clientIsSource = initiatorId !== state.candidateUser.id;
207
+ const clientUser = clientIsSource ? state.sourceUser : state.candidateUser;
208
+ const counterpartyUser = clientIsSource ? state.candidateUser : state.sourceUser;
209
+ let decision;
210
+ let failedOpen = false;
211
+ let screenError;
212
+ try {
213
+ const counterpartyContext = (await database.getUserContext(counterpartyUser.id, null).catch(() => null))?.text ?? "";
214
+ decision = await screener.invoke({
215
+ clientUser,
216
+ counterpartyUser,
217
+ ...(counterpartyContext && { counterpartyContext }),
218
+ // discoveryQuery belongs to the discovery session's source user; only
219
+ // meaningful for the client when the client holds the source side.
220
+ ...(clientIsSource && state.discoveryQuery && { discoveryQuery: state.discoveryQuery }),
221
+ seedAssessment: state.seedAssessment,
222
+ indexContext: state.indexContext,
223
+ });
224
+ }
225
+ catch (err) {
226
+ // Fail open: a screen failure must never block a negotiation.
227
+ failedOpen = true;
228
+ screenError = err instanceof Error ? err.message : String(err);
229
+ screenNodeLog.warn("Screen failed; proceeding open (reach_out)", {
230
+ taskId: state.taskId,
231
+ opportunityId: state.opportunityId || undefined,
232
+ error: screenError,
233
+ });
234
+ decision = {
235
+ decision: "reach_out",
236
+ reasoning: `screen_error: ${screenError}`,
237
+ evidence: { counterpartyPremiseFit: "", intentAlignment: "" },
238
+ };
239
+ }
240
+ const durationMs = Date.now() - start;
241
+ const record = {
242
+ ...decision,
243
+ mode,
244
+ ...(failedOpen && { failedOpen, error: screenError }),
245
+ screenedAt: new Date().toISOString(),
246
+ durationMs,
247
+ };
248
+ await database.setTaskScreenDecision?.(state.taskId, record).catch((err) => {
249
+ screenNodeLog.error("Failed to persist screen decision", { taskId: state.taskId, error: err });
250
+ });
251
+ screenNodeLog.info("negotiation_screen", {
252
+ taskId: state.taskId,
253
+ opportunityId: state.opportunityId || undefined,
254
+ decision: decision.decision,
255
+ mode,
256
+ failedOpen,
257
+ durationMs,
258
+ });
259
+ if (state.opportunityId) {
260
+ emitWide({
261
+ type: "negotiation_screen",
262
+ opportunityId: state.opportunityId,
263
+ negotiationConversationId: state.conversationId,
264
+ decision: decision.decision,
265
+ reasoning: decision.reasoning,
266
+ mode,
267
+ failedOpen,
268
+ durationMs,
269
+ });
270
+ }
271
+ // Shadow (and pre-P2.2 enforce): always proceed to the first turn.
272
+ return { screenDecision: record };
273
+ };
153
274
  const turnNode = async (state) => {
154
275
  const traceEmitter = requestContext.getStore()?.traceEmitter;
155
276
  // Local helper to emit events whose shape is wider than the declared
@@ -167,6 +288,13 @@ export class NegotiationGraphFactory {
167
288
  // Determine if this is the system agent's final allowed turn
168
289
  const maxTurns = state.maxTurns ?? 0;
169
290
  const isFinalTurn = maxTurns > 0 && (state.turnCount + 1) >= maxTurns;
291
+ // Seat attribution keys on initiatorUserId (rigid v2 stamp), never on
292
+ // parity or source/candidate position — under the conversation-scoped
293
+ // tie-break this run's source may hold the counterparty seat.
294
+ const version = state.protocolVersion ?? 'v1';
295
+ const seat = ownUser.id === (state.initiatorUserId ?? state.sourceUser.id)
296
+ ? 'initiator'
297
+ : 'counterparty';
170
298
  const payload = {
171
299
  negotiationId: state.taskId,
172
300
  ownUser,
@@ -176,14 +304,25 @@ export class NegotiationGraphFactory {
176
304
  history,
177
305
  isFinalTurn,
178
306
  isDiscoverer: isSource,
307
+ seat,
308
+ protocolVersion: version,
309
+ allowedActions: [...allowedActionsFor(version, seat, isFinalTurn)],
179
310
  ...(state.discoveryQuery && isSource && { discoveryQuery: state.discoveryQuery }),
180
311
  };
181
312
  const scope = { action: 'manage:negotiations', scopeType: 'network', scopeId: state.indexContext.networkId };
182
313
  const dispatchResult = await dispatcher.dispatch(ownUser.id, scope, payload, { timeoutMs: state.timeoutMs });
183
314
  let turn;
184
315
  if (dispatchResult.handled) {
185
- // Personal agent responded
316
+ // Personal agent responded. Under v2, coerce out-of-seat actions to
317
+ // the conservative fallback — the polling/respond surfaces reject
318
+ // these with a 400, but locally-dispatched turns land here directly.
186
319
  turn = dispatchResult.turn;
320
+ if (version === 'v2' && !allowedActionsFor(version, seat, isFinalTurn).includes(turn.action)) {
321
+ turnLog.warn('Personal agent returned out-of-seat action, coercing to conservative fallback', {
322
+ action: turn.action, seat, isFinalTurn,
323
+ });
324
+ turn = { ...turn, action: fallbackActionFor(version, seat, isFinalTurn) };
325
+ }
187
326
  }
188
327
  else if (dispatchResult.reason === 'waiting') {
189
328
  // Long timeout — graph suspends. Persist the full turn context so the
@@ -216,16 +355,24 @@ export class NegotiationGraphFactory {
216
355
  history,
217
356
  isFinalTurn,
218
357
  isDiscoverer: isSource,
358
+ seat,
359
+ protocolVersion: version,
219
360
  ...(state.discoveryQuery && isSource && { discoveryQuery: state.discoveryQuery }),
220
361
  isContinuation: state.isContinuation,
221
362
  ...(state.userAnswers.length > 0 && { userAnswers: state.userAnswers }),
222
363
  });
223
364
  }
224
365
  traceEmitter?.({ type: "agent_end", name: agentName, durationMs: Date.now() - agentStart, summary: `${turn.action}` });
225
- // First turn must be "propose" (unless continuing a prior conversation)
226
- if (state.turnCount === 0 && !state.isContinuation && turn.action !== "propose") {
227
- turnLog.warn("Agent returned unexpected action on turn 0, forcing to propose", { action: turn.action });
228
- turn.action = "propose";
366
+ // First turn must open the negotiation (unless continuing a prior
367
+ // conversation): v1 "propose"; v2 initiator "outreach". A v2 turn-0
368
+ // speaker holding the counterparty seat (tie-break inheritance) is left
369
+ // unforced — it is responding, not opening.
370
+ if (state.turnCount === 0 && !state.isContinuation) {
371
+ const openingAction = version === 'v2' ? 'outreach' : 'propose';
372
+ if ((version !== 'v2' || seat === 'initiator') && turn.action !== openingAction) {
373
+ turnLog.warn(`Agent returned unexpected action on turn 0, forcing to ${openingAction}`, { action: turn.action });
374
+ turn.action = openingAction;
375
+ }
229
376
  }
230
377
  const parts = [{ kind: "data", data: turn }];
231
378
  const message = await database.createMessage({
@@ -267,9 +414,12 @@ export class NegotiationGraphFactory {
267
414
  const errMsg = err instanceof Error ? err.message : String(err);
268
415
  turnLog.error("Agent invocation failed", { error: errMsg, stack: err instanceof Error ? err.stack : undefined, turnCount: state.turnCount });
269
416
  traceEmitter?.({ type: "agent_end", name: agentName, durationMs: Date.now() - agentStart, summary: `error: ${errMsg}` });
417
+ const errorSeat = (state.currentSpeaker === 'source' ? state.sourceUser.id : state.candidateUser.id) === (state.initiatorUserId ?? state.sourceUser.id)
418
+ ? 'initiator'
419
+ : 'counterparty';
270
420
  return {
271
421
  lastTurn: {
272
- action: "reject",
422
+ action: rejectActionFor(state.protocolVersion ?? 'v1', errorSeat),
273
423
  assessment: { reasoning: `Agent error: ${errMsg}`, suggestedRoles: { ownUser: "peer", otherUser: "peer" } },
274
424
  },
275
425
  turnCount: state.turnCount + 1,
@@ -284,9 +434,8 @@ export class NegotiationGraphFactory {
284
434
  return "finalize";
285
435
  if (!state.lastTurn)
286
436
  return "finalize";
287
- if (state.lastTurn.action === "accept")
288
- return "finalize";
289
- if (state.lastTurn.action === "reject")
437
+ // Terminal actions: accept (v1+v2), reject (v1), withdraw/decline (v2)
438
+ if (isTerminalAction(state.lastTurn.action))
290
439
  return "finalize";
291
440
  // question routes same as counter — next turn
292
441
  if ((state.maxTurns ?? 0) > 0 && state.turnCount >= state.maxTurns)
@@ -311,7 +460,7 @@ export class NegotiationGraphFactory {
311
460
  const history = turnsFromMessages(state.messages);
312
461
  const lastTurn = state.lastTurn;
313
462
  const hasOpportunity = lastTurn?.action === "accept";
314
- const atCap = (state.maxTurns ?? 0) > 0 && state.turnCount >= state.maxTurns && lastTurn?.action !== "accept" && lastTurn?.action !== "reject";
463
+ const atCap = (state.maxTurns ?? 0) > 0 && state.turnCount >= state.maxTurns && !isTerminalAction(lastTurn?.action);
315
464
  let agreedRoles = [];
316
465
  if (hasOpportunity && history.length >= 2) {
317
466
  const acceptTurn = history[history.length - 1];
@@ -352,7 +501,7 @@ export class NegotiationGraphFactory {
352
501
  if (state.opportunityId) {
353
502
  const nextStatus = lastTurn?.action === 'accept'
354
503
  ? 'pending'
355
- : lastTurn?.action === 'reject'
504
+ : isRejectLikeAction(lastTurn?.action)
356
505
  ? 'rejected'
357
506
  : 'stalled';
358
507
  await database.updateOpportunityStatus(state.opportunityId, nextStatus).catch((err) => {
@@ -370,9 +519,7 @@ export class NegotiationGraphFactory {
370
519
  ? "turn_cap"
371
520
  : state.error && /timeout/i.test(state.error)
372
521
  ? "timed_out"
373
- : lastTurn?.action === "reject"
374
- ? "rejected_stalled"
375
- : "rejected_stalled";
522
+ : "rejected_stalled";
376
523
  emitWide({
377
524
  type: "negotiation_outcome",
378
525
  opportunityId: state.opportunityId,
@@ -392,7 +539,7 @@ export class NegotiationGraphFactory {
392
539
  }
393
540
  // Enqueue question generation for stalled/capped negotiations (not accepted or explicitly rejected).
394
541
  // Require turnCount > 0 so early init/turn errors don't enqueue with empty context.
395
- if (!hasOpportunity && lastTurn?.action !== 'reject' && state.turnCount > 0 && state.opportunityId && questionerEnqueue) {
542
+ if (!hasOpportunity && !isRejectLikeAction(lastTurn?.action) && state.turnCount > 0 && state.opportunityId && questionerEnqueue) {
396
543
  const stallReason = atCap
397
544
  ? 'turn_cap'
398
545
  : (state.error && /timeout/i.test(state.error))
@@ -421,6 +568,7 @@ export class NegotiationGraphFactory {
421
568
  };
422
569
  const workflow = new StateGraph(NegotiationGraphState)
423
570
  .addNode("init", initNode)
571
+ .addNode("screen", screenNode)
424
572
  .addNode("turn", turnNode)
425
573
  .addNode("finalize", finalizeNode)
426
574
  .addConditionalEdges("turn", evaluateNode, {
@@ -428,8 +576,15 @@ export class NegotiationGraphFactory {
428
576
  finalize: "finalize",
429
577
  })
430
578
  .addConditionalEdges("init", (state) => {
431
- return state.error ? "finalize" : "turn";
432
- }, { turn: "turn", finalize: "finalize" })
579
+ if (state.error)
580
+ return "finalize";
581
+ // Screen gate: fresh negotiations only (continuations already passed
582
+ // the gate when the dialogue opened); off disables the node entirely.
583
+ if (!state.isContinuation && configuredScreenMode() !== "off")
584
+ return "screen";
585
+ return "turn";
586
+ }, { screen: "screen", turn: "turn", finalize: "finalize" })
587
+ .addEdge("screen", "turn")
433
588
  .addEdge("__start__", "init")
434
589
  .addEdge("finalize", "__end__");
435
590
  return workflow.compile();