@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.
- package/dist/chat/chat-streaming.types.d.ts +1 -1
- package/dist/chat/chat-streaming.types.d.ts.map +1 -1
- package/dist/chat/chat-streaming.types.js.map +1 -1
- package/dist/chat/chat.agent.d.ts +1 -1
- package/dist/chat/chat.agent.d.ts.map +1 -1
- package/dist/chat/chat.agent.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/negotiation/negotiation.agent.d.ts +9 -0
- package/dist/negotiation/negotiation.agent.d.ts.map +1 -1
- package/dist/negotiation/negotiation.agent.js +11 -3
- package/dist/negotiation/negotiation.agent.js.map +1 -1
- package/dist/negotiation/negotiation.graph.d.ts +133 -35
- package/dist/negotiation/negotiation.graph.d.ts.map +1 -1
- package/dist/negotiation/negotiation.graph.js +197 -9
- package/dist/negotiation/negotiation.graph.js.map +1 -1
- package/dist/negotiation/negotiation.protocol.d.ts +250 -2
- package/dist/negotiation/negotiation.protocol.d.ts.map +1 -1
- package/dist/negotiation/negotiation.protocol.js +57 -8
- package/dist/negotiation/negotiation.protocol.js.map +1 -1
- package/dist/negotiation/negotiation.reflect.d.ts +199 -0
- package/dist/negotiation/negotiation.reflect.d.ts.map +1 -0
- package/dist/negotiation/negotiation.reflect.js +153 -0
- package/dist/negotiation/negotiation.reflect.js.map +1 -0
- package/dist/negotiation/negotiation.state.d.ts +40 -7
- package/dist/negotiation/negotiation.state.d.ts.map +1 -1
- package/dist/negotiation/negotiation.state.js +5 -1
- package/dist/negotiation/negotiation.state.js.map +1 -1
- package/dist/opportunity/question.prompt.d.ts +1 -1
- package/dist/opportunity/question.prompt.d.ts.map +1 -1
- package/dist/opportunity/question.prompt.js.map +1 -1
- package/dist/shared/agent/model.config.d.ts +5 -0
- package/dist/shared/agent/model.config.d.ts.map +1 -1
- package/dist/shared/agent/model.config.js +1 -0
- package/dist/shared/agent/model.config.js.map +1 -1
- package/dist/shared/interfaces/negotiation-events.interface.d.ts +30 -0
- package/dist/shared/interfaces/negotiation-events.interface.d.ts.map +1 -1
- package/dist/shared/interfaces/negotiation-events.interface.js.map +1 -1
- package/dist/shared/schemas/discovery-question.schema.d.ts +8 -8
- package/dist/shared/schemas/discovery-question.schema.js +1 -1
- package/dist/shared/schemas/discovery-question.schema.js.map +1 -1
- package/dist/shared/schemas/negotiation-state.schema.d.ts +41 -4
- package/dist/shared/schemas/negotiation-state.schema.d.ts.map +1 -1
- package/dist/shared/schemas/negotiation-state.schema.js +14 -0
- package/dist/shared/schemas/negotiation-state.schema.js.map +1 -1
- 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) =>
|
|
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
|
|
67
|
-
|
|
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) {
|