@indexnetwork/protocol 4.5.0-rc.335.1 → 4.5.0-rc.337.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 (48) 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 +6 -4
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +3 -2
  10. package/dist/index.js.map +1 -1
  11. package/dist/negotiation/negotiation.agent.d.ts +9 -0
  12. package/dist/negotiation/negotiation.agent.d.ts.map +1 -1
  13. package/dist/negotiation/negotiation.agent.js +11 -3
  14. package/dist/negotiation/negotiation.agent.js.map +1 -1
  15. package/dist/negotiation/negotiation.graph.d.ts +133 -35
  16. package/dist/negotiation/negotiation.graph.d.ts.map +1 -1
  17. package/dist/negotiation/negotiation.graph.js +197 -9
  18. package/dist/negotiation/negotiation.graph.js.map +1 -1
  19. package/dist/negotiation/negotiation.protocol.d.ts +250 -2
  20. package/dist/negotiation/negotiation.protocol.d.ts.map +1 -1
  21. package/dist/negotiation/negotiation.protocol.js +57 -8
  22. package/dist/negotiation/negotiation.protocol.js.map +1 -1
  23. package/dist/negotiation/negotiation.reflect.d.ts +199 -0
  24. package/dist/negotiation/negotiation.reflect.d.ts.map +1 -0
  25. package/dist/negotiation/negotiation.reflect.js +153 -0
  26. package/dist/negotiation/negotiation.reflect.js.map +1 -0
  27. package/dist/negotiation/negotiation.state.d.ts +40 -7
  28. package/dist/negotiation/negotiation.state.d.ts.map +1 -1
  29. package/dist/negotiation/negotiation.state.js +5 -1
  30. package/dist/negotiation/negotiation.state.js.map +1 -1
  31. package/dist/opportunity/question.prompt.d.ts +1 -1
  32. package/dist/opportunity/question.prompt.d.ts.map +1 -1
  33. package/dist/opportunity/question.prompt.js.map +1 -1
  34. package/dist/shared/agent/model.config.d.ts +5 -0
  35. package/dist/shared/agent/model.config.d.ts.map +1 -1
  36. package/dist/shared/agent/model.config.js +1 -0
  37. package/dist/shared/agent/model.config.js.map +1 -1
  38. package/dist/shared/interfaces/negotiation-events.interface.d.ts +30 -0
  39. package/dist/shared/interfaces/negotiation-events.interface.d.ts.map +1 -1
  40. package/dist/shared/interfaces/negotiation-events.interface.js.map +1 -1
  41. package/dist/shared/schemas/discovery-question.schema.d.ts +8 -8
  42. package/dist/shared/schemas/discovery-question.schema.js +1 -1
  43. package/dist/shared/schemas/discovery-question.schema.js.map +1 -1
  44. package/dist/shared/schemas/negotiation-state.schema.d.ts +41 -4
  45. package/dist/shared/schemas/negotiation-state.schema.d.ts.map +1 -1
  46. package/dist/shared/schemas/negotiation-state.schema.js +14 -0
  47. package/dist/shared/schemas/negotiation-state.schema.js.map +1 -1
  48. package/package.json +1 -1
@@ -3,7 +3,7 @@ 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";
6
+ import { ASK_USER_LOCK_SLACK_MS, allowedActionsFor, askUserAnswerWindowMs, configuredAskUserEnabled, configuredProtocolVersion, fallbackActionFor, isRejectLikeAction, isTerminalAction, readProtocolVersion, rejectActionFor } from "./negotiation.protocol.js";
7
7
  import { NegotiationScreener, configuredScreenMode } from "./negotiation.screen.js";
8
8
  import { protocolLogger } from "../shared/observability/protocol.logger.js";
9
9
  const logger = protocolLogger("NegotiationGraph");
@@ -21,19 +21,35 @@ function turnsFromMessages(messages) {
21
21
  })
22
22
  .filter(Boolean);
23
23
  }
