@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.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +210 -0
  3. package/dist/artifact.d.ts +87 -0
  4. package/dist/artifact.d.ts.map +1 -0
  5. package/dist/artifact.js +99 -0
  6. package/dist/artifact.js.map +1 -0
  7. package/dist/audit.d.ts +28 -0
  8. package/dist/audit.d.ts.map +1 -0
  9. package/dist/audit.js +73 -0
  10. package/dist/audit.js.map +1 -0
  11. package/dist/auth/jwt.d.ts +18 -0
  12. package/dist/auth/jwt.d.ts.map +1 -0
  13. package/dist/auth/jwt.js +23 -0
  14. package/dist/auth/jwt.js.map +1 -0
  15. package/dist/auth/oidc.d.ts +30 -0
  16. package/dist/auth/oidc.d.ts.map +1 -0
  17. package/dist/auth/oidc.js +58 -0
  18. package/dist/auth/oidc.js.map +1 -0
  19. package/dist/chat.d.ts +161 -0
  20. package/dist/chat.d.ts.map +1 -0
  21. package/dist/chat.js +636 -0
  22. package/dist/chat.js.map +1 -0
  23. package/dist/cli.d.ts +6 -0
  24. package/dist/cli.d.ts.map +1 -0
  25. package/dist/cli.js +47 -0
  26. package/dist/cli.js.map +1 -0
  27. package/dist/config.d.ts +18 -0
  28. package/dist/config.d.ts.map +1 -0
  29. package/dist/config.js +80 -0
  30. package/dist/config.js.map +1 -0
  31. package/dist/index.d.ts +19 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +19 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/instrumentation.d.ts +31 -0
  36. package/dist/instrumentation.d.ts.map +1 -0
  37. package/dist/instrumentation.js +151 -0
  38. package/dist/instrumentation.js.map +1 -0
  39. package/dist/llm/anthropic.d.ts +22 -0
  40. package/dist/llm/anthropic.d.ts.map +1 -0
  41. package/dist/llm/anthropic.js +40 -0
  42. package/dist/llm/anthropic.js.map +1 -0
  43. package/dist/llm/bedrock.d.ts +34 -0
  44. package/dist/llm/bedrock.d.ts.map +1 -0
  45. package/dist/llm/bedrock.js +54 -0
  46. package/dist/llm/bedrock.js.map +1 -0
  47. package/dist/llm/factory.d.ts +3 -0
  48. package/dist/llm/factory.d.ts.map +1 -0
  49. package/dist/llm/factory.js +59 -0
  50. package/dist/llm/factory.js.map +1 -0
  51. package/dist/llm/google-gla.d.ts +22 -0
  52. package/dist/llm/google-gla.d.ts.map +1 -0
  53. package/dist/llm/google-gla.js +29 -0
  54. package/dist/llm/google-gla.js.map +1 -0
  55. package/dist/llm/google-vertex.d.ts +21 -0
  56. package/dist/llm/google-vertex.d.ts.map +1 -0
  57. package/dist/llm/google-vertex.js +28 -0
  58. package/dist/llm/google-vertex.js.map +1 -0
  59. package/dist/llm/interface.d.ts +45 -0
  60. package/dist/llm/interface.d.ts.map +1 -0
  61. package/dist/llm/interface.js +2 -0
  62. package/dist/llm/interface.js.map +1 -0
  63. package/dist/llm/openai.d.ts +19 -0
  64. package/dist/llm/openai.d.ts.map +1 -0
  65. package/dist/llm/openai.js +25 -0
  66. package/dist/llm/openai.js.map +1 -0
  67. package/dist/prompts.d.ts +7 -0
  68. package/dist/prompts.d.ts.map +1 -0
  69. package/dist/prompts.js +17 -0
  70. package/dist/prompts.js.map +1 -0
  71. package/dist/server.d.ts +39 -0
  72. package/dist/server.d.ts.map +1 -0
  73. package/dist/server.js +106 -0
  74. package/dist/server.js.map +1 -0
  75. package/dist/session.d.ts +68 -0
  76. package/dist/session.d.ts.map +1 -0
  77. package/dist/session.js +245 -0
  78. package/dist/session.js.map +1 -0
  79. package/dist/storage/factory.d.ts +28 -0
  80. package/dist/storage/factory.d.ts.map +1 -0
  81. package/dist/storage/factory.js +33 -0
  82. package/dist/storage/factory.js.map +1 -0
  83. package/dist/storage/fs.d.ts +14 -0
  84. package/dist/storage/fs.d.ts.map +1 -0
  85. package/dist/storage/fs.js +116 -0
  86. package/dist/storage/fs.js.map +1 -0
  87. package/dist/storage/gcs.d.ts +27 -0
  88. package/dist/storage/gcs.d.ts.map +1 -0
  89. package/dist/storage/gcs.js +81 -0
  90. package/dist/storage/gcs.js.map +1 -0
  91. package/dist/storage/interface.d.ts +33 -0
  92. package/dist/storage/interface.d.ts.map +1 -0
  93. package/dist/storage/interface.js +12 -0
  94. package/dist/storage/interface.js.map +1 -0
  95. package/dist/storage/s3.d.ts +29 -0
  96. package/dist/storage/s3.d.ts.map +1 -0
  97. package/dist/storage/s3.js +109 -0
  98. package/dist/storage/s3.js.map +1 -0
  99. package/dist/storage-keys.d.ts +33 -0
  100. package/dist/storage-keys.d.ts.map +1 -0
  101. package/dist/storage-keys.js +76 -0
  102. package/dist/storage-keys.js.map +1 -0
  103. package/dist/telemetry.d.ts +29 -0
  104. package/dist/telemetry.d.ts.map +1 -0
  105. package/dist/telemetry.js +116 -0
  106. package/dist/telemetry.js.map +1 -0
  107. package/dist/yaml.d.ts +42 -0
  108. package/dist/yaml.d.ts.map +1 -0
  109. package/dist/yaml.js +121 -0
  110. package/dist/yaml.js.map +1 -0
  111. 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