lynkr 9.14.1 → 9.14.2

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 (48) hide show
  1. package/package.json +10 -22
  2. package/src/api/health.js +0 -1
  3. package/src/api/middleware/session.js +0 -0
  4. package/src/api/openai-router.js +85 -11
  5. package/src/api/providers-handler.js +0 -1
  6. package/src/api/router.js +6 -3
  7. package/src/cache/prompt.js +0 -7
  8. package/src/clients/bedrock-utils.js +2 -1
  9. package/src/clients/codex-process.js +1 -1
  10. package/src/clients/codex-utils.js +0 -2
  11. package/src/clients/databricks.js +52 -25
  12. package/src/clients/gpt-utils.js +1 -1
  13. package/src/clients/openai-format.js +73 -23
  14. package/src/clients/provider-capabilities.js +0 -2
  15. package/src/clients/resilience.js +0 -1
  16. package/src/clients/responses-format.js +0 -56
  17. package/src/clients/routing.js +1 -1
  18. package/src/config/index.js +0 -1
  19. package/src/context/budget.js +1 -1
  20. package/src/context/tool-result-compressor.js +3 -5
  21. package/src/dashboard/api.js +0 -1
  22. package/src/logger/oversized-error-stream.js +1 -1
  23. package/src/memory/extractor.js +3 -3
  24. package/src/memory/search.js +2 -2
  25. package/src/memory/store.js +9 -24
  26. package/src/memory/tools.js +14 -0
  27. package/src/observability/metrics.js +0 -3
  28. package/src/orchestrator/index.js +1 -80
  29. package/src/orchestrator/sse-transformer.js +68 -10
  30. package/src/policy/safe-commands.js +0 -1
  31. package/src/routing/calibration.js +21 -1
  32. package/src/routing/client-profiles.js +26 -0
  33. package/src/routing/complexity-analyzer.js +5 -8
  34. package/src/routing/confidence-scorer.js +2 -2
  35. package/src/routing/cost-optimizer.js +1 -2
  36. package/src/routing/deescalator.js +1 -1
  37. package/src/routing/embedding-cache.js +0 -1
  38. package/src/routing/intent-score.js +0 -1
  39. package/src/routing/knn-router.js +27 -5
  40. package/src/routing/latency-tracker.js +1 -1
  41. package/src/routing/regret-estimator.js +1 -1
  42. package/src/stores/file-store.js +5 -1
  43. package/src/training/trajectory-compressor.js +1 -1
  44. package/src/workers/pool.js +1 -1
  45. package/src/workers/worker.js +1 -1
  46. package/scripts/convert-routellm.py +0 -105
  47. package/src/diff/comments.js +0 -153
  48. package/src/policy/web-fallback.js +0 -33
@@ -113,21 +113,48 @@ function convertOpenAIToAnthropic(openaiRequest) {
113
113
  });
114
114
  }
