@clien-ai/mcp 0.15.1 → 0.18.0
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 +102 -0
- package/README.md +3 -2
- package/dist/clause-atomicity.js +224 -0
- package/dist/clause-atomicity.js.map +1 -0
- package/dist/hypothesis-semantics.js +4 -50
- package/dist/hypothesis-semantics.js.map +1 -1
- package/dist/tools/collections.js +70 -32
- package/dist/tools/collections.js.map +1 -1
- package/dist/tools/projects.js.map +1 -1
- package/dist/tools/registry.js +39 -17
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/render-safety.js +78 -0
- package/dist/tools/render-safety.js.map +1 -1
- package/dist/tools/research.js +21 -13
- package/dist/tools/research.js.map +1 -1
- package/dist/tools/run-manifest.js +24 -2
- package/dist/tools/run-manifest.js.map +1 -1
- package/dist/tools/scoped-research.js +324 -60
- package/dist/tools/scoped-research.js.map +1 -1
- package/dist/tools/status.js +67 -25
- package/dist/tools/status.js.map +1 -1
- package/dist/types/report.js +4 -0
- package/dist/types/report.js.map +1 -1
- package/package.json +1 -1
|
@@ -40,9 +40,10 @@ import { contentTypeProvenanceLine } from './content-type-display.js';
|
|
|
40
40
|
import { publicationDateLine } from './publication-date-display.js';
|
|
41
41
|
import { readSemanticTheme } from '../types/semantic-theme.js';
|
|
42
42
|
import { apiCall, categorizeFetchError, promoteCompletedJob, webJobUrl, } from './research.js';
|
|
43
|
-
import { safeInline, safeId, truncate, firstQuoteWithCount } from './render-safety.js';
|
|
43
|
+
import { safeInline, safeId, safeFactValue, truncate, firstQuoteWithCount } from './render-safety.js';
|
|
44
44
|
import { permanentFailureMessage } from './permanent-failure.js';
|
|
45
45
|
import { OperationIdSchema, operationRetryHint, resolveOperationId } from './operation-id.js';
|
|
46
|
+
import { buildDisambiguation, fetchRecentRuns } from './status.js';
|
|
46
47
|
import { resolveMarketSignals, } from './market-signals.js';
|
|
47
48
|
// Per-endpoint timeouts. Node fetch has no implicit timeout, so a hung endpoint would otherwise
|
|
48
49
|
// wedge the MCP server forever (mirrors research.ts:START/EVENTS/REPORT_TIMEOUT_MS).
|
|
@@ -170,6 +171,27 @@ export const ScanMarketInputSchema = z.object({
|
|
|
170
171
|
'discover competitors — use `scan_competitors` for the landscape.'),
|
|
171
172
|
...sharedScopedFields,
|
|
172
173
|
});
|
|
174
|
+
/**
|
|
175
|
+
* FUL-1144 — what a dropped scoped call can recover by.
|
|
176
|
+
*
|
|
177
|
+
* ⚠️ NO `operation_id` HERE, deliberately. There is no read endpoint that maps an operation id to
|
|
178
|
+
* a job: the only thing that resolves one is `POST /api/validate/scoped/start`, and a status tool
|
|
179
|
+
* must never be a POST that can start (and charge for) a run. A caller holding its operation_id
|
|
180
|
+
* already has the right tool — re-calling the SAME scoped tool with the SAME operation_id and
|
|
181
|
+
* inputs replays the committed start without a second charge and re-attaches to it.
|
|
182
|
+
*/
|
|
183
|
+
export const ScopedStatusInputSchema = z.object({
|
|
184
|
+
job_id: z
|
|
185
|
+
.string()
|
|
186
|
+
.uuid()
|
|
187
|
+
.optional()
|
|
188
|
+
.describe('UUID of the scoped run (scan_competitors / search_forums / scan_market) to check. ' +
|
|
189
|
+
'OPTIONAL — omit it when a dropped call left you without one: this tool then lists your ' +
|
|
190
|
+
'recent runs, each with its job_id and topic, so you can re-call with the one you were ' +
|
|
191
|
+
'running. Where the id IS readable: the error text of a call that failed or timed out ' +
|
|
192
|
+
'after starting. It is also printed to the MCP server stderr log as ' +
|
|
193
|
+
'`[clien-mcp] <tool> started job_id=<uuid>` — which reaches the human operator, not you.'),
|
|
194
|
+
});
|
|
173
195
|
export class ScopedResearchToolError extends ToolError {
|
|
174
196
|
}
|
|
175
197
|
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -212,6 +234,192 @@ export async function scanMarket(input, deps) {
|
|
|
212
234
|
}
|
|
213
235
|
return runScopedResearchTool('market', parsed.data, deps);
|
|
214
236
|
}
|
|
237
|
+
/**
|
|
238
|
+
* `scoped_research_status` (FUL-1144) — recover a scoped run after a dropped call.
|
|
239
|
+
*
|
|
240
|
+
* The scoped tools are multi-minute synchronous calls; when the pipe drops mid-scan the job keeps
|
|
241
|
+
* running (and was paid for), but before this tool the caller had no way to read it back. This is
|
|
242
|
+
* the scoped counterpart of `clien_research_status`, built from the SAME pieces the live tools use
|
|
243
|
+
* so a recovered result cannot drift from a live one:
|
|
244
|
+
*
|
|
245
|
+
* - state + refund truth: one drained `/events` snapshot through `pollScopedJob`. The refund
|
|
246
|
+
* sentence is `scopedFailureText` → `buildFailureGuidance` over the server's own
|
|
247
|
+
* `refundedCredits` / `refundPending` / `errorCategory` / `startedSandbox`; nothing is inferred.
|
|
248
|
+
* - result: `fetchScopedReport` (the FUL-719 projection) → `buildScopedResult` (every renderer
|
|
249
|
+
* and its render-safety guards), exactly as the live call would have returned it.
|
|
250
|
+
* - persistence: the same idempotent `POST /promote` the live call ends with, so a run whose
|
|
251
|
+
* call dropped before that step reaches the Research tab now rather than on the ~10-minute
|
|
252
|
+
* sweep. The route answers `already_promoted` on a repeat.
|
|
253
|
+
* - discovery: `fetchRecentRuns` + `buildDisambiguation` from `status.ts`.
|
|
254
|
+
*/
|
|
255
|
+
export async function getScopedResearchStatus(input, deps) {
|
|
256
|
+
const parsed = ScopedStatusInputSchema.safeParse(input ?? {});
|
|
257
|
+
if (!parsed.success) {
|
|
258
|
+
throw new ScopedResearchToolError(`Invalid input — ${formatZodError(parsed.error)}`);
|
|
259
|
+
}
|
|
260
|
+
const fetchImpl = deps.fetch ?? fetch;
|
|
261
|
+
const ctx = { config: deps.config, session: deps.session, fetch: fetchImpl };
|
|
262
|
+
let jobId = parsed.data.job_id;
|
|
263
|
+
if (!jobId) {
|
|
264
|
+
// ⚠️ NEVER AUTO-PICK FROM A LIST OF SEVERAL. `GET /api/validate` returns hero and scoped runs
|
|
265
|
+
// together with nothing to tell them apart (the summary row carries no mode), so "the one
|
|
266
|
+
// active run" may be a `clien_research` run while the scan the caller lost has already
|
|
267
|
+
// finished. Only a list of exactly one is unambiguous; anything else is shown, not guessed.
|
|
268
|
+
const jobs = await fetchRecentRuns(ctx, deps, 'scan_competitors, search_forums or scan_market');
|
|
269
|
+
const [only] = jobs;
|
|
270
|
+
if (!only || jobs.length > 1)
|
|
271
|
+
return buildDisambiguation(jobs, 'recent', SCOPED_STATUS_TOOL);
|
|
272
|
+
// A server-sent id: state it only if it can be stated exactly.
|
|
273
|
+
const serverId = safeId(only.id);
|
|
274
|
+
if (!serverId) {
|
|
275
|
+
throw new ScopedResearchToolError('Your one recent run came back with an unreadable job id. Please try again.');
|
|
276
|
+
}
|
|
277
|
+
jobId = serverId;
|
|
278
|
+
}
|
|
279
|
+
const poll = await pollScopedJob(ctx, jobId, {
|
|
280
|
+
sleep: deps.sleep ?? defaultSleep,
|
|
281
|
+
pollIntervalMs: deps.pollIntervalMs ?? deps.config.pollIntervalMs,
|
|
282
|
+
deadline: Number.POSITIVE_INFINITY,
|
|
283
|
+
onProgress: deps.onProgress,
|
|
284
|
+
invalidateSession: deps.invalidateSession,
|
|
285
|
+
label: 'Research run',
|
|
286
|
+
config: deps.config,
|
|
287
|
+
projectId: null,
|
|
288
|
+
snapshot: true,
|
|
289
|
+
});
|
|
290
|
+
const projectId = poll.projectId ?? null;
|
|
291
|
+
const jobLink = webJobUrl(deps.config, jobId, projectId);
|
|
292
|
+
const status = safeInline(poll.status, 40) ?? 'unknown';
|
|
293
|
+
const base = {
|
|
294
|
+
job_id: jobId,
|
|
295
|
+
status: poll.status,
|
|
296
|
+
project_id: projectId,
|
|
297
|
+
promoted: false,
|
|
298
|
+
promotion_error_code: null,
|
|
299
|
+
promotion_retryable: null,
|
|
300
|
+
report_data: null,
|
|
301
|
+
...(poll.totalCostCents !== undefined ? { total_cost_cents: poll.totalCostCents } : {}),
|
|
302
|
+
};
|
|
303
|
+
if (!TERMINAL_STATUSES.has(poll.status)) {
|
|
304
|
+
const activity = poll.lastMessage ? ` Latest activity: ${poll.lastMessage}.` : '';
|
|
305
|
+
return {
|
|
306
|
+
content: [
|
|
307
|
+
{
|
|
308
|
+
type: 'text',
|
|
309
|
+
text: `Job ${jobId} is still running (phase: "${status}").${activity} It keeps running on ` +
|
|
310
|
+
`the server — do NOT start it again. Call ${SCOPED_STATUS_TOOL} with this same job_id ` +
|
|
311
|
+
`in a minute or two, or view ${jobLink}.`,
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
_meta: { ...base, done: false },
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
if (poll.status !== 'complete') {
|
|
318
|
+
return {
|
|
319
|
+
content: [
|
|
320
|
+
{
|
|
321
|
+
type: 'text',
|
|
322
|
+
text: `Job ${jobId}: ${scopedFailureText({ label: 'Research run', noun: 'results' }, poll, jobLink)}`,
|
|
323
|
+
},
|
|
324
|
+
],
|
|
325
|
+
_meta: {
|
|
326
|
+
...base,
|
|
327
|
+
done: true,
|
|
328
|
+
...(poll.errorCategory !== undefined ? { error_category: poll.errorCategory } : {}),
|
|
329
|
+
...(poll.refundedCredits !== undefined ? { refunded_credits: poll.refundedCredits } : {}),
|
|
330
|
+
...(poll.refundPending ? { refund_pending: true } : {}),
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
const { reportData, hasMarkdown } = await fetchScopedReport(ctx, jobId, deps.config, projectId);
|
|
335
|
+
// Only a full `clien_research` run writes report markdown. Its report owes the trust digest,
|
|
336
|
+
// which this tool does not render — so hand it to the tool that does rather than a partial view.
|
|
337
|
+
if (hasMarkdown) {
|
|
338
|
+
return {
|
|
339
|
+
content: [
|
|
340
|
+
{
|
|
341
|
+
type: 'text',
|
|
342
|
+
text: `Job ${jobId} is a full clien_research run, not a scoped scan. Call ` +
|
|
343
|
+
'clien_research_status (or get_report) with this same job_id for its report and trust digest.',
|
|
344
|
+
},
|
|
345
|
+
],
|
|
346
|
+
_meta: { ...base, done: true },
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
const mode = scopedModeOf(reportData);
|
|
350
|
+
if (!mode) {
|
|
351
|
+
return {
|
|
352
|
+
content: [
|
|
353
|
+
{
|
|
354
|
+
type: 'text',
|
|
355
|
+
text: `Job ${jobId} completed, but its stored result carries no competitor, community or ` +
|
|
356
|
+
`market section, so there is nothing to show here. View ${jobLink}.`,
|
|
357
|
+
},
|
|
358
|
+
],
|
|
359
|
+
_meta: { ...base, done: true, report_data: reportData },
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
const promotion = projectId ? await promoteScopedJob(ctx, jobId) : NO_PROMOTION;
|
|
363
|
+
const result = buildScopedResult({
|
|
364
|
+
mode,
|
|
365
|
+
jobId,
|
|
366
|
+
status: poll.status,
|
|
367
|
+
projectId,
|
|
368
|
+
promotion,
|
|
369
|
+
reportData,
|
|
370
|
+
// The list and events routes do not return the topic for a job id; the renderers fall back to
|
|
371
|
+
// "this topic" rather than printing a topic nobody stated.
|
|
372
|
+
topic: '',
|
|
373
|
+
totalCostCents: poll.totalCostCents,
|
|
374
|
+
});
|
|
375
|
+
const [first] = result.content;
|
|
376
|
+
return {
|
|
377
|
+
...result,
|
|
378
|
+
content: [
|
|
379
|
+
{
|
|
380
|
+
type: 'text',
|
|
381
|
+
text: `Recovered ${MODE_META[mode].tool} job ${jobId} (complete).\n\n${first?.text ?? ''}`,
|
|
382
|
+
},
|
|
383
|
+
],
|
|
384
|
+
_meta: { ...result._meta, done: true, mode },
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
const SCOPED_STATUS_TOOL = 'scoped_research_status';
|
|
388
|
+
/**
|
|
389
|
+
* The drop-recovery paragraph every scoped tool's description ends with (FUL-1144). One function,
|
|
390
|
+
* so the three adverts cannot drift apart about how a paid, still-running scan is recovered.
|
|
391
|
+
*/
|
|
392
|
+
export function scopedDropRecoveryAdvert(tool) {
|
|
393
|
+
return ('IMPORTANT: if the call drops mid-flight with "MCP error -32000", the job keeps running on the ' +
|
|
394
|
+
'backend — do NOT start it again (a fresh call starts a new run and debits again). Recover it: ' +
|
|
395
|
+
`call \`${SCOPED_STATUS_TOOL}\` — with NO arguments it lists your recent runs (job_id + topic) ` +
|
|
396
|
+
'so you can pick this one, then re-call it with that job_id for the state, the result, or the ' +
|
|
397
|
+
'refund outcome. If you set an `operation_id`, re-calling this tool with the SAME operation_id ' +
|
|
398
|
+
'and the SAME inputs also re-attaches to the run without a second charge. The job_id is logged ' +
|
|
399
|
+
`to the MCP server stderr as \`[clien-mcp] ${tool} started job_id=…\`, which reaches the human ` +
|
|
400
|
+
'operator rather than you.');
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Which scoped researcher produced `reportData`, read from the one envelope each mode writes
|
|
404
|
+
* (`agent/src/scoped-research.ts`: `webResearch` / `forumResearch` / `marketResearch`, never two).
|
|
405
|
+
* `null` when none is present — a run that recorded no findings — or, defensively, more than one.
|
|
406
|
+
*/
|
|
407
|
+
function scopedModeOf(reportData) {
|
|
408
|
+
if (!reportData)
|
|
409
|
+
return null;
|
|
410
|
+
const present = (key) => {
|
|
411
|
+
const value = reportData[key];
|
|
412
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
413
|
+
};
|
|
414
|
+
const modes = [];
|
|
415
|
+
if (present('webResearch'))
|
|
416
|
+
modes.push('web');
|
|
417
|
+
if (present('forumResearch'))
|
|
418
|
+
modes.push('forum');
|
|
419
|
+
if (present('marketResearch'))
|
|
420
|
+
modes.push('market');
|
|
421
|
+
return modes.length === 1 ? modes[0] : null;
|
|
422
|
+
}
|
|
215
423
|
async function runScopedResearchTool(mode, input, deps) {
|
|
216
424
|
const { topic, product_name, project_id, project_name } = input;
|
|
217
425
|
// FUL-810: one paid start, one id — see operation-id.ts.
|
|
@@ -287,52 +495,17 @@ async function runScopedResearchTool(mode, input, deps) {
|
|
|
287
495
|
// outcome (the user's money) and a cause-keyed retry hint so the calling
|
|
288
496
|
// agent advises correctly without re-deriving intent from the prose.
|
|
289
497
|
if (poll.status !== 'complete') {
|
|
290
|
-
const reason = poll.lastMessage ? ` (${poll.lastMessage})` : '';
|
|
291
498
|
// Shared with clien_research_status — the refund note is an ELIGIBILITY
|
|
292
499
|
// hint, not a promise (see buildFailureGuidance). NOTE: on a THROWN scoped
|
|
293
500
|
// failure this reaches the caller only as prose (ToolError carries no
|
|
294
501
|
// structured _meta); an agent that needs the machine-readable fields calls
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
errorCategory: poll.errorCategory,
|
|
298
|
-
refundedCredits: poll.refundedCredits,
|
|
299
|
-
refundPending: poll.refundPending,
|
|
300
|
-
startedSandbox: poll.startedSandbox,
|
|
301
|
-
});
|
|
302
|
-
/**
|
|
303
|
-
* ⚠️ THE CLOSING INSTRUCTION IS PART OF THE RETRY ADVICE (FUL-845 round-5).
|
|
304
|
-
*
|
|
305
|
-
* "Try a more specific topic" used to close every one of these, including
|
|
306
|
-
* the branch where `retryHint` has just said, in the same sentence, that
|
|
307
|
-
* retrying will not help until we fix our own configuration. A calling
|
|
308
|
-
* agent reading both does the thing it was last told to do: it re-words the
|
|
309
|
-
* topic and buys the same failure again. The run never reached a sandbox —
|
|
310
|
-
* the topic was never read by anything, so it cannot be the problem.
|
|
311
|
-
*
|
|
312
|
-
* The link stays on both branches: it is where the run's own words are.
|
|
313
|
-
*/
|
|
314
|
-
const jobLink = webJobUrl(deps.config, jobId, projectId);
|
|
315
|
-
const nextStep = retryPointless
|
|
316
|
-
? `View ${jobLink}.`
|
|
317
|
-
: `Try a more specific topic, or view ${jobLink}.`;
|
|
318
|
-
throw new ScopedResearchToolError(`${meta.label} ${poll.status === 'failed' ? 'failed' : 'was cancelled'}${reason}. ` +
|
|
319
|
-
`No ${meta.noun} were produced.${refundNote}${retryHint} ${nextStep}`, { jobId });
|
|
502
|
+
// scoped_research_status, which returns them in _meta. (review follow-up.)
|
|
503
|
+
throw new ScopedResearchToolError(scopedFailureText(meta, poll, webJobUrl(deps.config, jobId, projectId)), { jobId });
|
|
320
504
|
}
|
|
321
505
|
// 5. Promote into the Research collections (best-effort — needs a project; standalone runs skip).
|
|
322
|
-
|
|
323
|
-
promoted: false,
|
|
324
|
-
errorCode: null,
|
|
325
|
-
retryable: null,
|
|
326
|
-
// FUL-479: a scoped `scan_market` run IS eligible for market authority now, so this is no
|
|
327
|
-
// longer null on every path — `promoteScopedJob` fills it from the server's own discriminator
|
|
328
|
-
// below, and `buildScopedResult` refuses to claim a saved snapshot without it.
|
|
329
|
-
marketUpdated: null,
|
|
330
|
-
};
|
|
331
|
-
if (projectId) {
|
|
332
|
-
promotion = await promoteScopedJob(ctx, jobId);
|
|
333
|
-
}
|
|
506
|
+
const promotion = projectId ? await promoteScopedJob(ctx, jobId) : NO_PROMOTION;
|
|
334
507
|
// 6. Fetch the structured report.
|
|
335
|
-
const reportData = await fetchScopedReport(ctx, jobId, deps.config, projectId);
|
|
508
|
+
const { reportData } = await fetchScopedReport(ctx, jobId, deps.config, projectId);
|
|
336
509
|
// 7. Extract the RICH slice + build the result.
|
|
337
510
|
return buildScopedResult({
|
|
338
511
|
mode,
|
|
@@ -345,6 +518,49 @@ async function runScopedResearchTool(mode, input, deps) {
|
|
|
345
518
|
totalCostCents: poll.totalCostCents,
|
|
346
519
|
});
|
|
347
520
|
}
|
|
521
|
+
/**
|
|
522
|
+
* No promotion attempted (a standalone run). FUL-479: a scoped `scan_market` run IS eligible for
|
|
523
|
+
* market authority, so `marketUpdated` is not null on every path — `promoteScopedJob` fills it from
|
|
524
|
+
* the server's own discriminator, and `buildScopedResult` refuses to claim a saved snapshot without it.
|
|
525
|
+
*/
|
|
526
|
+
const NO_PROMOTION = {
|
|
527
|
+
promoted: false,
|
|
528
|
+
errorCode: null,
|
|
529
|
+
retryable: null,
|
|
530
|
+
marketUpdated: null,
|
|
531
|
+
};
|
|
532
|
+
/**
|
|
533
|
+
* The sentence a failed or cancelled scoped run is reported with — by the live tool (thrown) and by
|
|
534
|
+
* `scoped_research_status` (returned). One copy, so the recovery read can never word the refund
|
|
535
|
+
* differently from the call it recovers. The refund note comes from the SERVER's own fields via
|
|
536
|
+
* `buildFailureGuidance`; nothing here infers a refund.
|
|
537
|
+
*/
|
|
538
|
+
function scopedFailureText(meta, poll, jobLink) {
|
|
539
|
+
const reason = poll.lastMessage ? ` (${poll.lastMessage})` : '';
|
|
540
|
+
const { refundNote, retryHint, retryPointless } = buildFailureGuidance({
|
|
541
|
+
errorCategory: poll.errorCategory,
|
|
542
|
+
refundedCredits: poll.refundedCredits,
|
|
543
|
+
refundPending: poll.refundPending,
|
|
544
|
+
startedSandbox: poll.startedSandbox,
|
|
545
|
+
});
|
|
546
|
+
/**
|
|
547
|
+
* ⚠️ THE CLOSING INSTRUCTION IS PART OF THE RETRY ADVICE (FUL-845 round-5).
|
|
548
|
+
*
|
|
549
|
+
* "Try a more specific topic" used to close every one of these, including
|
|
550
|
+
* the branch where `retryHint` has just said, in the same sentence, that
|
|
551
|
+
* retrying will not help until we fix our own configuration. A calling
|
|
552
|
+
* agent reading both does the thing it was last told to do: it re-words the
|
|
553
|
+
* topic and buys the same failure again. The run never reached a sandbox —
|
|
554
|
+
* the topic was never read by anything, so it cannot be the problem.
|
|
555
|
+
*
|
|
556
|
+
* The link stays on both branches: it is where the run's own words are.
|
|
557
|
+
*/
|
|
558
|
+
const nextStep = retryPointless
|
|
559
|
+
? `View ${jobLink}.`
|
|
560
|
+
: `Try a more specific topic, or view ${jobLink}.`;
|
|
561
|
+
return (`${meta.label} ${poll.status === 'failed' ? 'failed' : 'was cancelled'}${reason}. ` +
|
|
562
|
+
`No ${meta.noun} were produced.${refundNote}${retryHint} ${nextStep}`);
|
|
563
|
+
}
|
|
348
564
|
/**
|
|
349
565
|
* Poll /events until a terminal status. Backs off on transient 5xx / poll timeouts (2s,5s,10s, then
|
|
350
566
|
* bail after 3 consecutive), invalidates the session on a genuine 401, and enforces `deadline`.
|
|
@@ -359,14 +575,17 @@ async function pollScopedJob(ctx, jobId, deps) {
|
|
|
359
575
|
let refundedCredits;
|
|
360
576
|
let refundPending = false;
|
|
361
577
|
let startedSandbox;
|
|
578
|
+
let projectId;
|
|
362
579
|
let hasMore = false;
|
|
363
580
|
let consecutiveServerErrors = 0;
|
|
364
581
|
while (!TERMINAL_STATUSES.has(status) || hasMore) {
|
|
365
|
-
if (!TERMINAL_STATUSES.has(status) && Date.now() > deps.deadline) {
|
|
582
|
+
if (!deps.snapshot && !TERMINAL_STATUSES.has(status) && Date.now() > deps.deadline) {
|
|
366
583
|
throw new ScopedResearchToolError(`${deps.label} did not complete within the allotted time. The job may still be running — ` +
|
|
367
584
|
`check ${webJobUrl(deps.config, jobId, deps.projectId)}.`, { jobId });
|
|
368
585
|
}
|
|
369
|
-
|
|
586
|
+
// A snapshot reads immediately: nothing is being waited for.
|
|
587
|
+
if (!deps.snapshot)
|
|
588
|
+
await deps.sleep(deps.pollIntervalMs);
|
|
370
589
|
let response;
|
|
371
590
|
try {
|
|
372
591
|
response = await apiCall(ctx, 'GET', `/api/validate/${jobId}/events?since=${lastSequence}&limit=${hasMore ? 1000 : 100}`, {
|
|
@@ -401,6 +620,9 @@ async function pollScopedJob(ctx, jobId, deps) {
|
|
|
401
620
|
refundPending = data?.job?.refundPending === true;
|
|
402
621
|
if (typeof data?.job?.startedSandbox === 'boolean')
|
|
403
622
|
startedSandbox = data.job.startedSandbox;
|
|
623
|
+
if (typeof data?.job?.projectId === 'string' || data?.job?.projectId === null) {
|
|
624
|
+
projectId = data.job.projectId;
|
|
625
|
+
}
|
|
404
626
|
if (typeof data?.lastSequence === 'number')
|
|
405
627
|
lastSequence = data.lastSequence;
|
|
406
628
|
const summary = summarizeEvent(data?.events);
|
|
@@ -409,10 +631,20 @@ async function pollScopedJob(ctx, jobId, deps) {
|
|
|
409
631
|
await emitProgress(deps, {
|
|
410
632
|
progress: PHASE_PROGRESS[status] ?? 0.5,
|
|
411
633
|
total: 1,
|
|
412
|
-
|
|
634
|
+
// FUL-1254: the server's job status, flattened like the event text `summary` carries.
|
|
635
|
+
message: summary ?? `phase: ${safeInline(status, 40) ?? 'unknown'}`,
|
|
413
636
|
});
|
|
637
|
+
// Snapshot: stop once the pages are drained, whatever the status.
|
|
638
|
+
if (deps.snapshot && !hasMore)
|
|
639
|
+
break;
|
|
414
640
|
continue;
|
|
415
641
|
}
|
|
642
|
+
// The events route answers 404 for a job that does not exist OR belongs to another account —
|
|
643
|
+
// deliberately indistinguishable, so this says both.
|
|
644
|
+
if (response.status === 404) {
|
|
645
|
+
throw new ScopedResearchToolError(`No research job found with id ${jobId} on this account. The id may belong to a different ` +
|
|
646
|
+
'account, may be malformed, or may never have existed.', { jobId });
|
|
647
|
+
}
|
|
416
648
|
const category = categorizeFetchError(response.status);
|
|
417
649
|
if (category === 'auth') {
|
|
418
650
|
if (response.refreshError?.kind === 'transient') {
|
|
@@ -434,7 +666,16 @@ async function pollScopedJob(ctx, jobId, deps) {
|
|
|
434
666
|
const text = await response.text().catch(() => '');
|
|
435
667
|
throw new ScopedResearchToolError(`Unexpected error polling ${deps.label.toLowerCase()} events (HTTP ${response.status}): ${truncate(text, 200)}`, { jobId });
|
|
436
668
|
}
|
|
437
|
-
return {
|
|
669
|
+
return {
|
|
670
|
+
status,
|
|
671
|
+
lastMessage,
|
|
672
|
+
totalCostCents,
|
|
673
|
+
errorCategory,
|
|
674
|
+
refundedCredits,
|
|
675
|
+
refundPending,
|
|
676
|
+
startedSandbox,
|
|
677
|
+
...(projectId !== undefined ? { projectId } : {}),
|
|
678
|
+
};
|
|
438
679
|
}
|
|
439
680
|
/**
|
|
440
681
|
* Backoff helper for transient poll failures. Returns true if the caller should `continue` (backed
|
|
@@ -472,7 +713,11 @@ async function promoteScopedJob(ctx, jobId) {
|
|
|
472
713
|
}
|
|
473
714
|
return outcome;
|
|
474
715
|
}
|
|
475
|
-
/**
|
|
716
|
+
/**
|
|
717
|
+
* GET the structured report (JSON variant). Returns report_data (may be null) and whether the job
|
|
718
|
+
* carries report MARKDOWN — which only a full `clien_research` run writes, so the recovery read can
|
|
719
|
+
* tell a hero job id from a scoped one without guessing from the payload's shape.
|
|
720
|
+
*/
|
|
476
721
|
async function fetchScopedReport(ctx, jobId, config, projectId) {
|
|
477
722
|
let response;
|
|
478
723
|
try {
|
|
@@ -508,7 +753,10 @@ async function fetchScopedReport(ctx, jobId, config, projectId) {
|
|
|
508
753
|
//
|
|
509
754
|
// Projected at the FETCH boundary rather than at the emit site so nothing downstream — the
|
|
510
755
|
// extractors included — ever holds the raw payload.
|
|
511
|
-
return
|
|
756
|
+
return {
|
|
757
|
+
reportData: projectReportDataForMcp(json?.reportData ?? null) ?? null,
|
|
758
|
+
hasMarkdown: typeof json?.reportMarkdown === 'string' && json.reportMarkdown.trim().length > 0,
|
|
759
|
+
};
|
|
512
760
|
}
|
|
513
761
|
// ------------------------------------------------------------------
|
|
514
762
|
// Result building
|
|
@@ -770,13 +1018,20 @@ function renderCompetitors(competitors, topic, persistenceLine) {
|
|
|
770
1018
|
const urlText = safeInline(c.url, 200);
|
|
771
1019
|
const url = urlText ? `\n ${urlText}` : '';
|
|
772
1020
|
// FUL-179: the M1b company-metadata fields. Rendered as a single "·"-joined line so a
|
|
773
|
-
// competitor missing all
|
|
1021
|
+
// competitor missing all of them adds no noise, and one with a couple stays compact. These are
|
|
774
1022
|
// un-receipted (see CompetitorProfileSchema) — presented as plain facts, not cited claims.
|
|
1023
|
+
// FUL-1217(a): `Serves` (where it operates) comes LAST, after HQ, matching the order the
|
|
1024
|
+
// report claim spine grades the facts in. A competitor without it renders exactly as before.
|
|
1025
|
+
// FUL-1249: `safeFactValue`, not `safeInline` — the same guard `list_competitors` uses.
|
|
1026
|
+
// This line prints no brackets, and the TRUST note below says the ABSENCE of one means
|
|
1027
|
+
// "not checked", so a value carrying `[GROUNDED]` (or ` · HQ: …`) must not be able to
|
|
1028
|
+
// supply one.
|
|
775
1029
|
const meta = [
|
|
776
|
-
['Funding',
|
|
777
|
-
['Employees',
|
|
778
|
-
['Founded',
|
|
779
|
-
['HQ',
|
|
1030
|
+
['Funding', safeFactValue(c.funding, 80)],
|
|
1031
|
+
['Employees', safeFactValue(c.employeeCount, 40)],
|
|
1032
|
+
['Founded', safeFactValue(c.founded, 40)],
|
|
1033
|
+
['HQ', safeFactValue(c.hqLocation, 80)],
|
|
1034
|
+
['Serves', safeFactValue(c.serviceArea, 80)],
|
|
780
1035
|
]
|
|
781
1036
|
.filter(([, v]) => v !== null)
|
|
782
1037
|
.map(([label, v]) => `${label}: ${v}`);
|
|
@@ -792,7 +1047,7 @@ function renderCompetitors(competitors, topic, persistenceLine) {
|
|
|
792
1047
|
});
|
|
793
1048
|
// FUL-247: state the ABSENCE of grounding here, in the text.
|
|
794
1049
|
//
|
|
795
|
-
// This surface renders the SAME
|
|
1050
|
+
// This surface renders the SAME five company facts as `list_competitors`, and
|
|
796
1051
|
// that tool now suffixes each one with a `[GROUNDED]`-style bracket. The
|
|
797
1052
|
// divergence is legitimate — `CompetitorProfileSchema` has no `*_grounding`
|
|
798
1053
|
// fields at all, because these are raw values a research agent just wrote and
|
|
@@ -802,7 +1057,7 @@ function renderCompetitors(competitors, topic, persistenceLine) {
|
|
|
802
1057
|
// can read the silence as "nothing flagged" rather than "categorically
|
|
803
1058
|
// unverified". The tool description says so, but a description is read once and
|
|
804
1059
|
// may be far outside the context window by now — so say it beside the facts.
|
|
805
|
-
const hasCompanyFacts = competitors.some((c) => Boolean(c.funding || c.employeeCount || c.founded || c.hqLocation));
|
|
1060
|
+
const hasCompanyFacts = competitors.some((c) => Boolean(c.funding || c.employeeCount || c.founded || c.hqLocation || c.serviceArea));
|
|
806
1061
|
const trustLine = hasCompanyFacts
|
|
807
1062
|
? '\n\nTRUST: the company facts above are UN-RECEIPTED — this surface has no per-fact ' +
|
|
808
1063
|
'grounding to show, so the ABSENCE of a `[GROUNDED]`-style bracket here means "not ' +
|
|
@@ -1011,7 +1266,7 @@ async function handleScopedStartError(response, label, operationId) {
|
|
|
1011
1266
|
}
|
|
1012
1267
|
if (status === 503 && parsed?.code === 'FEATURE_UNAVAILABLE') {
|
|
1013
1268
|
// FUL-479 kill switch. Distinguished from the circuit breaker below because the two ask for
|
|
1014
|
-
// OPPOSITE responses: the breaker is a transient outage and "try again in
|
|
1269
|
+
// OPPOSITE responses: the breaker is a transient outage and "try again in a few minutes" is the
|
|
1015
1270
|
// right advice, while this is a capability the deployment has not switched on — retrying will
|
|
1016
1271
|
// return the same 503 forever. Nothing was started and nothing was charged, and the agent is
|
|
1017
1272
|
// told to stop rather than to wait.
|
|
@@ -1022,7 +1277,9 @@ async function handleScopedStartError(response, label, operationId) {
|
|
|
1022
1277
|
'whatever market data the project already holds.');
|
|
1023
1278
|
}
|
|
1024
1279
|
if (status === 503 && parsed?.code === 'CIRCUIT_BREAKER_OPEN') {
|
|
1025
|
-
throw new ScopedResearchToolError(parsed?.error ??
|
|
1280
|
+
throw new ScopedResearchToolError(parsed?.error ??
|
|
1281
|
+
parsed?.message ??
|
|
1282
|
+
'Research is temporarily unavailable because a provider is down. Please try again in a few minutes.');
|
|
1026
1283
|
}
|
|
1027
1284
|
if (status === 401) {
|
|
1028
1285
|
throw new ScopedResearchToolError('Authentication failed. Re-run the tool to re-authenticate.');
|
|
@@ -1069,12 +1326,19 @@ function summarizeEvent(events) {
|
|
|
1069
1326
|
const last = events[events.length - 1];
|
|
1070
1327
|
if (!last)
|
|
1071
1328
|
return null;
|
|
1072
|
-
|
|
1329
|
+
// FUL-1144: event text is server/agent-written and lands in model-visible prose — in a thrown
|
|
1330
|
+
// failure line here, and in `scoped_research_status`'s returned text — so it is flattened, not
|
|
1331
|
+
// just clipped. `truncate` bounds length; only `safeInline` stops a newline forging a line.
|
|
1332
|
+
const type = safeInline(last.eventType, 40) ?? 'event';
|
|
1073
1333
|
const data = last.eventData ?? {};
|
|
1074
|
-
if (typeof data.message === 'string'
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1334
|
+
if (typeof data.message === 'string') {
|
|
1335
|
+
const message = safeInline(data.message, 120);
|
|
1336
|
+
if (message)
|
|
1337
|
+
return truncate(`${type}: ${message}`, 120);
|
|
1338
|
+
}
|
|
1339
|
+
const phase = safeInline(data.phase, 40);
|
|
1340
|
+
if (phase)
|
|
1341
|
+
return truncate(`${type}: ${phase}`, 120);
|
|
1078
1342
|
return type;
|
|
1079
1343
|
}
|
|
1080
1344
|
function formatZodError(error) {
|