lynkr 9.9.1 → 9.10.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 (67) hide show
  1. package/README.md +50 -9
  2. package/bin/cli.js +2 -0
  3. package/bin/lynkr-init.js +4 -12
  4. package/bin/lynkr-reset.js +71 -0
  5. package/config/model-tiers.json +199 -52
  6. package/package.json +2 -2
  7. package/scripts/validate-difficulty-classifier.js +27 -6
  8. package/src/api/providers-handler.js +0 -1
  9. package/src/api/router.js +275 -160
  10. package/src/clients/databricks.js +95 -6
  11. package/src/config/index.js +3 -43
  12. package/src/orchestrator/index.js +117 -1235
  13. package/src/orchestrator/passthrough-stream.js +382 -0
  14. package/src/orchestrator/sse-transformer.js +408 -0
  15. package/src/routing/affinity-store.js +17 -3
  16. package/src/routing/difficulty-classifier.js +52 -10
  17. package/src/routing/index.js +35 -3
  18. package/src/routing/intent-score.js +20 -2
  19. package/src/routing/session-affinity.js +8 -1
  20. package/src/routing/side-channel-detector.js +103 -0
  21. package/src/server.js +0 -46
  22. package/src/agents/context-manager.js +0 -236
  23. package/src/agents/decomposition/dispatcher.js +0 -185
  24. package/src/agents/decomposition/gate.js +0 -136
  25. package/src/agents/decomposition/index.js +0 -183
  26. package/src/agents/decomposition/model-call.js +0 -75
  27. package/src/agents/decomposition/planner.js +0 -223
  28. package/src/agents/decomposition/synthesizer.js +0 -89
  29. package/src/agents/decomposition/telemetry.js +0 -55
  30. package/src/agents/definitions/loader.js +0 -653
  31. package/src/agents/executor.js +0 -457
  32. package/src/agents/index.js +0 -165
  33. package/src/agents/parallel-coordinator.js +0 -68
  34. package/src/agents/reflector.js +0 -331
  35. package/src/agents/skillbook.js +0 -331
  36. package/src/agents/store.js +0 -259
  37. package/src/edits/index.js +0 -171
  38. package/src/indexer/babel-parser.js +0 -213
  39. package/src/indexer/index.js +0 -1629
  40. package/src/indexer/navigation/index.js +0 -32
  41. package/src/indexer/navigation/providers/treeSitter.js +0 -36
  42. package/src/indexer/parser.js +0 -443
  43. package/src/tasks/store.js +0 -349
  44. package/src/tests/coverage.js +0 -173
  45. package/src/tests/index.js +0 -171
  46. package/src/tests/store.js +0 -213
  47. package/src/tools/agent-task.js +0 -145
  48. package/src/tools/code-mode.js +0 -304
  49. package/src/tools/decompose.js +0 -91
  50. package/src/tools/edits.js +0 -94
  51. package/src/tools/execution.js +0 -171
  52. package/src/tools/git.js +0 -1346
  53. package/src/tools/index.js +0 -306
  54. package/src/tools/indexer.js +0 -360
  55. package/src/tools/lazy-loader.js +0 -366
  56. package/src/tools/mcp-remote.js +0 -88
  57. package/src/tools/mcp.js +0 -116
  58. package/src/tools/process.js +0 -167
  59. package/src/tools/smart-selection.js +0 -180
  60. package/src/tools/stubs.js +0 -55
  61. package/src/tools/tasks.js +0 -260
  62. package/src/tools/tests.js +0 -132
  63. package/src/tools/tinyfish.js +0 -358
  64. package/src/tools/truncate.js +0 -106
  65. package/src/tools/web-client.js +0 -71
  66. package/src/tools/web.js +0 -415
  67. package/src/tools/workspace.js +0 -204
package/src/api/router.js CHANGED
@@ -160,6 +160,47 @@ async function pickTierByIntent(body) {
160
160
  };
161
161
  }
162
162
 
