@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.
@@ -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
- // clien_research_status, which returns them in _meta. (review follow-up.)
296
- const { refundNote, retryHint, retryPointless } = buildFailureGuidance({
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
- let promotion = {
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
- await deps.sleep(deps.pollIntervalMs);
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
- message: summary ?? `phase: ${status}`,
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 { status, lastMessage, totalCostCents, errorCategory, refundedCredits, refundPending, startedSandbox };
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
- /** GET the structured report (JSON variant). Returns report_data (may be null). */
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 projectReportDataForMcp(json?.reportData ?? null) ?? null;
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 four adds no noise, and one with a couple stays compact. These are
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', safeInline(c.funding, 80)],
777
- ['Employees', safeInline(c.employeeCount, 40)],
778
- ['Founded', safeInline(c.founded, 40)],
779
- ['HQ', safeInline(c.hqLocation, 80)],
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 four company facts as `list_competitors`, and
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 30 minutes" is the
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 ?? parsed?.message ?? 'Our research infrastructure is experiencing issues. Please try again in 30 minutes.');
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
- const type = last.eventType ?? 'event';
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' && data.message.length > 0)
1075
- return truncate(`${type}: ${data.message}`, 120);
1076
- if (typeof data.phase === 'string')
1077
- return truncate(`${type}: ${data.phase}`, 120);
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) {