115
115
  } else if (msg.role === "tool") {
116
- // OpenAI tool response → Anthropic tool_result
117
- const previousMsg = anthropicMessages[anthropicMessages.length - 1];
118
-
119
- // Tool results must follow assistant message with tool_use
120
- // Add as separate user message with tool_result
121
- anthropicMessages.push({
122
- role: "user",
123
- content: [
124
- {
125
- type: "tool_result",
126
- tool_use_id: msg.tool_call_id,
127
- content: msg.content
128
- }
129
- ]
130
- });
116
+ // OpenAI tool response → Anthropic tool_result. Two Anthropic rules
117
+ // apply (violating either 400s the request):
118
+ // 1. Every tool_use_id must match a tool_use block in the preceding
119
+ // assistant message — orphans are rejected as "unexpected
120
+ // tool_use_id".
121
+ // 2. All tool_results for one assistant turn must arrive in a SINGLE
122
+ // user message — OpenAI sends parallel tool results as consecutive
123
+ // role:"tool" messages, which must merge, not split.
124
+ const lastAssistant = anthropicMessages.findLast?.(m => m.role === "assistant")
125
+ ?? [...anthropicMessages].reverse().find(m => m.role === "assistant");
126
+ const hasMatchingToolUse = Array.isArray(lastAssistant?.content) &&
127
+ lastAssistant.content.some(b => b?.type === "tool_use" && b.id === msg.tool_call_id);
128
+
129
+ if (!hasMatchingToolUse) {
130
+ // Orphaned result (history truncated or ids rewritten by the client):
131
+ // degrade to plain text so the request survives instead of 400ing.
132
+ logger.warn({ toolCallId: msg.tool_call_id }, "Orphaned tool result — no matching tool_use in prior assistant message; converting to text");
133
+ const text = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content);
134
+ anthropicMessages.push({
135
+ role: "user",
136
+ content: [{ type: "text", text: `[Tool result ${msg.tool_call_id}]: ${text}` }]
137
+ });
138
+ continue;
139
+ }
140
+
141
+ const resultBlock = {
142
+ type: "tool_result",
143
+ tool_use_id: msg.tool_call_id,
144
+ content: msg.content
145
+ };
146
+
147
+ const prev = anthropicMessages[anthropicMessages.length - 1];
148
+ const prevIsToolResultCarrier = prev?.role === "user" &&
149
+ Array.isArray(prev.content) &&
150
+ prev.content.length > 0 &&
151
+ prev.content.every(b => b?.type === "tool_result");
152
+
153
+ if (prevIsToolResultCarrier) {
154
+ prev.content.push(resultBlock);
155
+ } else {
156
+ anthropicMessages.push({ role: "user", content: [resultBlock] });
157
+ }
131
158
  }
132
159
  }
133
160
 
@@ -261,7 +288,10 @@ function convertAnthropicToOpenAI(anthropicResponse, model = "claude-3-5-sonnet-
261
288
 
262
289
  // Build OpenAI response
263
290
  // Ensure ID has the chatcmpl- prefix that OpenAI clients expect
264
- const responseId = id && id.startsWith("chatcmpl-") ? id : `chatcmpl-${Date.now()}`;
291
+ // Preserve the upstream id whatever its prefix (msg_… from Anthropic-shaped
292
+ // providers) — clients don't validate the prefix and a stable id keeps
293
+ // request tracing intact across the format conversion.
294
+ const responseId = id || `chatcmpl-${Date.now()}`;
265
295
  const openaiResponse = {
266
296
  id: responseId,
267
297
  object: "chat.completion",
@@ -277,11 +307,23 @@ function convertAnthropicToOpenAI(anthropicResponse, model = "claude-3-5-sonnet-
277
307
  finish_reason: mapStopReason(stop_reason)
278
308
  }
279
309
  ],
280
- usage: {
281
- prompt_tokens: usage?.input_tokens || 0,
282
- completion_tokens: usage?.output_tokens || 0,
283
- total_tokens: (usage?.input_tokens || 0) + (usage?.output_tokens || 0)
284
- }
310
+ // OpenAI semantics: prompt_tokens INCLUDES cached tokens (Anthropic's
311
+ // input_tokens excludes cache reads/writes). Dropping cache tokens made
312
+ // clients like opencode undercount context — with prompt caching on,
313
+ // most of a turn's input is cache-read (issue: sidebar token drift).
314
+ usage: (() => {
315
+ const cacheRead = usage?.cache_read_input_tokens || 0;
316
+ const cacheWrite = usage?.cache_creation_input_tokens || 0;
317
+ const promptTokens = (usage?.input_tokens || 0) + cacheRead + cacheWrite;
318
+ const completionTokens = usage?.output_tokens || 0;
319
+ const u = {
320
+ prompt_tokens: promptTokens,
321
+ completion_tokens: completionTokens,
322
+ total_tokens: promptTokens + completionTokens
323
+ };
324
+ if (cacheRead > 0) u.prompt_tokens_details = { cached_tokens: cacheRead };
325
+ return u;
326
+ })()
285
327
  };
286
328
 
287
329
  // Add citations if present
@@ -427,10 +469,18 @@ function convertAnthropicStreamChunkToOpenAI(chunk, model = "claude-3-5-sonnet-2
427
469
  finish_reason: mapStopReason(stopReason)
428
470
  }
429
471
  ],