163
+ // Condense a message's text for classifier context: text blocks only,
164
+ // reminders stripped, whitespace collapsed, hard-capped. Best-effort.
165
+ const _condenseText = (msg, max = 140) => {
166
+ if (!msg) return '';
167
+ let text = '';
168
+ if (typeof msg.content === 'string') text = msg.content;
169
+ else if (Array.isArray(msg.content)) {
170
+ text = msg.content
171
+ .filter((b) => b?.type === 'text' && typeof b.text === 'string')
172
+ .map((b) => b.text)
173
+ .join(' ');
174
+ }
175
+ return stripReminders(text).replace(/\s+/g, ' ').trim().slice(0, max);
176
+ };
177
+
178
+ // Conversation context for the difficulty classifier: the nearest prior
179
+ // assistant reply and the user turn before it. A bare follow-up ("Who
180
+ // kills him ?") is unclassifiable in isolation — the single-message
181
+ // intentPayload below is context-blind BY DESIGN (envelope invariance),
182
+ // so the context travels as a separate condensed string, never as extra
183
+ // messages that would pollute the anchor score.
184
+ const _contextBefore = (msg) => {
185
+ const idx = messages.indexOf(msg);
186
+ if (idx <= 0) return null;
187
+ let assistantText = '';
188
+ let priorUserText = '';
189
+ for (let j = idx - 1; j >= 0; j--) {
190
+ const m = messages[j];
191
+ if (!assistantText && m?.role === 'assistant') assistantText = _condenseText(m);
192
+ else if (assistantText && m?.role === 'user') {
193
+ priorUserText = _condenseText(m);
194
+ if (priorUserText) break;
195
+ }
196
+ }
197
+ if (!assistantText && !priorUserText) return null;
198
+ const parts = [];
199
+ if (priorUserText) parts.push(`user asked: """${priorUserText}"""`);
200
+ if (assistantText) parts.push(`assistant replied about: """${assistantText}"""`);
201
+ return parts.join(' → ');
202
+ };
203
+
163
204
  // Per-message scoring intentionally omits _sessionId so session affinity
164
205
  // isn't polluted by multiple intent-only routing calls per request. The
165
206
  // FINAL provider pick (downstream of this function) uses the full body
@@ -179,6 +220,9 @@ async function pickTierByIntent(body) {
179
220
  // agentic detector inside determineProviderSmart can subtract the
180
221
  // harness's baseline tools during intent scoring too.
181
222
  _clientProfile: body?._clientProfile || null,
223
+ // Classifier context (see _contextBefore). Underscored — stripped at
224
+ // the outbound chokepoint like every internal field.
225
+ _conversationContext: _contextBefore(windowUserMsgs[i]),
182
226
  };
