mixdog 0.9.52 → 0.9.54

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 (124) hide show
  1. package/package.json +1 -1
  2. package/scripts/agent-model-liveness-test.mjs +68 -0
  3. package/scripts/anthropic-transport-policy-test.mjs +260 -0
  4. package/scripts/bench-run.mjs +2 -2
  5. package/scripts/compact-pressure-test.mjs +104 -0
  6. package/scripts/desktop-session-bridge-test.mjs +704 -0
  7. package/scripts/freevar-smoke.mjs +7 -4
  8. package/scripts/gemini-provider-test.mjs +396 -1
  9. package/scripts/grok-oauth-refresh-race-test.mjs +76 -1
  10. package/scripts/lifecycle-api-test.mjs +65 -4
  11. package/scripts/max-output-recovery-test.mjs +31 -0
  12. package/scripts/memory-core-input-test.mjs +10 -0
  13. package/scripts/openai-oauth-ws-1006-retry-test.mjs +144 -4
  14. package/scripts/openai-ws-early-settle-test.mjs +40 -0
  15. package/scripts/parent-abort-link-test.mjs +24 -0
  16. package/scripts/process-lifecycle-test.mjs +91 -22
  17. package/scripts/provider-admission-scheduler-test.mjs +4 -5
  18. package/scripts/provider-contract-test.mjs +326 -11
  19. package/scripts/provider-toolcall-test.mjs +269 -27
  20. package/scripts/session-orphan-sweep-test.mjs +27 -1
  21. package/src/lib/keychain-cjs.cjs +36 -23
  22. package/src/repl.mjs +11 -0
  23. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -13
  24. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +7 -8
  25. package/src/runtime/agent/orchestrator/agent-trace.mjs +33 -9
  26. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +7 -2
  27. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +76 -325
  28. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +10 -6
  29. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +68 -289
  30. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +33 -5
  31. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +136 -27
  32. package/src/runtime/agent/orchestrator/providers/gemini.mjs +113 -144
  33. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +101 -190
  34. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +263 -0
  35. package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +6 -0
  36. package/src/runtime/agent/orchestrator/providers/lib/gemini-model-catalog.mjs +119 -0
  37. package/src/runtime/agent/orchestrator/providers/lib/grok-tool-schema.mjs +109 -0
  38. package/src/runtime/agent/orchestrator/providers/lib/openai-tool-args.mjs +70 -0
  39. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +19 -71
  40. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +35 -4
  41. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +3 -1
  42. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +96 -12
  43. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +15 -9
  44. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +81 -81
  45. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -20
  46. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +10 -10
  47. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +30 -3
  48. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +37 -28
  49. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +58 -20
  50. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +8 -0
  51. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +170 -0
  52. package/src/runtime/agent/orchestrator/session/context-utils.mjs +20 -223
  53. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +11 -17
  54. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +65 -12
  55. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +21 -5
  56. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +3 -58
  57. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +24 -0
  58. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +23 -1
  59. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +57 -16
  60. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +2 -2
  61. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -1
  62. package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +52 -0
  63. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +62 -0
  64. package/src/runtime/agent/orchestrator/session/store.mjs +305 -127
  65. package/src/runtime/agent/orchestrator/stall-policy.mjs +6 -13
  66. package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +46 -0
  67. package/src/runtime/agent/orchestrator/tools/builtin/lib/search-grep-chunks.mjs +173 -0
  68. package/src/runtime/agent/orchestrator/tools/builtin/lib/search-input-helpers.mjs +117 -0
  69. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-insights.mjs +199 -0
  70. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-spawn-helpers.mjs +107 -0
  71. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +1 -40
  72. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +19 -277
  73. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +22 -298
  74. package/src/runtime/agent/orchestrator/tools/lib/shell-spawn-retry.mjs +67 -0
  75. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -71
  76. package/src/runtime/channels/backends/discord-gateway.mjs +1 -1
  77. package/src/runtime/channels/backends/discord.mjs +6 -6
  78. package/src/runtime/channels/lib/config.mjs +15 -2
  79. package/src/runtime/channels/lib/memory-client.mjs +20 -3
  80. package/src/runtime/channels/lib/owned-runtime.mjs +19 -12
  81. package/src/runtime/channels/lib/status-snapshot.mjs +9 -0
  82. package/src/runtime/channels/lib/tool-dispatch.mjs +9 -5
  83. package/src/runtime/channels/lib/worker-main.mjs +16 -5
  84. package/src/runtime/memory/index.mjs +24 -198
  85. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -53
  86. package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +115 -0
  87. package/src/runtime/memory/lib/memory-port-advertiser.mjs +105 -0
  88. package/src/runtime/memory/lib/pg/supervisor.mjs +0 -4
  89. package/src/runtime/memory/lib/query-handlers.mjs +2 -122
  90. package/src/runtime/memory/lib/query-maintenance-handlers.mjs +126 -0
  91. package/src/runtime/memory/lib/tool-call-handler.mjs +57 -0
  92. package/src/runtime/search/lib/http-fetch.mjs +1 -1
  93. package/src/runtime/shared/config.mjs +58 -13
  94. package/src/runtime/shared/llm/cost.mjs +14 -4
  95. package/src/runtime/shared/memory-snapshot.mjs +245 -0
  96. package/src/runtime/shared/process-lifecycle.mjs +184 -34
  97. package/src/runtime/shared/process-shutdown.mjs +6 -0
  98. package/src/runtime/shared/resource-admission.mjs +7 -2
  99. package/src/session-runtime/channel-config-api.mjs +7 -7
  100. package/src/session-runtime/config-lifecycle.mjs +20 -17
  101. package/src/session-runtime/cwd-plugins.mjs +9 -7
  102. package/src/session-runtime/env.mjs +1 -2
  103. package/src/session-runtime/hitch-profile.mjs +45 -0
  104. package/src/session-runtime/lifecycle-api.mjs +36 -9
  105. package/src/session-runtime/mcp-glue.mjs +6 -11
  106. package/src/session-runtime/provider-init-key.mjs +17 -0
  107. package/src/session-runtime/runtime-core.mjs +44 -103
  108. package/src/session-runtime/runtime-paths.mjs +20 -0
  109. package/src/session-runtime/runtime-tool-routing.mjs +55 -0
  110. package/src/session-runtime/session-turn-api.mjs +1 -0
  111. package/src/session-runtime/tool-catalog-data.mjs +51 -0
  112. package/src/session-runtime/tool-catalog.mjs +15 -89
  113. package/src/standalone/agent-tool/spawn-preset.mjs +73 -0
  114. package/src/standalone/agent-tool/worker-rows.mjs +93 -0
  115. package/src/standalone/agent-tool.mjs +12 -162
  116. package/src/standalone/channel-admin.mjs +29 -0
  117. package/src/tui/App.jsx +11 -5
  118. package/src/tui/dist/index.mjs +233 -65
  119. package/src/tui/engine/session-api-ext.mjs +37 -4
  120. package/src/tui/engine/turn.mjs +31 -0
  121. package/src/tui/index.jsx +1 -0
  122. package/src/tui/lib/voice-setup.mjs +5 -5
  123. package/scripts/_devtools-stub.mjs +0 -1
  124. package/src/runtime/lib/keychain-cjs.cjs +0 -288
