lynkr 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.
Files changed (52) hide show
  1. package/.eslintrc.cjs +12 -0
  2. package/CLAUDE.md +39 -0
  3. package/LICENSE +21 -0
  4. package/README.md +417 -0
  5. package/bin/cli.js +3 -0
  6. package/index.js +3 -0
  7. package/package.json +54 -0
  8. package/src/api/middleware/logging.js +37 -0
  9. package/src/api/middleware/session.js +55 -0
  10. package/src/api/router.js +80 -0
  11. package/src/cache/prompt.js +183 -0
  12. package/src/clients/databricks.js +72 -0
  13. package/src/config/index.js +301 -0
  14. package/src/db/index.js +192 -0
  15. package/src/diff/comments.js +153 -0
  16. package/src/edits/index.js +171 -0
  17. package/src/indexer/index.js +1610 -0
  18. package/src/indexer/navigation/index.js +32 -0
  19. package/src/indexer/navigation/providers/treeSitter.js +36 -0
  20. package/src/indexer/parser.js +324 -0
  21. package/src/logger/index.js +27 -0
  22. package/src/mcp/client.js +194 -0
  23. package/src/mcp/index.js +34 -0
  24. package/src/mcp/permissions.js +69 -0
  25. package/src/mcp/registry.js +225 -0
  26. package/src/mcp/sandbox.js +238 -0
  27. package/src/metrics/index.js +38 -0
  28. package/src/orchestrator/index.js +1492 -0
  29. package/src/policy/index.js +212 -0
  30. package/src/policy/web-fallback.js +33 -0
  31. package/src/server.js +73 -0
  32. package/src/sessions/index.js +15 -0
  33. package/src/sessions/record.js +31 -0
  34. package/src/sessions/store.js +179 -0
  35. package/src/tasks/store.js +349 -0
  36. package/src/tests/coverage.js +173 -0
  37. package/src/tests/index.js +171 -0
  38. package/src/tests/store.js +213 -0
  39. package/src/tools/edits.js +94 -0
  40. package/src/tools/execution.js +169 -0
  41. package/src/tools/git.js +1346 -0
  42. package/src/tools/index.js +258 -0
  43. package/src/tools/indexer.js +360 -0
  44. package/src/tools/mcp-remote.js +81 -0
  45. package/src/tools/mcp.js +116 -0
  46. package/src/tools/process.js +151 -0
  47. package/src/tools/stubs.js +55 -0
  48. package/src/tools/tasks.js +260 -0
  49. package/src/tools/tests.js +132 -0
  50. package/src/tools/web.js +286 -0
  51. package/src/tools/workspace.js +173 -0
  52. package/src/workspace/index.js +95 -0