183
227
  try {
184
228
  const decision = await determineProviderSmart(intentPayload, {
@@ -365,19 +409,14 @@ async function handleOauthPassthrough(req, res, opts = {}) {
365
409
  if (contentType.includes("text/event-stream") && upstreamResp.body) {
366
410
  if (typeof res.flushHeaders === "function") res.flushHeaders();
367
411
 
368
- // For SSE: emit the badge as a synthetic content_block_start +
369
- // content_block_delta + content_block_stop at index 0, BEFORE the
370
- // upstream stream begins. Anthropic re-indexes subsequent blocks from 1+,
371
- // which is fine because Claude Code treats index as opaque and just
372
- // appends to the rendered content array.
373
- if (badgeText) {
374
- const synthetic = [
375
- `event: content_block_start\ndata: ${JSON.stringify({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } })}\n\n`,
376
- `event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: badgeText } })}\n\n`,
377
- `event: content_block_stop\ndata: ${JSON.stringify({ type: 'content_block_stop', index: 0 })}\n\n`,
378
- ].join('');
379
- res.write(synthetic);
380
- }
412
+ // Badge injection is frame-aware: the badge block must land AFTER the
413
+ // upstream's message_start, never before it Anthropic's SSE contract
414
+ // opens with message_start and Claude Code's incremental parser rejects
415
+ // streams that lead with content_block events (it retries with
416
+ // "empty or malformed response"). Same processor the native-stream
417
+ // passthrough uses.
418
+ const { _createFrameProcessor } = require("../orchestrator/passthrough-stream");
419
+ const badgeInjector = badgeText ? _createFrameProcessor({ badgeText }) : null;
381
420
 
382
421
  const reader = upstreamResp.body.getReader();
383
422
  const decoder = new TextDecoder();
@@ -385,6 +424,15 @@ async function handleOauthPassthrough(req, res, opts = {}) {
385
424
  while (true) {
386
425
  const { value, done } = await readWithIdleTimeout(reader, "oauth-passthrough");
387
426
  if (done) break;
427
+ if (badgeInjector) {
428
+ const outText = badgeInjector(decoder.decode(value, { stream: true }));
429
+ if (outText) res.write(outText);
430
+ if (typeof res.flush === "function") res.flush();
431
+ if (responseTextForObservability.length < 65536) {
432
+ responseTextForObservability += outText;
433
+ }
434
+ continue;
435
+ }
388
436
  const buf = Buffer.from(value);
389
437
  res.write(buf);
390
438
  if (typeof res.flush === "function") res.flush();
@@ -393,6 +441,10 @@ async function handleOauthPassthrough(req, res, opts = {}) {
393
441
  responseTextForObservability += decoder.decode(value, { stream: true });
394
442
  }
395
443
  }
444
+ if (badgeInjector) {
445
+ const flushed = badgeInjector(decoder.decode(), true);
446
+ if (flushed) res.write(flushed);
447
+ }
396
448
  } catch (err) {
397
449
  logger.warn({ err: err.message }, "OAuth passthrough stream stalled — ending response");
398
450
  try { reader.cancel(); } catch { /* already dead */ }
@@ -466,7 +518,10 @@ async function handleOauthPassthrough(req, res, opts = {}) {
466
518
  // Tier router telemetry (so it shows up in dashboards / routing stats)
467
519
  const tlm = require("../routing/telemetry");
468
520
  tlm.record({
469
- request_id: req.headers["request-id"] || req.headers["x-request-id"] || null,
521
+ // request_id is NOT NULL in the telemetry schema — a null id silently
522
+ // drops the whole row (subscription-passthrough serves were invisible
523
+ // in routing_telemetry whenever the client sent no request-id header).
524
+ request_id: req.headers["request-id"] || req.headers["x-request-id"] || require("crypto").randomUUID(),
470
525
  session_id: req.body?._sessionId || req.sessionId || null,
471
526
  timestamp: startedAt,
472
527
  tier: tier.tier || "COMPLEX",
@@ -512,6 +567,113 @@ async function handleOauthPassthrough(req, res, opts = {}) {
512
567
  });
513
568
  }
514
569
 
570
+ /**
571
+ * One-line routing badge for LIVE streams (native passthrough + stream
572
+ * transform), built from the tier decision. The buffered paths build theirs
573
+ * from the interaction block; that block doesn't exist yet at the stream
574
+ * hooks, and the tier object carries the same fields.
575
+ */
576
+ function _buildStreamBadge(tier) {
577
+ if (!config.routing?.visibleInteraction || !tier) return null;
578
+ const pin = typeof tier._pinScore === "number" ? ` · pin@${tier._pinScore}` : "";
579
+ return `*[Lynkr] ${tier.tier || "—"} → ${tier.model || "—"} (${tier.provider || "—"}) · score ${tier.score ?? "—"}${pin}*\n\n`;
580
+ }
581
+
582
+ /**
583
+ * Scan the entire payload for tool_use or tool_result content blocks.
584
+ * Unlike session-affinity.payloadHasToolHistory (which checks only the last
585
+ * message, targeted at mid-exchange detection), this scans every message so
586
+ * a side-channel replay of a tool-active conversation is detected regardless
587
+ * of which turn it lands on. Best-effort — returns false on malformed input.
588
+ */
589
+ function _payloadCarriesToolBlocks(body) {
590
+ const messages = body?.messages;
591
+ if (!Array.isArray(messages)) return false;
592
+ for (const m of messages) {
593
+ if (!Array.isArray(m?.content)) continue;
594
+ for (const b of m.content) {
595
+ if (b?.type === 'tool_use' || b?.type === 'tool_result') return true;
596
+ }
597
+ }
598
+ return false;
599
+ }
600
+
601
+ /**
602
+ * Forward a provider SSE stream (web ReadableStream) to the client response
603
+ * line by line. Assumes SSE headers were already set and flushed. Mid-stream
604
+ * failures surface as a parseable Anthropic error event, never a silent hang.
605
+ */
606
+ async function forwardProviderStream(res, stream) {
607
+ const reader = stream.getReader();
608
+ const decoder = new TextDecoder();
609
+ const bufferChunks = []; // Use array to avoid string concatenation overhead
610
+
611
+ try {
612
+ while (true) {
613
+ const { done, value } = await readWithIdleTimeout(reader, "provider-stream");
614
+ if (done) break;
615
+
616
+ const chunk = decoder.decode(value, { stream: true });
617
+ bufferChunks.push(chunk);
618
+
619
+ const buffer = bufferChunks.join('');
620
+ const lines = buffer.split('\n');
621
+
622
+ // Keep last incomplete line in buffer chunks
623
+ const remaining = lines.pop() || '';
624
+ bufferChunks.length = 0;
625
+ if (remaining) bufferChunks.push(remaining);
626
+
627
+ for (const line of lines) {
628
+ if (line.trim()) {
629
+ res.write(line + '\n');
630
+ } else {
631
+ // Blank lines are SSE event delimiters — they must survive.
632
+ res.write('\n');
633
+ }
634
+ }
635
+
636
+ if (typeof res.flush === 'function') {
637
+ res.flush();
638
+ }
639
+ }
640
+
641
+ const remaining = bufferChunks.join('');
642
+ if (remaining.trim()) {
643
+ res.write(remaining + '\n');
644
+ }
645
+
646
+ metrics.recordResponse(200);
647
+ res.end();
648
+ } catch (streamError) {
649
+ logger.error({ error: streamError }, "Error streaming response");
650
+
651
+ try {
652
+ await reader.cancel();
653
+ } catch (cancelError) {
654
+ logger.debug({ error: cancelError }, "Failed to cancel stream");
655
+ }
656
+
657
+ if (!res.headersSent) {
658
+ res.status(500).json({ error: "Streaming error" });
659
+ } else {
660
+ // Mid-stream failure: emit a parseable Anthropic error event so
661
+ // the client fails fast and retries, instead of seeing a
662
+ // truncated stream it may wait on.
663
+ try { res.write(SSE_STALL_EVENT); } catch { /* client gone */ }
664
+ res.end();
665
+ }
666
+ } finally {
667
+ // CRITICAL: Always release lock
668
+ try {
669
+ reader.releaseLock();
670
+ } catch (releaseError) {
671
+ // Lock may already be released, ignore
672
+ logger.debug({ error: releaseError }, "Stream lock already released");
673
+ }
674
+ }
675
+ }
676
+
515
677
  /**
516
678
  * Extract the final assembled Anthropic message from a captured SSE stream.
517
679
  * Looks at message_start (for id/model), content_block_delta (for text),
@@ -969,7 +1131,48 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
969
1131
  // (harness UA / tool fingerprint) before treating tool-less traffic as
970
1132
  // side traffic; a suggestion-mode tag is harness evidence by itself.
971
1133
  const isKnownHarness = !!req.body?._clientProfile;
1134
+ // Signal 1 — message-count regression. Real turns grow the transcript
1135
+ // monotonically; a harness replay (title-gen, recap, summary) truncates
1136
+ // the history down to the wrapper prompt. If this payload has fewer
1137
+ // messages than the session's pinned baseline, it cannot be a genuine
1138
+ // follow-up. Definitive — main conversations never shrink.
1139
+ //
1140
+ // Only trust the pin's counts when Lynkr is ACTUALLY serving from it
1141
+ // this turn. When pinCheck.serve === false the pin may be a stale
1142
+ // artifact from an earlier conversation with the same session
1143
+ // fingerprint (see the `new_conversation` and `opener_conversation`
1144
+ // guards in session-affinity.js) — in that case msg counts and
1145
+ // hasToolHistory are meaningless for the current session and must not
1146
+ // fire the side-channel signals.
1147
+ const _pinAuthoritative = pinCheck?.serve === true;
1148
+ const _currentMsgCount = Array.isArray(req.body?.messages) ? req.body.messages.length : 0;
1149
+ const _pinnedMsgCount = pinCheck?.pin?.messageCount ?? 0;
1150
+ const isMsgCountRegression = _pinAuthoritative
1151
+ && _pinnedMsgCount > 2
1152
+ && _currentMsgCount < _pinnedMsgCount;
1153
+ // Signal 2 — tool-history absence in a tool-active session. Once a
1154
+ // session has accumulated tool_use blocks, the main flow always carries
1155
+ // them forward. A payload with zero tool blocks in an active tool
1156
+ // session is a side channel by construction.
1157
+ const _pinHadTools = !!pinCheck?.pin?.hasToolHistory;
1158
+ const isBareRequest = _pinAuthoritative
1159
+ && _pinHadTools
1160
+ && !_payloadCarriesToolBlocks(req.body);
1161
+ // Signal 3 — first-user-message fingerprint drift. Handles the window
1162
+ // before signals 1 & 2 have data (no pin yet, no tool blocks yet). See
1163
+ // side-channel-detector.js — the detector caches the first observed
1164
+ // messages[0] for each session and flags payloads whose first message
1165
+ // no longer matches (title-gen wraps the original in <session>...</session>).
1166
+ let isFingerprintDrift = false;
1167
+ try {
1168
+ const sideChannelDetector = require('../routing/side-channel-detector');
1169
+ const sessionKey = pinCheck?.sessionId || req.body?.metadata?.user_id || null;
1170
+ isFingerprintDrift = sideChannelDetector.check(sessionKey, req.body?.messages);
1171
+ } catch { /* detector is best-effort */ }
972
1172
  const isSideRequest = isSuggestionMode
1173
+ || isMsgCountRegression
1174
+ || isBareRequest
1175
+ || isFingerprintDrift
973
1176
  || ((!Array.isArray(req.body?.tools) || req.body.tools.length === 0) && isKnownHarness);
974
1177
  // Side requests short-circuit to the static SIMPLE tier: no pin read
975
1178
  // (a COMPLEX-pinned conversation would burn expensive tokens on
@@ -980,12 +1183,23 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
980
1183
  if (isSideRequest) {
981
1184
  try {
982
1185
  const sel = getModelTierSelector().selectModel('SIMPLE', null);
1186
+ const _sideMethod = isSuggestionMode ? 'side_request_suggestion'
1187
+ : isMsgCountRegression ? 'side_request_msg_regression'
1188
+ : isBareRequest ? 'side_request_bare'
1189
+ : isFingerprintDrift ? 'side_request_fingerprint_drift'
1190
+ : 'side_request';
1191
+ logger.debug({
1192
+ method: _sideMethod,
1193
+ currentMsgCount: _currentMsgCount,
1194
+ pinnedMsgCount: _pinnedMsgCount,
1195
+ pinHadTools: _pinHadTools,
1196
+ }, '[Routing] Side-channel request detected');
983
1197
  sideTier = {
984
1198
  tier: 'SIMPLE',
985
1199
  provider: sel.provider,
986
1200
  model: sel.model || null,
987
1201
  score: null,
988
- method: isSuggestionMode ? 'side_request_suggestion' : 'side_request',
1202
+ method: _sideMethod,
989
1203
  reason: 'harness_side_request',
990
1204
  base_tier: null,
991
1205
  escalation_source: null,
@@ -1132,6 +1346,30 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
1132
1346
  return handleOauthPassthrough(req, res, { tier });
1133
1347
  }
1134
1348
 
1349
+ // Phase 2a — native-format streaming passthrough. Client speaks
1350
+ // Anthropic, upstream speaks Anthropic, request wants a stream: skip the
1351
+ // buffered orchestrator and pipe upstream SSE bytes straight through.
1352
+ // Falls back to the buffered path below when the upstream fails before
1353
+ // the first byte. Kill switch: LYNKR_NATIVE_PASSTHROUGH=false.
1354
+ {
1355
+ const nativeStream = require("../orchestrator/passthrough-stream");
1356
+ if (nativeStream.canPassthrough(req.body, tier)) {
1357
+ metrics.recordStreamingStart();
1358
+ const handled = await nativeStream.handleNativeStream(req, res, {
1359
+ tier,
1360
+ badgeText: _buildStreamBadge(tier),
1361
+ });
1362
+ if (handled) {
1363
+ metrics.recordResponse(200);
1364
+ return;
1365
+ }
1366
+ logger.info({
1367
+ tier: tier.tier,
1368
+ provider: tier.provider,
1369
+ }, "[NativeStream] Passthrough declined before first byte — using buffered path");
1370
+ }
1371
+ }
1372
+
1135
1373
  // All other cases (subscription→non-Anthropic, payg, oauth): pin the
1136
1374
  // window-scored tier so the orchestrator's internal tier router can't
1137
1375
  // override it with a full-body re-score. Badge/headers downstream show
@@ -1334,78 +1572,14 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
1334
1572
  maxSteps: req.body?.max_steps,
1335
1573
  maxDurationMs: req.body?.max_duration_ms,
1336
1574
  tenantPolicy: res.locals?.tenantPolicy || null,
1575
+ clientWantsStream: wantsStream,
1576
+ streamBadgeText: _buildStreamBadge(req._intentTier || null),
1337
1577
  },
1338
1578
  });
1339
1579
 
1340
1580
  if (result.stream) {
1341
- // Parse SSE stream from provider and forward to client
1342
- const reader = result.stream.getReader();
1343
- const decoder = new TextDecoder();
1344
- const bufferChunks = []; // Use array to avoid string concatenation overhead
1345
-
1346
- try {
1347
- while (true) {
1348
- const { done, value } = await readWithIdleTimeout(reader, "provider-stream");
1349
- if (done) break;
1350
-
1351
- const chunk = decoder.decode(value, { stream: true });
1352
- bufferChunks.push(chunk);
1353
-
1354
- const buffer = bufferChunks.join('');
1355
- const lines = buffer.split('\n');
1356
-
1357
- // Keep last incomplete line in buffer chunks
1358
- const remaining = lines.pop() || '';
1359
- bufferChunks.length = 0;
1360
- if (remaining) bufferChunks.push(remaining);
1361
-
1362
- for (const line of lines) {
1363
- if (line.trim()) {
1364
- res.write(line + '\n');
1365
- }
1366
- }
1367
-
1368
- if (typeof res.flush === 'function') {
1369
- res.flush();
1370
- }
1371
- }
1372
-
1373
- const remaining = bufferChunks.join('');
1374
- if (remaining.trim()) {
1375
- res.write(remaining + '\n');
1376
- }
1377
-
1378
- metrics.recordResponse(200);
1379
- res.end();
1380
- return;
1381
- } catch (streamError) {
1382
- logger.error({ error: streamError }, "Error streaming response");
1383
-
1384
- try {
1385
- await reader.cancel();
1386
- } catch (cancelError) {
1387
- logger.debug({ error: cancelError }, "Failed to cancel stream");
1388
- }
1389
-
1390
- if (!res.headersSent) {
1391
- res.status(500).json({ error: "Streaming error" });
1392
- } else {
1393
- // Mid-stream failure: emit a parseable Anthropic error event so
1394
- // the client fails fast and retries, instead of seeing a
1395
- // truncated stream it may wait on.
1396
- try { res.write(SSE_STALL_EVENT); } catch { /* client gone */ }
1397
- res.end();
1398
- }
1399
- return;
1400
- } finally {
1401
- // CRITICAL: Always release lock
1402
- try {
1403
- reader.releaseLock();
1404
- } catch (releaseError) {
1405
- // Lock may already be released, ignore
1406
- logger.debug({ error: releaseError }, "Stream lock already released");
1407
- }
1408
- }
1581
+ await forwardProviderStream(res, result.stream);
1582
+ return;
1409
1583
  }
1410
1584
 
1411
1585
  if (!result || !result.body) {
@@ -1432,17 +1606,10 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
1432
1606
  }
1433
1607
  })}\n\n`);
1434
1608
 
1435
- // Filter out server-side tools that shouldn't reach the client
1436
- // Server-tool filtering is server-mode only: in client mode Task and
1437
- // WebSearch are the CLIENT'S tools, and stripping them emits a
1438
- // malformed turn (stop_reason tool_use with no tool_use block).
1439
- const _serverTools = new Set(["task", "websearch", "webfetch", "web_search", "web_fetch", "web_agent"]);
1440
- const _clientOwnsTools = config.toolExecutionMode === "client" || config.toolExecutionMode === "passthrough";
1441
- let contentBlocks = _clientOwnsTools
1442
- ? (msg.content || []).slice()
1443
- : (msg.content || []).filter(b =>
1444
- !(b.type === "tool_use" && _serverTools.has((b.name || "").toLowerCase()))
1445
- );
1609
+ // The client owns all its tools every tool_use block passes through
1610
+ // untouched (stripping any would emit a malformed turn: stop_reason
1611
+ // tool_use with no tool_use block).
1612
+ let contentBlocks = (msg.content || []).slice();
1446
1613
 
1447
1614
  // When LYNKR_VISIBLE_ROUTING=true, prepend a one-line routing badge so
1448
1615
  // users can see which tier/provider/model handled the request inside
@@ -1584,6 +1751,8 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
1584
1751
  maxSteps: req.body?.max_steps,
1585
1752
  maxDurationMs: req.body?.max_duration_ms,
1586
1753
  tenantPolicy: res.locals?.tenantPolicy || null,
1754
+ clientWantsStream: wantsStream,
1755
+ streamBadgeText: _buildStreamBadge(req._intentTier || null),
1587
1756
  },
1588
1757
  });
1589
1758
  timer.mark("processMessage");
@@ -1596,11 +1765,19 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
1596
1765
  "Content-Type": "text/event-stream",
1597
1766
  "Cache-Control": "no-cache",
1598
1767
  Connection: "keep-alive",
1768
+ ...routingHeaders,
1599
1769
  });
1600
1770
  if (typeof res.flushHeaders === "function") {
1601
1771
  res.flushHeaders();
1602
1772
  }
1603
1773
 
1774
+ // Phase 2b: the orchestrator returned a live (already Anthropic-shaped)
1775
+ // stream instead of a buffered body — forward it as-is.
1776
+ if (result && result.stream) {
1777
+ await forwardProviderStream(res, result.stream);
1778
+ return;
1779
+ }
1780
+
1604
1781
  if (!result || !result.body) {
1605
1782
  res.write(`event: error\n`);
1606
1783
  res.write(`data: ${JSON.stringify({ type: "error", error: { message: "Empty response from provider" } })}\n\n`);
@@ -1625,17 +1802,10 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
1625
1802
  }
1626
1803
  })}\n\n`);
1627
1804
 
1628
- // Filter out server-side tools that shouldn't reach the client
1629
- // Server-tool filtering is server-mode only: in client mode Task and
1630
- // WebSearch are the CLIENT'S tools, and stripping them emits a
1631
- // malformed turn (stop_reason tool_use with no tool_use block).
1632
- const _serverTools = new Set(["task", "websearch", "webfetch", "web_search", "web_fetch", "web_agent"]);
1633
- const _clientOwnsTools = config.toolExecutionMode === "client" || config.toolExecutionMode === "passthrough";
1634
- let contentBlocks = _clientOwnsTools
1635
- ? (msg.content || []).slice()
1636
- : (msg.content || []).filter(b =>
1637
- !(b.type === "tool_use" && _serverTools.has((b.name || "").toLowerCase()))
1638
- );
1805
+ // The client owns all its tools every tool_use block passes through
1806
+ // untouched (stripping any would emit a malformed turn: stop_reason
1807
+ // tool_use with no tool_use block).
1808
+ let contentBlocks = (msg.content || []).slice();
1639
1809
 
1640
1810
  // When LYNKR_VISIBLE_ROUTING=true, prepend a one-line routing badge so
1641
1811
  // users can see which tier/provider/model handled the request inside
@@ -1805,61 +1975,6 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
1805
1975
  }