@@ -28,6 +28,7 @@ export const LEGACY_EFFORT_BUDGET = Object.freeze({
28
28
  });
29
29
  const _LOGGED_EFFORT_NORMALIZATION = new Set();
30
30
  const _LOGGED_UNKNOWN_EFFORT = new Set();
31
+ const MAX_LOGGED_UNKNOWN_EFFORTS = 100;
31
32
 
32
33
  function normalizeModelId(model) {
33
34
  return String(model || '').toLowerCase().replace(/\./g, '-');
@@ -223,8 +224,12 @@ export function applyAnthropicEffortToBody(
223
224
  return;
224
225
  }
225
226
 
226
- if (opts.effort && normalized === undefined && !_LOGGED_UNKNOWN_EFFORT.has(opts.effort)) {
227
- _LOGGED_UNKNOWN_EFFORT.add(opts.effort);
227
+ const unknownEffort = String(opts.effort ?? '');
228
+ if (opts.effort && normalized === undefined && !_LOGGED_UNKNOWN_EFFORT.has(unknownEffort)) {
229
+ if (_LOGGED_UNKNOWN_EFFORT.size >= MAX_LOGGED_UNKNOWN_EFFORTS) {
230
+ _LOGGED_UNKNOWN_EFFORT.delete(_LOGGED_UNKNOWN_EFFORT.values().next().value);
231
+ }
232
+ _LOGGED_UNKNOWN_EFFORT.add(unknownEffort);
228
233
  try {
229
234
  process.stderr.write(
230
235
  `[${logTag}] unknown effort=${opts.effort} ignored (known legacy: ${Object.keys(LEGACY_EFFORT_BUDGET).join(', ')})\n`,
@@ -25,7 +25,6 @@ import {
25
25
  ensureLatestAnthropicModel,
26
26
  } from './anthropic-model-resolve.mjs';
27
27
  import { sanitizeToolPairs, sanitizeAnthropicContentPairs, foldUserTextIntoToolResultTail } from '../session/context-utils.mjs';
28
- import { providerNativeToolPrefixCount } from '../../../../session-runtime/provider-request-tools.mjs';
29
28
  import {
30
29
  TOKEN_REFRESH_SKEW_MS,
31
30
  isAnthropicOAuthRefreshDisabled,
@@ -70,8 +69,19 @@ import {
70
69
  import { getLlmDispatcher, preconnect } from '../../../shared/llm/http-agent.mjs';
71
70
  import { normalizeContentForAnthropic } from './media-normalization.mjs';
72
71
  import { notifyCurrentAnthropicRateLimit } from './admission-scheduler.mjs';
72
+ import {
73
+ ANTHROPIC_CACHE_TTL_STABLE as CACHE_TTL_STABLE,
74
+ ANTHROPIC_CACHE_TTL_VOLATILE as CACHE_TTL_VOLATILE,
75
+ applyAnthropicCacheMarkers,
76
+ clampAnthropicThinkingBudget as clampThinkingBudgetTokens,
77
+ deferredAnthropicTools as sharedDeferredAnthropicTools,
78
+ requestAnthropicTools as sharedRequestAnthropicTools,
79
+ normalizeAnthropicNonStreamingResponse,
80
+ resolveAnthropicCacheTtls as resolveCacheTtls,
81
+ sanitizeAnthropicInputSchema,
82
+ toAnthropicToolChoice,
83
+ } from './lib/anthropic-request-utils.mjs';
73
84
 
74
- // --- Model catalog cache helpers: extracted to anthropic-model-resolve.mjs ---
75
85
  // SSE progress emits (per-request "Response …" and "Done:" lines). Off by default.
76
86
  const SSE_VERBOSE = process.env.MIXDOG_SSE_VERBOSE === '1';
77
87
 
@@ -202,113 +212,12 @@ function resolveMaxTokens(model) {
202
212
  return resolveAnthropicMaxTokens(model, { catalogLookup: _catalogOutputTokens });
203
213
  }
204
214
 
205
- const MIN_THINKING_BUDGET = 1024;
206
- const THINKING_OUTPUT_RESERVE = 1024;
207
-
208
- function clampThinkingBudgetTokens(value, maxTokens) {
209
- const desired = Math.floor(Number(value));
210
- const max = Math.floor(Number(maxTokens));
211
- if (!Number.isFinite(desired) || desired <= 0 || !Number.isFinite(max)) return null;
212
- const ceiling = max - THINKING_OUTPUT_RESERVE;
213
- if (ceiling < MIN_THINKING_BUDGET) return null;
214
- return Math.max(MIN_THINKING_BUDGET, Math.min(desired, ceiling));
215
- }
216
-
217
- // Layered cache TTLs — stable layers get 1h, volatile layers get 5m.
218
- // Anthropic requires 1h entries to appear before 5m entries in the request.
219
- const CACHE_TTL_STABLE = { type: 'ephemeral', ttl: '1h' }; // tools, system, tier3, messages
220
- const CACHE_TTL_VOLATILE = { type: 'ephemeral' }; // explicit 5m override
221
-
222
215
  // --- Message conversion ---
223
216
 
224
- function withCacheControl(block, ttl = CACHE_TTL_VOLATILE) {
225
- if (!block || typeof block !== 'object' || block.cache_control) return block;
226
- return { ...block, cache_control: ttl };
227
- }
228
-
229
- function appendCacheControl(content, ttl = CACHE_TTL_VOLATILE) {
230
- if (Array.isArray(content)) {
231
- if (content.length === 0) return content;
232
- const next = [...content];
233
- next[next.length - 1] = withCacheControl(next[next.length - 1], ttl);
234
- return next;
235
- }
236
- if (typeof content === 'string') {
237
- return [withCacheControl({ type: 'text', text: content }, ttl)];
238
- }
239
- return content;
240
- }
241
-
242
217
  // Anthropic's tool spec forbids oneOf / allOf / anyOf at the TOP level of
243
218
  // input_schema (nested usage inside properties is allowed). External MCP
244
219
  // servers sometimes emit such schemas.
245
220
  // Convert them to a flat object schema so the API never sees a 400.
246
- function _sanitizeInputSchema(schema, toolName) {
247
- if (!schema || typeof schema !== 'object') {
248
- return { type: 'object', properties: {} };
249
- }
250
- const compound = schema.oneOf || schema.anyOf || schema.allOf;
251
- if (!compound) return structuredClone(schema);
252
- // Merge all branch properties into one permissive object schema.
253
- // None of the branches' required lists are hoisted — callers that relied
254
- // on discriminated-union semantics will still function; the model simply
255
- // receives a union of the property surface with no hard-required constraint.
256
- const mergedProps = { ...(schema.properties && typeof schema.properties === 'object' ? schema.properties : {}) };
257
- const branchDescs = [];
258
- for (const branch of Array.isArray(compound) ? compound : []) {
259
- if (branch && typeof branch === 'object' && branch.properties) {
260
- Object.assign(mergedProps, branch.properties);
261
- }
262
- if (branch && typeof branch === 'object') {
263
- const parts = [];
264
- if (branch.description) parts.push(branch.description);
265
- else if (branch.type) parts.push(`type:${branch.type}`);
266
- if (parts.length) branchDescs.push(parts.join(' '));
267
- }
268
- }
269
- const compoundKey = schema.oneOf ? 'oneOf' : schema.anyOf ? 'anyOf' : 'allOf';
270
- let description = schema.description || '';
271
- if (branchDescs.length) {
272
- const parts = [];
273
- let used = 0;
274
- for (let i = 0; i < branchDescs.length; i++) {
275
- const v = `(variant ${i + 1}: ${branchDescs[i]})`;
276
- if (used + v.length + (parts.length ? 1 : 0) > 500) break;
277
- parts.push(v);
278
- used += v.length + (parts.length > 1 ? 1 : 0);
279
- }
280
- const addition = parts.join(' ');
281
- if (addition) description = description ? `${description} ${addition}` : addition;
282
- }
283
- const mergedPropsCount = Object.keys(mergedProps).length;
284
- if (process.env.MIXDOG_DEBUG_SESSION_LOG) {
285
- process.stderr.write(
286
- `[anthropic-oauth-sanitizer] tool="${toolName ?? ''}" compound="${compoundKey}" branches=${Array.isArray(compound) ? compound.length : 0} mergedProps=${mergedPropsCount}\n`
287
- );
288
- }
289
- return {
290
- type: 'object',
291
- ...(description ? { description } : {}),
292
- properties: mergedProps,
293
- };
294
- }
295
-
296
- function toAnthropicTools(tools) {
297
- return tools.map(t => {
298
- const out = {
299
- name: t.name,
300
- description: t.description,
301
- input_schema: _sanitizeInputSchema(t.inputSchema, t.name),
302
- };
303
- if (t.deferLoading === true || t.defer_loading === true) out.defer_loading = true;
304
- return out;
305
- });
306
- }
307
- function nativeAnthropicTools(opts) {
308
- return Array.isArray(opts?.nativeTools)
309
- ? opts.nativeTools.filter(t => t && typeof t === 'object')
310
- : [];
311
- }
312
221
  // Map the orchestrator-level opts.toolChoice into Anthropic's tool_choice.
313
222
  // Only 'none' is activated: it lets the hard-cap final turn keep the tool
314
223
  // DEFINITIONS in-request (so the tools->system->messages prefix — and its
@@ -321,59 +230,11 @@ function nativeAnthropicTools(opts) {
321
230
  // effort/thinking active on reasoning models — activating it would convert a
322
231
  // previously-harmless no-op into a hard 400 on exactly that turn. Attached
323
232
  // only when the request actually carries tools (see buildRequestBody).
324
- function toAnthropicToolChoice(toolChoice) {
325
- return toolChoice === 'none' ? { type: 'none' } : undefined;
326
- }
327
- function discoveredAnthropicToolNames(messages, opts, provider) {
328
- const anthropicNative = new Set(['anthropic', 'anthropic-oauth']);
329
- const discovered = new Set(
330
- Array.isArray(opts?.session?.deferredDiscoveredTools)
331
- ? opts.session.deferredDiscoveredTools.map((name) => String(name || '').trim()).filter(Boolean)
332
- : [],
333
- );
334
- for (const message of Array.isArray(messages) ? messages : []) {
335
- const native = message?.nativeToolSearch;
336
- const source = String(native?.provider || '').toLowerCase();
337
- if (source && source !== provider
338
- && !(anthropicNative.has(source) && anthropicNative.has(provider))) continue;
339
- for (const name of Array.isArray(native?.toolReferences) ? native.toolReferences : []) {
340
- const key = String(name || '').trim();
341
- if (key) discovered.add(key);
342
- }
343
- }
344
- return discovered;
345
- }
346
233
  function deferredAnthropicTools(activeTools, messages, opts) {
347
- if (opts?.session?.deferredNativeTools !== true) return [];
348
- // A request whose ONLY tools are deferred is rejected by the API with
349
- // `400: At least one tool must have defer_loading=false` — happens on the
350
- // iteration-cap final turn (loop sends tools: [] to force a text answer).
351
- // No active tools ⇒ send no deferred catalog either.
352
- if (!Array.isArray(activeTools) || activeTools.length === 0) return [];
353
- const active = new Set((activeTools || []).map((tool) => String(tool?.name || '').trim()).filter(Boolean));
354
- const discovered = discoveredAnthropicToolNames(messages, opts, 'anthropic-oauth');
355
- const catalog = Array.isArray(opts.session.deferredToolCatalog) ? opts.session.deferredToolCatalog : [];
356
- return catalog
357
- .filter((tool) => tool?.name && discovered.has(String(tool.name)) && !active.has(String(tool.name)))
358
- .map((tool) => ({ ...tool, deferLoading: true }));
234
+ return sharedDeferredAnthropicTools(activeTools, messages, opts, 'anthropic-oauth');
359
235
  }
360
236
  function requestAnthropicTools(tools, messages, opts) {
361
- const activeTools = Array.isArray(tools) ? tools : [];
362
- if (opts?.providerToolSnapshotAuthoritative === true) {
363
- const nativePrefixCount = providerNativeToolPrefixCount(
364
- activeTools,
365
- opts.providerNativeToolPrefixCount,
366
- );
367
- return [
368
- ...activeTools.slice(0, nativePrefixCount),
369
- ...toAnthropicTools(activeTools.slice(nativePrefixCount)),
370
- ];
371
- }
372
- const deferredTools = deferredAnthropicTools(activeTools, messages, opts);
373
- return [
374
- ...nativeAnthropicTools(opts),
375
- ...toAnthropicTools([...activeTools, ...deferredTools]),
376
- ];
237
+ return sharedRequestAnthropicTools(tools, messages, opts, 'anthropic-oauth');
377
238
  }
378
239
 
379
240
  function toAnthropicMessages(messages) {
@@ -475,154 +336,8 @@ function toAnthropicMessages(messages) {
475
336
  // messageTtl === null disables the tail. BP3 (tier3) now rides a system block,
476
337
  // so it is no longer marked here.
477
338
  // ANTHROPIC_MSG_SLOTS=0 is honoured upstream by passing messageTtl = null.
478
- function applyAnthropicCacheMarkers(sanitizedMessages, { messageTtl = CACHE_TTL_VOLATILE, messageSlots = 1 } = {}) {
479
- if (!Array.isArray(sanitizedMessages) || sanitizedMessages.length === 0) {
480
- return sanitizedMessages;
481
- }
482
-
483
- const firstText = (content) => {
484
- if (typeof content === 'string') return content;
485
- if (Array.isArray(content)) {
486
- const first = content.find((b) => b?.type === 'text');
487
- return first && typeof first.text === 'string' ? first.text : '';
488
- }
489
- return '';
490
- };
491
- const isSystemReminder = (content) => firstText(content).startsWith('<system-reminder>');
492
-
493
- const markLast = (msg, ttl) => {
494
- if (!msg) return;
495
- msg.content = appendCacheControl(msg.content, ttl);
496
- };
497
- const ttlRank = (ttl) => ttl?.ttl === '1h' ? 2 : 1;
498
- const canMarkMessageIdx = (idx) => {
499
- // System-reminder messages (volatileTail / roleSpecific BP4) vary
500
- // per-call, so never pin them with a 1h marker. The 1h system blocks
501
- // (BP1/BP2/BP3) already satisfy Anthropic's "1h before 5m" ordering.
502
- if (idx < 0) return false;
503
- const msg = sanitizedMessages[idx];
504
- if (ttlRank(messageTtl) > ttlRank(CACHE_TTL_VOLATILE)
505
- && isSystemReminder(msg?.content)) {
506
- return false;
507
- }
508
- return true;
509
- };
510
- const hasUserText = (msg) => {
511
- if (msg?.role !== 'user') return false;
512
- if (isSystemReminder(msg.content)) return false;
513
- if (typeof msg.content === 'string') return msg.content.trim().length > 0;
514
- if (!Array.isArray(msg.content)) return false;
515
- return msg.content.some(b => b?.type === 'text' && typeof b.text === 'string' && b.text.trim().length > 0);
516
- };
517
- const previousUserTextAnchorIdx = () => {
518
- // Prefer the user text turn before the current tail. In a normal
519
- // user->assistant->tool loop this is the last prompt that was already
520
- // present in the previous API request, so its prefix can overlap.
521
- const tailIdx = sanitizedMessages.length - 1;
522
- for (let i = tailIdx - 1; i >= 0; i--) {
523
- if (hasUserText(sanitizedMessages[i])) return i;
524
- }
525
- return -1;
526
- };
527
- const latestToolResultTailIdx = () => {
528
- // Claude/pi refs allow cache_control on tool_result blocks. Keep this
529
- // narrower than "last message" so a fresh user prompt or steering text
530
- // never becomes a 1h breakpoint.
531
- for (let i = sanitizedMessages.length - 1; i >= 0; i--) {
532
- const msg = sanitizedMessages[i];
533
- if (msg?.role !== 'user' || !Array.isArray(msg.content) || msg.content.length === 0) continue;
534
- const lastBlock = msg.content[msg.content.length - 1];
535
- if (lastBlock?.type === 'tool_result') return i;
536
- }
537
- return -1;
538
- };
539
-
540
- const firstRequestUserPromptIdx = () => {
541
- // Iteration-1 fallback: on the very first request a session has only the
542
- // current user prompt — no tool_result tail and no earlier user turn, so
543
- // both anchors above return -1 and NO message breakpoint is placed. That
544
- // left the whole tools+system prefix (~4.2k) uncached on iter1: nothing
545
- // was written, so iter2 re-sent it as a fresh full write instead of a
546
- // read hit. Anchor the current prompt's tail so the stable prefix is
547
- // cache-written on the first ask and read back on the next. Only used
548
- // when neither real anchor exists, so later turns still prefer the
549
- // tool_result / previous-user-text anchors (never the volatile new
550
- // prompt). Synthetic <system-reminder> turns are excluded by hasUserText.
551
- if (latestToolResultTailIdx() !== -1 || previousUserTextAnchorIdx() !== -1) return -1;
552
- const tailIdx = sanitizedMessages.length - 1;
553
- return hasUserText(sanitizedMessages[tailIdx]) ? tailIdx : -1;
554
- };
555
- if (messageTtl !== null) {
556
- const slots = Math.max(0, Math.min(4, Number(messageSlots) || 0));
557
- const marked = new Set();
558
- const candidates = [latestToolResultTailIdx(), previousUserTextAnchorIdx(), firstRequestUserPromptIdx()];
559
- for (const idx of candidates) {
560
- if (slots <= 0) break;
561
- if (idx < 0 || marked.has(idx) || !canMarkMessageIdx(idx)) continue;
562
- markLast(sanitizedMessages[idx], messageTtl);
563
- marked.add(idx);
564
- if (marked.size >= slots) break;
565
- }
566
- }
567
-
568
- return sanitizedMessages;
569
- }
570
-
571
- // --- SSE parser + midstream retry policy: extracted to anthropic-sse.mjs ---
572
-
573
339
  // --- Build request body ---
574
340
 
575
- function resolveCacheTtls(opts) {
576
- // Layered cache strategy — caller may override per-layer via opts.cacheStrategy.
577
- // Anthropic enforces: 1h entries must appear before 5m entries in the request.
578
- const strategy = opts.cacheStrategy || {};
579
- const pick = (layer, fallback) => {
580
- const v = strategy[layer];
581
- if (v === '1h') return CACHE_TTL_STABLE;
582
- if (v === '5m') return CACHE_TTL_VOLATILE;
583
- if (v === 'none') return null;
584
- return fallback;
585
- };
586
- // BP budget (4 total):
587
- // BP1 baseRules — 1h (shared tool policy + compact skill manifest)
588
- // BP2 stableSystem — 1h (role/system rules)
589
- // BP3 tier3 — 1h (sessionMarker: stable memory/meta body)
590
- // BP4 messages — 1h sliding tail (tool_result cache across iter)
591
- // tools BP is dropped — system BP covers the tools prefix via
592
- // Anthropic's prompt cache prefix semantics (order: tools → system
593
- // → messages).
594
- // tier3 defaults to 1h (stable) — sessionMarker content is stable per
595
- // memory/meta tuple and the BP slot is only spent when a 3rd
596
- // (cacheTier:'tier3') system block is actually present, so this default is
597
- // free for sessions that don't carry one. Previously null here meant any
598
- // caller that skipped agent runtime resolve (CLI, raw agent spawn)
599
- // silently lost the tier3 cache layer even though it supported one.
600
- const resolved = {
601
- tools: pick('tools', null),
602
- system: pick('system', CACHE_TTL_STABLE),
603
- tier3: pick('tier3', CACHE_TTL_STABLE),
604
- messages: pick('messages', CACHE_TTL_STABLE),
605
- };
606
- // A partial cacheStrategy override (e.g. {system:'5m'} while tier3/
607
- // messages default to '1h') can put a longer TTL after a shorter one in
608
- // request order, which Anthropic rejects: 1h breakpoints must all appear
609
- // before any 5m breakpoint. Normalize left-to-right in wire order
610
- // (system -> tier3 -> messages; tools is emitted before system and is
611
- // excluded from the run) so a later layer is downgraded to the earliest
612
- // shorter TTL seen so far — never re-promoted. Layers set to null ('none')
613
- // emit no breakpoint at all, so they neither violate nor constrain
614
- // ordering and are skipped.
615
- const ttlRank = (ttl) => (ttl === CACHE_TTL_STABLE ? 2 : 1); // 1h=2, 5m=1
616
- let minRank = Infinity;
617
- for (const layer of ['system', 'tier3', 'messages']) {
618
- if (!resolved[layer]) continue;
619
- const rank = ttlRank(resolved[layer]);
620
- if (rank > minRank) resolved[layer] = CACHE_TTL_VOLATILE;
621
- else minRank = rank;
622
- }
623
- return resolved;
624
- }
625
-
626
341
  // BP3 (tier3) is injected by session/manager as its own `system` role block —
627
342
  // the 3rd system block, tagged `cacheTier:'tier3'`. buildSystemBlocks applies
628
343
  // the tier3 1h cache_control to that block; BP1/BP2 take the system TTL. No
@@ -848,6 +563,7 @@ export class AnthropicOAuthProvider {
848
563
  const onStreamDelta = typeof opts.onStreamDelta === 'function' ? opts.onStreamDelta : null;
849
564
  const onToolCall = typeof opts.onToolCall === 'function' ? opts.onToolCall : null;
850
565
  const onTextDelta = typeof opts.onTextDelta === 'function' ? opts.onTextDelta : null;
566
+ const onTextReset = typeof opts.onTextReset === 'function' ? opts.onTextReset : null;
851
567
  const externalSignal = opts.signal || null;
852
568
  // Test seam: lets the retry harness drive stream outcomes without a
853
569
  // live OAuth session.
@@ -898,7 +614,7 @@ export class AnthropicOAuthProvider {
898
614
  try { totalSignal.removeEventListener('abort', handler); } catch {}
899
615
  };
900
616
 
901
- const doRequest = async (accessToken, requestSignal = null) => {
617
+ const doRequest = async (accessToken, requestSignal = null, requestBody = body) => {
902
618
  const controller = createAbortController();
903
619
  const fetchStartedAt = Date.now();
904
620
 
@@ -951,7 +667,7 @@ export class AnthropicOAuthProvider {
951
667
  'x-app': 'cli',
952
668
  'Content-Type': 'application/json',
953
669
  },
954
- body: JSON.stringify(body),
670
+ body: JSON.stringify(requestBody),
955
671
  signal: controller.signal,
956
672
  dispatcher: getLlmDispatcher(),
957
673
  });
@@ -993,8 +709,8 @@ export class AnthropicOAuthProvider {
993
709
  // Test seam: injectable request factory for retry-path tests.
994
710
  const doRequestImpl = typeof opts._doRequestFn === 'function' ? opts._doRequestFn : doRequest;
995
711
 
996
- const requestWithRetry = async (accessToken) => withRetry(async ({ signal: attemptSignal }) => {
997
- const result = await doRequestImpl(accessToken, attemptSignal);
712
+ const requestWithRetry = async (accessToken, requestBody = body) => withRetry(async ({ signal: attemptSignal }) => {
713
+ const result = await doRequestImpl(accessToken, attemptSignal, requestBody);
998
714
  const status = Number(result?.response?.status || 0);
999
715
  const transientStatus = classifyError({ httpStatus: status }) === 'transient';
1000
716
  if (transientStatus || status === 429) {
@@ -1024,8 +740,14 @@ export class AnthropicOAuthProvider {
1024
740
  backoffMs: ANTHROPIC_RETRY_BACKOFF_MS,
1025
741
  retryJitterRatio: ANTHROPIC_RETRY_JITTER_RATIO,
1026
742
  retryJitterMode: 'positive',
743
+ // Max/Pro OAuth sessions use subscription quota windows. Claude
744
+ // Code fails their 429s immediately rather than waiting through
745
+ // the API-key/PAYG retry budget (which may carry hours-long
746
+ // Retry-After values).
747
+ retry429: false,
1027
748
  perAttemptTimeoutMs: requestTimeoutMs,
1028
749
  perAttemptLabel: 'Anthropic OAuth initial response',
750
+ provider: 'anthropic',
1029
751
  model: useModel,
1030
752
  fallbackModel: opts._fallbackTriggered ? undefined : opts.fallbackModel,
1031
753
  onRetry: ({ attempt, lastErr, delayMs, delayReason }) => {
@@ -1046,6 +768,51 @@ export class AnthropicOAuthProvider {
1046
768
  let firstAttemptError = null;
1047
769
  let firstAttemptClassifier = null;
1048
770
 
771
+ const recoverNonStreaming = async (midState, streamingError, controller) => {
772
+ const exposedChars = Number(midState?.emittedTextChars) || 0;
773
+ if (!onTextReset || exposedChars <= 0
774
+ || midState.emittedToolCall || midState.partialToolCall || midState.emittedThinking) {
775
+ try { streamingError.liveTextEmitted = true; streamingError.unsafeToRetry = true; } catch {}
776
+ throw streamingError;
777
+ }
778
+ let resetAccepted = false;
779
+ try {
780
+ resetAccepted = await onTextReset({
781
+ chars: exposedChars,
782
+ reason: 'anthropic-streaming-fallback',
783
+ }) === true;
784
+ } catch {}
785
+ if (!resetAccepted) {
786
+ try { streamingError.liveTextEmitted = true; streamingError.unsafeToRetry = true; } catch {}
787
+ throw streamingError;
788
+ }
789
+ try { controller?.abort?.(streamingError); } catch {}
790
+ try { onStageChange?.('requesting', { transport: 'non-streaming-fallback' }); } catch {}
791
+ let fallback = await requestWithRetry(creds.accessToken, { ...body, stream: false });
792
+ if (fallback.response.status === 401) {
793
+ cleanupCancelHandler(fallback.cancelHandler);
794
+ try { fallback.controller?.abort?.(); } catch {}
795
+ creds = await this.ensureAuth({ forceRefresh: true, reason: '401' });
796
+ fallback = await requestWithRetry(creds.accessToken, { ...body, stream: false });
797
+ }
798
+ if (!fallback.response.ok) {
799
+ const text = await fallback.response.text().catch(() => '');
800
+ cleanupCancelHandler(fallback.cancelHandler);
801
+ try { fallback.controller?.abort?.(); } catch {}
802
+ const fallbackError = new Error(`Anthropic OAuth API ${fallback.response.status}: ${this.scrubTokens(text).slice(0, 200)}`);
803
+ fallbackError.status = fallback.response.status;
804
+ fallbackError.httpStatus = fallback.response.status;
805
+ throw fallbackError;
806
+ }
807
+ try {
808
+ const message = await fallback.response.json();
809
+ return normalizeAnthropicNonStreamingResponse(message, useModel);
810
+ } finally {
811
+ cleanupCancelHandler(fallback.cancelHandler);
812
+ try { fallback.controller?.abort?.('Anthropic non-streaming fallback complete'); } catch {}
813
+ }
814
+ };
815
+
1049
816
  try {
1050
817
  for (let attemptIndex = 0; attemptIndex <= MAX_MIDSTREAM_RETRIES; attemptIndex++) {
1051
818
  let response, controller, cancelHandler;
@@ -1205,28 +972,12 @@ export class AnthropicOAuthProvider {
1205
972
  } catch { /* ignore non-extensible result */ }
1206
973
  return result;
1207
974
  } catch (err) {
1208
- // Live-text invariant: once a non-empty text chunk has been
1209
- // relayed to the client (gateway live mode), the rendered output
1210
- // cannot be withdrawn and re-issuing would concatenate a second
1211
- // attempt. Surface the failure immediately never retry and
1212
- // tag the error so upstream layers refuse to retry as well.
975
+ // Acknowledged reset semantics let the owner tombstone this
976
+ // attempt before the full request is restarted non-streaming.
977
+ // Without that acknowledgement, recoverNonStreaming stamps
978
+ // the error unsafe and preserves the no-concatenation rule.
1213
979
  if (midState.emittedText) {
1214
- try { err.liveTextEmitted = true; err.unsafeToRetry = true; } catch {}
1215
- try { controller?.abort?.(err); } catch { /* best-effort teardown */ }
1216
- if (attemptIndex > 0 && firstAttemptError) {
1217
- try { firstAttemptError.midstreamRetries = attemptIndex; } catch {}
1218
- try { firstAttemptError.midstreamClassifier = firstAttemptClassifier; } catch {}
1219
- // firstAttemptError is what actually propagates here when
1220
- // live text was emitted this attempt — stamp the unsafe
1221
- // flags onto IT too, else upstream sees an unmarked error
1222
- // and retries, duplicating already-streamed output.
1223
- try {
1224
- firstAttemptError.liveTextEmitted = true;
1225
- firstAttemptError.unsafeToRetry = true;
1226
- } catch {}
1227
- throw err;
1228
- }
1229
- throw err;
980
+ return await recoverNonStreaming(midState, err, controller);
1230
981
  }
1231
982
  // Completed/partial tools and thinking blocks are also replay
1232
983
  // boundaries. Stamp the current (latest) error so no upstream
@@ -1444,5 +1195,5 @@ export const _test = {
1444
1195
  resolveMaxTokens,
1445
1196
  deferredAnthropicTools,
1446
1197
  requestAnthropicTools,
1447
- sanitizeInputSchema: _sanitizeInputSchema,
1198
+ sanitizeInputSchema: (schema, toolName) => sanitizeAnthropicInputSchema(schema, toolName, 'anthropic-oauth'),
1448
1199
  };
@@ -1,10 +1,8 @@
1
1
  /**
2
2
  * anthropic-sse.mjs — Anthropic SSE stream parser + mid-stream retry policy.
3
3
  *
4
- * Extracted from anthropic-oauth.mjs. Shared by BOTH Anthropic providers:
5
- * anthropic-oauth.mjs (OAuth) and anthropic.mjs (API key) import
6
- * parseSSEStream / _classifyMidstreamError / ANTHROPIC_MAX_MIDSTREAM_RETRIES
7
- * from here (via the anthropic-oauth.mjs re-export for legacy paths).
4
+ * Shared by both Anthropic providers. anthropic-oauth.mjs retains its public
5
+ * re-exports for provider test and integration entry points.
8
6
  */
9
7
  import { randomBytes } from 'crypto';
10
8
  import {
@@ -176,7 +174,10 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
176
174
  content += emit;
177
175
  try { onStreamDelta?.('text'); } catch {}
178
176
  if (onTextDelta) {
179
- if (state) state.emittedText = true;
177
+ if (state) {
178
+ state.emittedText = true;
179
+ state.emittedTextChars = (Number(state.emittedTextChars) || 0) + emit.length;
180
+ }
180
181
  try { onTextDelta(emit); } catch {}
181
182
  }
182
183
  }
@@ -460,7 +461,10 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
460
461
  } else {
461
462
  content += delta.text || '';
462
463
  if (delta.text && onTextDelta) {
463
- if (state) state.emittedText = true;
464
+ if (state) {
465
+ state.emittedText = true;
466
+ state.emittedTextChars = (Number(state.emittedTextChars) || 0) + delta.text.length;
467
+ }
464
468
  try { onTextDelta(delta.text); } catch {}
465
469
  }
466
470
  if (delta.text) {