@flowajs/chat-service 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +210 -0
- package/dist/artifact.d.ts +87 -0
- package/dist/artifact.d.ts.map +1 -0
- package/dist/artifact.js +99 -0
- package/dist/artifact.js.map +1 -0
- package/dist/audit.d.ts +28 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +73 -0
- package/dist/audit.js.map +1 -0
- package/dist/auth/jwt.d.ts +18 -0
- package/dist/auth/jwt.d.ts.map +1 -0
- package/dist/auth/jwt.js +23 -0
- package/dist/auth/jwt.js.map +1 -0
- package/dist/auth/oidc.d.ts +30 -0
- package/dist/auth/oidc.d.ts.map +1 -0
- package/dist/auth/oidc.js +58 -0
- package/dist/auth/oidc.js.map +1 -0
- package/dist/chat.d.ts +161 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +636 -0
- package/dist/chat.js.map +1 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +47 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +80 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/instrumentation.d.ts +31 -0
- package/dist/instrumentation.d.ts.map +1 -0
- package/dist/instrumentation.js +151 -0
- package/dist/instrumentation.js.map +1 -0
- package/dist/llm/anthropic.d.ts +22 -0
- package/dist/llm/anthropic.d.ts.map +1 -0
- package/dist/llm/anthropic.js +40 -0
- package/dist/llm/anthropic.js.map +1 -0
- package/dist/llm/bedrock.d.ts +34 -0
- package/dist/llm/bedrock.d.ts.map +1 -0
- package/dist/llm/bedrock.js +54 -0
- package/dist/llm/bedrock.js.map +1 -0
- package/dist/llm/factory.d.ts +3 -0
- package/dist/llm/factory.d.ts.map +1 -0
- package/dist/llm/factory.js +59 -0
- package/dist/llm/factory.js.map +1 -0
- package/dist/llm/google-gla.d.ts +22 -0
- package/dist/llm/google-gla.d.ts.map +1 -0
- package/dist/llm/google-gla.js +29 -0
- package/dist/llm/google-gla.js.map +1 -0
- package/dist/llm/google-vertex.d.ts +21 -0
- package/dist/llm/google-vertex.d.ts.map +1 -0
- package/dist/llm/google-vertex.js +28 -0
- package/dist/llm/google-vertex.js.map +1 -0
- package/dist/llm/interface.d.ts +45 -0
- package/dist/llm/interface.d.ts.map +1 -0
- package/dist/llm/interface.js +2 -0
- package/dist/llm/interface.js.map +1 -0
- package/dist/llm/openai.d.ts +19 -0
- package/dist/llm/openai.d.ts.map +1 -0
- package/dist/llm/openai.js +25 -0
- package/dist/llm/openai.js.map +1 -0
- package/dist/prompts.d.ts +7 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +17 -0
- package/dist/prompts.js.map +1 -0
- package/dist/server.d.ts +39 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +106 -0
- package/dist/server.js.map +1 -0
- package/dist/session.d.ts +68 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +245 -0
- package/dist/session.js.map +1 -0
- package/dist/storage/factory.d.ts +28 -0
- package/dist/storage/factory.d.ts.map +1 -0
- package/dist/storage/factory.js +33 -0
- package/dist/storage/factory.js.map +1 -0
- package/dist/storage/fs.d.ts +14 -0
- package/dist/storage/fs.d.ts.map +1 -0
- package/dist/storage/fs.js +116 -0
- package/dist/storage/fs.js.map +1 -0
- package/dist/storage/gcs.d.ts +27 -0
- package/dist/storage/gcs.d.ts.map +1 -0
- package/dist/storage/gcs.js +81 -0
- package/dist/storage/gcs.js.map +1 -0
- package/dist/storage/interface.d.ts +33 -0
- package/dist/storage/interface.d.ts.map +1 -0
- package/dist/storage/interface.js +12 -0
- package/dist/storage/interface.js.map +1 -0
- package/dist/storage/s3.d.ts +29 -0
- package/dist/storage/s3.d.ts.map +1 -0
- package/dist/storage/s3.js +109 -0
- package/dist/storage/s3.js.map +1 -0
- package/dist/storage-keys.d.ts +33 -0
- package/dist/storage-keys.d.ts.map +1 -0
- package/dist/storage-keys.js +76 -0
- package/dist/storage-keys.js.map +1 -0
- package/dist/telemetry.d.ts +29 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +116 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/yaml.d.ts +42 -0
- package/dist/yaml.d.ts.map +1 -0
- package/dist/yaml.js +121 -0
- package/dist/yaml.js.map +1 -0
- package/package.json +124 -0
package/dist/chat.js
ADDED
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
/** Main streaming chat endpoint. */
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { ToolLoopAgent, createAgentUIStream, createUIMessageStreamResponse, generateText, stepCountIs, } from "ai";
|
|
4
|
+
import { trace } from "@opentelemetry/api";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { withToolMetrics, recordTokenUsage, recordValidationError, recordToolValidationFailure, recordStorageWriteFailure, recordCachedInputTokens, } from "./telemetry.js";
|
|
7
|
+
import { CITATION_INSTRUCTIONS } from "./session.js";
|
|
8
|
+
import { loadExtraction, loadMarkdown, writeEditDraft, } from "./storage-keys.js";
|
|
9
|
+
import { logRequest, logStep, logResponse, markTurnTruncated, } from "./audit.js";
|
|
10
|
+
import { parseArtifactYaml, reattachBboxes, addLineNumbers, viewRange, insertAtLine, searchArtifact, artifactToYaml, } from "./yaml.js";
|
|
11
|
+
const tracer = trace.getTracer("chat-service");
|
|
12
|
+
/** Maximum tool-loop steps per turn. The AI SDK default is 20; allow more
|
|
13
|
+
* because str_replace retries (escaping mismatches, etc.) can burn steps. */
|
|
14
|
+
const MAX_STEPS = 30;
|
|
15
|
+
function resolveDoi(session, paperId) {
|
|
16
|
+
return session.paperIds[paperId] ?? null;
|
|
17
|
+
}
|
|
18
|
+
function resolveDois(session, paperIds) {
|
|
19
|
+
const dois = [];
|
|
20
|
+
const invalid = [];
|
|
21
|
+
for (const paperId of paperIds) {
|
|
22
|
+
const doi = resolveDoi(session, paperId);
|
|
23
|
+
if (doi)
|
|
24
|
+
dois.push(doi);
|
|
25
|
+
else
|
|
26
|
+
invalid.push(paperId);
|
|
27
|
+
}
|
|
28
|
+
return { dois, invalid };
|
|
29
|
+
}
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Triage state
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
export const TriageStateSchema = z.object({
|
|
34
|
+
version_id: z.string().optional(),
|
|
35
|
+
accepted: z
|
|
36
|
+
.array(z.object({
|
|
37
|
+
paper_id: z.string(),
|
|
38
|
+
claim_index: z.number().int().positive(),
|
|
39
|
+
}))
|
|
40
|
+
.default([]),
|
|
41
|
+
rejected: z
|
|
42
|
+
.array(z.object({
|
|
43
|
+
paper_id: z.string(),
|
|
44
|
+
claim_index: z.number().int().positive(),
|
|
45
|
+
}))
|
|
46
|
+
.default([]),
|
|
47
|
+
papers_done: z.array(z.string()).default([]),
|
|
48
|
+
/**
|
|
49
|
+
* Per-claim comments. Reviewers often explain why they rejected a claim
|
|
50
|
+
* or what caveat applies to an accepted one; those notes are
|
|
51
|
+
* rewrite-relevant.
|
|
52
|
+
*/
|
|
53
|
+
comments: z
|
|
54
|
+
.array(z.object({
|
|
55
|
+
paper_id: z.string(),
|
|
56
|
+
claim_index: z.number().int().positive(),
|
|
57
|
+
body: z.string(),
|
|
58
|
+
}))
|
|
59
|
+
.default([]),
|
|
60
|
+
});
|
|
61
|
+
/**
|
|
62
|
+
* Render the `{triage_state}` prompt block. Resolves each claim identified
|
|
63
|
+
* by `(paper_id, claim_index)` to human-readable form using the current
|
|
64
|
+
* artifact, which has the same claim order the client observed.
|
|
65
|
+
*/
|
|
66
|
+
export function renderTriageStateBlock(artifact, state) {
|
|
67
|
+
if (!state) {
|
|
68
|
+
return "No triage in progress; edit freely based on the reviewer's chat.";
|
|
69
|
+
}
|
|
70
|
+
const accKey = (p, i) => `${p}#${i}`;
|
|
71
|
+
const accepted = new Set(state.accepted.map((c) => accKey(c.paper_id, c.claim_index)));
|
|
72
|
+
const rejected = new Set(state.rejected.map((c) => accKey(c.paper_id, c.claim_index)));
|
|
73
|
+
const papersDone = new Set(state.papers_done);
|
|
74
|
+
const commentByClaim = new Map();
|
|
75
|
+
for (const c of state.comments ?? []) {
|
|
76
|
+
if (c.body.trim())
|
|
77
|
+
commentByClaim.set(accKey(c.paper_id, c.claim_index), c.body);
|
|
78
|
+
}
|
|
79
|
+
const claimsByPaper = new Map();
|
|
80
|
+
const paperOrder = [];
|
|
81
|
+
const indexByPaper = new Map();
|
|
82
|
+
for (const claim of artifact.claims) {
|
|
83
|
+
if (!claimsByPaper.has(claim.paper_id)) {
|
|
84
|
+
claimsByPaper.set(claim.paper_id, []);
|
|
85
|
+
paperOrder.push(claim.paper_id);
|
|
86
|
+
indexByPaper.set(claim.paper_id, 0);
|
|
87
|
+
}
|
|
88
|
+
const idx = (indexByPaper.get(claim.paper_id) ?? 0) + 1;
|
|
89
|
+
indexByPaper.set(claim.paper_id, idx);
|
|
90
|
+
const key = accKey(claim.paper_id, idx);
|
|
91
|
+
const paperDone = papersDone.has(claim.paper_id);
|
|
92
|
+
let label;
|
|
93
|
+
if (accepted.has(key))
|
|
94
|
+
label = "ACCEPTED";
|
|
95
|
+
else if (rejected.has(key))
|
|
96
|
+
label = "REJECTED";
|
|
97
|
+
else if (paperDone)
|
|
98
|
+
label = "REJECTED*"; // unreviewed in triage-done paper
|
|
99
|
+
else
|
|
100
|
+
label = "PENDING";
|
|
101
|
+
const suffix = label === "REJECTED*"
|
|
102
|
+
? " *unreviewed; paper triage marked done → treat as rejected"
|
|
103
|
+
: label === "PENDING"
|
|
104
|
+
? " ← paper triage NOT marked done; do not cite unless accepted"
|
|
105
|
+
: "";
|
|
106
|
+
const rows = claimsByPaper.get(claim.paper_id);
|
|
107
|
+
rows.push(` [${claim.paper_id}] ${label.padEnd(10)} ${claim.text}${suffix}`);
|
|
108
|
+
const comment = commentByClaim.get(key);
|
|
109
|
+
if (comment) {
|
|
110
|
+
const normalised = comment.trim().replace(/\r\n/g, "\n");
|
|
111
|
+
for (const line of normalised.split("\n")) {
|
|
112
|
+
rows.push(` ↳ reviewer note: ${line}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const doneLines = paperOrder
|
|
117
|
+
.filter((p) => papersDone.has(p))
|
|
118
|
+
.map((p) => ` - ${p}`);
|
|
119
|
+
const doneBlock = doneLines.length ? doneLines.join("\n") : " (none yet)";
|
|
120
|
+
const claimLines = paperOrder
|
|
121
|
+
.flatMap((p) => claimsByPaper.get(p) ?? [])
|
|
122
|
+
.join("\n");
|
|
123
|
+
return [
|
|
124
|
+
"Papers with triage marked done (reviewer has reviewed to their satisfaction for these):",
|
|
125
|
+
doneBlock,
|
|
126
|
+
"",
|
|
127
|
+
"Claim triage (order matches claims[] order in the current artifact):",
|
|
128
|
+
claimLines,
|
|
129
|
+
].join("\n");
|
|
130
|
+
}
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Content validation (citation-fidelity gate — runs on every commit)
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
const CITE_LINK_RE = /\[[^\]]*\]\(#cite:([^ )"]+)(?:\s+"([^"]*)")?\)/g;
|
|
135
|
+
/**
|
|
136
|
+
* Validate claim/paper integrity and citation fidelity. Returns an array
|
|
137
|
+
* of error messages; empty array means the artifact passes.
|
|
138
|
+
*
|
|
139
|
+
* `validPaperIds`, when provided, is the set of paper IDs known to the
|
|
140
|
+
* session (from aggregate.paper_id_mapping). Every paper in
|
|
141
|
+
* `artifact.papers[]` must be a member.
|
|
142
|
+
*/
|
|
143
|
+
export function validateArtifactContent(artifact, validPaperIds) {
|
|
144
|
+
const errors = [];
|
|
145
|
+
const paperIds = artifact.papers.map((p) => p.paper_id);
|
|
146
|
+
const paperIdSet = new Set(paperIds);
|
|
147
|
+
if (paperIds.length !== paperIdSet.size) {
|
|
148
|
+
const duplicates = paperIds.filter((pid, i) => paperIds.indexOf(pid) !== i);
|
|
149
|
+
errors.push(`papers[] has duplicate paper_id(s): ${[...new Set(duplicates)].join(", ")}`);
|
|
150
|
+
recordValidationError("paper_id_duplicate");
|
|
151
|
+
}
|
|
152
|
+
if (validPaperIds !== undefined) {
|
|
153
|
+
for (const pid of paperIds) {
|
|
154
|
+
if (!validPaperIds.has(pid)) {
|
|
155
|
+
errors.push(`papers[] contains unknown paper_id="${pid}" that is not in the session's paper_id_mapping`);
|
|
156
|
+
recordValidationError("paper_id_unknown_in_mapping");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
for (const claim of artifact.claims) {
|
|
161
|
+
if (!paperIdSet.has(claim.paper_id)) {
|
|
162
|
+
errors.push(`claim cites paper_id="${claim.paper_id}" which is not present in papers[]`);
|
|
163
|
+
recordValidationError("claim_paper_missing");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// Enforce grouping: claims must appear in contiguous runs per paper, and
|
|
167
|
+
// the group order must match papers[].
|
|
168
|
+
const firstSeen = new Map();
|
|
169
|
+
const lastSeen = new Map();
|
|
170
|
+
artifact.claims.forEach((claim, i) => {
|
|
171
|
+
if (!firstSeen.has(claim.paper_id))
|
|
172
|
+
firstSeen.set(claim.paper_id, i);
|
|
173
|
+
lastSeen.set(claim.paper_id, i);
|
|
174
|
+
});
|
|
175
|
+
for (const [pid, first] of firstSeen) {
|
|
176
|
+
const last = lastSeen.get(pid);
|
|
177
|
+
for (let i = first; i <= last; i++) {
|
|
178
|
+
if (artifact.claims[i]?.paper_id !== pid) {
|
|
179
|
+
errors.push(`claims[] must be grouped contiguously by paper_id — claim #${i + 1} breaks the "${pid}" group`);
|
|
180
|
+
recordValidationError("claims_not_contiguous");
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// Group order must match papers[] order (papers without claims may be skipped).
|
|
186
|
+
const claimPaperOrder = Array.from(firstSeen.keys());
|
|
187
|
+
const paperRankIndex = new Map(paperIds.map((pid, i) => [pid, i]));
|
|
188
|
+
for (let i = 1; i < claimPaperOrder.length; i++) {
|
|
189
|
+
const prev = paperRankIndex.get(claimPaperOrder[i - 1]);
|
|
190
|
+
const cur = paperRankIndex.get(claimPaperOrder[i]);
|
|
191
|
+
if (prev !== undefined && cur !== undefined && prev > cur) {
|
|
192
|
+
errors.push(`claims[] groups must match papers[] order — "${claimPaperOrder[i]}" (rank ${cur}) appears after "${claimPaperOrder[i - 1]}" (rank ${prev})`);
|
|
193
|
+
recordValidationError("claims_group_order");
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Build (paper_id → set of quotes) for citation fidelity lookup.
|
|
198
|
+
const claimQuotesByPaper = new Map();
|
|
199
|
+
for (const claim of artifact.claims) {
|
|
200
|
+
const set = claimQuotesByPaper.get(claim.paper_id) ?? new Set();
|
|
201
|
+
for (const c of claim.citations)
|
|
202
|
+
set.add(c.quote);
|
|
203
|
+
claimQuotesByPaper.set(claim.paper_id, set);
|
|
204
|
+
}
|
|
205
|
+
// Check every #cite: marker in notes + description.
|
|
206
|
+
for (const [field, text] of [
|
|
207
|
+
["notes", artifact.notes],
|
|
208
|
+
["description", artifact.description],
|
|
209
|
+
]) {
|
|
210
|
+
CITE_LINK_RE.lastIndex = 0;
|
|
211
|
+
let m;
|
|
212
|
+
while ((m = CITE_LINK_RE.exec(text)) !== null) {
|
|
213
|
+
const pid = m[1];
|
|
214
|
+
const quote = m[2];
|
|
215
|
+
if (!paperIdSet.has(pid)) {
|
|
216
|
+
errors.push(`${field}: #cite:${pid} references an unknown paper_id`);
|
|
217
|
+
recordValidationError("cite_unknown_paper_id");
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (quote === undefined) {
|
|
221
|
+
errors.push(`${field}: citation link for paper ${pid} is missing a "verbatim quote" title attribute`);
|
|
222
|
+
recordValidationError("cite_missing_quote");
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const quotes = claimQuotesByPaper.get(pid);
|
|
226
|
+
if (!quotes || !quotes.has(quote)) {
|
|
227
|
+
errors.push(`${field}: quote referenced by #cite:${pid} does not match any claim.citations[].quote for paper "${pid}" (quote: ${JSON.stringify(quote)})`);
|
|
228
|
+
recordValidationError("cite_quote_mismatch");
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return errors;
|
|
233
|
+
}
|
|
234
|
+
/** Shared validation + commit for str_replace, insert, and write. */
|
|
235
|
+
function validateAndCommit(session, schema, updatedYaml, tool) {
|
|
236
|
+
let parsed;
|
|
237
|
+
try {
|
|
238
|
+
parsed = parseArtifactYaml(updatedYaml);
|
|
239
|
+
}
|
|
240
|
+
catch (e) {
|
|
241
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
242
|
+
recordToolValidationFailure(tool);
|
|
243
|
+
return { error: `Edit produced invalid YAML: ${msg}`, is_error: true };
|
|
244
|
+
}
|
|
245
|
+
const validation = schema.safeParse(parsed);
|
|
246
|
+
if (!validation.success) {
|
|
247
|
+
const issues = validation.error.issues
|
|
248
|
+
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
|
249
|
+
.join("; ");
|
|
250
|
+
recordToolValidationFailure(tool);
|
|
251
|
+
return {
|
|
252
|
+
error: `Edit produced invalid artifact: ${issues}`,
|
|
253
|
+
is_error: true,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
const newCategory = validation.data.category;
|
|
257
|
+
if (newCategory !== session.category &&
|
|
258
|
+
session.aggregateCategories.includes(newCategory)) {
|
|
259
|
+
recordToolValidationFailure(tool);
|
|
260
|
+
return {
|
|
261
|
+
error: `Category ${newCategory} already has a result for this aggregate.`,
|
|
262
|
+
is_error: true,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
const contentErrors = validateArtifactContent(validation.data, new Set(Object.keys(session.paperIds)));
|
|
266
|
+
if (contentErrors.length) {
|
|
267
|
+
recordToolValidationFailure(tool);
|
|
268
|
+
return {
|
|
269
|
+
error: `Edit produced an artifact that fails content validation: ${contentErrors.join("; ")}`,
|
|
270
|
+
is_error: true,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
session.artifactYaml = updatedYaml;
|
|
274
|
+
session.artifactDirty = true;
|
|
275
|
+
return "Edit applied successfully.";
|
|
276
|
+
}
|
|
277
|
+
export function buildTools(ctx) {
|
|
278
|
+
const { session, storage, provider, schema } = ctx;
|
|
279
|
+
const metricsContext = { providerName: provider.name };
|
|
280
|
+
return {
|
|
281
|
+
loadPaperExtracts: {
|
|
282
|
+
description: "Load structured extraction results for specific papers.",
|
|
283
|
+
inputSchema: z.object({
|
|
284
|
+
paperIds: z
|
|
285
|
+
.array(z.string())
|
|
286
|
+
.describe("Paper IDs (e.g. ['Miyata2018', 'Smith2020'])"),
|
|
287
|
+
}),
|
|
288
|
+
execute: withToolMetrics(metricsContext, "loadPaperExtracts", async ({ paperIds: ids }) => {
|
|
289
|
+
console.log(`[tool] loadPaperExtracts: ${ids.join(", ")}`);
|
|
290
|
+
const { dois, invalid } = resolveDois(session, ids);
|
|
291
|
+
if (invalid.length)
|
|
292
|
+
return { error: `Unknown paper IDs: ${invalid.join(", ")}` };
|
|
293
|
+
const results = await Promise.all(dois.map((doi) => loadExtraction(storage, session.variantId, doi)));
|
|
294
|
+
return ids.map((id, i) => ({
|
|
295
|
+
paperId: id,
|
|
296
|
+
extraction: results[i],
|
|
297
|
+
}));
|
|
298
|
+
}),
|
|
299
|
+
},
|
|
300
|
+
loadFullPaper: {
|
|
301
|
+
description: "Load the full Markdown text of a paper into conversation context. Use when the reviewer wants to discuss specific passages.",
|
|
302
|
+
inputSchema: z.object({
|
|
303
|
+
paperId: z.string().describe("Paper ID (e.g. 'Miyata2018')"),
|
|
304
|
+
}),
|
|
305
|
+
execute: withToolMetrics(metricsContext, "loadFullPaper", async ({ paperId }) => {
|
|
306
|
+
console.log(`[tool] loadFullPaper: ${paperId}`);
|
|
307
|
+
const doi = resolveDoi(session, paperId);
|
|
308
|
+
if (!doi)
|
|
309
|
+
return { error: `Unknown paper ID: ${paperId}` };
|
|
310
|
+
const text = await loadMarkdown(storage, doi);
|
|
311
|
+
if (!text)
|
|
312
|
+
return { error: `Full text not available for ${paperId}` };
|
|
313
|
+
console.log(`[tool] loadFullPaper: ${paperId} → ${text.length} chars`);
|
|
314
|
+
return text;
|
|
315
|
+
}),
|
|
316
|
+
},
|
|
317
|
+
queryPapers: {
|
|
318
|
+
description: "Run a subagent that reads the full text of the specified papers and answers a question about them. The paper texts are not added to this conversation's context, keeping it lean. Prefer this over loadFullPaper unless the reviewer needs the text to remain available for follow-up discussion.",
|
|
319
|
+
inputSchema: z.object({
|
|
320
|
+
question: z.string().describe("The specific question to answer"),
|
|
321
|
+
paperIds: z.array(z.string()).describe("Paper IDs to query"),
|
|
322
|
+
}),
|
|
323
|
+
execute: withToolMetrics(metricsContext, "queryPapers", async ({ question, paperIds: ids, }) => {
|
|
324
|
+
console.log(`[tool] queryPapers: ${ids.join(", ")} — "${question}"`);
|
|
325
|
+
const { dois, invalid } = resolveDois(session, ids);
|
|
326
|
+
if (invalid.length)
|
|
327
|
+
return { error: `Unknown paper IDs: ${invalid.join(", ")}` };
|
|
328
|
+
const texts = await Promise.all(dois.map((doi) => loadMarkdown(storage, doi)));
|
|
329
|
+
const available = ids
|
|
330
|
+
.map((id, i) => texts[i] ? `## ${id}\n\n${texts[i]}` : null)
|
|
331
|
+
.filter(Boolean);
|
|
332
|
+
if (!available.length)
|
|
333
|
+
return {
|
|
334
|
+
error: "No full texts available for the requested papers",
|
|
335
|
+
};
|
|
336
|
+
const { text, usage } = await generateText({
|
|
337
|
+
model: provider.model,
|
|
338
|
+
providerOptions: provider.providerOptions,
|
|
339
|
+
experimental_telemetry: {
|
|
340
|
+
isEnabled: true,
|
|
341
|
+
recordInputs: false,
|
|
342
|
+
recordOutputs: false,
|
|
343
|
+
functionId: "query-papers",
|
|
344
|
+
},
|
|
345
|
+
prompt: `${available.join("\n\n---\n\n")}\n\n${CITATION_INSTRUCTIONS}\n\nQuestion: ${question}`,
|
|
346
|
+
});
|
|
347
|
+
if (usage) {
|
|
348
|
+
const modelLabel = provider.name;
|
|
349
|
+
if (usage.inputTokens)
|
|
350
|
+
recordTokenUsage({
|
|
351
|
+
model: modelLabel,
|
|
352
|
+
tokenType: "input",
|
|
353
|
+
count: usage.inputTokens,
|
|
354
|
+
});
|
|
355
|
+
if (usage.outputTokens)
|
|
356
|
+
recordTokenUsage({
|
|
357
|
+
model: modelLabel,
|
|
358
|
+
tokenType: "output",
|
|
359
|
+
count: usage.outputTokens,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
return text;
|
|
363
|
+
}),
|
|
364
|
+
},
|
|
365
|
+
view: {
|
|
366
|
+
description: "View the current artifact with line numbers. The artifact is shown with line numbers " +
|
|
367
|
+
"in your initial context — use this only to re-read after edits. Use view_range to " +
|
|
368
|
+
"read specific lines instead of the entire artifact.",
|
|
369
|
+
inputSchema: z.object({
|
|
370
|
+
view_range: z
|
|
371
|
+
.tuple([z.number(), z.number()])
|
|
372
|
+
.optional()
|
|
373
|
+
.describe("Optional [start, end] line range (1-indexed). Use -1 for end to mean end of file. " +
|
|
374
|
+
"Omit to view the entire artifact."),
|
|
375
|
+
}),
|
|
376
|
+
execute: withToolMetrics(metricsContext, "view", async ({ view_range }) => {
|
|
377
|
+
if (!session.artifactYaml) {
|
|
378
|
+
return { error: "Artifact not initialized" };
|
|
379
|
+
}
|
|
380
|
+
if (view_range) {
|
|
381
|
+
return viewRange(session.artifactYaml, view_range[0], view_range[1]);
|
|
382
|
+
}
|
|
383
|
+
return addLineNumbers(session.artifactYaml);
|
|
384
|
+
}),
|
|
385
|
+
},
|
|
386
|
+
search: {
|
|
387
|
+
description: "Find lines in the artifact YAML containing a literal substring. Returns each match with one line of context either side, prefixed with line numbers.",
|
|
388
|
+
inputSchema: z.object({
|
|
389
|
+
pattern: z
|
|
390
|
+
.string()
|
|
391
|
+
.describe("Literal substring to find (case-sensitive, no regex)."),
|
|
392
|
+
}),
|
|
393
|
+
execute: withToolMetrics(metricsContext, "search", async ({ pattern }) => {
|
|
394
|
+
if (!session.artifactYaml) {
|
|
395
|
+
return { error: "Artifact not initialized" };
|
|
396
|
+
}
|
|
397
|
+
const { output, count } = searchArtifact(session.artifactYaml, pattern);
|
|
398
|
+
if (count === 0)
|
|
399
|
+
return `No matches for ${JSON.stringify(pattern)}.`;
|
|
400
|
+
return `${count} match${count === 1 ? "" : "es"}:\n${output}`;
|
|
401
|
+
}),
|
|
402
|
+
},
|
|
403
|
+
str_replace: {
|
|
404
|
+
description: "Exact string replacement on the artifact YAML. The replacement must match exactly once. " +
|
|
405
|
+
"Each call in a turn operates on the result of prior edits (applied sequentially). " +
|
|
406
|
+
"The result is validated against the artifact schema; if invalid, the edit is rejected. " +
|
|
407
|
+
"IMPORTANT: Do NOT include line numbers in old_str or new_str — line numbers are for " +
|
|
408
|
+
"display only. Use the raw artifact text.",
|
|
409
|
+
inputSchema: z.object({
|
|
410
|
+
old_str: z
|
|
411
|
+
.string()
|
|
412
|
+
.describe("Exact text to find in the artifact (without line numbers). Must match exactly once."),
|
|
413
|
+
new_str: z
|
|
414
|
+
.string()
|
|
415
|
+
.describe("Replacement text (without line numbers)."),
|
|
416
|
+
}),
|
|
417
|
+
execute: withToolMetrics(metricsContext, "str_replace", async ({ old_str, new_str }) => {
|
|
418
|
+
if (!session.artifactYaml) {
|
|
419
|
+
return { error: "Artifact not initialized", is_error: true };
|
|
420
|
+
}
|
|
421
|
+
const parts = session.artifactYaml.split(old_str);
|
|
422
|
+
const matchCount = parts.length - 1;
|
|
423
|
+
if (matchCount === 0) {
|
|
424
|
+
return { error: "old_str not found in artifact.", is_error: true };
|
|
425
|
+
}
|
|
426
|
+
if (matchCount > 1) {
|
|
427
|
+
return {
|
|
428
|
+
error: `Found ${matchCount} matches for old_str. Provide more context for a unique match.`,
|
|
429
|
+
is_error: true,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
const updated = session.artifactYaml.replace(old_str, new_str);
|
|
433
|
+
return validateAndCommit(session, schema, updated, "str_replace");
|
|
434
|
+
}),
|
|
435
|
+
},
|
|
436
|
+
insert: {
|
|
437
|
+
description: "Insert text after a specific line number in the artifact YAML. " +
|
|
438
|
+
"Use line 0 to insert at the beginning. " +
|
|
439
|
+
"The result is validated against the artifact schema; if invalid, the insert is rejected.",
|
|
440
|
+
inputSchema: z.object({
|
|
441
|
+
insert_line: z
|
|
442
|
+
.number()
|
|
443
|
+
.describe("Line number after which to insert (1-indexed, 0 for beginning)."),
|
|
444
|
+
new_str: z.string().describe("Text to insert (without line numbers)."),
|
|
445
|
+
}),
|
|
446
|
+
execute: withToolMetrics(metricsContext, "insert", async ({ insert_line, new_str, }) => {
|
|
447
|
+
if (!session.artifactYaml) {
|
|
448
|
+
return { error: "Artifact not initialized", is_error: true };
|
|
449
|
+
}
|
|
450
|
+
const updated = insertAtLine(session.artifactYaml, insert_line, new_str);
|
|
451
|
+
return validateAndCommit(session, schema, updated, "insert");
|
|
452
|
+
}),
|
|
453
|
+
},
|
|
454
|
+
write: {
|
|
455
|
+
description: "Replace the entire artifact with new YAML. Use this for wholesale changes — applying triage decisions, major re-ranking, or restructuring. Prefer str_replace/insert for small, surgical edits. The provided YAML must parse and validate against the artifact schema.",
|
|
456
|
+
inputSchema: z.object({
|
|
457
|
+
artifact_yaml: z
|
|
458
|
+
.string()
|
|
459
|
+
.describe("Complete new artifact content as YAML. Replaces the entire artifact."),
|
|
460
|
+
}),
|
|
461
|
+
execute: withToolMetrics(metricsContext, "write", async ({ artifact_yaml }) => {
|
|
462
|
+
return validateAndCommit(session, schema, artifact_yaml, "write");
|
|
463
|
+
}),
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
const chatRequestBody = z.object({
|
|
468
|
+
messages: z.array(z.unknown()),
|
|
469
|
+
triage_state: TriageStateSchema.optional(),
|
|
470
|
+
});
|
|
471
|
+
export async function handleChat(ctx, req, session) {
|
|
472
|
+
const { storage, provider, schema } = ctx;
|
|
473
|
+
const raw = (await req.json());
|
|
474
|
+
const parsed = chatRequestBody.safeParse(raw);
|
|
475
|
+
if (!parsed.success) {
|
|
476
|
+
return new Response(JSON.stringify({
|
|
477
|
+
error: "Invalid request body",
|
|
478
|
+
details: parsed.error.issues,
|
|
479
|
+
}), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
480
|
+
}
|
|
481
|
+
const messages = parsed.data.messages;
|
|
482
|
+
const triageState = parsed.data.triage_state ?? null;
|
|
483
|
+
console.log(`[chat] session=${session.id} messages=${messages.length} triage=${triageState ? "yes" : "no"}`);
|
|
484
|
+
await logRequest({
|
|
485
|
+
sessionId: session.id,
|
|
486
|
+
userId: session.userId,
|
|
487
|
+
variantId: session.variantId,
|
|
488
|
+
messages,
|
|
489
|
+
});
|
|
490
|
+
// Triage state is prepended as a synthesised user message at the head of
|
|
491
|
+
// this turn's messages array rather than being spliced into the system
|
|
492
|
+
// prompt. That keeps session.systemPrompt byte-stable across turns so
|
|
493
|
+
// prompt caching can reuse it as a cached prefix even when reviewer
|
|
494
|
+
// triage decisions change. (If we baked triage into the system prompt,
|
|
495
|
+
// any decision flip between turns would invalidate the entire cached
|
|
496
|
+
// prefix — the largest part of every request.)
|
|
497
|
+
const currentArtifact = parseArtifactYaml(session.artifactYaml);
|
|
498
|
+
const triageBlock = renderTriageStateBlock(currentArtifact, triageState);
|
|
499
|
+
const triageMessage = {
|
|
500
|
+
id: `triage-state-${randomUUID()}`,
|
|
501
|
+
role: "user",
|
|
502
|
+
parts: [
|
|
503
|
+
{
|
|
504
|
+
type: "text",
|
|
505
|
+
text: `Reviewer triage state for this turn:\n\n${triageBlock}`,
|
|
506
|
+
},
|
|
507
|
+
],
|
|
508
|
+
};
|
|
509
|
+
const augmentedMessages = [triageMessage, ...messages];
|
|
510
|
+
const tools = buildTools({ session, storage, provider, schema });
|
|
511
|
+
return tracer.startActiveSpan("chat.turn", {
|
|
512
|
+
attributes: {
|
|
513
|
+
"session.id": session.id,
|
|
514
|
+
"variant.id": session.variantId,
|
|
515
|
+
"user.id": session.userId,
|
|
516
|
+
"triage.active": triageState != null,
|
|
517
|
+
},
|
|
518
|
+
}, async (span) => {
|
|
519
|
+
const agent = new ToolLoopAgent({
|
|
520
|
+
model: provider.model,
|
|
521
|
+
instructions: session.systemPrompt,
|
|
522
|
+
providerOptions: provider.providerOptions,
|
|
523
|
+
tools,
|
|
524
|
+
stopWhen: stepCountIs(MAX_STEPS),
|
|
525
|
+
experimental_telemetry: {
|
|
526
|
+
isEnabled: true,
|
|
527
|
+
recordInputs: false,
|
|
528
|
+
recordOutputs: false,
|
|
529
|
+
functionId: "chat-agent",
|
|
530
|
+
},
|
|
531
|
+
...(provider.prepareStep ? { prepareStep: provider.prepareStep } : {}),
|
|
532
|
+
onStepFinish: (step) => {
|
|
533
|
+
console.log(`[chat] step finished: reason=${step.finishReason} text=${step.text.length} chars toolCalls=${step.toolCalls?.length ?? 0}`);
|
|
534
|
+
void logStep({
|
|
535
|
+
sessionId: session.id,
|
|
536
|
+
finishReason: step.finishReason,
|
|
537
|
+
text: step.text,
|
|
538
|
+
toolCalls: step.toolCalls ?? [],
|
|
539
|
+
toolResults: (step.toolResults ?? []).map((r) => ({
|
|
540
|
+
toolName: r.toolName,
|
|
541
|
+
output: r.output,
|
|
542
|
+
})),
|
|
543
|
+
});
|
|
544
|
+
},
|
|
545
|
+
onFinish: async ({ text, toolCalls, usage, steps, finishReason }) => {
|
|
546
|
+
console.log(`[chat] finished: ${text.length} chars, ${toolCalls?.length ?? 0} tool calls, usage=${JSON.stringify(usage)}`);
|
|
547
|
+
if (usage) {
|
|
548
|
+
const modelLabel = provider.name;
|
|
549
|
+
if (usage.inputTokens)
|
|
550
|
+
recordTokenUsage({
|
|
551
|
+
model: modelLabel,
|
|
552
|
+
tokenType: "input",
|
|
553
|
+
count: usage.inputTokens,
|
|
554
|
+
});
|
|
555
|
+
if (usage.outputTokens)
|
|
556
|
+
recordTokenUsage({
|
|
557
|
+
model: modelLabel,
|
|
558
|
+
tokenType: "output",
|
|
559
|
+
count: usage.outputTokens,
|
|
560
|
+
});
|
|
561
|
+
if (usage.cachedInputTokens)
|
|
562
|
+
recordCachedInputTokens({
|
|
563
|
+
model: modelLabel,
|
|
564
|
+
type: "read",
|
|
565
|
+
count: usage.cachedInputTokens,
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
const truncated = steps.length >= MAX_STEPS && finishReason !== "stop";
|
|
569
|
+
if (truncated) {
|
|
570
|
+
console.log(`[edit] step limit reached (${steps.length}/${MAX_STEPS}) — draft may be incomplete`);
|
|
571
|
+
markTurnTruncated(session.id);
|
|
572
|
+
}
|
|
573
|
+
await logResponse(storage, {
|
|
574
|
+
sessionId: session.id,
|
|
575
|
+
userId: session.userId,
|
|
576
|
+
response: text,
|
|
577
|
+
toolCalls,
|
|
578
|
+
usage,
|
|
579
|
+
});
|
|
580
|
+
},
|
|
581
|
+
});
|
|
582
|
+
// Build the UI message stream, then intercept the `finish` chunk to
|
|
583
|
+
// write the edit draft (if dirty) and attach the new
|
|
584
|
+
// {version, parent_version} as message metadata. Doing the write
|
|
585
|
+
// inside this transform — rather than in onFinish — guarantees the
|
|
586
|
+
// finish chunk carries the metadata: messageMetadata callbacks fire
|
|
587
|
+
// before onFinish is awaited.
|
|
588
|
+
const baseStream = await createAgentUIStream({
|
|
589
|
+
agent,
|
|
590
|
+
uiMessages: augmentedMessages,
|
|
591
|
+
sendReasoning: true,
|
|
592
|
+
});
|
|
593
|
+
const stream = baseStream.pipeThrough(new TransformStream({
|
|
594
|
+
async transform(chunk, controller) {
|
|
595
|
+
if (chunk.type !== "finish") {
|
|
596
|
+
controller.enqueue(chunk);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
try {
|
|
600
|
+
if (session.artifactDirty) {
|
|
601
|
+
const parsedArtifact = parseArtifactYaml(session.artifactYaml);
|
|
602
|
+
const withBboxes = reattachBboxes(parsedArtifact, session.bboxCache);
|
|
603
|
+
const parentVersion = session.artifactVersion;
|
|
604
|
+
const writtenVersion = await writeEditDraft(storage, session.variantId, session.category, JSON.stringify(withBboxes), parentVersion + 1);
|
|
605
|
+
session.artifactVersion = writtenVersion;
|
|
606
|
+
session.artifactDirty = false;
|
|
607
|
+
console.log(`[edit] persisted draft v${writtenVersion} (parent v${parentVersion}) for ${session.variantId}/${session.category}`);
|
|
608
|
+
controller.enqueue({
|
|
609
|
+
...chunk,
|
|
610
|
+
messageMetadata: {
|
|
611
|
+
artifact_write: {
|
|
612
|
+
version: writtenVersion,
|
|
613
|
+
parent_version: parentVersion,
|
|
614
|
+
},
|
|
615
|
+
},
|
|
616
|
+
});
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
controller.enqueue(chunk);
|
|
620
|
+
}
|
|
621
|
+
catch (err) {
|
|
622
|
+
console.error(`[edit] failed to persist draft for ${session.variantId}/${session.category}`, err);
|
|
623
|
+
recordStorageWriteFailure();
|
|
624
|
+
controller.error(err);
|
|
625
|
+
}
|
|
626
|
+
finally {
|
|
627
|
+
span.end();
|
|
628
|
+
}
|
|
629
|
+
},
|
|
630
|
+
}));
|
|
631
|
+
return createUIMessageStreamResponse({ stream });
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
// Surface helpers for tests.
|
|
635
|
+
export { validateAndCommit, artifactToYaml };
|
|
636
|
+
//# sourceMappingURL=chat.js.map
|