@indexnetwork/protocol 14.3.2-rc.477.1 → 16.1.1-rc.478.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 +107 -0
- package/IMPLEMENTATION.md +50 -52
- package/STABILITY.md +3 -3
- package/dist/chat/chat.agent.js +2 -2
- package/dist/chat/chat.graph.d.ts +41 -1
- package/dist/chat/chat.graph.js +108 -127
- package/dist/discovery/hyde.frame.d.ts +4 -4
- package/dist/discovery/hyde.graph.d.ts +71 -12
- package/dist/discovery/hyde.graph.js +337 -335
- package/dist/discovery/lens.inferrer.d.ts +6 -6
- package/dist/enrichment/enrichment.graph.d.ts +127 -9
- package/dist/enrichment/enrichment.graph.js +633 -655
- package/dist/enrichment/enrichment.state.d.ts +1 -1
- package/dist/enrichment/enrichment.tools.context-read.d.ts +10 -0
- package/dist/enrichment/enrichment.tools.context-read.js +353 -0
- package/dist/enrichment/enrichment.tools.context-write.d.ts +9 -0
- package/dist/enrichment/enrichment.tools.context-write.js +417 -0
- package/dist/enrichment/enrichment.tools.d.ts +11 -1
- package/dist/enrichment/enrichment.tools.helpers.d.ts +139 -0
- package/dist/enrichment/enrichment.tools.helpers.js +234 -0
- package/dist/enrichment/enrichment.tools.js +16 -975
- package/dist/index.d.ts +27 -48
- package/dist/index.js +17 -27
- package/dist/intents/application/intent.graph.d.ts +71 -67
- package/dist/intents/application/intent.graph.execute.d.ts +59 -0
- package/dist/intents/application/intent.graph.execute.js +301 -0
- package/dist/intents/application/intent.graph.infer.d.ts +44 -0
- package/dist/intents/application/intent.graph.infer.js +97 -0
- package/dist/intents/application/intent.graph.js +96 -888
- package/dist/intents/application/intent.graph.reconcile.d.ts +71 -0
- package/dist/intents/application/intent.graph.reconcile.js +274 -0
- package/dist/intents/application/intent.graph.shared.d.ts +70 -0
- package/dist/intents/application/intent.graph.shared.js +153 -0
- package/dist/intents/domain/intent.state.d.ts +1 -1
- package/dist/maintenance/maintenance.graph.d.ts +66 -3
- package/dist/maintenance/maintenance.graph.js +155 -156
- package/dist/mcp/mcp.authorization-policy.d.ts +24 -28
- package/dist/mcp/mcp.authorization-policy.js +14 -34
- package/dist/mcp/mcp.server.d.ts +3 -3
- package/dist/mcp/mcp.server.js +12 -16
- package/dist/negotiations/application/negotiation.candidates.d.ts +83 -0
- package/dist/negotiations/application/negotiation.candidates.js +162 -0
- package/dist/negotiations/application/negotiation.graph.d.ts +91 -157
- package/dist/negotiations/application/negotiation.graph.finalize.d.ts +5 -0
- package/dist/negotiations/application/negotiation.graph.finalize.js +280 -0
- package/dist/negotiations/application/negotiation.graph.init.d.ts +54 -0
- package/dist/negotiations/application/negotiation.graph.init.js +227 -0
- package/dist/negotiations/application/negotiation.graph.js +69 -1388
- package/dist/negotiations/application/negotiation.graph.screen.d.ts +23 -0
- package/dist/negotiations/application/negotiation.graph.screen.js +108 -0
- package/dist/negotiations/application/negotiation.graph.shared.d.ts +75 -0
- package/dist/negotiations/application/negotiation.graph.shared.js +125 -0
- package/dist/negotiations/application/negotiation.graph.turn.d.ts +105 -0
- package/dist/negotiations/application/negotiation.graph.turn.js +484 -0
- package/dist/negotiations/domain/negotiation.state.d.ts +1 -1
- package/dist/negotiations/domain/negotiation.state.js +0 -1
- package/dist/networks/application/indexer.graph.d.ts +165 -7
- package/dist/networks/application/indexer.graph.js +339 -388
- package/dist/networks/application/indexer.state.d.ts +1 -1
- package/dist/networks/application/membership.graph.d.ts +83 -5
- package/dist/networks/application/membership.graph.js +177 -207
- package/dist/networks/application/network.graph.d.ts +148 -5
- package/dist/networks/application/network.graph.js +249 -278
- package/dist/networks/domain/membership.state.d.ts +1 -1
- package/dist/networks/domain/network.state.d.ts +1 -1
- package/dist/opportunities/application/delivery-card.cache.d.ts +1 -1
- package/dist/opportunities/application/delivery-card.cache.js +2 -2
- package/dist/opportunities/application/index.d.ts +2 -2
- package/dist/opportunities/application/index.js +2 -2
- package/dist/opportunities/application/opportunity.evaluator.js +1 -1
- package/dist/opportunities/application/opportunity.graph.d.ts +686 -473
- package/dist/opportunities/application/opportunity.graph.discovery-strategies.d.ts +109 -0
- package/dist/opportunities/application/opportunity.graph.discovery-strategies.js +451 -0
- package/dist/opportunities/application/opportunity.graph.discovery.d.ts +69 -0
- package/dist/opportunities/application/opportunity.graph.discovery.js +397 -0
- package/dist/opportunities/application/opportunity.graph.evaluation.d.ts +82 -0
- package/dist/opportunities/application/opportunity.graph.evaluation.js +608 -0
- package/dist/opportunities/application/opportunity.graph.js +120 -3690
- package/dist/opportunities/application/opportunity.graph.modes.d.ts +538 -0
- package/dist/opportunities/application/opportunity.graph.modes.js +538 -0
- package/dist/opportunities/application/opportunity.graph.negotiate.d.ts +109 -0
- package/dist/opportunities/application/opportunity.graph.negotiate.js +392 -0
- package/dist/opportunities/application/opportunity.graph.persist-node.d.ts +93 -0
- package/dist/opportunities/application/opportunity.graph.persist-node.js +765 -0
- package/dist/opportunities/application/opportunity.graph.prep.d.ts +150 -0
- package/dist/opportunities/application/opportunity.graph.prep.js +381 -0
- package/dist/opportunities/application/opportunity.graph.shared.d.ts +173 -0
- package/dist/opportunities/application/opportunity.graph.shared.js +199 -0
- package/dist/opportunities/application/opportunity.presentation.d.ts +399 -0
- package/dist/opportunities/application/{opportunity.presenter.js → opportunity.presentation.js} +735 -9
- package/dist/opportunities/application/opportunity.tools.cards.d.ts +152 -0
- package/dist/opportunities/application/opportunity.tools.cards.js +235 -0
- package/dist/opportunities/application/opportunity.tools.d.ts +8 -114
- package/dist/opportunities/application/opportunity.tools.js +28 -711
- package/dist/opportunities/application/opportunity.tools.list.d.ts +10 -0
- package/dist/opportunities/application/opportunity.tools.list.js +493 -0
- package/dist/opportunities/domain/index.d.ts +3 -3
- package/dist/opportunities/domain/index.js +3 -3
- package/dist/opportunities/domain/opportunity.state.d.ts +19 -19
- package/dist/opportunities/index.d.ts +6 -6
- package/dist/opportunities/index.js +4 -4
- package/dist/opportunities/ports/opportunity.tools.port.d.ts +1 -1
- package/dist/opportunities/radar/radar.graph.d.ts +57 -13
- package/dist/opportunities/radar/radar.graph.js +470 -471
- package/dist/premises/premise.graph.d.ts +88 -20
- package/dist/premises/premise.graph.js +207 -218
- package/dist/questions/domain/question.schema.d.ts +40 -40
- package/dist/shared/agent/tool.helpers.d.ts +21 -0
- package/dist/shared/agent/tool.registry.d.ts +4 -3
- package/dist/shared/agent/tool.registry.js +1 -1
- package/dist/shared/interfaces/database.capabilities.d.ts +100 -0
- package/dist/shared/interfaces/database.capabilities.js +7 -0
- package/dist/shared/interfaces/database.entities.d.ts +533 -0
- package/dist/shared/interfaces/database.entities.js +10 -0
- package/dist/shared/interfaces/database.identity-queries.d.ts +294 -0
- package/dist/shared/interfaces/database.identity-queries.js +4 -0
- package/dist/shared/interfaces/database.interface.d.ts +15 -2277
- package/dist/shared/interfaces/database.interface.js +8 -0
- package/dist/shared/interfaces/database.member-queries.d.ts +228 -0
- package/dist/shared/interfaces/database.member-queries.js +4 -0
- package/dist/shared/interfaces/database.negotiation.d.ts +305 -0
- package/dist/shared/interfaces/database.negotiation.js +26 -0
- package/dist/shared/interfaces/database.network-queries.d.ts +277 -0
- package/dist/shared/interfaces/database.network-queries.js +4 -0
- package/dist/shared/interfaces/database.opportunity-queries.d.ts +285 -0
- package/dist/shared/interfaces/database.opportunity-queries.js +4 -0
- package/dist/shared/interfaces/database.port.d.ts +322 -0
- package/dist/shared/interfaces/database.port.js +29 -0
- package/dist/shared/schemas/discovery-question.schema.d.ts +14 -14
- package/dist/shared/schemas/negotiation-digest.schema.d.ts +2 -2
- package/package.json +2 -15
- package/dist/opportunities/application/opportunity.card-presentation.d.ts +0 -44
- package/dist/opportunities/application/opportunity.card-presentation.js +0 -93
- package/dist/opportunities/application/opportunity.presenter.d.ts +0 -156
- package/dist/opportunities/domain/opportunity.presentation-cache.d.ts +0 -5
- package/dist/opportunities/domain/opportunity.presentation-cache.js +0 -11
- package/dist/opportunities/domain/opportunity.presentation.d.ts +0 -76
- package/dist/opportunities/domain/opportunity.presentation.js +0 -516
- package/dist/opportunities/domain/opportunity.safe-presentation.d.ts +0 -104
- package/dist/opportunities/domain/opportunity.safe-presentation.js +0 -102
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Negotiation graph, stage 4: persist the outcome and fan out follow-ups.
|
|
3
|
+
*/
|
|
4
|
+
import { requestContext } from "../../shared/observability/request-context.js";
|
|
5
|
+
import { isRejectLikeAction, isTerminalAction } from "../domain/negotiation.protocol.js";
|
|
6
|
+
import { blocksNegotiationBeforeFirstTurn } from "./negotiation.screen.js";
|
|
7
|
+
import { NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY, NEGOTIATION_QUESTION_GENERIC_NETWORK } from '../domain/negotiation.question-safety.js';
|
|
8
|
+
import { isNegotiationTurnCapReached } from "../domain/negotiation.turn-cap.js";
|
|
9
|
+
import { finalizeLog, turnsFromMessages } from "./negotiation.graph.shared.js";
|
|
10
|
+
export async function finalizeNode(state, deps) {
|
|
11
|
+
const traceEmitter = requestContext.getStore()?.traceEmitter;
|
|
12
|
+
const emitWide = (event) => traceEmitter?.(event);
|
|
13
|
+
const pauseReceipt = (outcome) => state.continuationExecution
|
|
14
|
+
? {
|
|
15
|
+
continuationReceipt: {
|
|
16
|
+
priorTaskId: state.continuationExecution.taskId,
|
|
17
|
+
settlementId: state.continuationExecution.settlementId,
|
|
18
|
+
successorTaskId: state.continuationExecution.successorTaskId,
|
|
19
|
+
fence: state.continuationExecution.fence,
|
|
20
|
+
outcome,
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
: undefined;
|
|
24
|
+
if (state.status === 'waiting_for_agent') {
|
|
25
|
+
if (state.opportunityId) {
|
|
26
|
+
emitWide({
|
|
27
|
+
type: "negotiation_outcome",
|
|
28
|
+
opportunityId: state.opportunityId,
|
|
29
|
+
outcome: "waiting_for_agent",
|
|
30
|
+
turnCount: state.turnCount,
|
|
31
|
+
isContinuation: state.isContinuation,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return pauseReceipt('waiting_for_agent') ?? {};
|
|
35
|
+
}
|
|
36
|
+
// ask_user pause: no outcome, no completed state — the task stays
|
|
37
|
+
// input_required until the client answers or the window expires.
|
|
38
|
+
if (state.status === 'input_required') {
|
|
39
|
+
if (state.opportunityId) {
|
|
40
|
+
emitWide({
|
|
41
|
+
type: "negotiation_outcome",
|
|
42
|
+
opportunityId: state.opportunityId,
|
|
43
|
+
outcome: "input_required",
|
|
44
|
+
turnCount: state.turnCount,
|
|
45
|
+
isContinuation: state.isContinuation,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return pauseReceipt('input_required') ?? {};
|
|
49
|
+
}
|
|
50
|
+
// Init can fail closed before an attempt owns a task. Such a rejection is
|
|
51
|
+
// not a completed negotiation and must not write through an empty task ID,
|
|
52
|
+
// create an artifact, or advance the opportunity lifecycle.
|
|
53
|
+
if (!state.taskId) {
|
|
54
|
+
finalizeLog.info('Skipping outcome persistence because no negotiation task was claimed', {
|
|
55
|
+
opportunityId: state.opportunityId || undefined,
|
|
56
|
+
error: state.error || undefined,
|
|
57
|
+
});
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
const history = turnsFromMessages(state.messages);
|
|
61
|
+
const lastTurn = state.lastTurn;
|
|
62
|
+
const hasOpportunity = lastTurn?.action === "accept";
|
|
63
|
+
// P2.2: the client's own outreach gate declined before any turn — the
|
|
64
|
+
// negotiation never happened from the counterparty's perspective.
|
|
65
|
+
// IND-564: an opening-move `withdraw` blocked before any message was
|
|
66
|
+
// persisted is the same quiet screen-out outcome (no in-task outreach to
|
|
67
|
+
// retract), reached from the turn node rather than the screen node.
|
|
68
|
+
// Two DISTINCT routes reach the same quiet `screened_out` outcome, and
|
|
69
|
+
// they disagree about who authored the decision:
|
|
70
|
+
// - the screen node blocked before any turn was drafted → the screen
|
|
71
|
+
// decision is the reason;
|
|
72
|
+
// - the acting agent refused on its opening turn (IND-611) → the
|
|
73
|
+
// withdrawing TURN is the reason.
|
|
74
|
+
// They are collapsed into `screenedOut` for status/lifecycle purposes but
|
|
75
|
+
// must stay separate when attributing `outcome.reasoning` below.
|
|
76
|
+
const blockedByScreenNode = blocksNegotiationBeforeFirstTurn(state.screenDecision, state.turnCount);
|
|
77
|
+
const refusedAtOpeningTurn = state.firstTurnScreenedOut === true;
|
|
78
|
+
const screenedOut = blockedByScreenNode || refusedAtOpeningTurn;
|
|
79
|
+
const atCap = !screenedOut && isNegotiationTurnCapReached(state.turnCount, state.maxTurns) && !isTerminalAction(lastTurn?.action);
|
|
80
|
+
let agreedRoles = [];
|
|
81
|
+
if (hasOpportunity && history.length >= 2) {
|
|
82
|
+
const acceptTurn = history[history.length - 1];
|
|
83
|
+
const precedingTurn = history[history.length - 2];
|
|
84
|
+
const accepterIsSource = state.currentSpeaker === "candidate";
|
|
85
|
+
const [sourceRole, candidateRole] = accepterIsSource
|
|
86
|
+
? [acceptTurn.assessment.suggestedRoles.ownUser, precedingTurn.assessment.suggestedRoles.ownUser]
|
|
87
|
+
: [precedingTurn.assessment.suggestedRoles.ownUser, acceptTurn.assessment.suggestedRoles.ownUser];
|
|
88
|
+
agreedRoles = [
|
|
89
|
+
{ userId: state.sourceUser.id, role: sourceRole },
|
|
90
|
+
{ userId: state.candidateUser.id, role: candidateRole },
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
const outcome = {
|
|
94
|
+
hasOpportunity,
|
|
95
|
+
agreedRoles,
|
|
96
|
+
// IND-611: attribute the reasoning to whoever actually made the
|
|
97
|
+
// decision. Before the turn-0 refusal path existed, `screenedOut`
|
|
98
|
+
// implied the screen node, so preferring `screenDecision.reasoning`
|
|
99
|
+
// was always right. It is now wrong for an opening-turn refusal taken
|
|
100
|
+
// while the screen said `reach_out`: that record argues FOR the match,
|
|
101
|
+
// and surfacing it as the reason the agent did NOT reach out is exactly
|
|
102
|
+
// the dishonesty this work removes (IND-610 renders this string in the
|
|
103
|
+
// owner-only gate-decision card). The screen-node branch is unchanged.
|
|
104
|
+
reasoning: blockedByScreenNode
|
|
105
|
+
? (state.screenDecision?.reasoning ?? lastTurn?.assessment?.reasoning ?? "")
|
|
106
|
+
: refusedAtOpeningTurn
|
|
107
|
+
? (lastTurn?.assessment?.reasoning ?? state.screenDecision?.reasoning ?? "")
|
|
108
|
+
: (lastTurn?.assessment.reasoning ?? ""),
|
|
109
|
+
turnCount: state.turnCount,
|
|
110
|
+
...(screenedOut
|
|
111
|
+
? { reason: "screened_out" }
|
|
112
|
+
: atCap
|
|
113
|
+
? { reason: "turn_cap" }
|
|
114
|
+
: {}),
|
|
115
|
+
};
|
|
116
|
+
try {
|
|
117
|
+
await deps.database.updateTaskState(state.taskId, "completed", undefined, state.continuationExecution);
|
|
118
|
+
await deps.database.createArtifact({
|
|
119
|
+
taskId: state.taskId,
|
|
120
|
+
name: "negotiation-outcome",
|
|
121
|
+
parts: [{ kind: "data", data: outcome }],
|
|
122
|
+
metadata: {
|
|
123
|
+
hasOpportunity,
|
|
124
|
+
turnCount: state.turnCount,
|
|
125
|
+
...(state.continuationExecution ? {
|
|
126
|
+
continuationOutcome: hasOpportunity
|
|
127
|
+
? 'accepted'
|
|
128
|
+
: (screenedOut || isRejectLikeAction(lastTurn?.action)) ? 'rejected' : 'stalled',
|
|
129
|
+
} : {}),
|
|
130
|
+
},
|
|
131
|
+
...(state.continuationExecution ? { continuationExecution: state.continuationExecution } : {}),
|
|
132
|
+
});
|
|
133
|
+
finalizeLog.info('Session complete', {
|
|
134
|
+
conversationId: state.conversationId,
|
|
135
|
+
taskId: state.taskId,
|
|
136
|
+
isContinuation: state.isContinuation,
|
|
137
|
+
turnsAdded: state.turnCount,
|
|
138
|
+
priorTurnCount: state.priorTurnCount,
|
|
139
|
+
outcome: hasOpportunity ? 'accepted' : screenedOut ? 'screened_out' : (atCap ? 'turn_cap' : (lastTurn?.action ?? 'unknown')),
|
|
140
|
+
opportunityId: state.opportunityId || undefined,
|
|
141
|
+
});
|
|
142
|
+
if (state.opportunityId) {
|
|
143
|
+
// screened_out → 'rejected': quiet terminal status (hidden from
|
|
144
|
+
// default lists), never 'stalled' — with zero turns the generic
|
|
145
|
+
// mapping would misfile the client's own gate decision.
|
|
146
|
+
const nextStatus = lastTurn?.action === 'accept'
|
|
147
|
+
? 'pending'
|
|
148
|
+
: (screenedOut || isRejectLikeAction(lastTurn?.action))
|
|
149
|
+
? 'rejected'
|
|
150
|
+
: 'stalled';
|
|
151
|
+
await deps.database.updateOpportunityStatus(state.opportunityId, nextStatus, undefined, state.continuationExecution).catch((err) => {
|
|
152
|
+
finalizeLog.error("Failed to update opportunity status", { opportunityId: state.opportunityId, nextStatus, error: err });
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
finalizeLog.error("Failed to persist outcome", { error: err });
|
|
158
|
+
}
|
|
159
|
+
if (state.opportunityId) {
|
|
160
|
+
const emittedOutcome = hasOpportunity
|
|
161
|
+
? "accepted"
|
|
162
|
+
: screenedOut
|
|
163
|
+
? "screened_out"
|
|
164
|
+
: atCap
|
|
165
|
+
? "turn_cap"
|
|
166
|
+
: state.error && /timeout/i.test(state.error)
|
|
167
|
+
? "timed_out"
|
|
168
|
+
: "rejected_stalled";
|
|
169
|
+
emitWide({
|
|
170
|
+
type: "negotiation_outcome",
|
|
171
|
+
opportunityId: state.opportunityId,
|
|
172
|
+
outcome: emittedOutcome,
|
|
173
|
+
turnCount: state.turnCount,
|
|
174
|
+
isContinuation: state.isContinuation,
|
|
175
|
+
turnsAdded: state.turnCount,
|
|
176
|
+
priorTurnCount: state.priorTurnCount,
|
|
177
|
+
...(outcome.reasoning && { reasoning: outcome.reasoning }),
|
|
178
|
+
...(hasOpportunity && agreedRoles.length >= 2 && {
|
|
179
|
+
agreedRoles: {
|
|
180
|
+
ownUser: agreedRoles[0]?.role,
|
|
181
|
+
otherUser: agreedRoles[1]?.role,
|
|
182
|
+
},
|
|
183
|
+
}),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
if (state.consultationPolicyReason) {
|
|
187
|
+
// Stable, content-free terminal funnel telemetry. This executes only
|
|
188
|
+
// after a fenced continuation has reached its terminal outcome.
|
|
189
|
+
finalizeLog.info('negotiation_consultation_policy', {
|
|
190
|
+
stage: 'terminal_outcome',
|
|
191
|
+
reason: state.consultationPolicyReason,
|
|
192
|
+
outcome: hasOpportunity ? 'accepted' : (screenedOut || isRejectLikeAction(lastTurn?.action)) ? 'rejected' : 'stalled',
|
|
193
|
+
});
|
|
194
|
+
emitWide({
|
|
195
|
+
type: 'negotiation_consultation_policy',
|
|
196
|
+
stage: 'terminal_outcome',
|
|
197
|
+
reason: state.consultationPolicyReason,
|
|
198
|
+
outcome: hasOpportunity ? 'accepted' : (screenedOut || isRejectLikeAction(lastTurn?.action)) ? 'rejected' : 'stalled',
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
// Enqueue post-negotiation reflection (P5.2 memory write path) — fire
|
|
202
|
+
// and forget: a reflection failure must never affect the outcome. Only
|
|
203
|
+
// sessions that actually exchanged turns teach anything; init/turn
|
|
204
|
+
// errors with turnCount 0 are skipped.
|
|
205
|
+
if (deps.reflectEnqueue && state.turnCount > 0 && !state.continuationExecution) {
|
|
206
|
+
deps.reflectEnqueue({
|
|
207
|
+
negotiationId: state.taskId,
|
|
208
|
+
conversationId: state.conversationId,
|
|
209
|
+
...(state.opportunityId && { opportunityId: state.opportunityId }),
|
|
210
|
+
sourceUser: {
|
|
211
|
+
id: state.sourceUser.id,
|
|
212
|
+
...(state.sourceUser.profile.name && { name: state.sourceUser.profile.name }),
|
|
213
|
+
...(state.sourceUser.profile.bio && { bio: state.sourceUser.profile.bio }),
|
|
214
|
+
},
|
|
215
|
+
candidateUser: {
|
|
216
|
+
id: state.candidateUser.id,
|
|
217
|
+
...(state.candidateUser.profile.name && { name: state.candidateUser.profile.name }),
|
|
218
|
+
...(state.candidateUser.profile.bio && { bio: state.candidateUser.profile.bio }),
|
|
219
|
+
},
|
|
220
|
+
initiatorUserId: state.initiatorUserId ?? state.sourceUser.id,
|
|
221
|
+
outcome: { hasOpportunity, reasoning: outcome.reasoning, turnCount: state.turnCount },
|
|
222
|
+
}).catch((err) => finalizeLog.error('Failed to enqueue negotiation reflection', {
|
|
223
|
+
taskId: state.taskId,
|
|
224
|
+
error: err,
|
|
225
|
+
}));
|
|
226
|
+
}
|
|
227
|
+
// Enqueue question generation for stalled/capped negotiations (not accepted or explicitly rejected).
|
|
228
|
+
// 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';
|
|
235
|
+
const userContext = (await deps.database.getUserContext(state.sourceUser.id, null))?.text ?? '';
|
|
236
|
+
const sourceIntent = state.sourceUser.intents.find((intent) => intent.id === state.sourceIntentId);
|
|
237
|
+
deps.questionerEnqueue({
|
|
238
|
+
mode: 'negotiation',
|
|
239
|
+
purpose: 'stalled_followup',
|
|
240
|
+
userId: state.sourceUser.id,
|
|
241
|
+
sourceType: 'opportunity',
|
|
242
|
+
sourceId: state.opportunityId,
|
|
243
|
+
negotiation: {
|
|
244
|
+
purpose: 'stalled_followup',
|
|
245
|
+
recipientUserId: state.sourceUser.id,
|
|
246
|
+
recipientIntentId: state.sourceIntentId,
|
|
247
|
+
opportunityId: state.opportunityId,
|
|
248
|
+
taskId: state.taskId,
|
|
249
|
+
networkId: state.indexContext.networkId,
|
|
250
|
+
},
|
|
251
|
+
context: {
|
|
252
|
+
negotiationId: state.taskId,
|
|
253
|
+
counterpartyHint: NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY,
|
|
254
|
+
indexContext: NEGOTIATION_QUESTION_GENERIC_NETWORK,
|
|
255
|
+
outcomeReason: stallReason,
|
|
256
|
+
recipientIntent: sourceIntent
|
|
257
|
+
? `${sourceIntent.title}: ${sourceIntent.description}`
|
|
258
|
+
: 'the signal attached to this match',
|
|
259
|
+
userContext,
|
|
260
|
+
},
|
|
261
|
+
}).catch((err) => finalizeLog.error('Failed to enqueue negotiation question generation', {
|
|
262
|
+
opportunityId: state.opportunityId,
|
|
263
|
+
error: err,
|
|
264
|
+
}));
|
|
265
|
+
}
|
|
266
|
+
const terminalReceipt = state.continuationExecution
|
|
267
|
+
? {
|
|
268
|
+
continuationReceipt: {
|
|
269
|
+
priorTaskId: state.continuationExecution.taskId,
|
|
270
|
+
settlementId: state.continuationExecution.settlementId,
|
|
271
|
+
successorTaskId: state.continuationExecution.successorTaskId,
|
|
272
|
+
fence: state.continuationExecution.fence,
|
|
273
|
+
outcome: hasOpportunity
|
|
274
|
+
? 'accepted'
|
|
275
|
+
: (screenedOut || isRejectLikeAction(lastTurn?.action)) ? 'rejected' : 'stalled',
|
|
276
|
+
},
|
|
277
|
+
}
|
|
278
|
+
: undefined;
|
|
279
|
+
return { outcome, status: 'completed', ...terminalReceipt };
|
|
280
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Negotiation graph, stage 1: claim the conversation, task and seat.
|
|
3
|
+
*/
|
|
4
|
+
import { type NegotiationConsultationReason } from "../domain/negotiation.consultation-policy.js";
|
|
5
|
+
import type { NegotiationProtocolVersion } from "../../shared/schemas/negotiation-state.schema.js";
|
|
6
|
+
import type { NegotiationGraphDeps, NegotiationState } from "./negotiation.graph.shared.js";
|
|
7
|
+
export declare function initNode(state: NegotiationState, deps: NegotiationGraphDeps): Promise<{
|
|
8
|
+
error: string;
|
|
9
|
+
} | {
|
|
10
|
+
messages?: {
|
|
11
|
+
id: string;
|
|
12
|
+
senderId: string;
|
|
13
|
+
role: "agent";
|
|
14
|
+
parts: unknown[];
|
|
15
|
+
createdAt: Date;
|
|
16
|
+
taskId: string | null;
|
|
17
|
+
}[] | undefined;
|
|
18
|
+
privateConsultation?: import("../../shared/interfaces/database.negotiation.js").NegotiationPrivateConsultation | undefined;
|
|
19
|
+
userAnswers?: import("../../shared/interfaces/database.negotiation.js").NegotiationUserAnswer[] | undefined;
|
|
20
|
+
priorAttribution?: import("../negotiation.attribution.js").SeededAttribution | undefined;
|
|
21
|
+
conversationId: string;
|
|
22
|
+
taskId: string;
|
|
23
|
+
currentSpeaker: "source" | "candidate";
|
|
24
|
+
turnCount: number;
|
|
25
|
+
maxTurns: number;
|
|
26
|
+
isContinuation: boolean;
|
|
27
|
+
initiatorUserId: string;
|
|
28
|
+
protocolVersion: NegotiationProtocolVersion;
|
|
29
|
+
priorTurnCount: number;
|
|
30
|
+
error?: undefined;
|
|
31
|
+
} | {
|
|
32
|
+
messages?: {
|
|
33
|
+
id: string;
|
|
34
|
+
senderId: string;
|
|
35
|
+
role: "agent";
|
|
36
|
+
parts: unknown[];
|
|
37
|
+
createdAt: Date;
|
|
38
|
+
taskId: string | null;
|
|
39
|
+
}[] | undefined;
|
|
40
|
+
consultationPolicyReason: NegotiationConsultationReason;
|
|
41
|
+
privateConsultation?: import("../../shared/interfaces/database.negotiation.js").NegotiationPrivateConsultation | undefined;
|
|
42
|
+
userAnswers?: import("../../shared/interfaces/database.negotiation.js").NegotiationUserAnswer[] | undefined;
|
|
43
|
+
priorAttribution?: import("../negotiation.attribution.js").SeededAttribution | undefined;
|
|
44
|
+
conversationId: string;
|
|
45
|
+
taskId: string;
|
|
46
|
+
currentSpeaker: "source" | "candidate";
|
|
47
|
+
turnCount: number;
|
|
48
|
+
maxTurns: number;
|
|
49
|
+
isContinuation: boolean;
|
|
50
|
+
initiatorUserId: string;
|
|
51
|
+
protocolVersion: NegotiationProtocolVersion;
|
|
52
|
+
priorTurnCount: number;
|
|
53
|
+
error?: undefined;
|
|
54
|
+
}>;
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Negotiation graph, stage 1: claim the conversation, task and seat.
|
|
3
|
+
*/
|
|
4
|
+
import { configuredProtocolVersion, readProtocolVersion } from "../domain/negotiation.protocol.js";
|
|
5
|
+
import { buildIntentSnapshots } from "../domain/negotiation.intent-snapshot-provenance.js";
|
|
6
|
+
import { holdsNegotiationConversationLock } from "../domain/negotiation.task-lock-policy.js";
|
|
7
|
+
import { expectedNegotiationSpeaker } from "../domain/negotiation.expected-speaker.js";
|
|
8
|
+
import { buildSeededAttribution } from '../negotiation.attribution.js';
|
|
9
|
+
import { initLog, resolveTaskAttribution, turnsFromMessages } from "./negotiation.graph.shared.js";
|
|
10
|
+
export async function initNode(state, deps) {
|
|
11
|
+
try {
|
|
12
|
+
// Exact continuations reuse the prior conversation and preclaimed
|
|
13
|
+
// successor; they must not create any mutable state before the fence.
|
|
14
|
+
const agentIdA = `agent:${state.sourceUser.id}`;
|
|
15
|
+
const agentIdB = `agent:${state.candidateUser.id}`;
|
|
16
|
+
const execution = state.continuationExecution;
|
|
17
|
+
const conversation = execution
|
|
18
|
+
? { id: execution.conversationId }
|
|
19
|
+
: await deps.database.getOrCreateDM(agentIdA, agentIdB, 'agent');
|
|
20
|
+
// --- Lock gate: check for an active task on this conversation ---
|
|
21
|
+
const priorMessages = await deps.database.getMessagesForConversation(conversation.id);
|
|
22
|
+
if (Boolean(state.resumeFromTaskId) !== Boolean(state.continuationSettlementId)
|
|
23
|
+
|| Boolean(state.resumeFromTaskId) !== Boolean(execution))
|
|
24
|
+
return { error: 'invalid continuation correlation' };
|
|
25
|
+
const exactContinuation = state.resumeFromTaskId && state.continuationSettlementId && execution
|
|
26
|
+
? { taskId: state.resumeFromTaskId, settlementId: state.continuationSettlementId, execution }
|
|
27
|
+
: null;
|
|
28
|
+
const priorTask = exactContinuation
|
|
29
|
+
? await deps.database.getTask(exactContinuation.taskId)
|
|
30
|
+
: state.opportunityId
|
|
31
|
+
? await deps.database.getNegotiationTaskForOpportunity(state.opportunityId)
|
|
32
|
+
: null;
|
|
33
|
+
const claimedSuccessor = exactContinuation
|
|
34
|
+
? await deps.database.getTask(exactContinuation.execution.successorTaskId)
|
|
35
|
+
: null;
|
|
36
|
+
if (exactContinuation) {
|
|
37
|
+
const settlement = priorTask?.metadata?.questionSettlement;
|
|
38
|
+
const storedExecution = claimedSuccessor?.metadata?.continuationExecution;
|
|
39
|
+
if (!priorTask
|
|
40
|
+
|| priorTask.conversationId !== conversation.id
|
|
41
|
+
|| priorTask.state !== 'canceled'
|
|
42
|
+
|| priorTask.metadata?.opportunityId !== state.opportunityId
|
|
43
|
+
|| settlement?.settlementId !== exactContinuation.settlementId
|
|
44
|
+
|| settlement?.taskId !== exactContinuation.taskId
|
|
45
|
+
|| !claimedSuccessor
|
|
46
|
+
|| claimedSuccessor.conversationId !== conversation.id
|
|
47
|
+
|| storedExecution?.token !== exactContinuation.execution.token
|
|
48
|
+
|| storedExecution?.fence !== exactContinuation.execution.fence
|
|
49
|
+
|| storedExecution?.status !== 'claimed')
|
|
50
|
+
return { error: 'invalid exact continuation task' };
|
|
51
|
+
}
|
|
52
|
+
const isLocked = !exactContinuation && !!priorTask && holdsNegotiationConversationLock(priorTask);
|
|
53
|
+
if (isLocked) {
|
|
54
|
+
initLog.info('Conversation locked by active task, returning busy', {
|
|
55
|
+
conversationId: conversation.id,
|
|
56
|
+
opportunityId: state.opportunityId,
|
|
57
|
+
});
|
|
58
|
+
return { error: 'busy' };
|
|
59
|
+
}
|
|
60
|
+
// --- Load prior messages and determine continuation ---
|
|
61
|
+
const priorTurns = turnsFromMessages(priorMessages);
|
|
62
|
+
const isContinuation = priorTurns.length > 0;
|
|
63
|
+
const expectedSpeaker = expectedNegotiationSpeaker({
|
|
64
|
+
sourceUserId: state.sourceUser.id,
|
|
65
|
+
candidateUserId: state.candidateUser.id,
|
|
66
|
+
}, priorMessages);
|
|
67
|
+
if (!expectedSpeaker)
|
|
68
|
+
return { error: 'invalid negotiation participants' };
|
|
69
|
+
const currentSpeaker = expectedSpeaker === state.sourceUser.id
|
|
70
|
+
? 'source'
|
|
71
|
+
: 'candidate';
|
|
72
|
+
// Determine scenario-based maxTurns
|
|
73
|
+
const scope = { action: 'manage:negotiations', scopeType: 'network', scopeId: state.indexContext.networkId };
|
|
74
|
+
const [sourceHasAgent, candidateHasAgent] = await Promise.all([
|
|
75
|
+
deps.dispatcher.hasExternalAgent(state.sourceUser.id, scope),
|
|
76
|
+
deps.dispatcher.hasExternalAgent(state.candidateUser.id, scope),
|
|
77
|
+
]);
|
|
78
|
+
const ambientMax = Number(process.env.NEGOTIATION_MAX_TURNS_AMBIENT) || 6;
|
|
79
|
+
let maxTurns = state.maxTurns;
|
|
80
|
+
if (maxTurns == null) {
|
|
81
|
+
maxTurns = (sourceHasAgent && candidateHasAgent) ? 0 : ambientMax;
|
|
82
|
+
}
|
|
83
|
+
// --- Initiator seat resolution (v2: rigid per match, stamped at discovery) ---
|
|
84
|
+
// 1. Continuations inherit from the prior task for the same opportunity —
|
|
85
|
+
// never re-derive, so the seat cannot flip between sessions.
|
|
86
|
+
// 2. Conversation-scoped tie-break: if another negotiation on this DM is
|
|
87
|
+
// active and fresh (symmetric concurrent start under a different
|
|
88
|
+
// opportunityId — the opportunity-scoped lock above cannot see it),
|
|
89
|
+
// the first created task keeps the seat; this run inherits its stamp.
|
|
90
|
+
// 3. Otherwise: explicit stamp from the caller, falling back to the
|
|
91
|
+
// session's sourceUser (pre-stamp heuristic behavior, unchanged).
|
|
92
|
+
const readInitiator = (metadata) => {
|
|
93
|
+
const v = metadata?.initiatorUserId;
|
|
94
|
+
return typeof v === 'string' && v.length > 0 ? v : null;
|
|
95
|
+
};
|
|
96
|
+
let initiatorUserId = readInitiator(priorTask?.metadata) ?? state.initiatorUserId ?? state.sourceUser.id;
|
|
97
|
+
// Conversation-scoped prior task: used only for the initiator tie-break
|
|
98
|
+
// (and only when active+fresh).
|
|
99
|
+
const convTask = !exactContinuation && !readInitiator(priorTask?.metadata)
|
|
100
|
+
? await deps.database.getLatestNegotiationTaskForConversation?.(conversation.id).catch(() => null)
|
|
101
|
+
: null;
|
|
102
|
+
if (!readInitiator(priorTask?.metadata)) {
|
|
103
|
+
if (convTask && convTask.id !== priorTask?.id && holdsNegotiationConversationLock(convTask)) {
|
|
104
|
+
const convInitiator = readInitiator(convTask.metadata);
|
|
105
|
+
if (convInitiator) {
|
|
106
|
+
initLog.info('Conversation-scoped tie-break: inheriting initiator seat from concurrent task', {
|
|
107
|
+
conversationId: conversation.id,
|
|
108
|
+
winningTaskId: convTask.id,
|
|
109
|
+
initiatorUserId: convInitiator,
|
|
110
|
+
});
|
|
111
|
+
initiatorUserId = convInitiator;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// --- Protocol version: pinned per negotiation, re-stamped per match ---
|
|
116
|
+
// A prior task for this same negotiation (exact continuation resume or
|
|
117
|
+
// a re-run of the same opportunity) pins the version, so one
|
|
118
|
+
// negotiation never flips semantics mid-flight (absent field on a
|
|
119
|
+
// genuine prior = pre-v2 task = v1). Everything else — including
|
|
120
|
+
// continuations of older conversations between the same pair — stamps
|
|
121
|
+
// fresh from NEGOTIATION_PROTOCOL_VERSION, so a version cutover
|
|
122
|
+
// reaches existing pairs on their next new match instead of being
|
|
123
|
+
// pinned to v1 forever by conversation history.
|
|
124
|
+
const protocolVersion = priorTask
|
|
125
|
+
? (readProtocolVersion(priorTask.metadata) ?? 'v1')
|
|
126
|
+
: configuredProtocolVersion();
|
|
127
|
+
const taskMetadata = {
|
|
128
|
+
type: 'negotiation',
|
|
129
|
+
sourceUserId: state.sourceUser.id,
|
|
130
|
+
initiatorUserId,
|
|
131
|
+
protocolVersion,
|
|
132
|
+
candidateUserId: state.candidateUser.id,
|
|
133
|
+
networkId: state.indexContext.networkId,
|
|
134
|
+
sourceIntentId: state.sourceIntentId,
|
|
135
|
+
candidateIntentId: state.candidateIntentId,
|
|
136
|
+
participantBindings: [
|
|
137
|
+
...(state.sourceIntentId ? [{ userId: state.sourceUser.id, intentId: state.sourceIntentId, networkId: state.indexContext.networkId }] : []),
|
|
138
|
+
...(state.candidateIntentId ? [{ userId: state.candidateUser.id, intentId: state.candidateIntentId, networkId: state.indexContext.networkId }] : []),
|
|
139
|
+
],
|
|
140
|
+
intentSnapshots: buildIntentSnapshots(state.sourceUser, state.candidateUser),
|
|
141
|
+
...(state.opportunityId && { opportunityId: state.opportunityId }),
|
|
142
|
+
maxTurns,
|
|
143
|
+
isContinuation,
|
|
144
|
+
priorTurnCount: priorTurns.length,
|
|
145
|
+
...(exactContinuation ? {
|
|
146
|
+
resumeFromTaskId: exactContinuation.taskId,
|
|
147
|
+
continuationSettlementId: exactContinuation.settlementId,
|
|
148
|
+
} : {}),
|
|
149
|
+
};
|
|
150
|
+
if (state.opportunityId && Boolean(state.opportunityStatus) !== Boolean(state.opportunityUpdatedAt)) {
|
|
151
|
+
throw new Error('Negotiation attempt requires both opportunity status and updatedAt');
|
|
152
|
+
}
|
|
153
|
+
const task = exactContinuation
|
|
154
|
+
? claimedSuccessor
|
|
155
|
+
: state.opportunityId && state.opportunityStatus && state.opportunityUpdatedAt
|
|
156
|
+
? await deps.database.createNegotiationTaskForAttempt({
|
|
157
|
+
conversationId: conversation.id,
|
|
158
|
+
opportunityId: state.opportunityId,
|
|
159
|
+
expectedStatus: state.opportunityStatus,
|
|
160
|
+
expectedUpdatedAt: state.opportunityUpdatedAt,
|
|
161
|
+
metadata: taskMetadata,
|
|
162
|
+
})
|
|
163
|
+
: await deps.database.createTask(conversation.id, taskMetadata);
|
|
164
|
+
if (!task) {
|
|
165
|
+
throw new Error('Negotiation attempt is stale or already claimed');
|
|
166
|
+
}
|
|
167
|
+
// Attempt-bound discovery atomically promoted the exact persisted state
|
|
168
|
+
// to `negotiating` while inserting the task. Legacy/direct invocations
|
|
169
|
+
// with only an opportunity ID retain the prior best-effort status update.
|
|
170
|
+
if (state.opportunityId && !state.opportunityUpdatedAt) {
|
|
171
|
+
await deps.database.updateOpportunityStatus(state.opportunityId, 'negotiating').catch((err) => {
|
|
172
|
+
initLog.error('Failed to set opportunity status to negotiating', { opportunityId: state.opportunityId, error: err });
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
// Load user answers collected by the questioner between sessions
|
|
176
|
+
const userAnswers = (isContinuation && state.opportunityId)
|
|
177
|
+
? await deps.database.getOpportunityUserAnswers(state.opportunityId).catch((err) => {
|
|
178
|
+
initLog.error('Failed to load user answers', { opportunityId: state.opportunityId, error: err });
|
|
179
|
+
return [];
|
|
180
|
+
})
|
|
181
|
+
: [];
|
|
182
|
+
// Seed messages with prior turns (additive reducer appends new turns on top).
|
|
183
|
+
// taskId is preserved so the turn/screen nodes can separate this
|
|
184
|
+
// session's turns from seeded prior-task turns (IND-569).
|
|
185
|
+
const seedMessages = isContinuation ? priorMessages.map((m) => ({
|
|
186
|
+
id: m.id,
|
|
187
|
+
senderId: m.senderId,
|
|
188
|
+
role: 'agent',
|
|
189
|
+
parts: m.parts,
|
|
190
|
+
createdAt: m.createdAt,
|
|
191
|
+
taskId: m.taskId ?? null,
|
|
192
|
+
})) : [];
|
|
193
|
+
// IND-569: attribute seeded prior turns to their originating opportunity
|
|
194
|
+
// once, up front. Earlier-opportunity and legacy unattributed blocks are
|
|
195
|
+
// immutable for the session; the current block is composed per turn.
|
|
196
|
+
const priorAttribution = isContinuation
|
|
197
|
+
? await buildSeededAttribution(priorMessages
|
|
198
|
+
.map((m) => ({ taskId: m.taskId ?? null, turn: turnsFromMessages([m])[0] }))
|
|
199
|
+
.filter((e) => Boolean(e.turn)), state.opportunityId, (taskId) => resolveTaskAttribution(deps, taskId))
|
|
200
|
+
: null;
|
|
201
|
+
return {
|
|
202
|
+
conversationId: conversation.id,
|
|
203
|
+
taskId: task.id,
|
|
204
|
+
currentSpeaker,
|
|
205
|
+
turnCount: 0,
|
|
206
|
+
maxTurns,
|
|
207
|
+
isContinuation,
|
|
208
|
+
initiatorUserId,
|
|
209
|
+
protocolVersion,
|
|
210
|
+
priorTurnCount: priorTurns.length,
|
|
211
|
+
...(priorAttribution && { priorAttribution }),
|
|
212
|
+
...(userAnswers.length > 0 && { userAnswers }),
|
|
213
|
+
...(exactContinuation?.execution.consultation
|
|
214
|
+
? { privateConsultation: exactContinuation.execution.consultation }
|
|
215
|
+
: {}),
|
|
216
|
+
...(exactContinuation && (() => {
|
|
217
|
+
const turnContext = priorTask?.metadata?.turnContext;
|
|
218
|
+
const reason = turnContext?.consultationPolicyReason;
|
|
219
|
+
return typeof reason === 'string' ? { consultationPolicyReason: reason } : {};
|
|
220
|
+
})()),
|
|
221
|
+
...(seedMessages.length > 0 && { messages: seedMessages }),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
return { error: `Init failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
226
|
+
}
|
|
227
|
+
}
|