1806
1976
  });
1807
1977
 
1808
- // List available agents (must come before parameterized routes)
1809
- router.get("/v1/agents", (req, res) => {
1810
- try {
1811
- const { listAgents } = require("../agents");
1812
- const agents = listAgents();
1813
- res.json({ agents });
1814
- } catch (error) {
1815
- res.status(500).json({ error: error.message });
1816
- }
1817
- });
1818
-
1819
- // Agent stats endpoint (specific path before parameterized)
1820
- router.get("/v1/agents/stats", (req, res) => {
1821
- try {
1822
- const { getAgentStats } = require("../agents");
1823
- const stats = getAgentStats();
1824
- res.json({ stats });
1825
- } catch (error) {
1826
- res.status(500).json({ error: error.message });
1827
- }
1828
- });
1829
-
1830
- // Read agent transcript (specific path with param before catch-all)
1831
- router.get("/v1/agents/:agentId/transcript", (req, res) => {
1832
- try {
1833
- const ContextManager = require("../agents/context-manager");
1834
- const cm = new ContextManager();
1835
- const transcript = cm.readTranscript(req.params.agentId);
1836
-
1837
- if (!transcript) {
1838
- return res.status(404).json({ error: "Transcript not found" });
1839
- }
1840
-
1841
- res.json({ transcript });
1842
- } catch (error) {
1843
- res.status(500).json({ error: error.message });
1844
- }
1845
- });
1846
-
1847
- // Agent execution details (parameterized - must come last)
1848
- router.get("/v1/agents/:executionId", (req, res) => {
1849
- try {
1850
- const { getAgentExecution } = require("../agents");
1851
- const details = getAgentExecution(req.params.executionId);
1852
-
1853
- if (!details) {
1854
- return res.status(404).json({ error: "Execution not found" });
1855
- }
1856
-
1857
- res.json(details);
1858
- } catch (error) {
1859
- res.status(500).json({ error: error.message });
1860
- }
1861
- });
1862
-
1863
1978
  // Token usage statistics for a session
1864
1979
  router.get("/api/sessions/:sessionId/tokens", (req, res) => {
1865
1980
  try {