@clien-ai/mcp 0.17.0 → 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 +39 -0
- package/README.md +2 -1
- package/dist/cli.js +0 -0
- package/dist/tools/projects.js.map +1 -1
- package/dist/tools/registry.js +30 -10
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/research.js +15 -9
- 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 +305 -50
- 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/package.json +3 -3
|
@@ -87,6 +87,11 @@ export function normalizeRunManifest(raw) {
|
|
|
87
87
|
? { notApplicablePhases: tokens(rawSummary.notApplicablePhases) }
|
|
88
88
|
: {}),
|
|
89
89
|
missingPhases: tokens(rawSummary.missingPhases),
|
|
90
|
+
// FUL-1188, same skew rule: `[]` would tell a structured host
|
|
91
|
+
// "this run kind has every phase", which an older deploy never said.
|
|
92
|
+
...(Array.isArray(rawSummary.structurallyAbsentPhases)
|
|
93
|
+
? { structurallyAbsentPhases: tokens(rawSummary.structurallyAbsentPhases) }
|
|
94
|
+
: {}),
|
|
90
95
|
incompletePhase: safeInline(rawSummary.incompletePhase, TOKEN_MAX),
|
|
91
96
|
incompleteReason: safeInline(rawSummary.incompleteReason, TOKEN_MAX),
|
|
92
97
|
// Same skew rule as `notApplicablePhases`: an older deploy never
|
|
@@ -104,6 +109,21 @@ export const MANIFEST_BLOCK_HEADING = 'RUN PROVENANCE';
|
|
|
104
109
|
function listOrNone(values) {
|
|
105
110
|
return values.length > 0 ? values.join(', ') : 'none';
|
|
106
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* FUL-1188 — the clause naming phases this run kind never has, or `''`.
|
|
114
|
+
*
|
|
115
|
+
* Printed only for a NON-EMPTY list. An absent list (an app deploy older than
|
|
116
|
+
* the field) and an empty one (a hero run) both print nothing: there is no
|
|
117
|
+
* "none" branch, so this renderer can never claim that no phases are absent on
|
|
118
|
+
* the strength of a server that did not say.
|
|
119
|
+
*/
|
|
120
|
+
function structurallyAbsentClause(summary) {
|
|
121
|
+
const absent = summary?.structurallyAbsentPhases;
|
|
122
|
+
if (!absent || absent.length === 0)
|
|
123
|
+
return '';
|
|
124
|
+
const kind = summary?.runKind ? `${summary.runKind} runs` : 'this kind of run';
|
|
125
|
+
return `; never part of ${kind}: ${absent.join(', ')}`;
|
|
126
|
+
}
|
|
107
127
|
/**
|
|
108
128
|
* Render one manifest answer as agent-visible text. `''` for `null` — an unknown
|
|
109
129
|
* answer prints NOTHING rather than a hedge an agent could mistake for a fact.
|
|
@@ -142,12 +162,13 @@ export function renderRunManifest(view, context) {
|
|
|
142
162
|
'phase, so nothing it did is in the provenance record (phases never sealed: ' +
|
|
143
163
|
`${listOrNone(summary.missingPhases ?? [])})`
|
|
144
164
|
: `phases still unsealed: ${listOrNone(summary?.missingPhases ?? [])}`;
|
|
165
|
+
const absent = structurallyAbsentClause(summary);
|
|
145
166
|
// The report clause is the CALLER's to make, never this renderer's: a
|
|
146
167
|
// cancelled run reaches here with no report at all.
|
|
147
168
|
const stillReadable = context.reportDelivered
|
|
148
169
|
? 'The report above is readable and its findings are unaffected, but this '
|
|
149
170
|
: 'This ';
|
|
150
|
-
return (`${head}INCOMPLETE. ${kind}${failed}. ${stillReadable}run's provenance ` +
|
|
171
|
+
return (`${head}INCOMPLETE. ${kind}${failed}${absent}. ${stillReadable}run's provenance ` +
|
|
151
172
|
'has a gap: what it ran cannot be fully reconstructed from the record, ' +
|
|
152
173
|
'and it is not resumable.');
|
|
153
174
|
}
|
|
@@ -161,7 +182,8 @@ export function renderRunManifest(view, context) {
|
|
|
161
182
|
const pending = summary && summary.missingPhases && summary.missingPhases.length > 0
|
|
162
183
|
? `; not sealed yet: ${summary.missingPhases.join(', ')}`
|
|
163
184
|
: '';
|
|
164
|
-
|
|
185
|
+
const absent = structurallyAbsentClause(summary);
|
|
186
|
+
return (`${head}recorded and coherent. ${kind}${sealed}${notApplicable}${pending}${absent}. This says the ` +
|
|
165
187
|
'provenance CHAIN holds — the manifest matches the run\'s start record and ' +
|
|
166
188
|
'carries no unrepaired failure. It is not a claim about the research findings.');
|
|
167
189
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"run-manifest.js","sourceRoot":"","sources":["../../src/tools/run-manifest.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAA;AAEzD;;;;GAIG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG;IAC/B,WAAW;IACX,oBAAoB;IACpB,SAAS;IACT,YAAY;CACJ,CAAA;AAGV,iFAAiF;AACjF,MAAM,SAAS,GAAG,EAAE,CAAA;AACpB,qGAAqG;AACrG,MAAM,cAAc,GAAG,EAAE,CAAA;
|
|
1
|
+
{"version":3,"file":"run-manifest.js","sourceRoot":"","sources":["../../src/tools/run-manifest.ts"],"names":[],"mappings":"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"}
|
|
@@ -43,6 +43,7 @@ import { apiCall, categorizeFetchError, promoteCompletedJob, webJobUrl, } from '
|
|
|
43
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
|
|
@@ -1078,12 +1326,19 @@ function summarizeEvent(events) {
|
|
|
1078
1326
|
const last = events[events.length - 1];
|
|
1079
1327
|
if (!last)
|
|
1080
1328
|
return null;
|
|
1081
|
-
|
|
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';
|
|
1082
1333
|
const data = last.eventData ?? {};
|
|
1083
|
-
if (typeof data.message === 'string'
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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);
|
|
1087
1342
|
return type;
|
|
1088
1343
|
}
|
|
1089
1344
|
function formatZodError(error) {
|