472
+ // Anthropic's message_delta usage carries output_tokens and (on newer
473
+ // API versions) cumulative input_tokens — forward whatever is present
474
+ // instead of hardcoding prompt_tokens: 0.
430
475
  usage: usage ? {
431
- prompt_tokens: 0, // Not available in streaming
476
+ prompt_tokens: (usage.input_tokens || 0) +
477
+ (usage.cache_read_input_tokens || 0) +
478
+ (usage.cache_creation_input_tokens || 0),
432
479
  completion_tokens: usage.output_tokens || 0,
433
- total_tokens: usage.output_tokens || 0
480
+ total_tokens: (usage.input_tokens || 0) +
481
+ (usage.cache_read_input_tokens || 0) +
482
+ (usage.cache_creation_input_tokens || 0) +
483
+ (usage.output_tokens || 0)
434
484
  } : undefined
435
485
  };
436
486
  } else if (eventType === "message_stop") {
@@ -1,5 +1,3 @@
1
- const config = require("../config");
2
-
3
1
  const NATIVE_THINKING_PROVIDERS = new Set(["azure-anthropic", "databricks"]);
4
2
 
5
3
  const NATIVE_THINKING_BEDROCK_MODELS = [
@@ -195,7 +195,6 @@ class CockatielCircuitBreaker {
195
195
  this._lastStateChange = Date.now();
196
196
 
197
197
  // Recreate the policy to reset state
198
- const oldPolicy = this._policy;
199
198
  this._policy = circuitBreaker(handleAll, {
200
199
  breaker: new ConsecutiveBreaker(this.failureThreshold),
201
200
  halfOpenAfter: this.halfOpenAfter,
@@ -10,61 +10,6 @@
10
10
  const logger = require("../logger");
11
11
  const { repairToolCallIds } = require("./tool-call-repair");
12
12
 
13
- /**
14
- * Map client tool names back to Lynkr tool names
15
- * Used when processing incoming function_call messages from various AI coding clients
16
- * Supports: Codex CLI, Cline (VS Code), Continue.dev
17
- * @param {string} clientToolName - Client tool name (e.g., shell_command, execute_command, read_file)
18
- * @returns {string} Lynkr tool name (e.g., Bash, Read)
19
- */
20
- function mapClientToolToLynkr(clientToolName) {
21
- const reverseMapping = {
22
- // ============== CODEX CLI ==============
23
- "shell": "Bash",
24
- "shell_command": "Bash",
25
- "read_file": "Read",
26
- "write_file": "Write",
27
- "apply_patch": "Edit",
28
- "glob_file_search": "Glob",
29
- "rg": "Grep",
30
- "list_dir": "ListDir",
31
-
32
- // ============== CLINE (VS Code) ==============
33
- "execute_command": "Bash",
34
- // "read_file" already mapped above
35
- "write_to_file": "Write",
36
- "replace_in_file": "Edit",
37
- "search_files": "Grep",
38
- "list_files": "ListDir",
39
-
40
- // ============== KILO CODE (Fork of Cline) ==============
41
- // Most tools same as Cline, but apply_diff is different
42
- "apply_diff": "Edit",
43
- "codebase_search": "Grep",
44
- "delete_file": "Bash", // No direct equivalent, use Bash rm
45
- "browser_action": "WebFetch", // Approximate mapping
46
-
47
- // ============== CONTINUE.DEV ==============
48
- "run_terminal_command": "Bash",
49
- // "read_file" already mapped above
50
- "create_new_file": "Write",
51
- "edit_existing_file": "Edit",
52
- "exact_search": "Grep",
53
- "read_currently_open_file": "Read",
54
-
55
- // ============== Lowercase Lynkr tools (pass-through) ==============
56
- "bash": "Bash",
57
- "read": "Read",
58
- "write": "Write",
59
- "edit": "Edit",
60
- "glob": "Glob",
61
- "grep": "Grep",
62
- "listdir": "ListDir"
63
- };
64
-
65
- return reverseMapping[clientToolName] || clientToolName;
66
- }
67
-
68
13
  /**
69
14
  * Convert Responses API request to Chat Completions format
70
15
  * @param {Object} responsesRequest - Responses API format request
@@ -189,7 +134,6 @@ function convertResponsesToChat(responsesRequest) {
189
134
  }, "Converted multimodal content array to string");
190
135
  } else {
191
136
  // No text found, keep as array (might be image-only)
192
- content = content;
193
137
  }
194
138
  }
195
139
 
@@ -15,7 +15,7 @@ const config = require('../config');
15
15
 
16
16
  // Synchronous version for benchmarking/tests
17
17
  // (when tiers are disabled, routing is purely static)
18
- function determineProviderSync(payload) {
18
+ function determineProviderSync(_payload) {
19
19
  const primaryProvider = config.modelProvider?.type || 'databricks';
20
20
  const defaultModel = config.modelProvider?.defaultModel || 'databricks-claude-sonnet-4-5';
21
21
 
@@ -545,7 +545,6 @@ const agentsEnabled = process.env.AGENTS_ENABLED === "true";
545
545
  // LLM Audit logging configuration
546
546
  const auditEnabled = process.env.LLM_AUDIT_ENABLED === "true"; // default false
547
547
  const auditLogFile = process.env.LLM_AUDIT_LOG_FILE ?? path.join(process.cwd(), "logs", "llm-audit.log");
548
- const auditMaxContentLength = Number.parseInt(process.env.LLM_AUDIT_MAX_CONTENT_LENGTH ?? "5000", 10); // Legacy fallback
549
548
  const auditMaxSystemLength = Number.parseInt(process.env.LLM_AUDIT_MAX_SYSTEM_LENGTH ?? "2000", 10);
550
549
  const auditMaxUserLength = Number.parseInt(process.env.LLM_AUDIT_MAX_USER_LENGTH ?? "3000", 10);
551
550
  const auditMaxResponseLength = Number.parseInt(process.env.LLM_AUDIT_MAX_RESPONSE_LENGTH ?? "3000", 10);
@@ -223,7 +223,7 @@ function finalizeBudgetEnforcement(original, optimized, strategy, initialCheck,
223
223
  /**
224
224
  * Compress system prompt aggressively for budget enforcement
225
225
  */
226
- function compressSystemPromptAggressively(systemPromptContent, payload) {
226
+ function compressSystemPromptAggressively(systemPromptContent, _payload) {
227
227
  let text = typeof systemPromptContent === 'string'
228
228
  ? systemPromptContent
229
229
  : systemPrompt.flattenBlocks(systemPromptContent);
@@ -696,7 +696,7 @@ function compressDirectoryListing(text, opts = {}) {
696
696
  // skips this check — real listings can contain names (spaces, unicode)
697
697
  // the shape regex would reject.
698
698
  if (!opts.trusted) {
699
- const pathLines = lines.filter(l => /^[.\w\/-]+\.\w+$/.test(l.trim()) || /^[.\w\/-]+\/$/.test(l.trim()) || /^[-drwx]{10}/.test(l.trim()));
699
+ const pathLines = lines.filter(l => /^[.\w/-]+\.\w+$/.test(l.trim()) || /^[.\w/-]+\/$/.test(l.trim()) || /^[-drwx]{10}/.test(l.trim()));
700
700
  if (pathLines.length < lines.length * 0.6) return null;
701
701
  }
702
702
 
@@ -823,7 +823,7 @@ function compressLintOutput(text, opts = {}) {
823
823
  for (const line of clean.split("\n")) {
824
824
  // ESLint stylish prints each file as a standalone path line above its
825
825
  // issue block — track it so issues group per file.
826
- if (/^\S+[\/\\]\S+\.\w+$/.test(line.trim()) && !line.includes(":")) {
826
+ if (/^\S+[/\\]\S+\.\w+$/.test(line.trim()) && !line.includes(":")) {
827
827
  currentFile = line.trim();
828
828
  continue;
829
829
  }
@@ -935,8 +935,6 @@ function compressLargeFile(text) {
935
935
 
936
936
  // Extract structural skeleton
937
937
  const skeleton = [];
938
- let inBlock = false;
939
- let braceDepth = 0;
940
938
 
941
939
  for (let i = 0; i < lines.length; i++) {
942
940
  const line = lines[i];
@@ -960,7 +958,7 @@ function compressLargeFile(text) {
960
958
  }
961
959
 
962
960
  // Keep decorators/attributes
963
- if (/^[@#\[]/.test(trimmed)) {
961
+ if (/^[@#[]/.test(trimmed)) {
964
962
  skeleton.push(line);
965
963
  continue;
966
964
  }
@@ -2,7 +2,6 @@ const config = require('../config');
2
2
  const telemetry = require('../routing/telemetry');
3
3
  const { getUsage } = require('../usage/aggregator');
4
4
  const metrics = require('../metrics');
5
- const { getMetricsCollector } = require('../observability/metrics');
6
5
  const { TIER_DEFINITIONS } = require('../routing/model-tiers');
7
6
 
8
7
  // Per-provider type + whether its credentials/endpoint are actually present.
@@ -66,7 +66,7 @@ function createOversizedErrorStream(config) {
66
66
  },
67
67
  final(callback) {
68
68
  // Close all open file handles when stream ends
69
- for (const [sessionId, { writeStream }] of sessionFiles.entries()) {
69
+ for (const [_sessionId, { writeStream }] of sessionFiles.entries()) {
70
70
  writeStream.end();
71
71
  }
72
72
  sessionFiles.clear();
@@ -26,14 +26,14 @@ const EXTRACTION_PATTERNS = {
26
26
  ],
27
27
 
28
28
  entity: [
29
- /(?:file|function|class|module|component|package|library)\s+['"`]?([A-Za-z0-9_./\-]+)['"`]?/gi,
30
- /in\s+['"`]([A-Za-z0-9_./\-]+\.(?:js|ts|py|java|go|rs|cpp|c|h))['"`]/gi,
29
+ /(?:file|function|class|module|component|package|library)\s+['"`]?([A-Za-z0-9_./-]+)['"`]?/gi,
30
+ /in\s+['"`]([A-Za-z0-9_./-]+\.(?:js|ts|py|java|go|rs|cpp|c|h))['"`]/gi,
31
31
  /`([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)`/g, // Code references
32
32
  ],
33
33
 
34
34
  relationship: [
35
35
  /([A-Za-z0-9_]+)\s+(?:depends on|imports|uses|extends|implements|inherits from)\s+([A-Za-z0-9_]+)/gi,
36
- /([A-Za-z0-9_./\-]+)\s+(?:calls|invokes|references)\s+([A-Za-z0-9_./\-]+)/gi,
36
+ /([A-Za-z0-9_./-]+)\s+(?:calls|invokes|references)\s+([A-Za-z0-9_./-]+)/gi,
37
37
  ],
38
38
  };
39
39
 
@@ -30,7 +30,7 @@ function sanitizeKeyword(keyword) {
30
30
 
31
31
  // Remove ALL FTS5 special characters
32
32
  const sanitized = keyword
33
- .replace(/[*()<>\-:\[\]{}|^~,.;!?'"@#$%&+=/\\\\]/g, ' ')
33
+ .replace(/[*()<>\-:[\]{}|^~,.;!?'"@#$%&+=/\\\\]/g, ' ')
34
34
  .replace(/\s+/g, ' ')
35
35
  .trim();
36
36
 
@@ -97,7 +97,7 @@ function prepareFTS5Query(query) {
97
97
  // - @ symbol → "syntax error near @"
98
98
  // - Quotes (") → can break string quoting
99
99
  // - Parentheses, brackets → break grouping syntax
100
- cleaned = cleaned.replace(/[*()<>\-:\[\]{}|^~,.;!?'"@#$%&+=/\\\\]/g, ' ');
100
+ cleaned = cleaned.replace(/[*()<>\-:[\]{}|^~,.;!?'"@#$%&+=/\\\\]/g, ' ');
101
101
  cleaned = cleaned.replace(/\s+/g, ' ').trim();
102
102
 
103
103
  if (!cleaned) {
@@ -122,12 +122,6 @@ const upsertEntityStmt = db.prepare(`
122
122
  properties = @properties
123
123
  `);
124
124
 
125
- const getEntityStmt = db.prepare(`
126
- SELECT id, entity_type, entity_name, first_seen_at, last_seen_at, occurrence_count, properties
127
- FROM memory_entities
128
- WHERE entity_type = ? AND entity_name = ?
129
- `);
130
-
131
125
  const getAllEntitiesStmt = db.prepare(`
132
126
  SELECT id, entity_type, entity_name, first_seen_at, last_seen_at, occurrence_count, properties
133
127
  FROM memory_entities
@@ -176,19 +170,6 @@ function toMemory(row) {
176
170
  };
177
171
  }
178
172
 
179
- function toEntity(row) {
180
- if (!row) return null;
181
- return {
182
- id: row.id,
183
- entityType: row.entity_type,
184
- entityName: row.entity_name,
185
- firstSeenAt: row.first_seen_at,
186
- lastSeenAt: row.last_seen_at,
187
- occurrenceCount: row.occurrence_count ?? 1,
188
- properties: parseJSON(row.properties, {}),
189
- };
190
- }
191
-
192
173
  /**
193
174
  * Create a new memory
194
175
  */
@@ -400,16 +381,20 @@ function trackEntity(options) {
400
381
  }
401
382
 
402
383
  /**
403
- * Get an entity
384
+ * Get an entity by name, optionally scoped to a type. Names can collide
385
+ * across types (e.g. a "file" and a "topic" both named "router"), so
386
+ * without a type filter the most recently seen entity wins,
387
+ * deterministically.
404
388
  */
405
- function getEntity(name) {
406
- // Since we only have the name, we need to search across all entity types
389
+ function getEntity(name, type = null) {
407
390
  const stmt = db.prepare(`
408
391
  SELECT id, entity_type, entity_name, first_seen_at, last_seen_at, occurrence_count, properties
409
392
  FROM memory_entities
410
- WHERE entity_name = ?
393
+ WHERE entity_name = ? ${type ? "AND entity_type = ?" : ""}
394
+ ORDER BY last_seen_at DESC
395
+ LIMIT 1
411
396
  `);
412
- const row = stmt.get(name);
397
+ const row = type ? stmt.get(name, type) : stmt.get(name);
413
398
  if (!row) return null;
414
399
 
415
400
  return {
@@ -340,6 +340,19 @@ async function memory_stats(args, context = {}) {
340
340
  };
341
341
  }
342
342
 
343
+ // Surface tracked entities — the extractor writes them on every
344
+ // conversation, but until this they were collected and never readable.
345
+ let entities = [];
346
+ try {
347
+ entities = store.getAllEntities(10).map(e => ({
348
+ name: e.name,
349
+ type: e.type,
350
+ count: e.count,
351
+ }));
352
+ } catch (err) {
353
+ logger.debug({ err: err.message }, 'Entity stats unavailable');
354
+ }
355
+
343
356
  return {
344
357
  ok: true,
345
358
  content: JSON.stringify({
@@ -350,6 +363,7 @@ async function memory_stats(args, context = {}) {
350
363
  recentCount: stats.recentCount,
351
364
  importantCount: stats.importantCount,
352
365
  sessionId: stats.sessionId || 'global',
366
+ topEntities: entities,
353
367
  }, null, 2),
354
368
  };
355
369
  } catch (err) {
@@ -1,6 +1,3 @@
1
- const config = require("../config");
2
- const logger = require("../logger");
3
-
4
1
  /**
5
2
  * High-performance metrics collection
6
3
  *
@@ -13,12 +13,11 @@ const { applyToonCompression } = require("../context/toon");
13
13
  const { applyGcfCompression } = require("../context/gcf");
14
14
  const { compressMessages: headroomCompress, isEnabled: isHeadroomEnabled } = require("../headroom");
15
15
  const { createAuditLogger } = require("../logger/audit-logger");
16
- const { getResolvedIp, runWithDnsContext } = require("../clients/dns-logger");
17
16
  const { getShuttingDown } = require("../api/health");
18
17
  const { tryPreflight, buildSatisfiedResponse: buildPreflightResponse } = require("./preflight");
19
18
  const { detectBypass, buildBypassResponse } = require("./bypass");
20
19
  const crypto = require("crypto");
21
- const { getSemanticCache, isSemanticCacheEnabled } = require("../cache/semantic");
20
+ const { getSemanticCache } = require("../cache/semantic");
22
21
  const { areSimilarToolCalls } = require("../clients/gpt-utils");
23
22
  const { getModelRegistrySync } = require("../routing/model-registry");
24
23
  const sessionAffinity = require("../routing/session-affinity");
@@ -416,37 +415,6 @@ function countToolCallsInHistory(messages) {
416
415
  return { toolUseCount, toolResultCount, lastUserTextIndex };
417
416
  }
418
417
 
419
- /**
420
- * Inject a "stop looping" instruction if there are too many tool calls in history.
421
- * This helps prevent infinite loops when the model keeps calling tools instead of responding.
422
- *
423
- * @param {Array} messages - The conversation messages
424
- * @param {number} threshold - Max tool results before injection (default: 5)
425
- * @returns {Array} - Messages with stop instruction injected if needed
426
- */
427
- function injectToolLoopStopInstruction(messages, threshold = 5) {
428
- if (!Array.isArray(messages)) return messages;
429
-
430
- const { toolResultCount } = countToolCallsInHistory(messages);
431
-
432
- if (toolResultCount >= threshold) {
433
- logger.warn({
434
- toolResultCount,
435
- threshold,
436
- }, "[ToolLoopGuard] Too many tool results in conversation - injecting stop instruction");
437
-
438
- // Inject instruction to stop tool calls and provide a final answer
439
- const stopInstruction = {
440
- role: "user",
441
- content: `⚠️ IMPORTANT: You have already executed ${toolResultCount} tool calls in this conversation. This is likely an infinite loop. STOP calling tools immediately and provide a direct text response to the user based on the information you have gathered. If you cannot complete the task, explain why. DO NOT call any more tools. Your response must contain your actual findings — NOT an acknowledgment or restatement of this warning.`,
442
- };
443
-
444
- return [...messages, stopInstruction];
445
- }
446
-
447
- return messages;
448
- }
449
-
450
418
  // === CROSS-REQUEST TOOL CALL DEDUP TRACKING ===
451
419
  // These helpers track tool call signatures across multiple HTTP requests within
452
420
  // the same session (client/passthrough mode). The inner-loop detection in
@@ -718,52 +686,6 @@ function normaliseToolChoice(choice) {
718
686
  return undefined;
719
687
  }
720
688
 
721
- /**
722
- * Strip thinking-style reasoning from Ollama model outputs
723
- * Patterns to remove:
724
- * - Lines starting with bullet points (●, •, -, *)
725
- * - Explanatory reasoning before the actual response
726
- * - Multiple newlines used to separate thinking from response
727
- */
728
- function stripThinkingBlocks(text) {
729
- if (typeof text !== "string") return text;
730
-
731
- const lines = text.split("\n");
732
- const cleanedLines = [];
733
- let inThinkingBlock = false;
734
- let consecutiveEmptyLines = 0;
735
-
736
- for (const line of lines) {
737
- const trimmed = line.trim();
738
-
739
- // Detect thinking block markers (bullet points followed by reasoning)
740
- if (/^[●•\-\*]\s/.test(trimmed)) {
741
- inThinkingBlock = true;
742
- continue;
743
- }
744
-
745
- // Empty lines might separate thinking from response
746
- if (trimmed === "") {
747
- consecutiveEmptyLines++;
748
- // If we've seen 2+ empty lines, likely end of thinking block
749
- if (consecutiveEmptyLines >= 2) {
750
- inThinkingBlock = false;
751
- }
752
- continue;
753
- }
754
-
755
- consecutiveEmptyLines = 0;
756
-
757
- if (inThinkingBlock) {
758
- continue;
759
- }
760
-
761
- cleanedLines.push(line);
762
- }
763
-
764
- return cleanedLines.join("\n").trim();
765
- }
766
-
767
689
  /**
768
690
  * Convert legacy Ollama /api/chat response to Anthropic Messages format.
769
691
  * Used when Ollama < v0.14.0 (no native Anthropic endpoint).
@@ -1392,7 +1314,6 @@ async function runAgentLoop({
1392
1314
  requestedModel,
1393
1315
  wantsThinking,
1394
1316
  session,
1395
- cwd,
1396
1317
  options,
1397
1318
  cacheKey,
1398
1319
  providerType,
@@ -158,7 +158,62 @@ async function* _openaiToAnthropicEvents(upstream, opts = {}) {
158
158
  }
159
159
  };
160
160
 
161
+ // GLM-family reasoning leak filter (streaming twin of the fix in
162
+ // convertOpenAIToAnthropic): suppresses matched <think>…</think> spans and
163
+ // strips a bare orphan closing tag from visible text deltas, holding back
164
+ // trailing partial-tag fragments so tags split across chunk boundaries
165
+ // still match. Orphan pre-text has already been emitted by the time the
166
+ // closer arrives — a stream can't retract — so only the tag is removed.
167
+ let _inThink = false;
168
+ let _tagTail = "";
169
+ const _partialTagLen = (s) => {
170
+ for (let k = Math.min(8, s.length); k > 0; k--) {
171
+ const suf = s.slice(-k);
172
+ if ("</think>".startsWith(suf) || "<think>".startsWith(suf)) return k;
173
+ }
174
+ return 0;
175
+ };
176
+ const filterThink = (text) => {
177
+ let s = _tagTail + text;
178
+ _tagTail = "";
179
+ let out = "";
180
+ for (;;) {
181
+ if (_inThink) {
182
+ const close = s.indexOf("</think>");
183
+ if (close === -1) break; // still inside thinking — discard s (tail kept below)
184
+ s = s.slice(close + 8);
185
+ _inThink = false;
186
+ } else {
187
+ const open = s.indexOf("<think>");
188
+ const close = s.indexOf("</think>");
189
+ if (close !== -1 && (open === -1 || close < open)) {
190
+ out += s.slice(0, close); // orphan closer: drop the tag only
191
+ s = s.slice(close + 8);
192
+ } else if (open !== -1) {
193
+ out += s.slice(0, open);
194
+ s = s.slice(open + 7);
195
+ _inThink = true;
196
+ } else {
197
+ break;
198
+ }
199
+ }
200
+ }
201
+ const keep = _partialTagLen(s);
202
+ if (keep > 0) _tagTail = s.slice(-keep);
203
+ if (!_inThink && s.length > keep) out += s.slice(0, s.length - keep);
204
+ return out;
205
+ };
206
+
161
207
  const closeTextBlock = function* () {
208
+ // Flush a held-back fragment that turned out not to be a tag.
209
+ if (_tagTail && !_inThink && textIndex !== null) {
210
+ yield _sse("content_block_delta", {
211
+ type: "content_block_delta",
212
+ index: textIndex,
213
+ delta: { type: "text_delta", text: _tagTail },
214
+ });
215
+ _tagTail = "";
216
+ }
162
217
  if (textIndex === null) return;
163
218
  yield _sse("content_block_stop", { type: "content_block_stop", index: textIndex });
164
219
  textIndex = null;
@@ -217,19 +272,22 @@ async function* _openaiToAnthropicEvents(upstream, opts = {}) {
217
272
  // Text deltas are straightforward: open a block on first text, then
218
273
  // emit a text_delta per chunk.
219
274
  if (typeof delta.content === "string" && delta.content.length > 0) {
220
- if (textIndex === null) {
221
- textIndex = nextIndex++;
222
- yield _sse("content_block_start", {
223
- type: "content_block_start",
275
+ const visible = filterThink(delta.content);
276
+ if (visible.length > 0) {
277
+ if (textIndex === null) {
278
+ textIndex = nextIndex++;
279
+ yield _sse("content_block_start", {
280
+ type: "content_block_start",
281
+ index: textIndex,
282
+ content_block: { type: "text", text: "" },
283
+ });
284
+ }
285
+ yield _sse("content_block_delta", {
286
+ type: "content_block_delta",
224
287
  index: textIndex,
225
- content_block: { type: "text", text: "" },
288
+ delta: { type: "text_delta", text: visible },
226
289
  });
227
290
  }
228
- yield _sse("content_block_delta", {
229
- type: "content_block_delta",
230
- index: textIndex,
231
- delta: { type: "text_delta", text: delta.content },
232
- });
233
291
  }
234
292
 
235
293
  // Tool-call fragments: accumulate per OpenAI tool index. Fragments of
@@ -1,4 +1,3 @@
1
- const logger = require("../logger");
2
1
  const config = require("../config");
3
2
 
4
3
  /**