24
+ /**
25
+ * Whether `userId`'s side has already spent its one `ask_user` client
26
+ * consultation in this conversation (P3.2 rationing: max one per negotiation
27
+ * per side, checked against the full message history so continuations count
28
+ * prior sessions' consultations too).
29
+ */
30
+ function hasPriorAskUser(messages, userId) {
31
+ const sender = `agent:${userId}`;
32
+ return messages.some((m) => {
33
+ if (m.senderId !== sender)
34
+ return false;
35
+ const dataPart = m.parts.find((p) => p.kind === "data");
36
+ return dataPart?.data?.action === "ask_user";
37
+ });
38
+ }
24
39
  /**
25
40
  * Factory for the bilateral negotiation LangGraph state machine.
26
41
  * @remarks Accepts an AgentDispatcher for per-turn agent resolution.
27
42
  */
28
43
  export class NegotiationGraphFactory {
29
- constructor(database, dispatcher, timeoutQueue, questionerEnqueue) {
44
+ constructor(database, dispatcher, timeoutQueue, questionerEnqueue, reflectEnqueue) {
30
45
  this.database = database;
31
46
  this.dispatcher = dispatcher;
32
47
  this.timeoutQueue = timeoutQueue;
33
48
  this.questionerEnqueue = questionerEnqueue;
49
+ this.reflectEnqueue = reflectEnqueue;
34
50
  }
35
51
  createGraph() {
36
- const { database, dispatcher, timeoutQueue, questionerEnqueue } = this;
52
+ const { database, dispatcher, timeoutQueue, questionerEnqueue, reflectEnqueue } = this;
37
53
  const systemAgent = new IndexNegotiator();
38
54
  const screener = new NegotiationScreener();
39
55
  const initNode = async (state) => {
@@ -45,7 +61,19 @@ export class NegotiationGraphFactory {
45
61
  // --- Lock gate: check for an active task on this conversation ---
46
62
  const priorMessages = await database.getMessagesForConversation(conversation.id);
47
63
  const activeStates = ['submitted', 'working', 'input_required', 'waiting_for_agent', 'claimed'];
48
- const isActiveAndFresh = (t) => activeStates.includes(t.state) && (Date.now() - new Date(t.updatedAt).getTime()) < 5 * 60 * 1000;
64
+ const isActiveAndFresh = (t) => {
65
+ if (!activeStates.includes(t.state))
66
+ return false;
67
+ // State-aware freshness (IND-401): an `input_required` task is an
68
+ // ask_user pause — it holds the conversation lock for its full answer
69
+ // window (+ slack for the expiry worker), not the 5-min turn window.
70
+ // Otherwise ambient rediscovery / chat negotiate_existing would start
71
+ // a fresh negotiation right past the pause after 5 minutes.
72
+ const freshnessMs = t.state === 'input_required'
73
+ ? askUserAnswerWindowMs() + ASK_USER_LOCK_SLACK_MS
74
+ : 5 * 60 * 1000;
75
+ return (Date.now() - new Date(t.updatedAt).getTime()) < freshnessMs;
76
+ };
49
77
  const priorTask = state.opportunityId
50
78
  ? await database.getNegotiationTaskForOpportunity(state.opportunityId)
51
79
  : null;
@@ -60,11 +88,21 @@ export class NegotiationGraphFactory {
60
88
  // --- Load prior messages and determine continuation ---
61
89
  const priorTurns = turnsFromMessages(priorMessages);
62
90
  const isContinuation = priorTurns.length > 0;
63
- // Determine currentSpeaker from last prior message
91
+ // Determine currentSpeaker from last prior message. An `ask_user` last
92
+ // turn does NOT pass the floor: the sender paused to consult its own
93
+ // client, so on resume the same side speaks again — now armed with the
94
+ // client's answer (or its recorded absence). Flipping here would hand
95
+ // the turn to the counterparty, who has nothing to respond to.
64
96
  let currentSpeaker = 'source';
65
97
  if (isContinuation && priorMessages.length > 0) {
66
- const lastSender = priorMessages[priorMessages.length - 1].senderId;
67
- currentSpeaker = lastSender === agentIdA ? 'candidate' : 'source';
98
+ const lastMessage = priorMessages[priorMessages.length - 1];
99
+ const lastAction = turnsFromMessages([lastMessage])[0]?.action;
100
+ if (lastAction === 'ask_user') {
101
+ currentSpeaker = lastMessage.senderId === agentIdA ? 'source' : 'candidate';
102
+ }
103
+ else {
104
+ currentSpeaker = lastMessage.senderId === agentIdA ? 'candidate' : 'source';
105
+ }
68
106
  }
69
107
  // Determine scenario-based maxTurns
70
108
  const scope = { action: 'manage:negotiations', scopeType: 'network', scopeId: state.indexContext.networkId };
@@ -295,6 +333,21 @@ export class NegotiationGraphFactory {
295
333
  const seat = ownUser.id === (state.initiatorUserId ?? state.sourceUser.id)
296
334
  ? 'initiator'
297
335
  : 'counterparty';
336
+ // ask_user availability (P3.2): flag on, full pause loop wired
337
+ // (questioner + answer-window timer + an opportunity to resume
338
+ // against), v2 non-final non-opening turn, and this side's one client
339
+ // consultation not yet spent (rationing). Chat-triggered runs get no
340
+ // special casing — the pause exits the graph at the turn boundary, so
341
+ // the stream never blocks on a question; the resume is always an async
342
+ // continuation.
343
+ const askUserAvailable = version === 'v2'
344
+ && !isFinalTurn
345
+ && configuredAskUserEnabled()
346
+ && !!questionerEnqueue
347
+ && !!timeoutQueue?.enqueueAskUserExpiry
348
+ && !!state.opportunityId
349
+ && !(state.turnCount === 0 && !state.isContinuation)
350
+ && !hasPriorAskUser(state.messages, ownUser.id);
298
351
  const payload = {
299
352
  negotiationId: state.taskId,
300
353
  ownUser,
@@ -306,7 +359,7 @@ export class NegotiationGraphFactory {
306
359
  isDiscoverer: isSource,
307
360
  seat,
308
361
  protocolVersion: version,
309
- allowedActions: [...allowedActionsFor(version, seat, isFinalTurn)],
362
+ allowedActions: [...allowedActionsFor(version, seat, isFinalTurn, { askUser: askUserAvailable })],
310
363
  ...(state.discoveryQuery && isSource && { discoveryQuery: state.discoveryQuery }),
311
364
  };
312
365
  const scope = { action: 'manage:negotiations', scopeType: 'network', scopeId: state.indexContext.networkId };
@@ -317,7 +370,7 @@ export class NegotiationGraphFactory {
317
370
  // the conservative fallback — the polling/respond surfaces reject
318
371
  // these with a 400, but locally-dispatched turns land here directly.
319
372
  turn = dispatchResult.turn;
320
- if (version === 'v2' && !allowedActionsFor(version, seat, isFinalTurn).includes(turn.action)) {
373
+ if (version === 'v2' && !allowedActionsFor(version, seat, isFinalTurn, { askUser: askUserAvailable }).includes(turn.action)) {
321
374
  turnLog.warn('Personal agent returned out-of-seat action, coercing to conservative fallback', {
322
375
  action: turn.action, seat, isFinalTurn,
323
376
  });
@@ -360,6 +413,7 @@ export class NegotiationGraphFactory {
360
413
  ...(state.discoveryQuery && isSource && { discoveryQuery: state.discoveryQuery }),
361
414
  isContinuation: state.isContinuation,
362
415
  ...(state.userAnswers.length > 0 && { userAnswers: state.userAnswers }),
416
+ ...(askUserAvailable && { canAskUser: true }),
363
417
  });
364
418
  }
365
419
  traceEmitter?.({ type: "agent_end", name: agentName, durationMs: Date.now() - agentStart, summary: `${turn.action}` });
@@ -374,6 +428,16 @@ export class NegotiationGraphFactory {
374
428
  turn.action = openingAction;
375
429
  }
376
430
  }
431
+ // Safety net: an ask_user that slipped past availability gating (e.g. a
432
+ // locally-dispatched agent ignoring allowedActions, or rationing already
433
+ // spent) is coerced to the conservative fallback BEFORE persisting — a
434
+ // pause we cannot resume must never enter the turn history.
435
+ if (turn.action === 'ask_user' && !askUserAvailable) {
436
+ turnLog.warn('ask_user emitted while unavailable, coercing to conservative fallback', {
437
+ seat, isFinalTurn, taskId: state.taskId,
438
+ });
439
+ turn = { ...turn, action: fallbackActionFor(version, seat, isFinalTurn) };
440
+ }
377
441
  const parts = [{ kind: "data", data: turn }];
378
442
  const message = await database.createMessage({
379
443
  conversationId: state.conversationId,
@@ -382,6 +446,88 @@ export class NegotiationGraphFactory {
382
446
  parts,
383
447
  taskId: state.taskId,
384
448
  });
449
+ // ─── ask_user pause (P3.2) ────────────────────────────────────────────
450
+ // The negotiator consults its OWN client: persist the turn (done above),
451
+ // park the full turn context, arm the answer-window timer, enqueue the
452
+ // question through the negotiation_inflight preset, then suspend the
453
+ // task as input_required. The graph exits at this turn boundary exactly
454
+ // like the waiting_for_agent suspend; the answer (or window expiry)
455
+ // resumes via the run-existing continuation path.
456
+ if (turn.action === 'ask_user') {
457
+ const disclosureSubject = turn.askUser?.disclosureSubject?.trim()
458
+ || turn.message
459
+ || turn.assessment.reasoning;
460
+ const draftQuestion = turn.askUser?.draftQuestion ?? turn.message ?? undefined;
461
+ await database.setTaskTurnContext(state.taskId, {
462
+ sourceUser: state.sourceUser,
463
+ candidateUser: state.candidateUser,
464
+ indexContext: state.indexContext,
465
+ seedAssessment: state.seedAssessment,
466
+ ...(isSource && state.discoveryQuery && { discoveryQuery: state.discoveryQuery }),
467
+ });
468
+ // Arm the timer BEFORE flipping state: a timer against a task that
469
+ // never reaches input_required no-ops harmlessly at fire time, while
470
+ // an input_required task without a timer would strand until the lock
471
+ // slack expires.
472
+ const windowMs = askUserAnswerWindowMs();
473
+ await timeoutQueue.enqueueAskUserExpiry(state.taskId, {
474
+ opportunityId: state.opportunityId,
475
+ userId: ownUser.id,
476
+ disclosureSubject,
477
+ }, windowMs);
478
+ // Counterparty referenced by attributes, never identity — the
479
+ // negotiation_inflight preset's referential-closure contract.
480
+ const counterpartyHint = [
481
+ otherUser.profile.bio,
482
+ otherUser.profile.location,
483
+ otherUser.profile.skills?.length ? `skills: ${otherUser.profile.skills.join(', ')}` : undefined,
484
+ ].filter(Boolean).join('; ') || 'a potential match on the network';
485
+ const userContext = (await database.getUserContext(ownUser.id, null).catch(() => null))?.text ?? '';
486
+ await questionerEnqueue({
487
+ mode: 'negotiation_inflight',
488
+ userId: ownUser.id,
489
+ sourceType: 'opportunity',
490
+ sourceId: state.opportunityId,
491
+ context: {
492
+ negotiationId: state.taskId,
493
+ counterpartyHint,
494
+ disclosureSubject,
495
+ ...(draftQuestion && { draftQuestion }),
496
+ indexContext: state.indexContext.prompt,
497
+ ...(userContext && { userContext }),
498
+ },
499
+ });
500
+ await database.updateTaskState(state.taskId, 'input_required');
501
+ turnLog.info('negotiation_ask_user_pause', {
502
+ taskId: state.taskId,
503
+ opportunityId: state.opportunityId,
504
+ seat,
505
+ askingUserId: ownUser.id,
506
+ windowMs,
507
+ });
508
+ traceEmitter?.({ type: "agent_end", name: agentName, durationMs: Date.now() - agentStart, summary: "ask_user" });
509
+ emitWide({
510
+ type: 'negotiation_ask_user',
511
+ opportunityId: state.opportunityId,
512
+ negotiationConversationId: state.conversationId,
513
+ turnIndex: state.turnCount,
514
+ actor: isSource ? 'source' : 'candidate',
515
+ disclosureSubject,
516
+ windowMs,
517
+ });
518
+ return {
519
+ messages: [{
520
+ id: message.id,
521
+ senderId: message.senderId,
522
+ role: "agent",
523
+ parts: message.parts,
524
+ createdAt: message.createdAt,
525
+ }],
526
+ turnCount: state.turnCount + 1,
527
+ lastTurn: turn,
528
+ status: 'input_required',
529
+ };
530
+ }
385
531
  await database.updateTaskState(state.taskId, "working");
386
532
  if (state.opportunityId) {
387
533
  emitWide({
@@ -430,6 +576,8 @@ export class NegotiationGraphFactory {
430
576
  const evaluateNode = (state) => {
431
577
  if (state.status === 'waiting_for_agent')
432
578
  return "finalize";
579
+ if (state.status === 'input_required')
580
+ return "finalize";
433
581
  if (state.error)
434
582
  return "finalize";
435
583
  if (!state.lastTurn)
@@ -457,6 +605,20 @@ export class NegotiationGraphFactory {
457
605
  }
458
606
  return {};
459
607
  }
608
+ // ask_user pause: no outcome, no completed state — the task stays
609
+ // input_required until the client answers or the window expires.
610
+ if (state.status === 'input_required') {
611
+ if (state.opportunityId) {
612
+ emitWide({
613
+ type: "negotiation_outcome",
614
+ opportunityId: state.opportunityId,
615
+ outcome: "input_required",
616
+ turnCount: state.turnCount,
617
+ isContinuation: state.isContinuation,
618
+ });
619
+ }
620
+ return {};
621
+ }
460
622
  const history = turnsFromMessages(state.messages);
461
623
  const lastTurn = state.lastTurn;
462
624
  const hasOpportunity = lastTurn?.action === "accept";
@@ -537,6 +699,32 @@ export class NegotiationGraphFactory {
537
699
  }),
538
700
  });
539
701
  }
702
+ // Enqueue post-negotiation reflection (P5.2 memory write path) — fire
703
+ // and forget: a reflection failure must never affect the outcome. Only
704
+ // sessions that actually exchanged turns teach anything; init/turn
705
+ // errors with turnCount 0 are skipped.
706
+ if (reflectEnqueue && state.turnCount > 0) {
707
+ reflectEnqueue({
708
+ negotiationId: state.taskId,
709
+ conversationId: state.conversationId,
710
+ ...(state.opportunityId && { opportunityId: state.opportunityId }),
711
+ sourceUser: {
712
+ id: state.sourceUser.id,
713
+ ...(state.sourceUser.profile.name && { name: state.sourceUser.profile.name }),
714
+ ...(state.sourceUser.profile.bio && { bio: state.sourceUser.profile.bio }),
715
+ },
716
+ candidateUser: {
717
+ id: state.candidateUser.id,
718
+ ...(state.candidateUser.profile.name && { name: state.candidateUser.profile.name }),
719
+ ...(state.candidateUser.profile.bio && { bio: state.candidateUser.profile.bio }),
720
+ },
721
+ initiatorUserId: state.initiatorUserId ?? state.sourceUser.id,
722
+ outcome: { hasOpportunity, reasoning: outcome.reasoning, turnCount: state.turnCount },
723
+ }).catch((err) => finalizeLog.error('Failed to enqueue negotiation reflection', {
724
+ taskId: state.taskId,
725
+ error: err,
726
+ }));
727
+ }
540
728
  // Enqueue question generation for stalled/capped negotiations (not accepted or explicitly rejected).
541
729
  // Require turnCount > 0 so early init/turn errors don't enqueue with empty context.
542
730
  if (!hasOpportunity && !isRejectLikeAction(lastTurn?.action) && state.turnCount > 0 && state.opportunityId && questionerEnqueue) {