@jangmanj/pi-webtasks 0.1.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/LICENSE +21 -0
- package/README.md +193 -0
- package/package.json +64 -0
- package/src/alert-delivery.ts +128 -0
- package/src/answer-service.ts +237 -0
- package/src/env.ts +68 -0
- package/src/index.ts +175 -0
- package/src/ops.ts +64 -0
- package/src/paid-call.ts +202 -0
- package/src/parallel-oauth.ts +249 -0
- package/src/provider.ts +63 -0
- package/src/recall.ts +151 -0
- package/src/receipt.ts +95 -0
- package/src/research-service.ts +407 -0
- package/src/research-tools.ts +244 -0
- package/src/source-policy.ts +60 -0
- package/src/status.ts +154 -0
- package/src/store.ts +1445 -0
- package/src/task-run-lifecycle.ts +393 -0
- package/src/task-service.ts +489 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Task run lifecycle: what a provider fact does to a local Task run, and the
|
|
3
|
+
* progress timetable while it runs.
|
|
4
|
+
*
|
|
5
|
+
* Callers report facts; this module decides and commits. Nothing here talks to
|
|
6
|
+
* Parallel: the collection loop in `task-service.ts` reads the event stream, the
|
|
7
|
+
* run status, and the result, and hands each fact over. That keeps every
|
|
8
|
+
* transition testable with a local store and plain values.
|
|
9
|
+
*
|
|
10
|
+
* - `bound`: Parallel accepted this run under this provider id. An unconfirmed
|
|
11
|
+
* run (`submitting`, `uncertain`) becomes `accepted`.
|
|
12
|
+
* - `observed`: Parallel reports the run with this status. Any report about a
|
|
13
|
+
* bound run confirms it. `running` makes it `pending` and starts (or, after
|
|
14
|
+
* `action_required`, restarts) its progress timetable. `queued` and
|
|
15
|
+
* `cancelling` change nothing more. `action_required` is alerted once per
|
|
16
|
+
* entry and stays watched. `completed`, `failed`, and `cancelled` settle it
|
|
17
|
+
* with its evidence and alert in one transaction. Returns whether the run is
|
|
18
|
+
* still open, which is whether collection should keep watching it.
|
|
19
|
+
* - `tick`: queue the progress alerts that are due and advance their schedules.
|
|
20
|
+
*
|
|
21
|
+
* A terminal run is never moved: the store's guards enforce that inside the
|
|
22
|
+
* same statement as each write, so a late or replayed fact changes nothing.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { FieldBasis, TaskRunResult } from "parallel-web/resources";
|
|
26
|
+
import {
|
|
27
|
+
OPEN_RUN_STATES,
|
|
28
|
+
TERMINAL_RUN_STATES,
|
|
29
|
+
UNCONFIRMED_RUN_STATES,
|
|
30
|
+
type AlertInput,
|
|
31
|
+
type CitationInput,
|
|
32
|
+
type EvidenceInput,
|
|
33
|
+
type ResearchStore,
|
|
34
|
+
type RunState,
|
|
35
|
+
} from "./store.ts";
|
|
36
|
+
import { clean, errorText, oneLine } from "./receipt.ts";
|
|
37
|
+
|
|
38
|
+
export const TASK_PROCESSORS = ["core", "core2x", "pro", "ultra", "ultra2x"] as const;
|
|
39
|
+
export type TaskProcessor = (typeof TASK_PROCESSORS)[number];
|
|
40
|
+
export const TASK_FLOOR: TaskProcessor = "core";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Progress guidance per processor: one third of the lower of the two published
|
|
44
|
+
* upper latency estimates (processor guide and pricing page). These are
|
|
45
|
+
* guidance for "send an update this often", never deadlines or percentages.
|
|
46
|
+
* Override with WEBTASKS_PROGRESS_MS='{"core":100000,...}'.
|
|
47
|
+
*/
|
|
48
|
+
export const PROGRESS_INTERVALS_MS: Record<TaskProcessor, number> = {
|
|
49
|
+
core: 100_000,
|
|
50
|
+
core2x: 200_000,
|
|
51
|
+
pro: 200_000,
|
|
52
|
+
ultra: 500_000,
|
|
53
|
+
ultra2x: 500_000,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export function progressIntervals(): Record<TaskProcessor, number> {
|
|
57
|
+
const raw = process.env.WEBTASKS_PROGRESS_MS?.trim();
|
|
58
|
+
if (!raw) return PROGRESS_INTERVALS_MS;
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
61
|
+
const merged = { ...PROGRESS_INTERVALS_MS };
|
|
62
|
+
for (const processor of TASK_PROCESSORS) {
|
|
63
|
+
const value = parsed[processor];
|
|
64
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 5_000) merged[processor] = Math.trunc(value);
|
|
65
|
+
}
|
|
66
|
+
return merged;
|
|
67
|
+
} catch {
|
|
68
|
+
return PROGRESS_INTERVALS_MS;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Provider statuses that settle a run. */
|
|
73
|
+
const SETTLING: ReadonlySet<string> = new Set(["completed", "failed", "cancelled"]);
|
|
74
|
+
/** Provider statuses of a run that is still active and needs nothing from us. */
|
|
75
|
+
const ACTIVE: ReadonlySet<string> = new Set(["queued", "running", "cancelling"]);
|
|
76
|
+
/** States a run can enter action_required from: open, and not already halted, so a repeat is not a new entry. */
|
|
77
|
+
const HALTABLE: ReadonlySet<RunState> = new Set([...OPEN_RUN_STATES].filter(state => state !== "action_required"));
|
|
78
|
+
/** Result fetches for a completed run before it is committed without output. */
|
|
79
|
+
const RESULT_ATTEMPTS = 5;
|
|
80
|
+
|
|
81
|
+
/** One thing Parallel said about a run. */
|
|
82
|
+
export type ProviderFact = {
|
|
83
|
+
/** The provider run status (`queued`, `running`, `completed`, ...). */
|
|
84
|
+
status: string;
|
|
85
|
+
interactionId?: string;
|
|
86
|
+
/** The provider's error object or message, for a failed or halted run. */
|
|
87
|
+
error?: unknown;
|
|
88
|
+
/** The provider run object, kept with a settled run. */
|
|
89
|
+
run?: unknown;
|
|
90
|
+
/** For `completed`: the fetched result, or undefined when the fetch failed. */
|
|
91
|
+
result?: TaskRunResult;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
type Options = {
|
|
95
|
+
store: ResearchStore;
|
|
96
|
+
sessionId: string;
|
|
97
|
+
/** Alerts were committed: wake the delivery. */
|
|
98
|
+
onAlerts?: () => void;
|
|
99
|
+
log?: (message: string) => void;
|
|
100
|
+
intervals?: Record<TaskProcessor, number>;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** The provider status of a run-shaped value, or "unknown". */
|
|
104
|
+
export function providerStatus(run: unknown): string {
|
|
105
|
+
const status = (run as { status?: unknown } | null | undefined)?.status;
|
|
106
|
+
return typeof status === "string" ? status : "unknown";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function createTaskRunLifecycle(options: Options) {
|
|
110
|
+
const { store, sessionId } = options;
|
|
111
|
+
const log = options.log ?? (() => undefined);
|
|
112
|
+
const onAlerts = options.onAlerts ?? (() => undefined);
|
|
113
|
+
const intervals = options.intervals ?? progressIntervals();
|
|
114
|
+
/** Completed runs whose result could not be fetched yet, with the attempts made so far. */
|
|
115
|
+
const resultAttempts = new Map<string, number>();
|
|
116
|
+
let ticking = false;
|
|
117
|
+
|
|
118
|
+
function intervalFor(processor: string | undefined): number {
|
|
119
|
+
return intervals[processor as TaskProcessor] ?? intervals[TASK_FLOOR];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function bound(reference: string, input: { providerRunId: string; groupId?: string; interactionId?: string }): Promise<void> {
|
|
123
|
+
await store.bindProviderRun({ sessionId, reference, ...input });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function observed(reference: string, fact: ProviderFact): Promise<{ open: boolean }> {
|
|
127
|
+
const run = await store.run(sessionId, reference);
|
|
128
|
+
if (!run || TERMINAL_RUN_STATES.has(run.state)) return { open: false };
|
|
129
|
+
const status = fact.status;
|
|
130
|
+
|
|
131
|
+
// Any report about a bound run confirms it exists, and may carry its interaction id.
|
|
132
|
+
if (run.provider_run_id && (UNCONFIRMED_RUN_STATES.has(run.state) || (fact.interactionId && fact.interactionId !== run.provider_interaction_id))) {
|
|
133
|
+
await store.bindProviderRun({ sessionId, reference, providerRunId: run.provider_run_id, interactionId: fact.interactionId });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (status === "running") {
|
|
137
|
+
await store.markRunning(sessionId, reference, intervalFor(run.processor));
|
|
138
|
+
return { open: true };
|
|
139
|
+
}
|
|
140
|
+
if (ACTIVE.has(status)) return { open: true };
|
|
141
|
+
if (status === "action_required") {
|
|
142
|
+
const reason = errorText(fact.error);
|
|
143
|
+
const moved = await store.transitionRun({
|
|
144
|
+
sessionId, reference, from: HALTABLE, to: "action_required", error: reason,
|
|
145
|
+
alert: {
|
|
146
|
+
kind: "action_required",
|
|
147
|
+
headline: "Research needs attention: Parallel reports action required",
|
|
148
|
+
body: [
|
|
149
|
+
`Run ${reference} is halted at Parallel with status action_required${reason ? `: ${reason}` : ""}.`,
|
|
150
|
+
"It is still watched: if it resumes or ends, that arrives as another alert.",
|
|
151
|
+
`Read what it stored so far with web_recall { reference: "${reference}" }.`,
|
|
152
|
+
].join(" "),
|
|
153
|
+
// One alert per entry into the state: a run that resumes and halts again is alerted again.
|
|
154
|
+
dedup_key: `task-action_required:${reference}:${store.now()}`,
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
if (moved) onAlerts();
|
|
158
|
+
return { open: true };
|
|
159
|
+
}
|
|
160
|
+
if (!SETTLING.has(status)) {
|
|
161
|
+
log(`run ${reference} reported an unknown status ${status}`);
|
|
162
|
+
return { open: true };
|
|
163
|
+
}
|
|
164
|
+
return settle(reference, run.provider_run_id, run.provider_interaction_id, fact);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function settle(reference: string, providerRunId: string | undefined, knownInteraction: string | undefined, fact: ProviderFact): Promise<{ open: boolean }> {
|
|
168
|
+
const status = fact.status as "completed" | "failed" | "cancelled";
|
|
169
|
+
const interactionId = fact.interactionId ?? knownInteraction;
|
|
170
|
+
|
|
171
|
+
if (status === "completed") {
|
|
172
|
+
const stateOutput = (await latestStateEvent(reference)) as { output?: TaskRunResult["output"] } | undefined;
|
|
173
|
+
const output = fact.result?.output ?? stateOutput?.output;
|
|
174
|
+
if (!output) {
|
|
175
|
+
// A completed run is final: committing it without output would lose the result for good.
|
|
176
|
+
// Leave it open so collection fetches again, and give up only after repeated failures.
|
|
177
|
+
const attempts = (resultAttempts.get(reference) ?? 0) + 1;
|
|
178
|
+
resultAttempts.set(reference, attempts);
|
|
179
|
+
if (attempts < RESULT_ATTEMPTS) return { open: true };
|
|
180
|
+
}
|
|
181
|
+
resultAttempts.delete(reference);
|
|
182
|
+
const { evidence, citations } = outputEvidence(output);
|
|
183
|
+
const summary = summarizeOutput(output, evidence.filter(item => item.kind === "source").length);
|
|
184
|
+
const committed = await store.completeRun({
|
|
185
|
+
sessionId, reference, state: "completed", summary,
|
|
186
|
+
providerRunId, providerInteractionId: interactionId,
|
|
187
|
+
rawResult: fact.result ?? stateOutput ?? { run: fact.run },
|
|
188
|
+
evidence,
|
|
189
|
+
citations,
|
|
190
|
+
alerts: [{
|
|
191
|
+
kind: "completion",
|
|
192
|
+
headline: `Research completed: ${summary}`,
|
|
193
|
+
body: completionBody(reference, evidence),
|
|
194
|
+
dedup_key: `task-complete:${reference}`,
|
|
195
|
+
}],
|
|
196
|
+
}).then(() => true, err => {
|
|
197
|
+
log(`completion for ${reference} could not be committed: ${errorText(err) ?? "unknown error"}`);
|
|
198
|
+
return false;
|
|
199
|
+
});
|
|
200
|
+
if (committed) onAlerts();
|
|
201
|
+
return { open: !committed };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const reason = errorText(fact.error) ?? status;
|
|
205
|
+
const state: RunState = status;
|
|
206
|
+
const alert: AlertInput = {
|
|
207
|
+
kind: "failure",
|
|
208
|
+
headline: status === "failed" ? `Research failed: ${oneLine(reason, 120)}` : "Research was cancelled",
|
|
209
|
+
body: `Run ${reference} ended as ${status}${reason && reason !== status ? `: ${reason}` : ""}. Read what it stored with web_recall { reference: "${reference}" }.`,
|
|
210
|
+
dedup_key: `task-${state}:${reference}`,
|
|
211
|
+
};
|
|
212
|
+
const committed = await store.completeRun({
|
|
213
|
+
sessionId, reference, state: status,
|
|
214
|
+
error: reason,
|
|
215
|
+
providerRunId,
|
|
216
|
+
providerInteractionId: interactionId,
|
|
217
|
+
rawResult: { run: fact.run },
|
|
218
|
+
alerts: [alert],
|
|
219
|
+
}).then(() => true, err => {
|
|
220
|
+
log(`terminal state for ${reference} could not be committed: ${errorText(err) ?? "unknown error"}`);
|
|
221
|
+
return false;
|
|
222
|
+
});
|
|
223
|
+
if (committed) onAlerts();
|
|
224
|
+
return { open: !committed };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function latestStateEvent(reference: string): Promise<unknown> {
|
|
228
|
+
return (await store.latestObservations(sessionId, reference, { eventType: "task_run.state", limit: 1 }))[0]?.payload;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Queue due progress alerts and advance their schedules. Returns how many were queued. */
|
|
232
|
+
async function tick(): Promise<number> {
|
|
233
|
+
if (ticking) return 0;
|
|
234
|
+
ticking = true;
|
|
235
|
+
let queued = 0;
|
|
236
|
+
try {
|
|
237
|
+
for (const run of await store.runsNeedingProgress(sessionId)) {
|
|
238
|
+
const intervalMs = run.alert_interval_ms ?? intervalFor(run.processor);
|
|
239
|
+
const processor = run.processor ?? TASK_FLOOR;
|
|
240
|
+
const latest = await latestProgress(run.reference);
|
|
241
|
+
const queuedOne = await store.queueProgress(sessionId, run.reference, {
|
|
242
|
+
kind: "progress",
|
|
243
|
+
headline: latest.sources !== undefined
|
|
244
|
+
? `Research still running (${processor}): ${latest.sources} source(s) considered`
|
|
245
|
+
: `Research still running (${processor})`,
|
|
246
|
+
body: [
|
|
247
|
+
latest.finding
|
|
248
|
+
? `Latest finding: ${oneLine(latest.finding, 220)}`
|
|
249
|
+
: latest.message ? `Latest activity: ${oneLine(latest.message, 220)}` : "No new details yet; the run is still active.",
|
|
250
|
+
`Partial evidence so far: web_recall { reference: "${run.reference}" }.`,
|
|
251
|
+
].join(" "),
|
|
252
|
+
dedup_key: `task-progress:${run.reference}:${Math.floor(store.now() / intervalMs)}`,
|
|
253
|
+
});
|
|
254
|
+
if (queuedOne) queued++;
|
|
255
|
+
}
|
|
256
|
+
if (queued) onAlerts();
|
|
257
|
+
return queued;
|
|
258
|
+
} finally {
|
|
259
|
+
ticking = false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function latestProgress(reference: string): Promise<{ sources?: number; message?: string; finding?: string }> {
|
|
264
|
+
// Newest first: the first value of each kind is the latest one.
|
|
265
|
+
const events = await store.latestObservations(sessionId, reference, { limit: 100 });
|
|
266
|
+
let sources: number | undefined;
|
|
267
|
+
let message: string | undefined;
|
|
268
|
+
let finding: string | undefined;
|
|
269
|
+
for (const event of events) {
|
|
270
|
+
const payload = event.payload as { source_stats?: { num_sources_considered?: number | null }; num_sources_considered?: number | null; message?: string };
|
|
271
|
+
const considered = payload?.source_stats?.num_sources_considered ?? payload?.num_sources_considered;
|
|
272
|
+
if (sources === undefined && typeof considered === "number") sources = considered;
|
|
273
|
+
if (typeof payload?.message === "string") {
|
|
274
|
+
message ??= payload.message;
|
|
275
|
+
if (finding === undefined && event.eventType.endsWith(".result")) finding = payload.message;
|
|
276
|
+
}
|
|
277
|
+
if (sources !== undefined && message !== undefined && finding !== undefined) break;
|
|
278
|
+
}
|
|
279
|
+
return { sources, message, finding };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return { bound, observed, tick };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export type TaskRunLifecycle = ReturnType<typeof createTaskRunLifecycle>;
|
|
286
|
+
|
|
287
|
+
function completionBody(reference: string, evidence: EvidenceInput[]): string {
|
|
288
|
+
const sources = evidence.filter(item => item.kind === "source").length;
|
|
289
|
+
const fields = evidence.filter(item => item.kind === "structured").length;
|
|
290
|
+
const parts = [sources ? `${sources} cited source(s)` : undefined, fields ? `${fields} structured field(s)` : undefined].filter(Boolean);
|
|
291
|
+
return [
|
|
292
|
+
`Run ${reference} completed${parts.length ? ` with ${parts.join(" and ")}` : ""}.`,
|
|
293
|
+
`Read the report with web_recall { reference: "${reference}" }, or read one item by handle.`,
|
|
294
|
+
].join(" ");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Build evidence and citation links from a completed Task output and its basis. */
|
|
298
|
+
function outputEvidence(output: TaskRunResult["output"] | undefined): { evidence: EvidenceInput[]; citations: CitationInput[] } {
|
|
299
|
+
if (!output) return { evidence: [], citations: [] };
|
|
300
|
+
const evidence: EvidenceInput[] = [];
|
|
301
|
+
const citations: CitationInput[] = [];
|
|
302
|
+
const basis: FieldBasis[] = Array.isArray(output.basis) ? output.basis : [];
|
|
303
|
+
|
|
304
|
+
const sourceRefs = new Map<string, string>();
|
|
305
|
+
for (const field of basis) {
|
|
306
|
+
for (const citation of field.citations ?? []) {
|
|
307
|
+
const url = clean(citation.url);
|
|
308
|
+
if (!url || sourceRefs.has(url)) continue;
|
|
309
|
+
const ref = `src:${sourceRefs.size}`;
|
|
310
|
+
sourceRefs.set(url, ref);
|
|
311
|
+
const excerpts = (citation.excerpts ?? []).filter(text => text.trim());
|
|
312
|
+
evidence.push({
|
|
313
|
+
kind: "source",
|
|
314
|
+
origin: "provider_source",
|
|
315
|
+
title: clean(citation.title),
|
|
316
|
+
url,
|
|
317
|
+
pointer: `basis[${basis.indexOf(field)}].citations`,
|
|
318
|
+
body: excerpts.length ? excerpts.join("\n\n") : url,
|
|
319
|
+
payload: citation,
|
|
320
|
+
ref,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const content = output.content;
|
|
326
|
+
if (typeof content === "string") {
|
|
327
|
+
evidence.push({ kind: "report", origin: "provider_report", title: "Research report", body: content, pointer: "output.content", payload: output, ref: "report" });
|
|
328
|
+
for (const field of basis) {
|
|
329
|
+
for (const citation of field.citations ?? []) {
|
|
330
|
+
const url = clean(citation.url);
|
|
331
|
+
const ref = url ? sourceRefs.get(url) : undefined;
|
|
332
|
+
if (!ref) continue;
|
|
333
|
+
citations.push({
|
|
334
|
+
evidence: "report",
|
|
335
|
+
source: ref,
|
|
336
|
+
claim: oneLine(field.reasoning ?? field.field ?? "", 400) || undefined,
|
|
337
|
+
url,
|
|
338
|
+
title: clean(citation.title),
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return { evidence, citations };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const fields = content && typeof content === "object" && !Array.isArray(content) ? Object.entries(content) : [];
|
|
346
|
+
evidence.push({
|
|
347
|
+
kind: "structured",
|
|
348
|
+
origin: "provider_structured",
|
|
349
|
+
title: "Structured research output",
|
|
350
|
+
body: JSON.stringify(content, null, 2),
|
|
351
|
+
pointer: "output.content",
|
|
352
|
+
payload: output,
|
|
353
|
+
ref: "document",
|
|
354
|
+
});
|
|
355
|
+
for (const [name, value] of fields) {
|
|
356
|
+
const ref = `field:${name}`;
|
|
357
|
+
evidence.push({
|
|
358
|
+
kind: "structured",
|
|
359
|
+
origin: "provider_structured",
|
|
360
|
+
title: name,
|
|
361
|
+
body: typeof value === "string" ? value : JSON.stringify(value, null, 2),
|
|
362
|
+
pointer: `output.content.${name}`,
|
|
363
|
+
ref,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
for (const field of basis) {
|
|
367
|
+
const name = typeof field.field === "string" ? field.field : undefined;
|
|
368
|
+
const target = name && fields.some(([candidate]) => candidate === name) ? `field:${name}` : "document";
|
|
369
|
+
for (const citation of field.citations ?? []) {
|
|
370
|
+
const url = clean(citation.url);
|
|
371
|
+
const source = url ? sourceRefs.get(url) : undefined;
|
|
372
|
+
if (!source) continue;
|
|
373
|
+
citations.push({
|
|
374
|
+
evidence: target,
|
|
375
|
+
source,
|
|
376
|
+
claim: oneLine(field.reasoning ?? field.field ?? "", 400) || undefined,
|
|
377
|
+
url,
|
|
378
|
+
title: clean(citation.title),
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return { evidence, citations };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function summarizeOutput(output: TaskRunResult["output"] | undefined, sources: number): string {
|
|
386
|
+
if (!output) return "completed without retrievable output";
|
|
387
|
+
if (typeof output.content === "string") {
|
|
388
|
+
const firstLine = output.content.split("\n").map(line => line.replace(/^#+\s*/, "").trim()).find(Boolean);
|
|
389
|
+
return oneLine(firstLine ?? output.content.replace(/\s+/g, " "), 120);
|
|
390
|
+
}
|
|
391
|
+
const fields = output.content && typeof output.content === "object" ? Object.keys(output.content) : [];
|
|
392
|
+
return fields.length ? `structured output: ${fields.slice(0, 6).join(", ")}${fields.length > 6 ? `, +${fields.length - 6} more` : ""}${sources ? ` (${sources} cited sources)` : ""}` : "structured output";
|
|
393
|
+
}
|