@indexnetwork/protocol 6.15.0-rc.407.1 → 8.0.1-rc.417.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 +302 -0
- package/IMPLEMENTATION.md +7 -4
- package/dist/capabilities/opportunities.facade.d.ts +4 -0
- package/dist/capabilities/opportunities.facade.js +4 -0
- package/dist/chat/chat-streaming.types.d.ts +4 -5
- package/dist/chat/chat.agent.d.ts +0 -19
- package/dist/chat/chat.agent.js +1 -70
- package/dist/chat/chat.persona.d.ts +0 -6
- package/dist/chat/chat.persona.js +0 -1
- package/dist/chat/chat.prompt.js +10 -10
- package/dist/chat/chat.prompt.modules.js +0 -104
- package/dist/chat/chat.tools.js +11 -7
- package/dist/chat/negotiator.persona.js +0 -1
- package/dist/chat/onboarding.persona.js +0 -1
- package/dist/chat/reporter.persona.d.ts +1 -1
- package/dist/chat/reporter.persona.js +1 -2
- package/dist/chat/reporter.prompt.js +5 -5
- package/dist/chat/signal.persona.js +0 -1
- package/dist/communities/application/network.tools.js +48 -27
- package/dist/contacts/application/contact.tools.js +9 -9
- package/dist/index.d.ts +9 -2
- package/dist/index.js +5 -0
- package/dist/maintenance/maintenance.graph.js +7 -0
- package/dist/mcp/mcp.authorization-policy.d.ts +250 -0
- package/dist/mcp/mcp.authorization-policy.js +522 -0
- package/dist/mcp/mcp.server.d.ts +6 -29
- package/dist/mcp/mcp.server.js +202 -228
- package/dist/negotiation/application/negotiation.agent.js +29 -5
- package/dist/negotiation/application/negotiation.graph.d.ts +2 -5
- package/dist/negotiation/application/negotiation.graph.js +53 -23
- package/dist/negotiation/domain/index.d.ts +3 -0
- package/dist/negotiation/domain/index.js +3 -0
- package/dist/negotiation/domain/negotiation.deadlock.d.ts +7 -1
- package/dist/negotiation/domain/negotiation.deadlock.js +48 -1
- package/dist/negotiation/domain/negotiation.stance.contracts.d.ts +85 -0
- package/dist/negotiation/domain/negotiation.stance.contracts.js +140 -0
- package/dist/negotiation/domain/negotiation.state.d.ts +2 -3
- package/dist/negotiation/domain/negotiation.state.js +2 -3
- package/dist/negotiation/public/index.d.ts +2 -0
- package/dist/negotiation/public/index.js +2 -0
- package/dist/opportunity/application/index.d.ts +0 -3
- package/dist/opportunity/application/index.js +0 -3
- package/dist/opportunity/application/opportunity.graph.d.ts +0 -43
- package/dist/opportunity/application/opportunity.graph.js +7 -113
- package/dist/opportunity/application/opportunity.introducer-feature.d.ts +2 -0
- package/dist/opportunity/application/opportunity.introducer-feature.js +4 -0
- package/dist/opportunity/application/opportunity.owner-approval.d.ts +118 -0
- package/dist/opportunity/application/opportunity.owner-approval.js +34 -0
- package/dist/opportunity/application/opportunity.owner-provenance.d.ts +6 -0
- package/dist/opportunity/application/opportunity.owner-provenance.js +28 -0
- package/dist/opportunity/application/opportunity.tools.d.ts +3 -3
- package/dist/opportunity/application/opportunity.tools.js +77 -870
- package/dist/opportunity/discriminator/discriminator.shadow.js +17 -10
- package/dist/opportunity/discriminator/discriminator.types.d.ts +2 -0
- package/dist/opportunity/domain/index.d.ts +0 -2
- package/dist/opportunity/domain/index.js +0 -2
- package/dist/opportunity/domain/opportunity.state.d.ts +2 -70
- package/dist/opportunity/domain/opportunity.state.js +2 -55
- package/dist/opportunity/opportunity.owner-approval.d.ts +5 -0
- package/dist/opportunity/opportunity.owner-approval.js +5 -0
- package/dist/opportunity/ports/opportunity.tools.port.d.ts +12 -1
- package/dist/opportunity/public/index.d.ts +3 -3
- package/dist/opportunity/public/index.js +2 -1
- package/dist/participant-agents/application/agent.tools.d.ts +1 -1
- package/dist/participant-agents/application/agent.tools.js +44 -6
- package/dist/participant-agents/application/index.d.ts +5 -3
- package/dist/participant-agents/application/index.js +5 -3
- package/dist/questions/application/question.tools.js +31 -2
- package/dist/runtime/foreground/composition/tool.factory.d.ts +2 -1
- package/dist/runtime/foreground/composition/tool.factory.js +9 -2
- package/dist/runtime/foreground/composition/tool.registry.d.ts +16 -2
- package/dist/runtime/foreground/composition/tool.registry.js +41 -38
- package/dist/shared/agent/activity-projection.d.ts +271 -0
- package/dist/shared/agent/activity-projection.js +248 -0
- package/dist/shared/agent/canonical-guidance.d.ts +30 -0
- package/dist/shared/agent/canonical-guidance.js +249 -0
- package/dist/shared/agent/tool.helpers.d.ts +9 -17
- package/dist/shared/agent/tool.runtime.js +1 -3
- package/dist/shared/agent/utility.tools.d.ts +11 -1
- package/dist/shared/agent/utility.tools.js +126 -90
- package/dist/shared/interfaces/auth.interface.d.ts +2 -8
- package/dist/shared/interfaces/chat-session.interface.js +5 -0
- package/dist/shared/interfaces/database.interface.d.ts +12 -5
- package/dist/shared/observability/request-context.d.ts +4 -40
- package/dist/shared/schemas/mcp-auth.schema.d.ts +87 -15
- package/dist/shared/schemas/mcp-auth.schema.js +49 -1
- package/dist/signals/application/intent.tools.js +7 -8
- package/package.json +10 -9
- package/dist/opportunity/application/opportunity.discover.d.ts +0 -215
- package/dist/opportunity/application/opportunity.discover.js +0 -793
- package/dist/opportunity/application/opportunity.discovery-continuation-finalization.d.ts +0 -34
- package/dist/opportunity/application/opportunity.discovery-continuation-finalization.js +0 -74
- package/dist/opportunity/application/opportunity.discovery-mcp-lifecycle-finalization.d.ts +0 -44
- package/dist/opportunity/application/opportunity.discovery-mcp-lifecycle-finalization.js +0 -92
- package/dist/opportunity/domain/opportunity.discovery-run-coalescing.d.ts +0 -33
- package/dist/opportunity/domain/opportunity.discovery-run-coalescing.js +0 -33
- package/dist/opportunity/domain/opportunity.discovery.contracts.d.ts +0 -45
- package/dist/opportunity/domain/opportunity.discovery.contracts.js +0 -1
- package/dist/opportunity/opportunity.discover.d.ts +0 -5
- package/dist/opportunity/opportunity.discover.js +0 -5
- package/dist/opportunity/opportunity.discovery-continuation-finalization.d.ts +0 -5
- package/dist/opportunity/opportunity.discovery-continuation-finalization.js +0 -5
- package/dist/opportunity/opportunity.discovery-mcp-lifecycle-finalization.d.ts +0 -5
- package/dist/opportunity/opportunity.discovery-mcp-lifecycle-finalization.js +0 -5
- package/dist/opportunity/opportunity.discovery-run-coalescing.d.ts +0 -5
- package/dist/opportunity/opportunity.discovery-run-coalescing.js +0 -5
- package/dist/opportunity/opportunity.discovery.contracts.d.ts +0 -5
- package/dist/opportunity/opportunity.discovery.contracts.js +0 -5
- package/dist/shared/interfaces/discovery-run.interface.d.ts +0 -70
- package/dist/shared/interfaces/discovery-run.interface.js +0 -1
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { requestContext } from "../../shared/observability/request-context.js";
|
|
3
3
|
import { success, error, UUID_REGEX } from "../../shared/agent/tool.helpers.js";
|
|
4
|
-
import {
|
|
4
|
+
import { focusedIntentId, focusedNetworkId, focusedNetworkLabel } from "../../shared/agent/tool.scope.js";
|
|
5
5
|
import { MINIMAL_MAIN_TEXT_MAX_CHARS, getPrimaryActionLabel, SECONDARY_ACTION_LABEL } from "../domain/opportunity.labels.js";
|
|
6
6
|
import { narratorRemarkFromReasoning, stripUuids } from "../domain/opportunity.presentation.js";
|
|
7
|
-
import { safeFallbackSummary
|
|
7
|
+
import { safeFallbackSummary } from "../domain/opportunity.safe-presentation.js";
|
|
8
8
|
import { buildOpportunityPresentation } from "./opportunity.card-presentation.js";
|
|
9
|
-
import {
|
|
10
|
-
import { finalizeMcpDiscoveryLifecycle } from './opportunity.discovery-mcp-lifecycle-finalization.js';
|
|
11
|
-
import { runDiscoverFromQuery, continueDiscovery } from "./opportunity.discover.js";
|
|
12
|
-
import { isDiscoveryQuestionsEnabled, isUptakeGuardEnabled } from "../../capabilities/questions.runtime.facade.js";
|
|
9
|
+
import { isUptakeGuardEnabled } from "../../capabilities/questions.runtime.facade.js";
|
|
13
10
|
import { OpportunityPresenter, gatherPresenterContext } from "./opportunity.presenter.js";
|
|
14
11
|
import { loadNegotiationContext } from "./negotiation-context.loader.js";
|
|
15
12
|
import { admitOpportunityUpdate } from './opportunity.update-admission.js';
|
|
13
|
+
import { opportunityOwnerActionForStatus } from './opportunity.owner-approval.js';
|
|
14
|
+
import { ownerApprovalProvenanceFor } from './opportunity.owner-provenance.js';
|
|
16
15
|
import { selectOpportunityFeed } from './opportunity.feed-selection.js';
|
|
17
16
|
export { buildOpportunityPresentation } from "./opportunity.card-presentation.js";
|
|
18
17
|
function stripLeadingNarratorName(remark, narratorName) {
|
|
@@ -40,10 +39,8 @@ function stripLeadingNarratorName(remark, narratorName) {
|
|
|
40
39
|
return t;
|
|
41
40
|
}
|
|
42
41
|
import { protocolLogger } from "../../shared/observability/protocol.logger.js";
|
|
43
|
-
import { mergePendingQuestions } from "./opportunity.pending-questions.js";
|
|
44
42
|
import { invokeWithAbortSignal } from "../../shared/agent/model-signal.js";
|
|
45
43
|
const logger = protocolLogger("ChatTools:Opportunity");
|
|
46
|
-
const discoverOpportunitiesLog = protocolLogger("ChatTools:Opportunity:discover_opportunities");
|
|
47
44
|
/**
|
|
48
45
|
* Pure status × role → ConnectLinkKind matrix.
|
|
49
46
|
*
|
|
@@ -54,14 +51,14 @@ const discoverOpportunitiesLog = protocolLogger("ChatTools:Opportunity:discover_
|
|
|
54
51
|
* flips the opp to accepted and opens the chat with the counterpart.
|
|
55
52
|
* - `approve_introduction` — draft or latent opp where viewer is an unapproved
|
|
56
53
|
* introducer. Clicking flips approved=true and triggers negotiation. The
|
|
57
|
-
* `draft` case
|
|
54
|
+
* `draft` case represents a pending introduction; the `latent`
|
|
58
55
|
* case comes from background-discovered connector-flow cards surfaced in
|
|
59
56
|
* `list_opportunities`. In both, status remains pre-send and the `/c/<code>`
|
|
60
57
|
* link is the only MCP path to approve.
|
|
61
58
|
* - `outreach` — accepted opp where viewer is a non-introducer party.
|
|
62
59
|
* Clicking opens the existing chat (no state change).
|
|
63
60
|
* - `send_direct` — draft or latent opp where viewer is a non-introducer
|
|
64
|
-
* party. Issued
|
|
61
|
+
* party. Issued for a direct opportunity without an introducer
|
|
65
62
|
* mode: the match has already passed evaluation, the row exists in
|
|
66
63
|
* draft state, and the sender just needs to release it. Clicking flips
|
|
67
64
|
* the opp straight to accepted and opens the chat with a greeting —
|
|
@@ -135,7 +132,7 @@ export async function attachActionableLinks(card, opts) {
|
|
|
135
132
|
// is actionable — every counterpart has a profile page worth linking to,
|
|
136
133
|
// even on a fresh draft where there is no acceptUrl yet. Setting it before
|
|
137
134
|
// the early-return below means cards from non-actionable combinations
|
|
138
|
-
// (e.g. draft
|
|
135
|
+
// (e.g. a draft party card) still carry
|
|
139
136
|
// the profile link the agent needs to render. Without this, the agent gets
|
|
140
137
|
// a name with no URL attached and tends to fabricate one.
|
|
141
138
|
const profileUrl = buildProfileUrl(opts.counterpartUserId, opts.frontendUrl);
|
|
@@ -198,6 +195,26 @@ function uptakeAdvisory(opportunityId, questions) {
|
|
|
198
195
|
},
|
|
199
196
|
});
|
|
200
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* IND-593: stable fail-closed denial for the owner-approval boundary. The
|
|
200
|
+
* `missing` reason carries the fresh, server-derived interaction challenge the
|
|
201
|
+
* owner must explicitly approve; all other reasons carry no challenge.
|
|
202
|
+
*/
|
|
203
|
+
function ownerApprovalDenial(opportunityId, action, verdict) {
|
|
204
|
+
return JSON.stringify({
|
|
205
|
+
success: false,
|
|
206
|
+
error: `Owner approval required for this opportunity ${action} (${verdict.reason}).`,
|
|
207
|
+
approval: {
|
|
208
|
+
code: "owner_approval_required",
|
|
209
|
+
reason: verdict.reason,
|
|
210
|
+
opportunityId,
|
|
211
|
+
action,
|
|
212
|
+
...(verdict.challenge
|
|
213
|
+
? { interactionId: verdict.challenge.interactionId, expiresAt: verdict.challenge.expiresAt }
|
|
214
|
+
: {}),
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
}
|
|
201
218
|
/**
|
|
202
219
|
* Maximum number of opportunity cards to show per chat response.
|
|
203
220
|
* Sized for `selectByComposition` to fill both feed buckets — up to 3
|
|
@@ -280,861 +297,10 @@ function confirmDeliveryError(code, retryable, message) {
|
|
|
280
297
|
}
|
|
281
298
|
export function createOpportunityTools(defineTool, deps) {
|
|
282
299
|
const { database, userDb, systemDb, graphs, cache } = deps;
|
|
283
|
-
const runDiscoveryFromQuery = deps.opportunityDiscovery?.runDiscoverFromQuery ??
|
|
284
|
-
runDiscoverFromQuery;
|
|
285
|
-
const continueOpportunityDiscovery = deps.opportunityDiscovery?.continueDiscovery ??
|
|
286
|
-
continueDiscovery;
|
|
287
300
|
const createOpportunityPresenter = deps.opportunityPresentation?.createPresenter ??
|
|
288
301
|
(() => new OpportunityPresenter());
|
|
289
302
|
const gatherOpportunityPresenterContext = deps.opportunityPresentation?.gatherPresenterContext ??
|
|
290
303
|
gatherPresenterContext;
|
|
291
|
-
const discoverOpportunities = defineTool({
|
|
292
|
-
name: "discover_opportunities",
|
|
293
|
-
description: "Discovers opportunities — connections between users based on complementary intents — and persists them as drafts. " +
|
|
294
|
-
"Opportunities are the core output of the discovery engine, representing potential valuable connections between people.\n\n" +
|
|
295
|
-
"**NOT for person lookup** — use read_user_contexts(query=name) to find people by name.\n\n" +
|
|
296
|
-
"**Four modes:**\n" +
|
|
297
|
-
"1. **Discovery** (most common): pass `searchQuery` and/or `networkId`. The system finds other users in shared networkes " +
|
|
298
|
-
"whose intents semantically complement the query. Uses HyDE embeddings and LLM evaluation for scoring.\n" +
|
|
299
|
-
"2. **Introduction**: pass `partyUserIds` (2+ user IDs) + `entities` (pre-gathered profiles and intents from shared networkes). " +
|
|
300
|
-
"You MUST call read_user_contexts and read_intents for each party BEFORE calling this. " +
|
|
301
|
-
"Optionally pass `hint` with the user's reason for the introduction.\n" +
|
|
302
|
-
"3. **Direct connection**: pass `targetUserId` + `searchQuery`. Creates an opportunity between the current user and one specific person.\n" +
|
|
303
|
-
"4. **Introducer discovery**: pass `introTargetUserId` (find matches FOR that person; current user becomes the introducer). " +
|
|
304
|
-
"Use when user asks 'who should I introduce to [person]?'\n\n" +
|
|
305
|
-
"**Returns:** In regular chat, opportunity code blocks (render as interactive cards) with opportunityId, match reasoning, confidence score, and status. " +
|
|
306
|
-
"In MCP contexts, starts an async discovery run and returns `discoveryRunId` with status `queued`. " +
|
|
307
|
-
"Then poll get_discovery_run with that id roughly every 5 seconds until status is `succeeded`, `failed`, or `cancelled`, and present its `result`. " +
|
|
308
|
-
"Do NOT call discover_opportunities again for the same request while a run is in progress — a repeat call with the same parameters " +
|
|
309
|
-
"returns the SAME in-progress run (with `coalesced: true`), not a new one. Keep polling the run id instead of starting new runs. " +
|
|
310
|
-
"All results start as drafts. Supports pagination via `continueFrom` for large result sets.\n\n" +
|
|
311
|
-
"**Next steps:** Use update_opportunity(opportunityId, status='pending') to send a draft to the other party.\n\n" +
|
|
312
|
-
"**Discovery-first rule.** For open-ended connection-seeking requests (\"find me a mentor\", " +
|
|
313
|
-
"\"who needs a React dev\", \"looking for investors\"), call this tool with `searchQuery` FIRST. " +
|
|
314
|
-
"Do NOT call create_intent for these phrasings — create_intent is only for when the user explicitly " +
|
|
315
|
-
"asks to \"create\", \"save\", \"add\", or \"remember\" a signal.\n\n" +
|
|
316
|
-
"**Personal-index scoping.** When the user says \"in my network\", \"from my contacts\", \"people I know\", " +
|
|
317
|
-
"or similar scoping language, pass the user's personal network ID (from memberships where `isPersonal: true`) " +
|
|
318
|
-
"as `networkId`. The personal network contains the user's contacts — scoping discovery to it restricts " +
|
|
319
|
-
"results to people the user already knows. Without this scoping language, omit networkId to let discovery " +
|
|
320
|
-
"run across all networks.\n\n" +
|
|
321
|
-
"**Introduction mode prerequisites.** When using `partyUserIds` + `entities`, YOU must pre-fetch each party's " +
|
|
322
|
-
"profile and intents before calling this tool. The entities array must include each party's userId, profile, " +
|
|
323
|
-
"intents from shared networkes, and the shared networkId. Call read_user_contexts, read_network_memberships, " +
|
|
324
|
-
"and read_intents for both parties first. The introducer (current user) must NOT appear in entities.\n\n" +
|
|
325
|
-
"**Signal-visibility follow-up.** If the response includes `suggestIntentCreationForVisibility: true` and " +
|
|
326
|
-
"`suggestedIntentDescription`, after presenting opportunity cards ask the user ONCE whether they'd also like " +
|
|
327
|
-
"to create a signal so others can find them. On yes, call create_intent with the suggested description. " +
|
|
328
|
-
"Never suggest this after introducer-mode (`introTargetUserId`) calls — the query describes the other person's " +
|
|
329
|
-
"needs, not the signed-in user's.",
|
|
330
|
-
querySchema: z.object({
|
|
331
|
-
continueFrom: z
|
|
332
|
-
.string()
|
|
333
|
-
.optional()
|
|
334
|
-
.describe("Pagination token: pass the discoveryId from a previous discover_opportunities result to evaluate the next batch of candidates. Do not combine with searchQuery or other mode parameters — when a fresh searchQuery is also present, the server ignores continueFrom and runs a fresh discovery."),
|
|
335
|
-
searchQuery: z
|
|
336
|
-
.string()
|
|
337
|
-
.optional()
|
|
338
|
-
.describe("Discovery mode: natural language description of what to look for (e.g. 'AI/ML engineers', 'startup advisors in fintech'). Drives semantic matching against other users' intents and profiles."),
|
|
339
|
-
networkId: z
|
|
340
|
-
.string()
|
|
341
|
-
.optional()
|
|
342
|
-
.describe("Network UUID to scope discovery to a specific community. Get from read_networks. In an network-scoped chat, omitting this runs discovery only in the scoped community; pass the personal network ID (from read_networks, isPersonal=true) only when the user explicitly asks to discover among contacts."),
|
|
343
|
-
intentId: z
|
|
344
|
-
.string()
|
|
345
|
-
.optional()
|
|
346
|
-
.describe("Optional intent UUID to use as the discovery source. The intent's description drives matching instead of searchQuery. Get from read_intents. Typically used by background processing, not direct agent calls."),
|
|
347
|
-
targetUserId: z
|
|
348
|
-
.string()
|
|
349
|
-
.optional()
|
|
350
|
-
.describe("Direct connection mode: create an opportunity with this specific user. Get the userId from read_user_contexts(query=name). Combine with searchQuery to explain the connection reason."),
|
|
351
|
-
introTargetUserId: z
|
|
352
|
-
.string()
|
|
353
|
-
.optional()
|
|
354
|
-
.describe("Introducer discovery mode: find matches FOR this user ID (the current user becomes the introducer). " +
|
|
355
|
-
"Get the userId from read_user_contexts(query=name). " +
|
|
356
|
-
"Use when the user asks 'who should I introduce to [person]?'. " +
|
|
357
|
-
"Do NOT combine with partyUserIds (that's full introduction mode)."),
|
|
358
|
-
partyUserIds: z
|
|
359
|
-
.array(z.string())
|
|
360
|
-
.optional()
|
|
361
|
-
.describe("Introduction mode: array of 2+ user IDs to introduce to each other. Get user IDs from read_user_contexts or read_network_memberships. Must also provide entities with pre-gathered profile/intent data."),
|
|
362
|
-
entities: z
|
|
363
|
-
.array(z.object({
|
|
364
|
-
userId: z.string(),
|
|
365
|
-
profile: z
|
|
366
|
-
.object({
|
|
367
|
-
name: z.string().optional(),
|
|
368
|
-
bio: z.string().optional(),
|
|
369
|
-
location: z.string().optional(),
|
|
370
|
-
interests: z.array(z.string()).optional(),
|
|
371
|
-
skills: z.array(z.string()).optional(),
|
|
372
|
-
context: z.string().optional(),
|
|
373
|
-
})
|
|
374
|
-
.optional(),
|
|
375
|
-
intents: z
|
|
376
|
-
.array(z.object({
|
|
377
|
-
intentId: z.string(),
|
|
378
|
-
payload: z.string(),
|
|
379
|
-
summary: z.string().optional(),
|
|
380
|
-
}))
|
|
381
|
-
.optional(),
|
|
382
|
-
networkId: z
|
|
383
|
-
.string()
|
|
384
|
-
.describe("Shared network this entity's data comes from (required for intro mode)"),
|
|
385
|
-
}))
|
|
386
|
-
.optional()
|
|
387
|
-
.describe("Introduction mode: pre-gathered profile and intent data for each party being introduced. " +
|
|
388
|
-
"Each entry needs userId, networkId (the shared network), and optionally profile (name, bio, skills, interests) and intents (intentId, payload). " +
|
|
389
|
-
"Gather this data by calling read_user_contexts and read_intents for each party BEFORE calling discover_opportunities. " +
|
|
390
|
-
"All entities must share the same networkId (the shared network where both parties are members)."),
|
|
391
|
-
hint: z
|
|
392
|
-
.string()
|
|
393
|
-
.optional()
|
|
394
|
-
.describe("Introduction mode: the user's reason for making this introduction (e.g. 'both working on AI in healthcare', " +
|
|
395
|
-
"'complementary skills for a startup'). Helps the evaluator produce better match reasoning."),
|
|
396
|
-
}),
|
|
397
|
-
handler: async ({ context, query }) => {
|
|
398
|
-
const scopedNetworkId = focusedNetworkId(context) ?? context.networkId?.trim();
|
|
399
|
-
const scopedIndexLabel = focusedNetworkLabel(context);
|
|
400
|
-
// Strict scope enforcement: when chat is network-scoped, only allow that index
|
|
401
|
-
if (scopedNetworkId &&
|
|
402
|
-
query.networkId?.trim() &&
|
|
403
|
-
query.networkId.trim() !== scopedNetworkId) {
|
|
404
|
-
return error(`This chat is scoped to ${scopedIndexLabel}. You can only create opportunities in this community.`);
|
|
405
|
-
}
|
|
406
|
-
// Distinguish an explicit `query.networkId` override (caller wants discovery
|
|
407
|
-
// scoped to one specific index) from an implicit scoped-chat context.
|
|
408
|
-
// Scoped-chat discovery stays focused to the scoped community only; the
|
|
409
|
-
// personal-inclusive allowed reach is reserved for self-owned writes.
|
|
410
|
-
const explicitIndexId = query.networkId?.trim() || undefined;
|
|
411
|
-
const effectiveIndexId = explicitIndexId;
|
|
412
|
-
if (effectiveIndexId && !UUID_REGEX.test(effectiveIndexId)) {
|
|
413
|
-
return error("Invalid network ID format.");
|
|
414
|
-
}
|
|
415
|
-
const contextIntentId = focusedIntentId(context);
|
|
416
|
-
const requestedIntentId = query.intentId?.trim() || undefined;
|
|
417
|
-
if (requestedIntentId != null && !UUID_REGEX.test(requestedIntentId)) {
|
|
418
|
-
return error("Invalid intent ID format.");
|
|
419
|
-
}
|
|
420
|
-
if (contextIntentId && requestedIntentId && requestedIntentId !== contextIntentId) {
|
|
421
|
-
return error("This chat is scoped to a different intent.");
|
|
422
|
-
}
|
|
423
|
-
const triggerIntentId = contextIntentId ?? requestedIntentId;
|
|
424
|
-
if (triggerIntentId) {
|
|
425
|
-
const triggerIntent = await systemDb.getIntent(triggerIntentId);
|
|
426
|
-
if (!triggerIntent || triggerIntent.userId !== context.userId) {
|
|
427
|
-
return error("Intent not found or you are not authorized to use it for discovery.");
|
|
428
|
-
}
|
|
429
|
-
if (triggerIntent.archivedAt ||
|
|
430
|
-
(triggerIntent.status != null && triggerIntent.status !== 'ACTIVE')) {
|
|
431
|
-
return error("This intent is not active. Resume it before starting discovery.");
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
if (context.isMcp && deps.discoveryRuns && deps.discoveryRunQueue) {
|
|
435
|
-
// Coalesce: if an equivalent discovery is already queued/running for this
|
|
436
|
-
// user, return that run instead of spawning a duplicate. An over-eager
|
|
437
|
-
// MCP client that re-fires discover_opportunities (instead of polling
|
|
438
|
-
// get_discovery_run) would otherwise kick off a fresh, expensive
|
|
439
|
-
// opportunity-graph run on every call — the loop that drives the agent
|
|
440
|
-
// into provider rate limits.
|
|
441
|
-
try {
|
|
442
|
-
const active = await deps.discoveryRuns.listActive(context.userId);
|
|
443
|
-
const existing = findCoalescedDiscoveryRun(query, context, active);
|
|
444
|
-
if (existing) {
|
|
445
|
-
return success({
|
|
446
|
-
status: existing.status === "running" ? "running" : "queued",
|
|
447
|
-
discoveryRunId: existing.id,
|
|
448
|
-
coalesced: true,
|
|
449
|
-
message: `A discovery run for this exact request is already ${existing.status}. ` +
|
|
450
|
-
`Do NOT call discover_opportunities again — keep calling get_discovery_run with ` +
|
|
451
|
-
`discoveryRunId="${existing.id}" (about every 5 seconds) until it succeeds, fails, ` +
|
|
452
|
-
`or is cancelled, then present its result.`,
|
|
453
|
-
});
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
catch {
|
|
457
|
-
// listActive is a best-effort optimization; fall through to create on error.
|
|
458
|
-
}
|
|
459
|
-
const run = await deps.discoveryRuns.create({
|
|
460
|
-
userId: context.userId,
|
|
461
|
-
agentId: context.agentId ?? null,
|
|
462
|
-
input: query,
|
|
463
|
-
context: {
|
|
464
|
-
userId: context.userId,
|
|
465
|
-
userName: context.userName,
|
|
466
|
-
userEmail: context.userEmail,
|
|
467
|
-
...(context.scopeType && context.scopeId ? { scopeType: context.scopeType, scopeId: context.scopeId } : scopedNetworkId ? { scopeType: 'network', scopeId: scopedNetworkId } : {}),
|
|
468
|
-
...(context.indexName ? { indexName: context.indexName } : {}),
|
|
469
|
-
...(context.sessionId ? { sessionId: context.sessionId } : {}),
|
|
470
|
-
...(context.agentId ? { agentId: context.agentId } : {}),
|
|
471
|
-
...(context.clientSurface ? { clientSurface: context.clientSurface } : {}),
|
|
472
|
-
},
|
|
473
|
-
});
|
|
474
|
-
try {
|
|
475
|
-
await deps.discoveryRunQueue.enqueue(run.id);
|
|
476
|
-
}
|
|
477
|
-
catch (err) {
|
|
478
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
479
|
-
await deps.discoveryRuns.markFailed(run.id, message);
|
|
480
|
-
return error(`Failed to enqueue discovery run: ${message}`);
|
|
481
|
-
}
|
|
482
|
-
return success({
|
|
483
|
-
status: "queued",
|
|
484
|
-
discoveryRunId: run.id,
|
|
485
|
-
message: `Discovery started. Poll get_discovery_run with discoveryRunId="${run.id}" about every 5 seconds ` +
|
|
486
|
-
`until status is succeeded, failed, or cancelled, then present its result. ` +
|
|
487
|
-
`Do NOT call discover_opportunities again for this request while the run is in progress — ` +
|
|
488
|
-
`a repeat call returns this same run, not a new one.`,
|
|
489
|
-
});
|
|
490
|
-
}
|
|
491
|
-
// ── Continuation mode ──
|
|
492
|
-
// `continueFrom` is a pagination token for resuming a prior discovery's
|
|
493
|
-
// cached candidates. When a caller (typically an MCP client's LLM) sends
|
|
494
|
-
// a fresh `searchQuery` alongside a stale `continueFrom`, treat it as a
|
|
495
|
-
// fresh search — the explicit search intent wins. Resuming against the
|
|
496
|
-
// stale session's exhausted cache silently produced the "No more
|
|
497
|
-
// matching opportunities found in the remaining candidates" response
|
|
498
|
-
// for users who expected fresh results (IND-305).
|
|
499
|
-
if (query.continueFrom && query.searchQuery?.trim()) {
|
|
500
|
-
logger.warn("discover_opportunities: dropping stale continueFrom in favor of fresh searchQuery", {
|
|
501
|
-
userId: context.userId,
|
|
502
|
-
continueFrom: query.continueFrom,
|
|
503
|
-
});
|
|
504
|
-
}
|
|
505
|
-
if (query.continueFrom && !query.searchQuery?.trim()) {
|
|
506
|
-
const _continueTraceEmitter = requestContext.getStore()?.traceEmitter;
|
|
507
|
-
const _graphStart = Date.now();
|
|
508
|
-
_continueTraceEmitter?.({ type: "graph_start", name: "opportunity" });
|
|
509
|
-
const result = await continueOpportunityDiscovery({
|
|
510
|
-
opportunityGraph: graphs.opportunity,
|
|
511
|
-
database,
|
|
512
|
-
cache,
|
|
513
|
-
userId: context.userId,
|
|
514
|
-
discoveryId: query.continueFrom,
|
|
515
|
-
expectedIndexId: scopedNetworkId,
|
|
516
|
-
limit: 20,
|
|
517
|
-
presenter: createOpportunityPresenter(),
|
|
518
|
-
useHomeCardFormat: true,
|
|
519
|
-
...(context.sessionId ? { chatSessionId: context.sessionId } : {}),
|
|
520
|
-
});
|
|
521
|
-
const _graphMs = Date.now() - _graphStart;
|
|
522
|
-
_continueTraceEmitter?.({ type: "graph_end", name: "opportunity", durationMs: _graphMs });
|
|
523
|
-
const allDebugSteps = [...(result.debugSteps ?? [])];
|
|
524
|
-
if (!result.found) {
|
|
525
|
-
return success({
|
|
526
|
-
found: false,
|
|
527
|
-
count: 0,
|
|
528
|
-
message: result.message ?? "No more matching opportunities found in the remaining candidates.",
|
|
529
|
-
summary: "No more matches found",
|
|
530
|
-
...(result.pagination ? { pagination: result.pagination } : {}),
|
|
531
|
-
debugSteps: allDebugSteps,
|
|
532
|
-
_graphTimings: [{ name: 'opportunity', durationMs: _graphMs, agents: [] }],
|
|
533
|
-
});
|
|
534
|
-
}
|
|
535
|
-
// Continuation now negotiates just like fresh discovery. For MCP callers,
|
|
536
|
-
// surface only post-negotiation drafts; keep in-flight attempts count-only
|
|
537
|
-
// and never render rejected/stalled or fallback latent rows as cards.
|
|
538
|
-
let negotiatingCount = 0;
|
|
539
|
-
const continuationCards = context.isMcp
|
|
540
|
-
? (result.opportunities ?? []).filter((opportunity) => {
|
|
541
|
-
if (opportunity.status === 'draft')
|
|
542
|
-
return true;
|
|
543
|
-
if (opportunity.status === 'negotiating') {
|
|
544
|
-
negotiatingCount += 1;
|
|
545
|
-
return false;
|
|
546
|
-
}
|
|
547
|
-
if (opportunity.status === 'rejected' || opportunity.status === 'stalled' || opportunity.status === 'latent') {
|
|
548
|
-
return false;
|
|
549
|
-
}
|
|
550
|
-
discoverOpportunitiesLog.warn('unexpected continuation status — counting as negotiating', {
|
|
551
|
-
opportunityId: opportunity.opportunityId,
|
|
552
|
-
status: opportunity.status,
|
|
553
|
-
});
|
|
554
|
-
negotiatingCount += 1;
|
|
555
|
-
return false;
|
|
556
|
-
})
|
|
557
|
-
: (result.opportunities ?? []);
|
|
558
|
-
// Build card data; cap at CHAT_DISPLAY_LIMIT (remaining feeds into pagination)
|
|
559
|
-
const allCardData = continuationCards.map((opp) => ({
|
|
560
|
-
opportunityId: opp.opportunityId,
|
|
561
|
-
userId: opp.userId,
|
|
562
|
-
name: opp.name,
|
|
563
|
-
avatar: opp.avatar,
|
|
564
|
-
mainText: getSafePresentationOrSkip(opp, { counterpartName: opp.name })?.summary ?? "",
|
|
565
|
-
cta: opp.homeCardPresentation?.suggestedAction,
|
|
566
|
-
headline: opp.homeCardPresentation?.headline,
|
|
567
|
-
primaryActionLabel: opp.homeCardPresentation?.primaryActionLabel,
|
|
568
|
-
secondaryActionLabel: opp.homeCardPresentation?.secondaryActionLabel,
|
|
569
|
-
mutualIntentsLabel: opp.homeCardPresentation?.mutualIntentsLabel,
|
|
570
|
-
narratorChip: opp.narratorChip,
|
|
571
|
-
viewerRole: opp.viewerRole,
|
|
572
|
-
isGhost: opp.isGhost ?? false,
|
|
573
|
-
score: opp.score,
|
|
574
|
-
status: opp.status,
|
|
575
|
-
viewerActedAt: opp.viewerActedAt,
|
|
576
|
-
}));
|
|
577
|
-
const displayedCards = allCardData.slice(0, CHAT_DISPLAY_LIMIT);
|
|
578
|
-
const extraFromCap = allCardData.length - displayedCards.length;
|
|
579
|
-
let message = buildOpportunityPresentation(displayedCards, {
|
|
580
|
-
isMcp: context.isMcp ?? false,
|
|
581
|
-
leadIn: `Found ${displayedCards.length} more potential connection(s).`,
|
|
582
|
-
});
|
|
583
|
-
if (context.isMcp && negotiatingCount > 0) {
|
|
584
|
-
message = displayedCards.length > 0
|
|
585
|
-
? `${message}\n\n${negotiatingCount} more opportunit${negotiatingCount === 1 ? 'y is' : 'ies are'} still being evaluated — check back via \`list_opportunities\` shortly.`
|
|
586
|
-
: `Found candidates, but they're still being evaluated. Try \`list_opportunities\` in a minute — ${negotiatingCount} pending.`;
|
|
587
|
-
}
|
|
588
|
-
else if (context.isMcp && displayedCards.length === 0) {
|
|
589
|
-
message = 'No additional actionable matches were found in this continuation.';
|
|
590
|
-
}
|
|
591
|
-
const isIntroducerContinuation = !!query.introTargetUserId?.trim();
|
|
592
|
-
const totalRemaining = (result.pagination?.remaining ?? 0) + extraFromCap;
|
|
593
|
-
if (totalRemaining > 0 && result.pagination?.discoveryId) {
|
|
594
|
-
message += `\n\nThere are ${totalRemaining} more candidates. Ask if the user wants to see more — they can say "show me more" and you should call discover_opportunities with continueFrom="${result.pagination.discoveryId}".`;
|
|
595
|
-
}
|
|
596
|
-
else if (displayedCards.length > 0 && isIntroducerContinuation) {
|
|
597
|
-
message += `\n\nThese are all the introduction candidates I found for this person.`;
|
|
598
|
-
}
|
|
599
|
-
else if (displayedCards.length > 0) {
|
|
600
|
-
message += `\n\nThese are all the connections I found. If the user wants to attract more connections, suggest they create a signal — e.g. "Would you like to create a signal so others looking for someone like you can find you?" If they agree, call create_intent with a description based on what they were searching for.`;
|
|
601
|
-
}
|
|
602
|
-
return success({
|
|
603
|
-
found: true,
|
|
604
|
-
count: displayedCards.length,
|
|
605
|
-
message,
|
|
606
|
-
summary: `Found ${displayedCards.length} more match(es)`,
|
|
607
|
-
...(result.pagination ? { pagination: result.pagination } : {}),
|
|
608
|
-
debugSteps: allDebugSteps,
|
|
609
|
-
_graphTimings: [{ name: 'opportunity', durationMs: _graphMs, agents: [] }],
|
|
610
|
-
});
|
|
611
|
-
}
|
|
612
|
-
// Normalize entity networkIds before any checks to avoid raw-vs-trimmed mismatches.
|
|
613
|
-
const normalizedEntities = query.entities?.map((e) => ({ ...e, networkId: e.networkId?.trim() }));
|
|
614
|
-
// Derive partyUserIds from entities when agent passes entities but omits partyUserIds (intro mode).
|
|
615
|
-
// Only derive when all entities share the same networkId to prevent cross-network introductions.
|
|
616
|
-
const partyUserIdsFromEntities = normalizedEntities &&
|
|
617
|
-
normalizedEntities.length >= 2 &&
|
|
618
|
-
normalizedEntities.every((e) => e.userId && e.networkId) &&
|
|
619
|
-
new Set(normalizedEntities.map((e) => e.networkId)).size === 1
|
|
620
|
-
? [...new Set(normalizedEntities.map((e) => e.userId))]
|
|
621
|
-
: undefined;
|
|
622
|
-
const effectivePartyUserIds = query.partyUserIds && query.partyUserIds.length >= 2
|
|
623
|
-
? query.partyUserIds
|
|
624
|
-
: (partyUserIdsFromEntities?.length ?? 0) >= 2
|
|
625
|
-
? partyUserIdsFromEntities
|
|
626
|
-
: undefined;
|
|
627
|
-
// ── Introduction mode ── (validation and persistence via opportunity graph)
|
|
628
|
-
if (effectivePartyUserIds && effectivePartyUserIds.length >= 2) {
|
|
629
|
-
if (!normalizedEntities || normalizedEntities.length === 0) {
|
|
630
|
-
return error("Introduction requires pre-gathered entity data. " +
|
|
631
|
-
"First use read_network_memberships to find shared networks, " +
|
|
632
|
-
"then read_user_contexts and read_intents for each party, " +
|
|
633
|
-
"then pass the results as entities.");
|
|
634
|
-
}
|
|
635
|
-
const normalizedEntityNetworkIds = normalizedEntities
|
|
636
|
-
.map((e) => e.networkId)
|
|
637
|
-
.filter((id) => Boolean(id));
|
|
638
|
-
if (normalizedEntityNetworkIds.length !== normalizedEntities.length ||
|
|
639
|
-
new Set(normalizedEntityNetworkIds).size !== 1) {
|
|
640
|
-
return error("All entities must include the same shared networkId.");
|
|
641
|
-
}
|
|
642
|
-
const [primaryNetworkId] = normalizedEntityNetworkIds;
|
|
643
|
-
const introducedPartyUserIds = effectivePartyUserIds.filter((uid) => uid !== context.userId);
|
|
644
|
-
if (introducedPartyUserIds.length === 0) {
|
|
645
|
-
return error("No counterpart to introduce. Provide at least one other user ID in partyUserIds (besides yourself).");
|
|
646
|
-
}
|
|
647
|
-
const evaluatorEntities = normalizedEntities.map((e) => ({
|
|
648
|
-
userId: e.userId,
|
|
649
|
-
profile: e.profile ?? {},
|
|
650
|
-
intents: e.intents,
|
|
651
|
-
networkId: e.networkId,
|
|
652
|
-
}));
|
|
653
|
-
const _introGraphStart = Date.now();
|
|
654
|
-
const _introTraceEmitter = requestContext.getStore()?.traceEmitter;
|
|
655
|
-
_introTraceEmitter?.({ type: "graph_start", name: "opportunity" });
|
|
656
|
-
const result = await invokeWithAbortSignal(graphs.opportunity, {
|
|
657
|
-
operationMode: "create_introduction",
|
|
658
|
-
userId: context.userId,
|
|
659
|
-
networkId: primaryNetworkId,
|
|
660
|
-
introductionEntities: evaluatorEntities,
|
|
661
|
-
introductionHint: query.hint,
|
|
662
|
-
requiredNetworkId: scopedNetworkId,
|
|
663
|
-
options: {
|
|
664
|
-
initialStatus: "draft",
|
|
665
|
-
...(context.sessionId ? { conversationId: context.sessionId } : {}),
|
|
666
|
-
},
|
|
667
|
-
});
|
|
668
|
-
const _introGraphMs = Date.now() - _introGraphStart;
|
|
669
|
-
_introTraceEmitter?.({ type: "graph_end", name: "opportunity", durationMs: _introGraphMs });
|
|
670
|
-
if (result.error || !result.opportunities?.length) {
|
|
671
|
-
return error(result.error ?? "Failed to create introduction.");
|
|
672
|
-
}
|
|
673
|
-
const created = result.opportunities[0];
|
|
674
|
-
const reasoning = created.interpretation?.reasoning ?? "A suggested connection.";
|
|
675
|
-
const confidence = typeof created.interpretation?.confidence === "number"
|
|
676
|
-
? created.interpretation.confidence
|
|
677
|
-
: parseFloat(String(created.confidence ?? 0)) || 0;
|
|
678
|
-
const introducerUser = await userDb.getUser();
|
|
679
|
-
const firstPartyId = introducedPartyUserIds[0];
|
|
680
|
-
const firstEntity = query.entities?.find((e) => e.userId === firstPartyId);
|
|
681
|
-
const counterpartUser = firstPartyId
|
|
682
|
-
? await database.getUser(firstPartyId)
|
|
683
|
-
: null;
|
|
684
|
-
const counterpartName = firstEntity?.profile?.name ?? firstPartyId ?? "Someone";
|
|
685
|
-
// Second party — used in the headline and arrow layout for the introducer view ("A → B")
|
|
686
|
-
const secondPartyId = introducedPartyUserIds[1];
|
|
687
|
-
const secondEntity = query.entities?.find((e) => e.userId === secondPartyId);
|
|
688
|
-
const secondPartyName = secondEntity?.profile?.name;
|
|
689
|
-
const secondPartyAvatar = secondEntity?.profile?.avatar ?? null;
|
|
690
|
-
const secondPartyUser = secondPartyId ? await database.getUser(secondPartyId) : null;
|
|
691
|
-
const viewerIsParty = effectivePartyUserIds.includes(context.userId);
|
|
692
|
-
const viewerRole = viewerIsParty ? "party" : "introducer";
|
|
693
|
-
const isCounterpartGhost = counterpartUser?.isGhost ?? false;
|
|
694
|
-
const primaryActionLabel = getPrimaryActionLabel(viewerRole);
|
|
695
|
-
const publicMatchReason = safeFallbackSummary(reasoning, {
|
|
696
|
-
counterpartName,
|
|
697
|
-
introducerName: introducerUser?.name ?? undefined,
|
|
698
|
-
maxChars: MINIMAL_MAIN_TEXT_MAX_CHARS,
|
|
699
|
-
emptyText: "A suggested connection.",
|
|
700
|
-
});
|
|
701
|
-
const narratorText = narratorRemarkFromReasoning(reasoning, counterpartName, introducerUser?.name ?? undefined);
|
|
702
|
-
const narratorChip = viewerIsParty
|
|
703
|
-
? {
|
|
704
|
-
name: "Index",
|
|
705
|
-
text: narratorText,
|
|
706
|
-
}
|
|
707
|
-
: {
|
|
708
|
-
name: "You",
|
|
709
|
-
text: narratorText,
|
|
710
|
-
userId: context.userId,
|
|
711
|
-
};
|
|
712
|
-
const headline = !viewerIsParty && secondPartyName
|
|
713
|
-
? `${counterpartName} → ${secondPartyName}`
|
|
714
|
-
: `Connection with ${counterpartName}`;
|
|
715
|
-
const cardData = {
|
|
716
|
-
opportunityId: created.id,
|
|
717
|
-
userId: firstPartyId,
|
|
718
|
-
name: counterpartName,
|
|
719
|
-
avatar: counterpartUser?.avatar ??
|
|
720
|
-
firstEntity?.profile
|
|
721
|
-
?.avatar ??
|
|
722
|
-
null,
|
|
723
|
-
mainText: safeFallbackSummary(reasoning, {
|
|
724
|
-
counterpartName,
|
|
725
|
-
// viewerName not available in this context; introducer name passed separately
|
|
726
|
-
introducerName: introducerUser?.name ?? undefined,
|
|
727
|
-
maxChars: MINIMAL_MAIN_TEXT_MAX_CHARS,
|
|
728
|
-
emptyText: "A suggested connection.",
|
|
729
|
-
}),
|
|
730
|
-
cta: "Start a conversation to connect.",
|
|
731
|
-
headline,
|
|
732
|
-
primaryActionLabel,
|
|
733
|
-
secondaryActionLabel: SECONDARY_ACTION_LABEL,
|
|
734
|
-
mutualIntentsLabel: "Suggested connection",
|
|
735
|
-
narratorChip,
|
|
736
|
-
viewerRole,
|
|
737
|
-
isGhost: isCounterpartGhost,
|
|
738
|
-
score: confidence,
|
|
739
|
-
status: created.status ?? "draft",
|
|
740
|
-
...(!viewerIsParty && secondPartyName
|
|
741
|
-
? {
|
|
742
|
-
secondParty: {
|
|
743
|
-
name: secondPartyName,
|
|
744
|
-
avatar: secondPartyUser?.avatar ?? secondPartyAvatar,
|
|
745
|
-
...(secondPartyId ? { userId: secondPartyId } : {}),
|
|
746
|
-
},
|
|
747
|
-
}
|
|
748
|
-
: {}),
|
|
749
|
-
};
|
|
750
|
-
if (context.isMcp && deps.mintConnectLink) {
|
|
751
|
-
await attachActionableLinks(cardData, {
|
|
752
|
-
viewerId: context.userId,
|
|
753
|
-
viewerApproved: false,
|
|
754
|
-
counterpartUserId: firstPartyId,
|
|
755
|
-
mintConnectLink: deps.mintConnectLink,
|
|
756
|
-
frontendUrl: deps.frontendUrl,
|
|
757
|
-
preferredSurface: context.clientSurface,
|
|
758
|
-
});
|
|
759
|
-
}
|
|
760
|
-
return success({
|
|
761
|
-
found: true,
|
|
762
|
-
count: 1,
|
|
763
|
-
summary: "Draft introduction created",
|
|
764
|
-
message: buildOpportunityPresentation([cardData], {
|
|
765
|
-
isMcp: context.isMcp ?? false,
|
|
766
|
-
leadIn: "Draft introduction created.",
|
|
767
|
-
label: "opportunity",
|
|
768
|
-
}),
|
|
769
|
-
opportunities: [
|
|
770
|
-
{
|
|
771
|
-
opportunityId: created.id,
|
|
772
|
-
matchReason: publicMatchReason,
|
|
773
|
-
score: confidence,
|
|
774
|
-
status: created.status ?? "draft",
|
|
775
|
-
},
|
|
776
|
-
],
|
|
777
|
-
_graphTimings: [{ name: 'opportunity', durationMs: _introGraphMs, agents: result.agentTimings ?? [] }],
|
|
778
|
-
});
|
|
779
|
-
}
|
|
780
|
-
// ── Discovery mode ──
|
|
781
|
-
const searchQuery = query.searchQuery?.trim() ?? "";
|
|
782
|
-
if (query.intentId != null && query.intentId !== "" && !UUID_REGEX.test(query.intentId.trim())) {
|
|
783
|
-
return error("Invalid intent ID format.");
|
|
784
|
-
}
|
|
785
|
-
let indexScope;
|
|
786
|
-
const _scopeGraphTimings = [];
|
|
787
|
-
if (effectiveIndexId) {
|
|
788
|
-
if (!UUID_REGEX.test(effectiveIndexId)) {
|
|
789
|
-
return error("Invalid network ID format.");
|
|
790
|
-
}
|
|
791
|
-
const _scopeGraphStart = Date.now();
|
|
792
|
-
const _scopeIndexMembershipTraceEmitter = requestContext.getStore()?.traceEmitter;
|
|
793
|
-
_scopeIndexMembershipTraceEmitter?.({ type: "graph_start", name: "network_membership" });
|
|
794
|
-
const memberResult = await invokeWithAbortSignal(graphs.networkMembership, {
|
|
795
|
-
userId: context.userId,
|
|
796
|
-
networkId: effectiveIndexId,
|
|
797
|
-
operationMode: "read",
|
|
798
|
-
});
|
|
799
|
-
const _scopeIndexMembershipMs = Date.now() - _scopeGraphStart;
|
|
800
|
-
_scopeIndexMembershipTraceEmitter?.({ type: "graph_end", name: "network_membership", durationMs: _scopeIndexMembershipMs });
|
|
801
|
-
_scopeGraphTimings.push({ name: 'network_membership', durationMs: _scopeIndexMembershipMs, agents: [] });
|
|
802
|
-
if (memberResult.error) {
|
|
803
|
-
return error("Network not found or you are not a member.");
|
|
804
|
-
}
|
|
805
|
-
indexScope = [effectiveIndexId];
|
|
806
|
-
}
|
|
807
|
-
else if (context.scopeType === 'network' && context.scopeId) {
|
|
808
|
-
// Scoped chat: discovery is focused-network only. Self-owned writes may
|
|
809
|
-
// include personal networkes, but opportunity visibility must not.
|
|
810
|
-
const scopedDiscoveryIds = deriveDiscoveryNetworkIds({
|
|
811
|
-
memberships: context.userNetworks,
|
|
812
|
-
scopeType: context.scopeType,
|
|
813
|
-
scopeId: context.scopeId,
|
|
814
|
-
});
|
|
815
|
-
indexScope = scopedDiscoveryIds;
|
|
816
|
-
}
|
|
817
|
-
else if (scopedNetworkId) {
|
|
818
|
-
// Scoped context: preserve focused-only discovery using the scope envelope.
|
|
819
|
-
indexScope = [scopedNetworkId];
|
|
820
|
-
}
|
|
821
|
-
else {
|
|
822
|
-
// No scope - use all networks (only in unscoped chat)
|
|
823
|
-
const _scopeGraphStart = Date.now();
|
|
824
|
-
const _scopeIndexTraceEmitter = requestContext.getStore()?.traceEmitter;
|
|
825
|
-
_scopeIndexTraceEmitter?.({ type: "graph_start", name: "index" });
|
|
826
|
-
const indexResult = await invokeWithAbortSignal(graphs.index, {
|
|
827
|
-
userId: context.userId,
|
|
828
|
-
operationMode: "read",
|
|
829
|
-
showAll: true,
|
|
830
|
-
});
|
|
831
|
-
const _scopeIndexMs = Date.now() - _scopeGraphStart;
|
|
832
|
-
_scopeIndexTraceEmitter?.({ type: "graph_end", name: "index", durationMs: _scopeIndexMs });
|
|
833
|
-
_scopeGraphTimings.push({ name: 'index', durationMs: _scopeIndexMs, agents: [] });
|
|
834
|
-
if (indexResult.error) {
|
|
835
|
-
return error(indexResult.error);
|
|
836
|
-
}
|
|
837
|
-
indexScope = (indexResult.readResult?.memberOf || []).map((m) => m.networkId);
|
|
838
|
-
}
|
|
839
|
-
const toolDebugSteps = [
|
|
840
|
-
{ step: "resolve_index_scope", detail: `${indexScope.length} index(es)` },
|
|
841
|
-
];
|
|
842
|
-
if (query.introTargetUserId?.trim() && query.introTargetUserId.trim() === context.userId) {
|
|
843
|
-
return error("You cannot discover introductions for yourself. Try regular discovery instead.");
|
|
844
|
-
}
|
|
845
|
-
const _discoverTraceEmitter = requestContext.getStore()?.traceEmitter;
|
|
846
|
-
const _discoverGraphStart = Date.now();
|
|
847
|
-
_discoverTraceEmitter?.({ type: "graph_start", name: "opportunity" });
|
|
848
|
-
// Chat-driven invocations run under the orchestrator trigger: persist
|
|
849
|
-
// opens at 'negotiating', negotiate fans out with a 60s park window,
|
|
850
|
-
// each accepted draft streams via traceEmitter, and the persist step
|
|
851
|
-
// surfaces already-accepted pairs. Other callers (maintenance, queue
|
|
852
|
-
// workers) still get the 'ambient' default.
|
|
853
|
-
// Orchestrator trigger fires for both web chat (has sessionId) and MCP
|
|
854
|
-
// (isMcp=true, no sessionId). Both are user-initiated discovery that
|
|
855
|
-
// should persist as `negotiating` and flip to `draft` post-finalize via
|
|
856
|
-
// onCandidateResolved. Ambient/cron paths leave both falsy and use the
|
|
857
|
-
// `pending` default.
|
|
858
|
-
const runDiscoveryOrchestrator = !!context.sessionId || !!context.isMcp;
|
|
859
|
-
const result = await runDiscoveryFromQuery({
|
|
860
|
-
opportunityGraph: graphs.opportunity,
|
|
861
|
-
database,
|
|
862
|
-
userId: context.userId,
|
|
863
|
-
query: searchQuery,
|
|
864
|
-
indexScope,
|
|
865
|
-
limit: 20,
|
|
866
|
-
presenter: createOpportunityPresenter(),
|
|
867
|
-
useHomeCardFormat: true,
|
|
868
|
-
triggerIntentId,
|
|
869
|
-
targetUserId: query.targetUserId?.trim() || undefined,
|
|
870
|
-
onBehalfOfUserId: query.introTargetUserId?.trim() || undefined,
|
|
871
|
-
cache,
|
|
872
|
-
// MCP-only: cap the negotiate phase at 20 s so Railway's edge proxy
|
|
873
|
-
// (which 502s the client at ~57 s) never beats the response. The
|
|
874
|
-
// remainder finalizes in the background and is fetched on the
|
|
875
|
-
// user's next list_opportunities call. Removable when IND-274
|
|
876
|
-
// (negotiation conversation continuation) lands.
|
|
877
|
-
...(context.isMcp ? { negotiateTimeoutMs: 20000 } : {}),
|
|
878
|
-
...(context.sessionId ? { chatSessionId: context.sessionId } : {}),
|
|
879
|
-
...(runDiscoveryOrchestrator && { trigger: 'orchestrator' }),
|
|
880
|
-
...(deps.chatSummary && { chatSummary: deps.chatSummary }),
|
|
881
|
-
...(deps.questionGenerator && { questionGenerator: deps.questionGenerator }),
|
|
882
|
-
...(deps.questionerEnqueue && { questionerEnqueue: deps.questionerEnqueue }),
|
|
883
|
-
...(context.scopeType && context.scopeId ? { scopeType: context.scopeType, scopeId: context.scopeId } : {}),
|
|
884
|
-
...(deps.negotiationSummary && { negotiationSummary: deps.negotiationSummary }),
|
|
885
|
-
// Decision questions add an LLM call after the negotiation phase.
|
|
886
|
-
// Capped at QUESTIONER_DISCOVERY_TIMEOUT_MS (12 s default,
|
|
887
|
-
// env-overridable; see opportunity.discover.ts). Aborted calls return
|
|
888
|
-
// no questions but the rest of the discovery payload still ships.
|
|
889
|
-
// For chat sessions, questions are rendered by the frontend via
|
|
890
|
-
// streamed events (Slice 4). For MCP, they drive a sequential
|
|
891
|
-
// elicitation/create flow (Slice 5) — the MCP tool handler awaits the
|
|
892
|
-
// elicitations before returning the tool result. The per-negotiation
|
|
893
|
-
// summarizer is similarly capped at NEGOTIATION_SUMMARY_TIMEOUT_MS
|
|
894
|
-
// (5 s default per negotiation). Gated by QUESTIONER_DISCOVERY_ENABLED
|
|
895
|
-
// (hierarchical: requires QUESTIONER_ENABLED too).
|
|
896
|
-
enableQuestions: isDiscoveryQuestionsEnabled() &&
|
|
897
|
-
(!!context.sessionId || !!context.isMcp),
|
|
898
|
-
});
|
|
899
|
-
// ── Pending question injection ────────────────────────────────────
|
|
900
|
-
// Look up previously-generated questions relevant to this user's
|
|
901
|
-
// discovery context and merge them into the result alongside any
|
|
902
|
-
// inline-generated questions from the current run.
|
|
903
|
-
const pendingQuestionResult = await mergePendingQuestions({
|
|
904
|
-
findPendingQuestions: deps.findPendingQuestions,
|
|
905
|
-
userId: context.userId,
|
|
906
|
-
sourceType: 'discovery',
|
|
907
|
-
...(context.scopeType === 'network' && context.scopeId ? { networkId: context.scopeId } : {}),
|
|
908
|
-
...(contextIntentId ? { scopeType: 'intent', scopeId: contextIntentId } : {}),
|
|
909
|
-
surfacedQuestionIds: new Set(), // Dedup handled at chat.agent level
|
|
910
|
-
});
|
|
911
|
-
const pendingQuestions = pendingQuestionResult.questions;
|
|
912
|
-
const _discoverGraphMs = Date.now() - _discoverGraphStart;
|
|
913
|
-
_discoverTraceEmitter?.({ type: "graph_end", name: "opportunity", durationMs: _discoverGraphMs });
|
|
914
|
-
const _discoverGraphTimings = [
|
|
915
|
-
..._scopeGraphTimings,
|
|
916
|
-
{ name: 'opportunity', durationMs: _discoverGraphMs, agents: [] },
|
|
917
|
-
];
|
|
918
|
-
const allDebugSteps = [
|
|
919
|
-
...toolDebugSteps,
|
|
920
|
-
...(result.debugSteps ?? []),
|
|
921
|
-
];
|
|
922
|
-
// Extract negotiation timing from trace (if negotiation ran)
|
|
923
|
-
const negotiateStep = (result.debugSteps ?? []).find(s => s.step === 'negotiate' && s.data?.durationMs != null);
|
|
924
|
-
const _allGraphTimings = [
|
|
925
|
-
..._discoverGraphTimings,
|
|
926
|
-
...(negotiateStep?.data?.durationMs != null
|
|
927
|
-
? [{ name: 'negotiation', durationMs: negotiateStep.data.durationMs, agents: [] }]
|
|
928
|
-
: []),
|
|
929
|
-
];
|
|
930
|
-
const isIntroducerFlow = !!query.introTargetUserId?.trim();
|
|
931
|
-
if (result.createIntentSuggested && result.suggestedIntentDescription && !isIntroducerFlow) {
|
|
932
|
-
return success({
|
|
933
|
-
found: false,
|
|
934
|
-
count: 0,
|
|
935
|
-
createIntentSuggested: true,
|
|
936
|
-
suggestedIntentDescription: result.suggestedIntentDescription,
|
|
937
|
-
message: "No matching opportunities found. Call create_intent with the suggested description, then discover_opportunities again.",
|
|
938
|
-
summary: "No matches found",
|
|
939
|
-
...(result.pagination ? { pagination: result.pagination } : {}),
|
|
940
|
-
debugSteps: allDebugSteps,
|
|
941
|
-
_graphTimings: _allGraphTimings,
|
|
942
|
-
...(() => {
|
|
943
|
-
const allQ = [...(result.questions ?? []), ...pendingQuestions];
|
|
944
|
-
return allQ.length > 0 ? { questions: allQ } : {};
|
|
945
|
-
})(),
|
|
946
|
-
...(result.discoveryQuestionsDebug ? { _discoveryQuestionsDebug: result.discoveryQuestionsDebug } : {}),
|
|
947
|
-
});
|
|
948
|
-
}
|
|
949
|
-
if (!result.found) {
|
|
950
|
-
return success({
|
|
951
|
-
found: false,
|
|
952
|
-
count: 0,
|
|
953
|
-
message: result.message ?? "No matching opportunities found.",
|
|
954
|
-
summary: "No matches found",
|
|
955
|
-
...(result.pagination ? { pagination: result.pagination } : {}),
|
|
956
|
-
debugSteps: allDebugSteps,
|
|
957
|
-
_graphTimings: _allGraphTimings,
|
|
958
|
-
...(() => {
|
|
959
|
-
const allQ = [...(result.questions ?? []), ...pendingQuestions];
|
|
960
|
-
return allQ.length > 0 ? { questions: allQ } : {};
|
|
961
|
-
})(),
|
|
962
|
-
...(result.discoveryQuestionsDebug ? { _discoveryQuestionsDebug: result.discoveryQuestionsDebug } : {}),
|
|
963
|
-
});
|
|
964
|
-
}
|
|
965
|
-
// Found but only existing connections (no new opportunities created)
|
|
966
|
-
const forMention = result.existingConnectionsForMention ?? result.existingConnections ?? [];
|
|
967
|
-
if ((result.opportunities?.length ?? 0) === 0 && forMention.length > 0) {
|
|
968
|
-
return success({
|
|
969
|
-
found: true,
|
|
970
|
-
count: 0,
|
|
971
|
-
message: result.message ??
|
|
972
|
-
"No new opportunities created; you already have a connection with: " +
|
|
973
|
-
forMention.map((c) => c.name + (c.status ? " (" + c.status + ")" : "")).join(", ") +
|
|
974
|
-
". View on your home page.",
|
|
975
|
-
existingConnections: result.existingConnections,
|
|
976
|
-
summary: "No new matches (existing connections only)",
|
|
977
|
-
debugSteps: allDebugSteps,
|
|
978
|
-
_graphTimings: _allGraphTimings,
|
|
979
|
-
...(() => {
|
|
980
|
-
const allQ = [...(result.questions ?? []), ...pendingQuestions];
|
|
981
|
-
return allQ.length > 0 ? { questions: allQ } : {};
|
|
982
|
-
})(),
|
|
983
|
-
...(result.discoveryQuestionsDebug ? { _discoveryQuestionsDebug: result.discoveryQuestionsDebug } : {}),
|
|
984
|
-
});
|
|
985
|
-
}
|
|
986
|
-
const lifecycleFinalization = await finalizeMcpDiscoveryLifecycle({
|
|
987
|
-
isMcp: context.isMcp === true,
|
|
988
|
-
candidates: result.opportunities ?? [],
|
|
989
|
-
existingConnections: result.existingConnections,
|
|
990
|
-
existingConnectionsForMention: result.existingConnectionsForMention,
|
|
991
|
-
alreadyAcceptedPairs: result.alreadyAcceptedPairs,
|
|
992
|
-
pagination: result.pagination,
|
|
993
|
-
isIntroducerFlow,
|
|
994
|
-
displayLimit: CHAT_DISPLAY_LIMIT,
|
|
995
|
-
readOpportunitiesByIds: (ids) => database.getOpportunitiesByIds(ids),
|
|
996
|
-
warn: (message, data) => discoverOpportunitiesLog.warn(message, data),
|
|
997
|
-
projectSafeCard: (opp) => ({
|
|
998
|
-
opportunityId: opp.opportunityId,
|
|
999
|
-
userId: opp.userId,
|
|
1000
|
-
name: opp.name,
|
|
1001
|
-
avatar: opp.avatar,
|
|
1002
|
-
mainText: getSafePresentationOrSkip(opp, { counterpartName: opp.name })?.summary ?? "",
|
|
1003
|
-
cta: opp.homeCardPresentation?.suggestedAction,
|
|
1004
|
-
headline: opp.homeCardPresentation?.headline,
|
|
1005
|
-
primaryActionLabel: opp.homeCardPresentation?.primaryActionLabel,
|
|
1006
|
-
secondaryActionLabel: opp.homeCardPresentation?.secondaryActionLabel,
|
|
1007
|
-
mutualIntentsLabel: opp.homeCardPresentation?.mutualIntentsLabel,
|
|
1008
|
-
narratorChip: opp.narratorChip,
|
|
1009
|
-
viewerRole: opp.viewerRole,
|
|
1010
|
-
isGhost: opp.isGhost ?? false,
|
|
1011
|
-
score: opp.score,
|
|
1012
|
-
status: opp.status,
|
|
1013
|
-
...(opp.secondParty && { secondParty: opp.secondParty }),
|
|
1014
|
-
}),
|
|
1015
|
-
});
|
|
1016
|
-
const { displayedCards, displayedCandidates } = lifecycleFinalization;
|
|
1017
|
-
if (context.isMcp && deps.mintConnectLink) {
|
|
1018
|
-
const mintConnectLink = deps.mintConnectLink;
|
|
1019
|
-
await Promise.all(displayedCards.map(async (card, idx) => {
|
|
1020
|
-
const source = displayedCandidates[idx];
|
|
1021
|
-
await attachActionableLinks(card, {
|
|
1022
|
-
viewerId: context.userId,
|
|
1023
|
-
viewerApproved: source?.viewerApproved,
|
|
1024
|
-
viewerActedAt: source?.viewerActedAt,
|
|
1025
|
-
counterpartUserId: source?.userId ?? card.userId,
|
|
1026
|
-
mintConnectLink,
|
|
1027
|
-
frontendUrl: deps.frontendUrl,
|
|
1028
|
-
preferredSurface: context.clientSurface,
|
|
1029
|
-
});
|
|
1030
|
-
}));
|
|
1031
|
-
}
|
|
1032
|
-
let message = buildOpportunityPresentation(displayedCards, {
|
|
1033
|
-
isMcp: context.isMcp ?? false,
|
|
1034
|
-
leadIn: `Found ${displayedCards.length} potential connection(s).`,
|
|
1035
|
-
});
|
|
1036
|
-
message = lifecycleFinalization.composeMessage(message);
|
|
1037
|
-
return success({
|
|
1038
|
-
found: true,
|
|
1039
|
-
count: displayedCards.length,
|
|
1040
|
-
message,
|
|
1041
|
-
summary: `Found ${displayedCards.length} match(es)`,
|
|
1042
|
-
...(result.existingConnections?.length ? { existingConnections: result.existingConnections } : {}),
|
|
1043
|
-
...(result.pagination ? { pagination: result.pagination } : {}),
|
|
1044
|
-
debugSteps: allDebugSteps,
|
|
1045
|
-
// Distinct from `createIntentSuggested` (no-results path) intentionally:
|
|
1046
|
-
// `handleCreateIntentCallback` in chat.agent.ts auto-creates for that key.
|
|
1047
|
-
// This flag is for the results-found path where the agent must ask the user first.
|
|
1048
|
-
...(searchQuery && !query.targetUserId && !isIntroducerFlow
|
|
1049
|
-
? {
|
|
1050
|
-
suggestIntentCreationForVisibility: true,
|
|
1051
|
-
suggestedIntentDescription: searchQuery,
|
|
1052
|
-
}
|
|
1053
|
-
: {}),
|
|
1054
|
-
...(() => {
|
|
1055
|
-
const allQ = [...(result.questions ?? []), ...pendingQuestions];
|
|
1056
|
-
return allQ.length > 0 ? { questions: allQ } : {};
|
|
1057
|
-
})(),
|
|
1058
|
-
...(result.discoveryQuestionsDebug ? { _discoveryQuestionsDebug: result.discoveryQuestionsDebug } : {}),
|
|
1059
|
-
_graphTimings: _allGraphTimings,
|
|
1060
|
-
});
|
|
1061
|
-
},
|
|
1062
|
-
});
|
|
1063
|
-
const getDiscoveryRun = defineTool({
|
|
1064
|
-
name: "get_discovery_run",
|
|
1065
|
-
description: "Checks the status of an async discovery run started by discover_opportunities in MCP contexts. " +
|
|
1066
|
-
"Poll this tool with the discoveryRunId roughly every 5 seconds until status is succeeded, failed, or cancelled. " +
|
|
1067
|
-
"While status is queued or running, keep polling THIS tool — do NOT call discover_opportunities again (that does not speed anything up and returns the same run). " +
|
|
1068
|
-
"When succeeded, the result field contains the same discovery payload that discover_opportunities would have returned synchronously.",
|
|
1069
|
-
querySchema: z.object({
|
|
1070
|
-
discoveryRunId: z.string().describe("Discovery run ID returned by discover_opportunities."),
|
|
1071
|
-
}),
|
|
1072
|
-
handler: async ({ context, query }) => {
|
|
1073
|
-
if (!deps.discoveryRuns) {
|
|
1074
|
-
return error("Async discovery runs are not available in this context.");
|
|
1075
|
-
}
|
|
1076
|
-
const run = await deps.discoveryRuns.get(query.discoveryRunId, context.userId);
|
|
1077
|
-
if (!run)
|
|
1078
|
-
return error("Discovery run not found.");
|
|
1079
|
-
return success({
|
|
1080
|
-
discoveryRunId: run.id,
|
|
1081
|
-
status: run.status,
|
|
1082
|
-
progress: run.progress ?? null,
|
|
1083
|
-
result: run.result ?? null,
|
|
1084
|
-
error: run.error ?? null,
|
|
1085
|
-
cancelRequestedAt: run.cancelRequestedAt?.toISOString?.() ?? null,
|
|
1086
|
-
createdAt: run.createdAt.toISOString(),
|
|
1087
|
-
startedAt: run.startedAt?.toISOString?.() ?? null,
|
|
1088
|
-
completedAt: run.completedAt?.toISOString?.() ?? null,
|
|
1089
|
-
});
|
|
1090
|
-
},
|
|
1091
|
-
});
|
|
1092
|
-
const cancelDiscoveryRun = defineTool({
|
|
1093
|
-
name: "cancel_discovery_run",
|
|
1094
|
-
description: "Requests cancellation for an async discovery run. If the queued job has not started, it is removed and marked cancelled. " +
|
|
1095
|
-
"If already running, the worker observes cancellation and stops at the next cancellation check.",
|
|
1096
|
-
querySchema: z.object({
|
|
1097
|
-
discoveryRunId: z.string().describe("Discovery run ID returned by discover_opportunities."),
|
|
1098
|
-
}),
|
|
1099
|
-
handler: async ({ context, query }) => {
|
|
1100
|
-
if (!deps.discoveryRuns || !deps.discoveryRunQueue) {
|
|
1101
|
-
return error("Async discovery runs are not available in this context.");
|
|
1102
|
-
}
|
|
1103
|
-
const existing = await deps.discoveryRuns.get(query.discoveryRunId, context.userId);
|
|
1104
|
-
if (!existing)
|
|
1105
|
-
return error("Discovery run not found.");
|
|
1106
|
-
if (!["queued", "running"].includes(existing.status)) {
|
|
1107
|
-
return success({
|
|
1108
|
-
discoveryRunId: existing.id,
|
|
1109
|
-
status: existing.status,
|
|
1110
|
-
cancelled: existing.status === "cancelled",
|
|
1111
|
-
message: `Discovery run is already ${existing.status}.`,
|
|
1112
|
-
});
|
|
1113
|
-
}
|
|
1114
|
-
const run = await deps.discoveryRuns.requestCancel(query.discoveryRunId, context.userId);
|
|
1115
|
-
if (!run)
|
|
1116
|
-
return error("Discovery run is no longer cancellable.");
|
|
1117
|
-
const removed = await deps.discoveryRunQueue.cancel(run.id);
|
|
1118
|
-
if (removed) {
|
|
1119
|
-
await deps.discoveryRuns.markCancelled(run.id, "cancelled before worker start");
|
|
1120
|
-
}
|
|
1121
|
-
const updated = await deps.discoveryRuns.get(run.id, context.userId);
|
|
1122
|
-
const status = updated?.status ?? (removed ? "cancelled" : run.status);
|
|
1123
|
-
const message = removed
|
|
1124
|
-
? "Discovery run cancelled."
|
|
1125
|
-
: status === "queued"
|
|
1126
|
-
? "Cancellation requested while the discovery run is still queued. It will be skipped or cancelled before work starts."
|
|
1127
|
-
: status === "running"
|
|
1128
|
-
? "Cancellation requested. The running worker will stop at the next cancellation check."
|
|
1129
|
-
: `Cancellation requested. Discovery run is now ${status}.`;
|
|
1130
|
-
return success({
|
|
1131
|
-
discoveryRunId: run.id,
|
|
1132
|
-
status,
|
|
1133
|
-
cancelled: removed || status === "cancelled",
|
|
1134
|
-
message,
|
|
1135
|
-
});
|
|
1136
|
-
},
|
|
1137
|
-
});
|
|
1138
304
|
const listOpportunities = defineTool({
|
|
1139
305
|
name: "list_opportunities",
|
|
1140
306
|
description: "Lists the authenticated user's actionable opportunities (discovered connections). Returns opportunity cards ready for display.\n\n" +
|
|
@@ -1248,7 +414,7 @@ export function createOpportunityTools(defineTool, deps) {
|
|
|
1248
414
|
found: false,
|
|
1249
415
|
count: 0,
|
|
1250
416
|
summary: "No opportunities yet",
|
|
1251
|
-
message: "You have no opportunities yet. Use
|
|
417
|
+
message: "You have no opportunities yet. Create or refine an approved signal with create_intent or update_intent; matches are created in the background. Use list_opportunities later to review persisted results.",
|
|
1252
418
|
});
|
|
1253
419
|
}
|
|
1254
420
|
// Batch-fetch profiles and users for all counterpart and introducer userIds to avoid N+1
|
|
@@ -1594,7 +760,7 @@ export function createOpportunityTools(defineTool, deps) {
|
|
|
1594
760
|
found: false,
|
|
1595
761
|
count: 0,
|
|
1596
762
|
summary: "No opportunities yet",
|
|
1597
|
-
message: "You have no opportunities yet. Use
|
|
763
|
+
message: "You have no opportunities yet. Create or refine an approved signal with create_intent or update_intent; matches are created in the background. Use list_opportunities later to review persisted results.",
|
|
1598
764
|
});
|
|
1599
765
|
}
|
|
1600
766
|
return success({
|
|
@@ -1615,22 +781,33 @@ export function createOpportunityTools(defineTool, deps) {
|
|
|
1615
781
|
description: "Updates an opportunity's status, advancing it through the connection lifecycle.\n\n" +
|
|
1616
782
|
"**Status transitions:**\n" +
|
|
1617
783
|
"- `pending`: Sends a draft opportunity to the other party. They'll be notified and can accept or reject. " +
|
|
1618
|
-
"This is the primary action after
|
|
784
|
+
"This is the primary action after a persisted draft is returned.\n" +
|
|
1619
785
|
"- `accepted`: Accept a received opportunity — opens a direct conversation between both parties. Returns a conversationId to surface to the user.\n" +
|
|
1620
786
|
"- `rejected`: Decline a received opportunity.\n" +
|
|
1621
787
|
"- `expired`: Mark as expired (typically done by the system after timeout).\n\n" +
|
|
1622
|
-
"**When to use:** After
|
|
788
|
+
"**When to use:** After list_opportunities returns persisted opportunity cards. " +
|
|
1623
789
|
"The user clicks 'Send' (pending), 'Accept', or 'Reject' on the card, and the agent calls this tool. " +
|
|
1624
790
|
"An accepted transition may first return a non-success uptake advisory with preparatory questions. Surface those questions, then retry with all returned question ids in acknowledgedUptakeQuestionIds; acknowledgement confirms presentation, not an answer.\n\n" +
|
|
791
|
+
"**Owner approval (agents):** Agent-driven send/accept/reject transitions require an explicit owner-issued approval proof. " +
|
|
792
|
+
"Call without ownerApprovalProof first: the denial returns an approval challenge (interactionId, expiresAt) bound to the exact opportunity, action, owner, and agent. " +
|
|
793
|
+
"Relay that challenge to the owner for explicit approval, then retry once with the issued ownerApprovalProof. " +
|
|
794
|
+
"Proofs are single-use and expire; acknowledgedUptakeQuestionIds, negotiation approvals, and advisory values are never substitutes.\n\n" +
|
|
1625
795
|
"**Returns:** Confirmation with the new status and notification details (who was notified), or a structured uptake advisory without mutation.",
|
|
1626
796
|
querySchema: z.object({
|
|
1627
797
|
opportunityId: z
|
|
1628
798
|
.string()
|
|
1629
|
-
.describe("The UUID of the opportunity to update. Get from
|
|
799
|
+
.describe("The UUID of the opportunity to update. Get from list_opportunities results."),
|
|
1630
800
|
status: z
|
|
1631
801
|
.enum(["pending", "accepted", "rejected", "expired"])
|
|
1632
802
|
.describe("New status: 'pending' = send the draft to the other party, 'accepted' = accept the connection, " +
|
|
1633
803
|
"'rejected' = decline, 'expired' = mark as timed out."),
|
|
804
|
+
ownerApprovalProof: z
|
|
805
|
+
.string()
|
|
806
|
+
.min(1)
|
|
807
|
+
.optional()
|
|
808
|
+
.describe("Opaque owner-issued approval proof for this exact transition (agents only). Obtained after the owner " +
|
|
809
|
+
"explicitly approves the interaction challenge returned by a proof-less call. The opportunity, action, " +
|
|
810
|
+
"owner, agent, and interaction binding is always derived server-side; only this token is presented."),
|
|
1634
811
|
acknowledgedUptakeQuestionIds: z
|
|
1635
812
|
.array(z.string().min(1))
|
|
1636
813
|
.optional()
|
|
@@ -1677,6 +854,39 @@ export function createOpportunityTools(defineTool, deps) {
|
|
|
1677
854
|
});
|
|
1678
855
|
if (admission.kind === 'denied')
|
|
1679
856
|
return error(admission.message);
|
|
857
|
+
// IND-593 owner-approval boundary: every owner-gated transition
|
|
858
|
+
// (send/accept/reject) requires an explicit owner-issued, fresh,
|
|
859
|
+
// atomically single-use proof before any graph/state persistence. The
|
|
860
|
+
// binding is derived ONLY from the resolved context and validated input —
|
|
861
|
+
// caller-controlled identity or proof-binding fields are never trusted.
|
|
862
|
+
// Registered agents present the opaque proof token; direct authenticated
|
|
863
|
+
// owners traverse the same boundary via host attestation. Fail closed
|
|
864
|
+
// when no authority is wired.
|
|
865
|
+
const ownerAction = opportunityOwnerActionForStatus(query.status);
|
|
866
|
+
if (ownerAction) {
|
|
867
|
+
const authority = deps.opportunityOwnerApproval;
|
|
868
|
+
if (!authority) {
|
|
869
|
+
return ownerApprovalDenial(opportunityId, ownerAction, { kind: 'denied', reason: 'missing' });
|
|
870
|
+
}
|
|
871
|
+
const directProvenance = ownerApprovalProvenanceFor(context);
|
|
872
|
+
const verdict = context.agentId
|
|
873
|
+
? await authority.consumeAgentProof(query.ownerApprovalProof, {
|
|
874
|
+
opportunityId,
|
|
875
|
+
action: ownerAction,
|
|
876
|
+
ownerId: context.userId,
|
|
877
|
+
agentId: context.agentId,
|
|
878
|
+
})
|
|
879
|
+
: directProvenance
|
|
880
|
+
? await authority.attestOwnerInteraction({
|
|
881
|
+
opportunityId,
|
|
882
|
+
action: ownerAction,
|
|
883
|
+
ownerId: context.userId,
|
|
884
|
+
provenance: directProvenance,
|
|
885
|
+
})
|
|
886
|
+
: { kind: 'denied', reason: 'untrusted_provenance' };
|
|
887
|
+
if (verdict.kind === 'denied')
|
|
888
|
+
return ownerApprovalDenial(opportunityId, ownerAction, verdict);
|
|
889
|
+
}
|
|
1680
890
|
// The caller actor's own network is the exact question lookup boundary,
|
|
1681
891
|
// even for an otherwise unscoped request. A focused network may only be
|
|
1682
892
|
// equal to this after the guard above.
|
|
@@ -1824,9 +1034,6 @@ export function createOpportunityTools(defineTool, deps) {
|
|
|
1824
1034
|
},
|
|
1825
1035
|
});
|
|
1826
1036
|
return [
|
|
1827
|
-
discoverOpportunities,
|
|
1828
|
-
getDiscoveryRun,
|
|
1829
|
-
cancelDiscoveryRun,
|
|
1830
1037
|
listOpportunities,
|
|
1831
1038
|
updateOpportunity,
|
|
1832
1039
|
confirmOpportunityDelivery,
|