@@ -0,0 +1,1492 @@
1
+ const config = require("../config");
2
+ const { invokeModel } = require("../clients/databricks");
3
+ const { appendTurnToSession } = require("../sessions/record");
4
+ const { executeToolCall } = require("../tools");
5
+ const policy = require("../policy");
6
+ const logger = require("../logger");
7
+ const { needsWebFallback } = require("../policy/web-fallback");
8
+ const promptCache = require("../cache/prompt");
9
+
10
+ const DROP_KEYS = new Set([
11
+ "provider",
12
+ "api_type",
13
+ "beta",
14
+ "context_management",
15
+ "stream",
16
+ "thinking",
17
+ "max_steps",
18
+ "max_duration_ms",
19
+ ]);
20
+
21
+ const DEFAULT_AZURE_TOOLS = Object.freeze([
22
+ {
23
+ name: "WebSearch",
24
+ input_schema: {
25
+ type: "object",
26
+ properties: {
27
+ query: {
28
+ type: "string",
29
+ description: "Search query to execute.",
30
+ },
31
+ },
32
+ required: ["query"],
33
+ additionalProperties: false,
34
+ },
35
+ },
36
+ {
37
+ name: "WebFetch",
38
+ input_schema: {
39
+ type: "object",
40
+ properties: {
41
+ url: {
42
+ type: "string",
43
+ description: "URL to fetch.",
44
+ },
45
+ prompt: {
46
+ type: "string",
47
+ description: "Optional summarisation prompt.",
48
+ },
49
+ },
50
+ required: ["url"],
51
+ additionalProperties: false,
52
+ },
53
+ },
54
+ {
55
+ name: "Bash",
56
+ input_schema: {
57
+ type: "object",
58
+ properties: {
59
+ command: {
60
+ type: "string",
61
+ description: "Shell command to execute.",
62
+ },
63
+ timeout: {
64
+ type: "integer",
65
+ description: "Optional timeout in milliseconds.",
66
+ },
67
+ },
68
+ required: ["command"],
69
+ additionalProperties: false,
70
+ },
71
+ },
72
+ {
73
+ name: "BashOutput",
74
+ input_schema: {
75
+ type: "object",
76
+ properties: {
77
+ bash_id: {
78
+ type: "string",
79
+ description: "Identifier of the background bash process.",
80
+ },
81
+ },
82
+ required: ["bash_id"],
83
+ additionalProperties: false,
84
+ },
85
+ },
86
+ {
87
+ name: "KillShell",
88
+ input_schema: {
89
+ type: "object",
90
+ properties: {
91
+ shell_id: {
92
+ type: "string",
93
+ description: "Identifier of the background shell to terminate.",
94
+ },
95
+ },
96
+ required: ["shell_id"],
97
+ additionalProperties: false,
98
+ },
99
+ },
100
+ ]);
101
+
102
+ const PLACEHOLDER_WEB_RESULT_REGEX = /^Web search results for query:/i;
103
+
104
+ function flattenBlocks(blocks) {
105
+ if (!Array.isArray(blocks)) return String(blocks ?? "");
106
+ return blocks
107
+ .map((block) => {
108
+ if (!block) return "";
109
+ if (typeof block === "string") return block;
110
+ if (block.type === "text" && typeof block.text === "string") return block.text;
111
+ if (block.type === "tool_result") {
112
+ const payload = block?.content ?? "";
113
+ return typeof payload === "string" ? payload : JSON.stringify(payload);
114
+ }
115
+ if (block.input_text) return block.input_text;
116
+ return "";
117
+ })
118
+ .join("");
119
+ }
120
+
121
+ function normaliseMessages(payload, options = {}) {
122
+ const flattenContent = options.flattenContent !== false;
123
+ const normalised = [];
124
+ if (Array.isArray(payload.system) && payload.system.length) {
125
+ const text = flattenBlocks(payload.system).trim();
126
+ if (text) normalised.push({ role: "system", content: text });
127
+ }
128
+ if (Array.isArray(payload.messages)) {
129
+ for (const message of payload.messages) {
130
+ if (!message) continue;
131
+ const role = message.role ?? "user";
132
+ const rawContent = message.content;
133
+ let content;
134
+ if (Array.isArray(rawContent)) {
135
+ content = flattenContent ? flattenBlocks(rawContent) : rawContent.slice();
136
+ } else if (rawContent === undefined || rawContent === null) {
137
+ content = flattenContent ? "" : rawContent;
138
+ } else if (typeof rawContent === "string") {
139
+ content = rawContent;
140
+ } else if (flattenContent) {
141
+ content = String(rawContent);
142
+ } else {
143
+ content = rawContent;
144
+ }
145
+ normalised.push({ role, content });
146
+ }
147
+ }
148
+ return normalised;
149
+ }
150
+
151
+ function normaliseTools(tools) {
152
+ if (!Array.isArray(tools) || tools.length === 0) return undefined;
153
+ return tools.map((tool) => ({
154
+ type: "function",
155
+ function: {
156
+ name: tool.name,
157
+ description: tool.description,
158
+ parameters: tool.input_schema ?? {},
159
+ },
160
+ }));
161
+ }
162
+
163
+ function stripPlaceholderWebSearchContent(message) {
164
+ if (!message || message.content === undefined || message.content === null) {
165
+ return message;
166
+ }
167
+
168
+ if (typeof message.content === "string") {
169
+ return PLACEHOLDER_WEB_RESULT_REGEX.test(message.content.trim()) ? null : message;
170
+ }
171
+
172
+ if (!Array.isArray(message.content)) {
173
+ return message;
174
+ }
175
+
176
+ const filtered = message.content.filter((block) => {
177
+ if (!block) return false;
178
+ if (block.type === "tool_result") {
179
+ const content = typeof block.content === "string" ? block.content.trim() : "";
180
+ if (PLACEHOLDER_WEB_RESULT_REGEX.test(content)) {
181
+ return false;
182
+ }
183
+ }
184
+ if (block.type === "text" && typeof block.text === "string") {
185
+ if (PLACEHOLDER_WEB_RESULT_REGEX.test(block.text.trim())) {
186
+ return false;
187
+ }
188
+ }
189
+ return true;
190
+ });
191
+
192
+ if (filtered.length === 0) {
193
+ return null;
194
+ }
195
+
196
+ if (filtered.length === message.content.length) {
197
+ return message;
198
+ }
199
+
200
+ return {
201
+ ...message,
202
+ content: filtered,
203
+ };
204
+ }
205
+
206
+ function isPlaceholderToolResultMessage(message) {
207
+ if (!message) return false;
208
+ if (message.role !== "user" && message.role !== "tool") return false;
209
+
210
+ if (typeof message.content === "string") {
211
+ return PLACEHOLDER_WEB_RESULT_REGEX.test(message.content.trim());
212
+ }
213
+
214
+ if (!Array.isArray(message.content) || message.content.length === 0) {
215
+ return false;
216
+ }
217
+
218
+ return message.content.every((block) => {
219
+ if (!block || block.type !== "tool_result") return false;
220
+ const text = typeof block.content === "string" ? block.content.trim() : "";
221
+ return PLACEHOLDER_WEB_RESULT_REGEX.test(text);
222
+ });
223
+ }
224
+
225
+ function removeMatchingAssistantToolUse(cleanMessages, toolUseId) {
226
+ if (!toolUseId || cleanMessages.length === 0) return;
227
+ const lastIndex = cleanMessages.length - 1;
228
+ const candidate = cleanMessages[lastIndex];
229
+ if (!candidate || candidate.role !== "assistant") return;
230
+
231
+ if (Array.isArray(candidate.content)) {
232
+ const remainingBlocks = candidate.content.filter((block) => {
233
+ if (!block || block.type !== "tool_use") return true;
234
+ return block.id !== toolUseId;
235
+ });
236
+
237
+ if (remainingBlocks.length === 0) {
238
+ cleanMessages.pop();
239
+ } else if (remainingBlocks.length !== candidate.content.length) {
240
+ cleanMessages[lastIndex] = {
241
+ ...candidate,
242
+ content: remainingBlocks,
243
+ };
244
+ }
245
+ return;
246
+ }
247
+
248
+ if (Array.isArray(candidate.tool_calls)) {
249
+ const remainingCalls = candidate.tool_calls.filter((call) => call.id !== toolUseId);
250
+ if (remainingCalls.length === 0) {
251
+ cleanMessages.pop();
252
+ } else if (remainingCalls.length !== candidate.tool_calls.length) {
253
+ cleanMessages[lastIndex] = {
254
+ ...candidate,
255
+ tool_calls: remainingCalls,
256
+ };
257
+ }
258
+ }
259
+ }
260
+
261
+ const WEB_SEARCH_NORMALIZED = new Set(["websearch", "web_search", "web-search"]);
262
+
263
+ function normaliseToolIdentifier(name = "") {
264
+ return String(name).toLowerCase().replace(/[^a-z0-9]/g, "");
265
+ }
266
+
267
+ function buildWebSearchSummary(rawContent, options = {}) {
268
+ if (rawContent === undefined || rawContent === null) return null;
269
+ let data = rawContent;
270
+ if (typeof data === "string") {
271
+ const trimmed = data.trim();
272
+ if (!trimmed) return null;
273
+ try {
274
+ data = JSON.parse(trimmed);
275
+ } catch {
276
+ return null;
277
+ }
278
+ }
279
+ if (!data || typeof data !== "object") return null;
280
+ const results = Array.isArray(data.results) ? data.results : [];
281
+ if (results.length === 0) return null;
282
+ const maxItems =
283
+ Number.isInteger(options.maxItems) && options.maxItems > 0 ? options.maxItems : 5;
284
+ const lines = [];
285
+ for (let i = 0; i < results.length && lines.length < maxItems; i += 1) {
286
+ const item = results[i];
287
+ if (!item || typeof item !== "object") continue;
288
+ const title = item.title || item.name || item.url || item.href;
289
+ const url = item.url || item.href || "";
290
+ const snippet = item.snippet || item.summary || item.excerpt || "";
291
+ if (!title && !snippet) continue;
292
+ let line = `${lines.length + 1}. ${title ?? snippet}`;
293
+ if (snippet && snippet !== title) {
294
+ line += ` — ${snippet}`;
295
+ }
296
+ if (url) {
297
+ line += ` (${url})`;
298
+ }
299
+ lines.push(line);
300
+ }
301
+ if (lines.length === 0) return null;
302
+ return `Top search hits:\n${lines.join("\n")}`;
303
+ }
304
+
305
+ function sanitiseAzureTools(tools) {
306
+ if (!Array.isArray(tools) || tools.length === 0) return undefined;
307
+ const allowed = new Set([
308
+ "WebSearch",
309
+ "Web_Search",
310
+ "websearch",
311
+ "web_search",
312
+ "web-fetch",
313
+ "webfetch",
314
+ "web_fetch",
315
+ "bash",
316
+ "shell",
317
+ "bash_output",
318
+ "bashoutput",
319
+ "kill_shell",
320
+ "killshell",
321
+ ]);
322
+ const cleaned = new Map();
323
+ for (const tool of tools) {
324
+ if (!tool || typeof tool !== "object") continue;
325
+ const rawName = typeof tool.name === "string" ? tool.name.trim() : "";
326
+ if (!rawName) continue;
327
+ const identifier = normaliseToolIdentifier(rawName);
328
+ if (!allowed.has(identifier)) continue;
329
+ if (cleaned.has(identifier)) continue;
330
+ let schema = null;
331
+ if (tool.input_schema && typeof tool.input_schema === "object") {
332
+ schema = tool.input_schema;
333
+ } else if (tool.parameters && typeof tool.parameters === "object") {
334
+ schema = tool.parameters;
335
+ }
336
+ if (!schema || typeof schema !== "object") {
337
+ schema = { type: "object" };
338
+ }
339
+ cleaned.set(identifier, {
340
+ name: rawName,
341
+ input_schema: schema,
342
+ });
343
+ }
344
+ return cleaned.size > 0 ? Array.from(cleaned.values()) : undefined;
345
+ }
346
+
347
+ function parseToolArguments(toolCall) {
348
+ if (!toolCall?.function?.arguments) return {};
349
+ const raw = toolCall.function.arguments;
350
+ if (typeof raw !== "string") return raw ?? {};
351
+ try {
352
+ return JSON.parse(raw);
353
+ } catch {
354
+ return {};
355
+ }
356
+ }
357
+
358
+ function parseExecutionContent(content) {
359
+ if (content === undefined || content === null) {
360
+ return null;
361
+ }
362
+ if (typeof content === "string") {
363
+ const trimmed = content.trim();
364
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
365
+ try {
366
+ return JSON.parse(trimmed);
367
+ } catch {
368
+ return content;
369
+ }
370
+ }
371
+ return content;
372
+ }
373
+ return content;
374
+ }
375
+
376
+ function createFallbackAssistantMessage(providerType, { text, toolCall }) {
377
+ if (providerType === "azure-anthropic") {
378
+ const blocks = [];
379
+ if (typeof text === "string" && text.trim().length > 0) {
380
+ blocks.push({ type: "text", text: text.trim() });
381
+ }
382
+ blocks.push({
383
+ type: "tool_use",
384
+ id: toolCall.id ?? `tool_${Date.now()}`,
385
+ name: toolCall.function?.name ?? "tool",
386
+ input: parseToolArguments(toolCall),
387
+ });
388
+ return {
389
+ role: "assistant",
390
+ content: blocks,
391
+ };
392
+ }
393
+ return {
394
+ role: "assistant",
395
+ content: text ?? "",
396
+ tool_calls: [
397
+ {
398
+ id: toolCall.id,
399
+ function: toolCall.function,
400
+ },
401
+ ],
402
+ };
403
+ }
404
+
405
+ function createFallbackToolResultMessage(providerType, { toolCall, execution }) {
406
+ const toolName = execution.name ?? toolCall.function?.name ?? "tool";
407
+ const toolId = execution.id ?? toolCall.id ?? `tool_${Date.now()}`;
408
+ if (providerType === "azure-anthropic") {
409
+ const parsed = parseExecutionContent(execution.content);
410
+ let contentBlocks;
411
+ if (typeof parsed === "string" || parsed === null) {
412
+ contentBlocks = [
413
+ {
414
+ type: "tool_result",
415
+ tool_use_id: toolId,
416
+ content: parsed ?? "",
417
+ is_error: execution.ok === false,
418
+ },
419
+ ];
420
+ } else {
421
+ contentBlocks = [
422
+ {
423
+ type: "tool_result",
424
+ tool_use_id: toolId,
425
+ content: JSON.stringify(parsed),
426
+ is_error: execution.ok === false,
427
+ },
428
+ ];
429
+ }
430
+ return {
431
+ role: "user",
432
+ content: contentBlocks,
433
+ };
434
+ }
435
+ return {
436
+ role: "tool",
437
+ tool_call_id: toolId,
438
+ name: toolCall.function?.name ?? toolName,
439
+ content: execution.content,
440
+ };
441
+ }
442
+
443
+ function extractWebSearchUrls(messages, options = {}, toolNameLookup = new Map()) {
444
+ const max = Number.isInteger(options.max) && options.max > 0 ? options.max : 10;
445
+ const urls = [];
446
+ const seen = new Set();
447
+ if (!Array.isArray(messages)) return urls;
448
+
449
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
450
+ const message = messages[i];
451
+ if (!message) continue;
452
+ if (Array.isArray(message.content)) {
453
+ for (const part of message.content) {
454
+ if (!part || part.type !== "tool_result") continue;
455
+ const toolIdentifier = toolNameLookup.get(part.tool_use_id ?? "") ?? null;
456
+ if (!toolIdentifier || !WEB_SEARCH_NORMALIZED.has(toolIdentifier)) continue;
457
+ let data = part.content;
458
+ if (typeof data === "string") {
459
+ try {
460
+ data = JSON.parse(data);
461
+ } catch {
462
+ continue;
463
+ }
464
+ }
465
+ if (!data || typeof data !== "object") continue;
466
+ const results = Array.isArray(data.results) ? data.results : [];
467
+ for (const entry of results) {
468
+ if (!entry || typeof entry !== "object") continue;
469
+ const url = entry.url ?? entry.href ?? null;
470
+ if (!url) continue;
471
+ if (seen.has(url)) continue;
472
+ seen.add(url);
473
+ urls.push(url);
474
+ if (urls.length >= max) return urls;
475
+ }
476
+ }
477
+ continue;
478
+ }
479
+
480
+ if (message.role === "tool") {
481
+ const toolIdentifier = normaliseToolIdentifier(message.name ?? "");
482
+ if (!WEB_SEARCH_NORMALIZED.has(toolIdentifier)) continue;
483
+ let data = message.content;
484
+ if (typeof data === "string") {
485
+ try {
486
+ data = JSON.parse(data);
487
+ } catch {
488
+ continue;
489
+ }
490
+ }
491
+ if (!data || typeof data !== "object") continue;
492
+ const results = Array.isArray(data.results) ? data.results : [];
493
+ for (const entry of results) {
494
+ if (!entry || typeof entry !== "object") continue;
495
+ const url = entry.url ?? entry.href ?? null;
496
+ if (!url) continue;
497
+ if (seen.has(url)) continue;
498
+ seen.add(url);
499
+ urls.push(url);
500
+ if (urls.length >= max) return urls;
501
+ }
502
+ continue;
503
+ }
504
+ }
505
+
506
+ return urls;
507
+ }
508
+
509
+ function normaliseToolChoice(choice) {
510
+ if (!choice) return undefined;
511
+ if (typeof choice === "string") return choice; // "auto", "none"
512
+ if (choice.type === "tool" && choice.name) {
513
+ return { type: "function", function: { name: choice.name } };
514
+ }
515
+ return undefined;
516
+ }
517
+
518
+ function toAnthropicResponse(openai, requestedModel, wantsThinking) {
519
+ const choice = openai?.choices?.[0];
520
+ const message = choice?.message ?? {};
521
+ const usage = openai?.usage ?? {};
522
+ const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
523
+ const contentItems = [];
524
+
525
+ if (wantsThinking) {
526
+ contentItems.push({
527
+ type: "thinking",
528
+ thinking: "Reasoning not available from the backing Databricks model.",
529
+ });
530
+ }
531
+
532
+ if (toolCalls.length) {
533
+ for (const call of toolCalls) {
534
+ let input = {};
535
+ try {
536
+ input = call.function?.arguments ? JSON.parse(call.function.arguments) : {};
537
+ } catch {
538
+ input = {};
539
+ }
540
+ contentItems.push({
541
+ type: "tool_use",
542
+ id: call.id ?? `tool_${Date.now()}`,
543
+ name: call.function?.name ?? "function",
544
+ input,
545
+ });
546
+ }
547
+ }
548
+
549
+ const textContent = message.content;
550
+ if (typeof textContent === "string" && textContent.trim()) {
551
+ contentItems.push({ type: "text", text: textContent });
552
+ } else if (Array.isArray(textContent)) {
553
+ for (const part of textContent) {
554
+ if (typeof part === "string") {
555
+ contentItems.push({ type: "text", text: part });
556
+ } else if (part?.type === "text" && typeof part.text === "string") {
557
+ contentItems.push({ type: "text", text: part.text });
558
+ }
559
+ }
560
+ }
561
+
562
+ if (contentItems.length === 0) {
563
+ contentItems.push({ type: "text", text: "" });
564
+ }
565
+
566
+ return {
567
+ id: openai.id ?? `msg_${Date.now()}`,
568
+ type: "message",
569
+ role: "assistant",
570
+ model: requestedModel,
571
+ content: contentItems,
572
+ stop_reason:
573
+ choice?.finish_reason === "stop"
574
+ ? "end_turn"
575
+ : choice?.finish_reason === "length"
576
+ ? "max_tokens"
577
+ : choice?.finish_reason === "tool_calls"
578
+ ? "tool_use"
579
+ : choice?.finish_reason ?? "end_turn",
580
+ stop_sequence: null,
581
+ usage: {
582
+ input_tokens: usage.prompt_tokens ?? 0,
583
+ output_tokens: usage.completion_tokens ?? 0,
584
+ cache_creation_input_tokens: 0,
585
+ cache_read_input_tokens: 0,
586
+ },
587
+ };
588
+ }
589
+
590
+ function sanitizePayload(payload) {
591
+ const clean = JSON.parse(JSON.stringify(payload ?? {}));
592
+ const requestedModel =
593
+ (typeof payload?.model === "string" && payload.model.trim().length > 0
594
+ ? payload.model.trim()
595
+ : null) ??
596
+ config.modelProvider?.defaultModel ??
597
+ "databricks-claude-sonnet-4-5";
598
+ clean.model = requestedModel;
599
+ const providerType = config.modelProvider?.type ?? "databricks";
600
+ const flattenContent = providerType !== "azure-anthropic";
601
+ clean.messages = normaliseMessages(clean, { flattenContent }).filter((msg) => {
602
+ const hasToolCalls =
603
+ Array.isArray(msg?.tool_calls) && msg.tool_calls.length > 0;
604
+ if (!msg?.content) {
605
+ return hasToolCalls;
606
+ }
607
+ if (typeof msg.content === "string") {
608
+ return hasToolCalls || msg.content.trim().length > 0;
609
+ }
610
+ if (Array.isArray(msg.content)) {
611
+ return hasToolCalls || msg.content.length > 0;
612
+ }
613
+ if (typeof msg.content === "object" && msg.content !== null) {
614
+ return hasToolCalls || Object.keys(msg.content).length > 0;
615
+ }
616
+ return hasToolCalls;
617
+ });
618
+ if (providerType === "azure-anthropic") {
619
+ const cleanedMessages = [];
620
+ for (const message of clean.messages) {
621
+ if (isPlaceholderToolResultMessage(message)) {
622
+ let toolUseId = null;
623
+ if (Array.isArray(message.content)) {
624
+ for (const block of message.content) {
625
+ if (block?.type === "tool_result" && block.tool_use_id) {
626
+ toolUseId = block.tool_use_id;
627
+ break;
628
+ }
629
+ }
630
+ }
631
+ removeMatchingAssistantToolUse(cleanedMessages, toolUseId);
632
+ continue;
633
+ }
634
+ const stripped = stripPlaceholderWebSearchContent(message);
635
+ if (stripped) {
636
+ cleanedMessages.push(stripped);
637
+ }
638
+ }
639
+ clean.messages = cleanedMessages;
640
+
641
+ const systemChunks = [];
642
+ clean.messages = clean.messages.filter((msg) => {
643
+ if (msg?.role === "tool") {
644
+ return false;
645
+ }
646
+ if (msg?.role === "system") {
647
+ if (typeof msg.content === "string" && msg.content.trim().length > 0) {
648
+ systemChunks.push(msg.content.trim());
649
+ }
650
+ return false;
651
+ }
652
+ return true;
653
+ });
654
+ if (systemChunks.length > 0) {
655
+ clean.system = systemChunks.join("\n\n");
656
+ } else if (typeof clean.system === "string" && clean.system.trim().length > 0) {
657
+ clean.system = clean.system.trim();
658
+ } else {
659
+ delete clean.system;
660
+ }
661
+ const azureDefaultModel =
662
+ config.modelProvider?.defaultModel && config.modelProvider.defaultModel.trim().length > 0
663
+ ? config.modelProvider.defaultModel.trim()
664
+ : "claude-opus-4-5";
665
+ clean.model = azureDefaultModel;
666
+ } else {
667
+ delete clean.system;
668
+ }
669
+ DROP_KEYS.forEach((key) => delete clean[key]);
670
+
671
+ if (Array.isArray(clean.tools) && clean.tools.length === 0) {
672
+ delete clean.tools;
673
+ } else if (providerType === "databricks") {
674
+ const tools = normaliseTools(clean.tools);
675
+ if (tools) clean.tools = tools;
676
+ else delete clean.tools;
677
+ } else if (providerType === "azure-anthropic") {
678
+ const tools = sanitiseAzureTools(clean.tools);
679
+ clean.tools =
680
+ tools && tools.length > 0
681
+ ? tools
682
+ : DEFAULT_AZURE_TOOLS.map((tool) => ({
683
+ name: tool.name,
684
+ input_schema: JSON.parse(JSON.stringify(tool.input_schema)),
685
+ }));
686
+ delete clean.tool_choice;
687
+ } else if (Array.isArray(clean.tools)) {
688
+ delete clean.tools;
689
+ }
690
+
691
+ if (providerType === "databricks") {
692
+ const toolChoice = normaliseToolChoice(clean.tool_choice);
693
+ if (toolChoice !== undefined) clean.tool_choice = toolChoice;
694
+ else delete clean.tool_choice;
695
+ } else if (clean.tool_choice === undefined || clean.tool_choice === null) {
696
+ delete clean.tool_choice;
697
+ }
698
+
699
+ clean.stream = false;
700
+
701
+ if (
702
+ config.modelProvider?.type === "azure-anthropic" &&
703
+ logger &&
704
+ typeof logger.debug === "function"
705
+ ) {
706
+ try {
707
+ logger.debug(
708
+ {
709
+ model: clean.model,
710
+ temperature: clean.temperature ?? null,
711
+ max_tokens: clean.max_tokens ?? null,
712
+ tool_count: Array.isArray(clean.tools) ? clean.tools.length : 0,
713
+ has_tool_choice: clean.tool_choice !== undefined,
714
+ messages: clean.messages,
715
+ },
716
+ "Azure Anthropic sanitized payload",
717
+ );
718
+ logger.debug(
719
+ {
720
+ payload: JSON.parse(JSON.stringify(clean)),
721
+ },
722
+ "Azure Anthropic request payload",
723
+ );
724
+ } catch (err) {
725
+ logger.debug({ err }, "Failed logging Azure Anthropic payload");
726
+ }
727
+ }
728
+
729
+ return clean;
730
+ }
731
+
732
+ const DEFAULT_LOOP_OPTIONS = {
733
+ maxSteps: config.policy.maxStepsPerTurn ?? 6,
734
+ maxDurationMs: 120000,
735
+ };
736
+
737
+ function resolveLoopOptions(options = {}) {
738
+ const maxSteps =
739
+ Number.isInteger(options.maxSteps) && options.maxSteps > 0
740
+ ? options.maxSteps
741
+ : DEFAULT_LOOP_OPTIONS.maxSteps;
742
+ const maxDurationMs =
743
+ Number.isInteger(options.maxDurationMs) && options.maxDurationMs > 0
744
+ ? options.maxDurationMs
745
+ : DEFAULT_LOOP_OPTIONS.maxDurationMs;
746
+ return {
747
+ ...DEFAULT_LOOP_OPTIONS,
748
+ maxSteps,
749
+ maxDurationMs,
750
+ };
751
+ }
752
+
753
+ function buildNonJsonResponse(databricksResponse) {
754
+ return {
755
+ status: databricksResponse.status,
756
+ headers: {
757
+ "Content-Type": databricksResponse.contentType ?? "text/plain",
758
+ },
759
+ body: databricksResponse.text,
760
+ terminationReason: "non_json_response",
761
+ };
762
+ }
763
+
764
+ function buildErrorResponse(databricksResponse) {
765
+ return {
766
+ status: databricksResponse.status,
767
+ body: databricksResponse.json,
768
+ terminationReason: "api_error",
769
+ };
770
+ }
771
+
772
+ async function runAgentLoop({
773
+ cleanPayload,
774
+ requestedModel,
775
+ wantsThinking,
776
+ session,
777
+ options,
778
+ cacheKey,
779
+ providerType,
780
+ }) {
781
+ const settings = resolveLoopOptions(options);
782
+ const start = Date.now();
783
+ let steps = 0;
784
+ let toolCallsExecuted = 0;
785
+ let fallbackPerformed = false;
786
+ const toolCallNames = new Map();
787
+
788
+ while (steps < settings.maxSteps) {
789
+ if (Date.now() - start > settings.maxDurationMs) {
790
+ break;
791
+ }
792
+
793
+ steps += 1;
794
+ logger.debug(
795
+ {
796
+ sessionId: session?.id ?? null,
797
+ step: steps,
798
+ maxSteps: settings.maxSteps,
799
+ },
800
+ "Agent loop step",
801
+ );
802
+
803
+ // Debug: Log payload before sending to Azure
804
+ if (providerType === "azure-anthropic") {
805
+ logger.debug(
806
+ {
807
+ sessionId: session?.id ?? null,
808
+ messageCount: cleanPayload.messages?.length ?? 0,
809
+ messageRoles: cleanPayload.messages?.map(m => m.role) ?? [],
810
+ lastMessage: cleanPayload.messages?.[cleanPayload.messages.length - 1],
811
+ },
812
+ "Azure Anthropic request payload structure",
813
+ );
814
+ }
815
+
816
+ const databricksResponse = await invokeModel(cleanPayload);
817
+
818
+ if (!databricksResponse.json) {
819
+ appendTurnToSession(session, {
820
+ role: "assistant",
821
+ type: "error",
822
+ status: databricksResponse.status,
823
+ content: databricksResponse.text ?? "",
824
+ metadata: { termination: "non_json_response" },
825
+ });
826
+ const response = buildNonJsonResponse(databricksResponse);
827
+ logger.warn(
828
+ {
829
+ sessionId: session?.id ?? null,
830
+ status: response.status,
831
+ termination: response.terminationReason,
832
+ },
833
+ "Agent loop terminated without JSON",
834
+ );
835
+ return {
836
+ response,
837
+ steps,
838
+ durationMs: Date.now() - start,
839
+ terminationReason: response.terminationReason,
840
+ };
841
+ }
842
+
843
+ if (!databricksResponse.ok) {
844
+ appendTurnToSession(session, {
845
+ role: "assistant",
846
+ type: "error",
847
+ status: databricksResponse.status,
848
+ content: databricksResponse.json,
849
+ metadata: { termination: "api_error" },
850
+ });
851
+
852
+ const response = buildErrorResponse(databricksResponse);
853
+ logger.error(
854
+ {
855
+ sessionId: session?.id ?? null,
856
+ status: response.status,
857
+ },
858
+ "Agent loop encountered API error",
859
+ );
860
+ return {
861
+ response,
862
+ steps,
863
+ durationMs: Date.now() - start,
864
+ terminationReason: response.terminationReason,
865
+ };
866
+ }
867
+
868
+ // Extract message and tool calls based on provider response format
869
+ let message = {};
870
+ let toolCalls = [];
871
+
872
+ if (providerType === "azure-anthropic") {
873
+ // Anthropic format: { content: [{ type: "tool_use", ... }], stop_reason: "tool_use" }
874
+ message = {
875
+ content: databricksResponse.json?.content ?? [],
876
+ stop_reason: databricksResponse.json?.stop_reason,
877
+ };
878
+ // Extract tool_use blocks from content array
879
+ const contentArray = Array.isArray(databricksResponse.json?.content)
880
+ ? databricksResponse.json.content
881
+ : [];
882
+ toolCalls = contentArray
883
+ .filter(block => block?.type === "tool_use")
884
+ .map(block => ({
885
+ id: block.id,
886
+ function: {
887
+ name: block.name,
888
+ arguments: JSON.stringify(block.input ?? {}),
889
+ },
890
+ // Keep original block for reference
891
+ _anthropic_block: block,
892
+ }));
893
+
894
+ logger.debug(
895
+ {
896
+ sessionId: session?.id ?? null,
897
+ contentBlocks: contentArray.length,
898
+ toolCallsFound: toolCalls.length,
899
+ stopReason: databricksResponse.json?.stop_reason,
900
+ },
901
+ "Azure Anthropic response parsed",
902
+ );
903
+ } else {
904
+ // OpenAI/Databricks format: { choices: [{ message: { tool_calls: [...] } }] }
905
+ const choice = databricksResponse.json?.choices?.[0];
906
+ message = choice?.message ?? {};
907
+ toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
908
+ }
909
+
910
+ if (toolCalls.length > 0) {
911
+ appendTurnToSession(session, {
912
+ role: "assistant",
913
+ type: "tool_request",
914
+ status: 200,
915
+ content: message,
916
+ metadata: {
917
+ termination: "tool_use",
918
+ toolCalls: toolCalls.map((call) => ({
919
+ id: call.id,
920
+ name: call.function?.name ?? call.name,
921
+ })),
922
+ },
923
+ });
924
+
925
+ let assistantToolMessage;
926
+ if (providerType === "azure-anthropic") {
927
+ // For Azure Anthropic, use the content array directly from the response
928
+ // It already contains both text and tool_use blocks in the correct format
929
+ assistantToolMessage = {
930
+ role: "assistant",
931
+ content: databricksResponse.json?.content ?? [],
932
+ };
933
+ } else {
934
+ assistantToolMessage = {
935
+ role: "assistant",
936
+ content: message.content ?? "",
937
+ tool_calls: message.tool_calls,
938
+ };
939
+ }
940
+
941
+ // Only add fallback content for Databricks format (Azure already has content)
942
+ if (
943
+ providerType !== "azure-anthropic" &&
944
+ (!assistantToolMessage.content ||
945
+ (typeof assistantToolMessage.content === "string" &&
946
+ assistantToolMessage.content.trim().length === 0)) &&
947
+ toolCalls.length > 0
948
+ ) {
949
+ const toolNames = toolCalls
950
+ .map((call) => call.function?.name ?? "tool")
951
+ .join(", ");
952
+ assistantToolMessage.content = `Invoking tool(s): ${toolNames}`;
953
+ }
954
+
955
+ cleanPayload.messages.push(assistantToolMessage);
956
+
957
+ for (const call of toolCalls) {
958
+ const callId =
959
+ call.id ??
960
+ `tool_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
961
+ if (!call.id) {
962
+ call.id = callId;
963
+ }
964
+ toolCallNames.set(
965
+ callId,
966
+ normaliseToolIdentifier(call.function?.name ?? call.name ?? "tool"),
967
+ );
968
+ const decision = policy.evaluateToolCall({
969
+ call,
970
+ toolCallsExecuted,
971
+ });
972
+
973
+ if (!decision.allowed) {
974
+ policy.logPolicyDecision(decision, {
975
+ sessionId: session?.id ?? null,
976
+ toolCall: call,
977
+ });
978
+
979
+ const denialContent = JSON.stringify(
980
+ {
981
+ error: decision.code ?? "tool_blocked",
982
+ message: decision.reason ?? "Tool invocation blocked by policy.",
983
+ },
984
+ null,
985
+ 2,
986
+ );
987
+
988
+ let toolResultMessage;
989
+ if (providerType === "azure-anthropic") {
990
+ // Anthropic format: tool_result in user message content array
991
+ toolResultMessage = {
992
+ role: "user",
993
+ content: [
994
+ {
995
+ type: "tool_result",
996
+ tool_use_id: call.id ?? `${call.function?.name ?? "tool"}_${Date.now()}`,
997
+ content: denialContent,
998
+ is_error: true,
999
+ },
1000
+ ],
1001
+ };
1002
+ } else {
1003
+ // OpenAI format
1004
+ toolResultMessage = {
1005
+ role: "tool",
1006
+ tool_call_id: call.id ?? `${call.function?.name ?? "tool"}_${Date.now()}`,
1007
+ name: call.function?.name ?? call.name,
1008
+ content: denialContent,
1009
+ };
1010
+ }
1011
+
1012
+ cleanPayload.messages.push(toolResultMessage);
1013
+ appendTurnToSession(session, {
1014
+ role: "tool",
1015
+ type: "tool_result",
1016
+ status: decision.status ?? 403,
1017
+ content: toolResultMessage,
1018
+ metadata: {
1019
+ tool: toolResultMessage.name,
1020
+ ok: false,
1021
+ blocked: true,
1022
+ reason: decision.reason ?? "Policy violation",
1023
+ },
1024
+ });
1025
+ continue;
1026
+ }
1027
+
1028
+ toolCallsExecuted += 1;
1029
+
1030
+ const execution = await executeToolCall(call, {
1031
+ session,
1032
+ requestMessages: cleanPayload.messages,
1033
+ });
1034
+
1035
+ let toolMessage;
1036
+ if (providerType === "azure-anthropic") {
1037
+ const parsedContent = parseExecutionContent(execution.content);
1038
+ const serialisedContent =
1039
+ typeof parsedContent === "string" || parsedContent === null
1040
+ ? parsedContent ?? ""
1041
+ : JSON.stringify(parsedContent);
1042
+ let contentForToolResult = serialisedContent;
1043
+ if (execution.ok) {
1044
+ const toolIdentifier = normaliseToolIdentifier(
1045
+ call.function?.name ?? call.name ?? execution.name ?? "tool",
1046
+ );
1047
+ if (WEB_SEARCH_NORMALIZED.has(toolIdentifier)) {
1048
+ const summary = buildWebSearchSummary(parsedContent, {
1049
+ maxItems: options?.webSearchSummaryLimit ?? 5,
1050
+ });
1051
+ if (summary) {
1052
+ try {
1053
+ const structured =
1054
+ typeof parsedContent === "object" && parsedContent !== null
1055
+ ? { ...parsedContent, summary }
1056
+ : { raw: serialisedContent, summary };
1057
+ contentForToolResult = JSON.stringify(structured, null, 2);
1058
+ } catch {
1059
+ contentForToolResult = `${serialisedContent}\n\nSummary:\n${summary}`;
1060
+ }
1061
+ }
1062
+ }
1063
+ }
1064
+ toolMessage = {
1065
+ role: "user",
1066
+ content: [
1067
+ {
1068
+ type: "tool_result",
1069
+ tool_use_id: call.id ?? execution.id,
1070
+ content: contentForToolResult,
1071
+ is_error: execution.ok === false,
1072
+ },
1073
+ ],
1074
+ };
1075
+ toolCallNames.set(
1076
+ call.id ?? execution.id,
1077
+ normaliseToolIdentifier(
1078
+ call.function?.name ?? call.name ?? execution.name ?? "tool",
1079
+ ),
1080
+ );
1081
+
1082
+ } else {
1083
+ toolMessage = {
1084
+ role: "tool",
1085
+ tool_call_id: execution.id,
1086
+ name: execution.name,
1087
+ content: execution.content,
1088
+ };
1089
+ }
1090
+
1091
+ cleanPayload.messages.push(toolMessage);
1092
+
1093
+ appendTurnToSession(session, {
1094
+ role: "tool",
1095
+ type: "tool_result",
1096
+ status: execution.status,
1097
+ content: toolMessage,
1098
+ metadata: {
1099
+ tool: execution.name,
1100
+ ok: execution.ok,
1101
+ registered: execution.metadata?.registered ?? null,
1102
+ },
1103
+ });
1104
+
1105
+ if (execution.ok) {
1106
+ logger.debug(
1107
+ {
1108
+ sessionId: session?.id ?? null,
1109
+ tool: execution.name,
1110
+ toolCallId: execution.id,
1111
+ },
1112
+ "Tool executed successfully",
1113
+ );
1114
+ } else {
1115
+ logger.warn(
1116
+ {
1117
+ sessionId: session?.id ?? null,
1118
+ tool: execution.name,
1119
+ toolCallId: execution.id,
1120
+ status: execution.status,
1121
+ },
1122
+ "Tool execution returned an error response",
1123
+ );
1124
+ }
1125
+ }
1126
+
1127
+ continue;
1128
+ }
1129
+
1130
+ let anthropicPayload;
1131
+ if (providerType === "azure-anthropic") {
1132
+ anthropicPayload = databricksResponse.json;
1133
+ if (Array.isArray(anthropicPayload?.content)) {
1134
+ anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
1135
+ }
1136
+ } else {
1137
+ anthropicPayload = toAnthropicResponse(
1138
+ databricksResponse.json,
1139
+ requestedModel,
1140
+ wantsThinking,
1141
+ );
1142
+ anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
1143
+ }
1144
+
1145
+ const fallbackCandidate = anthropicPayload.content.find(
1146
+ (item) => item.type === "text" && needsWebFallback(item.text),
1147
+ );
1148
+
1149
+ if (fallbackCandidate && !fallbackPerformed) {
1150
+ if (providerType === "azure-anthropic") {
1151
+ anthropicPayload.content.push({
1152
+ type: "text",
1153
+ text: "Automatic web fetch policy fallback is not supported with the Azure-hosted Anthropic provider.",
1154
+ });
1155
+ fallbackPerformed = true;
1156
+ continue;
1157
+ }
1158
+ const lastUserMessage = cleanPayload.messages
1159
+ .slice()
1160
+ .reverse()
1161
+ .find((msg) => msg.role === "user" && typeof msg.content === "string");
1162
+
1163
+ let queryUrl = null;
1164
+ if (lastUserMessage) {
1165
+ const urlMatch = lastUserMessage.content.match(/(https?:\/\/[^\s"']+)/i);
1166
+ if (urlMatch) {
1167
+ queryUrl = urlMatch[1];
1168
+ }
1169
+ }
1170
+
1171
+ if (!queryUrl) {
1172
+ const text = lastUserMessage?.content ?? "";
1173
+ queryUrl = `https://www.google.com/search?q=${encodeURIComponent(text)}`;
1174
+ }
1175
+
1176
+ if (
1177
+ lastUserMessage &&
1178
+ /https?:\/\/[^\s"']+/.test(lastUserMessage.content) === false &&
1179
+ /price|stock|data|quote/i.test(lastUserMessage.content)
1180
+ ) {
1181
+ queryUrl = "https://query1.finance.yahoo.com/v8/finance/chart/NVDA";
1182
+ }
1183
+
1184
+ logger.info(
1185
+ {
1186
+ sessionId: session?.id ?? null,
1187
+ queryUrl,
1188
+ },
1189
+ "Policy web fallback triggered",
1190
+ );
1191
+
1192
+ const toolCallId = `policy_web_fetch_${Date.now()}`;
1193
+ const toolCall = {
1194
+ id: toolCallId,
1195
+ function: {
1196
+ name: "web_fetch",
1197
+ arguments: JSON.stringify({ url: queryUrl }),
1198
+ },
1199
+ };
1200
+
1201
+ const decision = policy.evaluateToolCall({
1202
+ call: toolCall,
1203
+ toolCallsExecuted,
1204
+ });
1205
+
1206
+ if (!decision.allowed) {
1207
+ anthropicPayload.content.push({
1208
+ type: "text",
1209
+ text: `Automatic web fetch was blocked: ${decision.reason ?? "policy denied."}`,
1210
+ });
1211
+ } else {
1212
+ const candidateUrls = extractWebSearchUrls(
1213
+ cleanPayload.messages,
1214
+ { max: 5 },
1215
+ toolCallNames,
1216
+ );
1217
+ const orderedCandidates = [];
1218
+ const seenCandidates = new Set();
1219
+
1220
+ const pushCandidate = (url) => {
1221
+ if (typeof url !== "string") return;
1222
+ const trimmed = url.trim();
1223
+ if (!/^https?:\/\//i.test(trimmed)) return;
1224
+ if (seenCandidates.has(trimmed)) return;
1225
+ seenCandidates.add(trimmed);
1226
+ orderedCandidates.push(trimmed);
1227
+ };
1228
+
1229
+ pushCandidate(queryUrl);
1230
+ for (const candidate of candidateUrls) {
1231
+ pushCandidate(candidate);
1232
+ }
1233
+
1234
+ if (orderedCandidates.length === 0 && typeof queryUrl === "string") {
1235
+ pushCandidate(queryUrl);
1236
+ }
1237
+
1238
+ if (orderedCandidates.length === 0) {
1239
+ anthropicPayload.content.push({
1240
+ type: "text",
1241
+ text: "Automatic web fetch was skipped: no candidate URLs were available.",
1242
+ });
1243
+ continue;
1244
+ }
1245
+
1246
+ let attemptSucceeded = false;
1247
+
1248
+ for (let attemptIndex = 0; attemptIndex < orderedCandidates.length; attemptIndex += 1) {
1249
+ const targetUrl = orderedCandidates[attemptIndex];
1250
+ const attemptId = `${toolCallId}_${attemptIndex}`;
1251
+ const attemptCall = {
1252
+ id: attemptId,
1253
+ function: {
1254
+ name: "web_fetch",
1255
+ arguments: JSON.stringify({ url: targetUrl }),
1256
+ },
1257
+ };
1258
+ toolCallNames.set(attemptId, "web_fetch");
1259
+
1260
+ const assistantToolMessage = createFallbackAssistantMessage(providerType, {
1261
+ text: orderedCandidates.length > 1
1262
+ ? `Attempting to fetch data via web_fetch fallback (${attemptIndex + 1}/${orderedCandidates.length}).`
1263
+ : "Attempting to fetch data via web_fetch fallback.",
1264
+ toolCall: attemptCall,
1265
+ });
1266
+
1267
+ cleanPayload.messages.push(assistantToolMessage);
1268
+ appendTurnToSession(session, {
1269
+ role: "assistant",
1270
+ type: "tool_request",
1271
+ status: 200,
1272
+ content: assistantToolMessage,
1273
+ metadata: {
1274
+ termination: "tool_use",
1275
+ toolCalls: [{ id: attemptCall.id, name: attemptCall.function.name }],
1276
+ fallback: true,
1277
+ query: targetUrl,
1278
+ attempt: attemptIndex + 1,
1279
+ },
1280
+ });
1281
+
1282
+ const execution = await executeToolCall(attemptCall, {
1283
+ session,
1284
+ requestMessages: cleanPayload.messages,
1285
+ });
1286
+
1287
+ const toolResultMessage = createFallbackToolResultMessage(providerType, {
1288
+ toolCall: attemptCall,
1289
+ execution,
1290
+ });
1291
+
1292
+ cleanPayload.messages.push(toolResultMessage);
1293
+ appendTurnToSession(session, {
1294
+ role: "tool",
1295
+ type: "tool_result",
1296
+ status: execution.status,
1297
+ content: toolResultMessage,
1298
+ metadata: {
1299
+ tool: attemptCall.function.name,
1300
+ ok: execution.ok,
1301
+ registered: execution.metadata?.registered ?? true,
1302
+ fallback: true,
1303
+ query: targetUrl,
1304
+ attempt: attemptIndex + 1,
1305
+ },
1306
+ });
1307
+
1308
+ toolCallsExecuted += 1;
1309
+
1310
+ if (execution.ok) {
1311
+ fallbackPerformed = true;
1312
+ attemptSucceeded = true;
1313
+ break;
1314
+ }
1315
+ }
1316
+
1317
+ if (!attemptSucceeded) {
1318
+ anthropicPayload.content.push({
1319
+ type: "text",
1320
+ text: "Automatic web fetch could not retrieve data from any candidate URLs.",
1321
+ });
1322
+ }
1323
+ continue;
1324
+ }
1325
+ }
1326
+
1327
+ appendTurnToSession(session, {
1328
+ role: "assistant",
1329
+ type: "message",
1330
+ status: 200,
1331
+ content: anthropicPayload,
1332
+ metadata: { termination: "completion" },
1333
+ });
1334
+
1335
+ if (cacheKey && steps === 1 && toolCallsExecuted === 0) {
1336
+ const storedKey = promptCache.storeResponse(cacheKey, databricksResponse);
1337
+ if (storedKey) {
1338
+ const promptTokens = databricksResponse.json?.usage?.prompt_tokens ?? 0;
1339
+ anthropicPayload.usage.cache_creation_input_tokens = promptTokens;
1340
+ }
1341
+ }
1342
+
1343
+ logger.info(
1344
+ {
1345
+ sessionId: session?.id ?? null,
1346
+ steps,
1347
+ durationMs: Date.now() - start,
1348
+ },
1349
+ "Agent loop completed",
1350
+ );
1351
+ return {
1352
+ response: {
1353
+ status: 200,
1354
+ body: anthropicPayload,
1355
+ terminationReason: "completion",
1356
+ },
1357
+ steps,
1358
+ durationMs: Date.now() - start,
1359
+ terminationReason: "completion",
1360
+ };
1361
+ }
1362
+
1363
+ appendTurnToSession(session, {
1364
+ role: "assistant",
1365
+ type: "error",
1366
+ status: 504,
1367
+ content: {
1368
+ error: "max_steps_exceeded",
1369
+ message: "Reached agent loop limits without producing a response.",
1370
+ limits: {
1371
+ maxSteps: settings.maxSteps,
1372
+ maxDurationMs: settings.maxDurationMs,
1373
+ },
1374
+ },
1375
+ metadata: { termination: "max_steps" },
1376
+ });
1377
+ logger.warn(
1378
+ {
1379
+ sessionId: session?.id ?? null,
1380
+ steps,
1381
+ durationMs: Date.now() - start,
1382
+ },
1383
+ "Agent loop exceeded limits",
1384
+ );
1385
+
1386
+ return {
1387
+ response: {
1388
+ status: 504,
1389
+ body: {
1390
+ error: "max_steps_exceeded",
1391
+ message: "Reached agent loop limits without producing a response.",
1392
+ limits: {
1393
+ maxSteps: settings.maxSteps,
1394
+ maxDurationMs: settings.maxDurationMs,
1395
+ },
1396
+ },
1397
+ terminationReason: "max_steps",
1398
+ },
1399
+ steps,
1400
+ durationMs: Date.now() - start,
1401
+ terminationReason: "max_steps",
1402
+ };
1403
+ }
1404
+
1405
+ async function processMessage({ payload, headers, session, options = {} }) {
1406
+ const requestedModel =
1407
+ payload?.model ??
1408
+ config.modelProvider?.defaultModel ??
1409
+ "claude-3-unknown";
1410
+ const wantsThinking =
1411
+ typeof headers?.["anthropic-beta"] === "string" &&
1412
+ headers["anthropic-beta"].includes("interleaved-thinking");
1413
+
1414
+ const cleanPayload = sanitizePayload(payload);
1415
+ appendTurnToSession(session, {
1416
+ role: "user",
1417
+ content: {
1418
+ raw: payload?.messages ?? [],
1419
+ normalized: cleanPayload.messages,
1420
+ },
1421
+ type: "message",
1422
+ });
1423
+
1424
+ let cacheKey = null;
1425
+ let cachedResponse = null;
1426
+ if (promptCache.isEnabled()) {
1427
+ const cacheSeedPayload = JSON.parse(JSON.stringify(cleanPayload));
1428
+ const { key, entry } = promptCache.lookup(cacheSeedPayload);
1429
+ cacheKey = key;
1430
+ if (entry?.value) {
1431
+ try {
1432
+ cachedResponse = JSON.parse(JSON.stringify(entry.value));
1433
+ } catch {
1434
+ cachedResponse = entry.value;
1435
+ }
1436
+ }
1437
+ }
1438
+
1439
+ if (cachedResponse) {
1440
+ const anthropicPayload = toAnthropicResponse(
1441
+ cachedResponse.json,
1442
+ requestedModel,
1443
+ wantsThinking,
1444
+ );
1445
+ anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
1446
+
1447
+ const promptTokens = cachedResponse.json?.usage?.prompt_tokens ?? 0;
1448
+ const completionTokens = cachedResponse.json?.usage?.completion_tokens ?? 0;
1449
+ anthropicPayload.usage.input_tokens = promptTokens;
1450
+ anthropicPayload.usage.output_tokens = completionTokens;
1451
+ anthropicPayload.usage.cache_read_input_tokens = promptTokens;
1452
+ anthropicPayload.usage.cache_creation_input_tokens = 0;
1453
+
1454
+ appendTurnToSession(session, {
1455
+ role: "assistant",
1456
+ type: "message",
1457
+ status: 200,
1458
+ content: anthropicPayload,
1459
+ metadata: { termination: "completion", cacheHit: true },
1460
+ });
1461
+
1462
+ logger.info(
1463
+ {
1464
+ sessionId: session?.id ?? null,
1465
+ cacheKey,
1466
+ },
1467
+ "Agent response served from prompt cache",
1468
+ );
1469
+
1470
+ return {
1471
+ status: 200,
1472
+ body: anthropicPayload,
1473
+ terminationReason: "completion",
1474
+ };
1475
+ }
1476
+
1477
+ const loopResult = await runAgentLoop({
1478
+ cleanPayload,
1479
+ requestedModel,
1480
+ wantsThinking,
1481
+ session,
1482
+ options,
1483
+ cacheKey,
1484
+ providerType: config.modelProvider?.type ?? "databricks",
1485
+ });
1486
+
1487
+ return loopResult.response;
1488
+ }
1489
+
1490
+ module.exports = {
1491
+ processMessage,
1492
+ };