@visorcraft/idlehands 2.2.5 → 2.2.8

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 (43) hide show
  1. package/dist/agent/capture.js +98 -0
  2. package/dist/agent/capture.js.map +1 -0
  3. package/dist/agent/client-pool.js +115 -0
  4. package/dist/agent/client-pool.js.map +1 -0
  5. package/dist/agent/conversation-branch.js +50 -0
  6. package/dist/agent/conversation-branch.js.map +1 -0
  7. package/dist/agent/tools-schema.js +16 -3
  8. package/dist/agent/tools-schema.js.map +1 -1
  9. package/dist/agent.js +320 -118
  10. package/dist/agent.js.map +1 -1
  11. package/dist/bot/basic-commands.js +8 -0
  12. package/dist/bot/basic-commands.js.map +1 -1
  13. package/dist/bot/budget-command.js +74 -0
  14. package/dist/bot/budget-command.js.map +1 -0
  15. package/dist/bot/capture-commands.js +82 -0
  16. package/dist/bot/capture-commands.js.map +1 -0
  17. package/dist/bot/command-logic.js +6 -0
  18. package/dist/bot/command-logic.js.map +1 -1
  19. package/dist/bot/commands.js +90 -1
  20. package/dist/bot/commands.js.map +1 -1
  21. package/dist/bot/cost-command.js +80 -0
  22. package/dist/bot/cost-command.js.map +1 -0
  23. package/dist/bot/diff-command.js +48 -0
  24. package/dist/bot/diff-command.js.map +1 -0
  25. package/dist/bot/discord-commands.js +36 -1
  26. package/dist/bot/discord-commands.js.map +1 -1
  27. package/dist/bot/metrics-command.js +51 -0
  28. package/dist/bot/metrics-command.js.map +1 -0
  29. package/dist/bot/rollback-command.js +33 -0
  30. package/dist/bot/rollback-command.js.map +1 -0
  31. package/dist/bot/telegram.js +9 -1
  32. package/dist/bot/telegram.js.map +1 -1
  33. package/dist/cli/commands/editing.js +11 -2
  34. package/dist/cli/commands/editing.js.map +1 -1
  35. package/dist/config.js +27 -0
  36. package/dist/config.js.map +1 -1
  37. package/dist/progress/turn-progress.js +203 -129
  38. package/dist/progress/turn-progress.js.map +1 -1
  39. package/dist/routing/hysteresis.js +69 -0
  40. package/dist/routing/hysteresis.js.map +1 -0
  41. package/dist/tools/transaction.js +60 -0
  42. package/dist/tools/transaction.js.map +1 -0
  43. package/package.json +1 -1
package/dist/agent.js CHANGED
@@ -16,6 +16,9 @@ import { PromptGuard } from './security/prompt-guard.js';
16
16
  import { ResponseCache } from './agent/response-cache.js';
17
17
  import { resilientCall } from './agent/resilient-provider.js';
18
18
  import { ToolLoopGuard } from './agent/tool-loop-guard.js';
19
+ import { CaptureManager } from './agent/capture.js';
20
+ import { ClientPool } from './agent/client-pool.js';
21
+ import { ConversationBranch } from './agent/conversation-branch.js';
19
22
  import { isLspTool, isMutationTool, isReadOnlyTool, planModeSummary } from './agent/tool-policy.js';
20
23
  import { buildToolsSchema } from './agent/tools-schema.js';
21
24
  import { OpenAIClient } from './client.js';
@@ -33,10 +36,12 @@ import { BASE_MAX_TOKENS, deriveContextWindow, deriveGenerationParams, supportsV
33
36
  import { ReplayStore } from './replay.js';
34
37
  import { checkExecSafety, checkPathSafety } from './safety.js';
35
38
  import { decideTurnRoute } from './routing/turn-router.js';
39
+ import { RouteHysteresis } from './routing/hysteresis.js';
36
40
  import { normalizeApprovalMode } from './shared/config-utils.js';
37
41
  import { collectSnapshot } from './sys/context.js';
38
42
  import { ToolError, ValidationError } from './tools/tool-error.js';
39
43
  import * as tools from './tools.js';
44
+ import { EditTransaction } from './tools/transaction.js';
40
45
  import { stateDir, timestampedId } from './utils.js';
41
46
  import { VaultStore } from './vault.js';
42
47
  export { parseToolCallsFromContent };
@@ -219,13 +224,19 @@ export async function createSession(opts) {
219
224
  const mcpHasEnabledTools = (mcpManager?.listTools().length ?? 0) > 0;
220
225
  const mcpLazySchemaMode = Boolean(mcpManager && mcpHasEnabledTools);
221
226
  let mcpToolsLoaded = !mcpLazySchemaMode;
222
- const getToolsSchema = () => buildToolsSchema({
227
+ const routeHysteresis = new RouteHysteresis({
228
+ minDwell: cfg.routing?.hysteresisMinDwell ?? 2,
229
+ enabled: cfg.routing?.hysteresis !== false,
230
+ });
231
+ const conversationBranch = new ConversationBranch();
232
+ const getToolsSchema = (slimFast) => buildToolsSchema({
223
233
  activeVaultTools,
224
234
  passiveVault: !activeVaultTools && vaultEnabled && vaultMode === 'passive',
225
235
  sysMode: cfg.mode === 'sys',
226
236
  lspTools: lspManager?.hasServers() === true,
227
237
  mcpTools: mcpToolsLoaded ? (mcpManager?.getEnabledToolSchemas() ?? []) : [],
228
238
  allowSpawnTask: spawnTaskEnabled,
239
+ slimFast,
229
240
  });
230
241
  const vault = vaultEnabled
231
242
  ? (opts.runtime?.vault ??
@@ -394,6 +405,27 @@ export async function createSession(opts) {
394
405
  console.warn(`[warn] sys-eager snapshot failed: ${e?.message ?? e}`);
395
406
  }
396
407
  }
408
+ const buildCompactSessionMeta = () => {
409
+ const caps = [];
410
+ if (vaultEnabled)
411
+ caps.push('vault');
412
+ if (lspManager?.hasServers())
413
+ caps.push('lsp');
414
+ if (mcpManager)
415
+ caps.push('mcp');
416
+ if (spawnTaskEnabled)
417
+ caps.push('subagents');
418
+ const lines = [
419
+ `[cwd: ${cfg.dir}]`,
420
+ `[harness: ${harness.id}]`,
421
+ '[fast-lane prelude: concise response by default; ask for details if needed.]',
422
+ caps.length ? `[optional capabilities: ${caps.join(', ')}]` : '',
423
+ ].filter(Boolean);
424
+ const maxChars = cfg.routing?.fastCompactPreludeMaxChars ?? 320;
425
+ const joined = lines.join('\n');
426
+ return joined.length > maxChars ? `${joined.slice(0, maxChars - 1)}…` : joined;
427
+ };
428
+ const compactSessionMeta = buildCompactSessionMeta();
397
429
  const defaultSystemPromptBase = SYSTEM_PROMPT;
398
430
  let activeSystemPromptBase = (cfg.system_prompt_override ?? '').trim() || defaultSystemPromptBase;
399
431
  let systemPromptOverridden = (cfg.system_prompt_override ?? '').trim().length > 0;
@@ -431,6 +463,8 @@ export async function createSession(opts) {
431
463
  lastEditedPath = undefined;
432
464
  initialConnectionProbeDone = false;
433
465
  mcpToolsLoaded = !mcpLazySchemaMode;
466
+ routeHysteresis.reset();
467
+ conversationBranch.reset();
434
468
  };
435
469
  const restore = (next) => {
436
470
  if (!Array.isArray(next) || next.length < 2) {
@@ -456,6 +490,7 @@ export async function createSession(opts) {
456
490
  let inFlight = null;
457
491
  let initialConnectionProbeDone = false;
458
492
  let lastEditedPath;
493
+ let lastTurnTransaction;
459
494
  // Plan mode state (Phase 8)
460
495
  let planSteps = [];
461
496
  // Sub-agent queue state (Phase 18): enforce sequential execution on single-GPU setups.
@@ -1058,6 +1093,7 @@ export async function createSession(opts) {
1058
1093
  const ppSamples = [];
1059
1094
  const tgSamples = [];
1060
1095
  let lastTurnMetrics;
1096
+ let lastTurnDebug;
1061
1097
  let lastServerHealth;
1062
1098
  let lastToolLoopStats = {
1063
1099
  totalHistory: 0,
@@ -1076,70 +1112,35 @@ export async function createSession(opts) {
1076
1112
  },
1077
1113
  };
1078
1114
  let lastModelsProbeMs = 0;
1079
- const capturesDir = path.join(stateDir(), 'captures');
1080
- let captureEnabled = false;
1081
- let capturePath;
1082
- let lastCaptureRecord = null;
1083
- const routedClients = new Map();
1084
- const probedEndpoints = new Set();
1115
+ const capture = new CaptureManager(stateDir());
1085
1116
  const normalizeEndpoint = (endpoint) => endpoint.trim().replace(/\/+$/, '');
1086
- const defaultCapturePath = () => {
1087
- const stamp = new Date().toISOString().replace(/[:.]/g, '-');
1088
- return path.join(capturesDir, `${stamp}.jsonl`);
1089
- };
1090
- const appendCaptureRecord = async (record, outPath) => {
1091
- await fs.mkdir(path.dirname(outPath), { recursive: true });
1092
- await fs.appendFile(outPath, JSON.stringify(record) + '\n', 'utf8');
1093
- };
1117
+ const clientPool = new ClientPool({
1118
+ primary: client,
1119
+ primaryEndpoint: cfg.endpoint,
1120
+ apiKey: opts.apiKey,
1121
+ cfg,
1122
+ capture,
1123
+ ClientCtor: OpenAIClient,
1124
+ });
1125
+ // Thin wrapper used by setEndpoint when primary client is replaced.
1094
1126
  const applyClientRuntimeOptions = (target) => {
1095
- if (typeof target.setVerbose === 'function') {
1127
+ if (typeof target.setVerbose === 'function')
1096
1128
  target.setVerbose(cfg.verbose);
1097
- }
1098
- if (typeof cfg.response_timeout === 'number' && cfg.response_timeout > 0) {
1129
+ if (typeof cfg.response_timeout === 'number' && cfg.response_timeout > 0)
1099
1130
  target.setResponseTimeout(cfg.response_timeout);
1100
- }
1101
- if (typeof target.setConnectionTimeout === 'function' &&
1102
- typeof cfg.connection_timeout === 'number' &&
1103
- cfg.connection_timeout > 0) {
1131
+ if (typeof target.setConnectionTimeout === 'function' && typeof cfg.connection_timeout === 'number' && cfg.connection_timeout > 0)
1104
1132
  target.setConnectionTimeout(cfg.connection_timeout);
1105
- }
1106
- if (typeof target.setInitialConnectionCheck === 'function' &&
1107
- typeof cfg.initial_connection_check === 'boolean') {
1133
+ if (typeof target.setInitialConnectionCheck === 'function' && typeof cfg.initial_connection_check === 'boolean')
1108
1134
  target.setInitialConnectionCheck(cfg.initial_connection_check);
1109
- }
1110
- if (typeof target.setInitialConnectionProbeTimeout === 'function' &&
1111
- typeof cfg.initial_connection_timeout === 'number' &&
1112
- cfg.initial_connection_timeout > 0) {
1135
+ if (typeof target.setInitialConnectionProbeTimeout === 'function' && typeof cfg.initial_connection_timeout === 'number' && cfg.initial_connection_timeout > 0)
1113
1136
  target.setInitialConnectionProbeTimeout(cfg.initial_connection_timeout);
1114
- }
1115
1137
  };
1116
1138
  const attachCaptureHook = (target) => {
1117
1139
  if (typeof target.setExchangeHook !== 'function')
1118
1140
  return;
1119
- target.setExchangeHook(async (record) => {
1120
- lastCaptureRecord = record;
1121
- if (!captureEnabled)
1122
- return;
1123
- const outFile = capturePath || defaultCapturePath();
1124
- capturePath = outFile;
1125
- await appendCaptureRecord(record, outFile);
1126
- });
1127
- };
1128
- const getClientForEndpoint = (endpoint) => {
1129
- if (!endpoint)
1130
- return client;
1131
- const normalized = normalizeEndpoint(endpoint);
1132
- if (!normalized || normalized === normalizeEndpoint(cfg.endpoint))
1133
- return client;
1134
- const existing = routedClients.get(normalized);
1135
- if (existing)
1136
- return existing;
1137
- const routed = new OpenAIClient(normalized, opts.apiKey, cfg.verbose);
1138
- applyClientRuntimeOptions(routed);
1139
- attachCaptureHook(routed);
1140
- routedClients.set(normalized, routed);
1141
- return routed;
1141
+ target.setExchangeHook(capture.createExchangeHook());
1142
1142
  };
1143
+ const getClientForEndpoint = (endpoint) => clientPool.getForEndpoint(endpoint);
1143
1144
  let runtimeRoutingModules = null;
1144
1145
  let runtimeRoutingUnavailable = false;
1145
1146
  let runtimeModelIdsCache = null;
@@ -1351,8 +1352,8 @@ export async function createSession(opts) {
1351
1352
  client = new OpenAIClient(normalized, opts.apiKey, cfg.verbose);
1352
1353
  }
1353
1354
  applyClientRuntimeOptions(client);
1354
- routedClients.clear();
1355
- probedEndpoints.clear();
1355
+ clientPool.setPrimary(client);
1356
+ clientPool.reset();
1356
1357
  wireCaptureHook();
1357
1358
  modelsList = normalizeModelsResponse(await client.models());
1358
1359
  const chosen = modelName?.trim()
@@ -1361,25 +1362,12 @@ export async function createSession(opts) {
1361
1362
  (await autoPickModel(client, modelsList)));
1362
1363
  setModel(chosen);
1363
1364
  };
1364
- const captureOn = async (filePath) => {
1365
- const target = filePath?.trim() ? path.resolve(filePath) : defaultCapturePath();
1366
- await fs.mkdir(path.dirname(target), { recursive: true });
1367
- await fs.appendFile(target, '', 'utf8');
1368
- captureEnabled = true;
1369
- capturePath = target;
1370
- return target;
1371
- };
1372
- const captureOff = () => {
1373
- captureEnabled = false;
1374
- };
1375
- const captureLast = async (filePath) => {
1376
- if (!lastCaptureRecord) {
1377
- throw new Error('No captured request/response pair is available yet.');
1378
- }
1379
- const target = filePath?.trim() ? path.resolve(filePath) : capturePath || defaultCapturePath();
1380
- await appendCaptureRecord(lastCaptureRecord, target);
1381
- return target;
1382
- };
1365
+ const captureOn = (filePath) => capture.on(filePath);
1366
+ const captureOff = () => capture.off();
1367
+ const captureSetRedact = (enabled) => capture.setRedact(enabled);
1368
+ const captureGetRedact = () => capture.getRedact();
1369
+ const captureOpen = () => capture.open();
1370
+ const captureLast = (filePath) => capture.last(filePath);
1383
1371
  const listMcpServers = () => {
1384
1372
  return mcpManager?.listServers() ?? [];
1385
1373
  };
@@ -1410,7 +1398,7 @@ export async function createSession(opts) {
1410
1398
  const close = async () => {
1411
1399
  await mcpManager?.close().catch(() => { });
1412
1400
  await lspManager?.close().catch(() => { });
1413
- routedClients.clear();
1401
+ await clientPool.closeAll();
1414
1402
  vault?.close();
1415
1403
  lens?.close();
1416
1404
  };
@@ -1511,16 +1499,38 @@ export async function createSession(opts) {
1511
1499
  : cfg.max_iterations;
1512
1500
  const wallStart = Date.now();
1513
1501
  const delegationForbiddenByUser = userDisallowsDelegation(instruction);
1502
+ const rawInstructionText = userContentToText(instruction).trim();
1503
+ // Route early so first-turn prelude/tool choices can adapt.
1504
+ const turnRoute = decideTurnRoute(cfg, rawInstructionText, model);
1505
+ // Apply hysteresis to suppress rapid lane thrashing in auto mode.
1506
+ const hysteresisResult = routeHysteresis.apply(turnRoute.selectedMode, turnRoute.selectedModeSource);
1507
+ if (hysteresisResult.suppressed) {
1508
+ // Override the selected mode with the hysteresis-stabilized lane.
1509
+ turnRoute.selectedMode = hysteresisResult.lane;
1510
+ turnRoute.selectedModeSource = 'hysteresis';
1511
+ }
1512
+ const routeFastByAuto = turnRoute.requestedMode === 'auto' &&
1513
+ turnRoute.selectedMode === 'fast' &&
1514
+ turnRoute.selectedModeSource !== 'override';
1515
+ const compactPreludeEnabled = cfg.routing?.fastCompactPrelude !== false;
1516
+ // Never use compact prelude when the harness injected format reminders
1517
+ // (e.g. tool_calls format for nemotron) — those are critical for correctness.
1518
+ const hasHarnessInjection = sessionMetaPending
1519
+ ? sessionMetaPending.includes('Use the tool_calls mechanism') ||
1520
+ sessionMetaPending.includes('[Format reminder]')
1521
+ : false;
1522
+ const useCompactPrelude = Boolean(sessionMetaPending && compactPreludeEnabled && routeFastByAuto && !hasHarnessInjection);
1514
1523
  // Prepend session meta to the first user instruction (§9b: variable context
1515
1524
  // goes in first user message, not system prompt, to preserve KV cache).
1516
1525
  // This avoids two consecutive user messages without an assistant response.
1517
1526
  let userContent = instruction;
1518
1527
  if (sessionMetaPending) {
1528
+ const prelude = useCompactPrelude ? compactSessionMeta : sessionMetaPending;
1519
1529
  if (typeof instruction === 'string') {
1520
- userContent = `${sessionMetaPending}\n\n${instruction}`;
1530
+ userContent = `${prelude}\n\n${instruction}`;
1521
1531
  }
1522
1532
  else {
1523
- userContent = [{ type: 'text', text: sessionMetaPending }, ...instruction];
1533
+ userContent = [{ type: 'text', text: prelude }, ...instruction];
1524
1534
  }
1525
1535
  sessionMetaPending = null;
1526
1536
  }
@@ -1559,6 +1569,8 @@ export async function createSession(opts) {
1559
1569
  // Vault search is best-effort; don't fail the turn
1560
1570
  }
1561
1571
  }
1572
+ // Save rollback checkpoint before this turn (captures pre-turn state).
1573
+ conversationBranch.checkpoint(messages.length, typeof instruction === 'string' ? instruction : '[multimodal]');
1562
1574
  messages.push({ role: 'user', content: userContent });
1563
1575
  const hookObj = typeof hooks === 'function' ? { onToken: hooks } : (hooks ?? {});
1564
1576
  let turns = 0;
@@ -1684,7 +1696,6 @@ export async function createSession(opts) {
1684
1696
  }
1685
1697
  return { text: finalText, turns, toolCalls };
1686
1698
  };
1687
- const rawInstructionText = userContentToText(instruction).trim();
1688
1699
  lastAskInstructionText = rawInstructionText;
1689
1700
  lastCompactionReminderObjective = '';
1690
1701
  if (hooksEnabled)
@@ -1699,7 +1710,7 @@ export async function createSession(opts) {
1699
1710
  await client.probeConnection();
1700
1711
  initialConnectionProbeDone = true;
1701
1712
  if (typeof client.getEndpoint === 'function') {
1702
- probedEndpoints.add(normalizeEndpoint(client.getEndpoint()));
1713
+ clientPool.markProbed(client.getEndpoint());
1703
1714
  }
1704
1715
  }
1705
1716
  }
@@ -1747,12 +1758,24 @@ export async function createSession(opts) {
1747
1758
  });
1748
1759
  return await finalizeAsk(miss);
1749
1760
  }
1750
- const turnRoute = decideTurnRoute(cfg, rawInstructionText, model);
1751
1761
  const primaryRoute = turnRoute.providerTargets[0];
1752
1762
  const runtimeModelIds = await loadRuntimeModelIds();
1753
1763
  const routeRuntimeFallbackModels = (primaryRoute?.fallbackModels ?? []).filter((m) => runtimeModelIds.has(m));
1754
- const routeApiFallbackModels = (primaryRoute?.fallbackModels ?? []).filter((m) => !runtimeModelIds.has(m));
1764
+ const apiProviderTargets = turnRoute.providerTargets.map((target) => ({
1765
+ ...target,
1766
+ fallbackModels: (target.fallbackModels ?? []).filter((m) => !runtimeModelIds.has(m)),
1767
+ }));
1768
+ const routeApiFallbackModels = apiProviderTargets[0]?.fallbackModels ?? [];
1755
1769
  const primaryUsesRuntimeModel = !!primaryRoute?.model && runtimeModelIds.has(primaryRoute.model);
1770
+ const fastLaneToolless = cfg.routing?.fastLaneToolless !== false &&
1771
+ routeFastByAuto &&
1772
+ turnRoute.classificationHint === 'fast';
1773
+ // Fast-lane slim tools: on subsequent turns of a fast-route ask, include only
1774
+ // read-only / lightweight tools to reduce per-turn token overhead (~40-50%).
1775
+ // Only active when the classifier explicitly said 'fast' (not heuristic/fallback).
1776
+ const fastLaneSlimTools = cfg.routing?.fastLaneSlimTools !== false &&
1777
+ routeFastByAuto &&
1778
+ turnRoute.classificationHint === 'fast';
1756
1779
  // Non-runtime route models can be selected directly in-session.
1757
1780
  if (!primaryUsesRuntimeModel && primaryRoute?.model && primaryRoute.model !== model) {
1758
1781
  setModel(primaryRoute.model);
@@ -1777,6 +1800,10 @@ export async function createSession(opts) {
1777
1800
  else if (routeApiFallbackModels.length) {
1778
1801
  routeParts.push(`api_fallbacks=${routeApiFallbackModels.join(',')}`);
1779
1802
  }
1803
+ if (useCompactPrelude)
1804
+ routeParts.push('compact_prelude=on');
1805
+ if (fastLaneToolless)
1806
+ routeParts.push('fast_toolless=on');
1780
1807
  console.error(`[routing] ${routeParts.join(' ')}`);
1781
1808
  }
1782
1809
  const persistReviewArtifact = async (finalText) => {
@@ -2176,10 +2203,38 @@ export async function createSession(opts) {
2176
2203
  let resp;
2177
2204
  try {
2178
2205
  try {
2179
- const toolsForTurn = cfg.no_tools || forceToollessRecoveryTurn
2206
+ // turns is 1-indexed (incremented at loop top), so first iteration = 1.
2207
+ const forceToollessByRouting = fastLaneToolless && turns === 1;
2208
+ // On fast-lane subsequent turns, slim the schema to read-only tools.
2209
+ const useSlimFast = !forceToollessByRouting && fastLaneSlimTools && turns > 1;
2210
+ const toolsForTurn = cfg.no_tools || forceToollessRecoveryTurn || forceToollessByRouting
2180
2211
  ? []
2181
- : getToolsSchema().filter((t) => !suppressedTools.has(t.function.name));
2182
- const toolChoiceForTurn = cfg.no_tools || forceToollessRecoveryTurn ? 'none' : 'auto';
2212
+ : getToolsSchema(useSlimFast).filter((t) => !suppressedTools.has(t.function.name));
2213
+ const toolChoiceForTurn = cfg.no_tools || forceToollessRecoveryTurn || forceToollessByRouting ? 'none' : 'auto';
2214
+ const promptBytesEstimate = Buffer.byteLength(JSON.stringify(messages), 'utf8');
2215
+ const toolSchemaBytesEstimate = toolsForTurn.length
2216
+ ? Buffer.byteLength(JSON.stringify(toolsForTurn), 'utf8')
2217
+ : 0;
2218
+ const toolSchemaTokenEstimate = estimateToolSchemaTokens(toolsForTurn);
2219
+ lastTurnDebug = {
2220
+ requestedMode: turnRoute.requestedMode,
2221
+ selectedMode: turnRoute.selectedMode,
2222
+ selectedModeSource: turnRoute.selectedModeSource,
2223
+ classificationHint: turnRoute.classificationHint,
2224
+ provider: primaryRoute?.name ?? 'default',
2225
+ model: primaryRoute?.model ?? model,
2226
+ runtimeRoute: primaryUsesRuntimeModel,
2227
+ compactPrelude: useCompactPrelude,
2228
+ fastLaneToolless,
2229
+ fastLaneSlimTools: useSlimFast,
2230
+ promptBytes: promptBytesEstimate,
2231
+ toolSchemaBytes: toolSchemaBytesEstimate,
2232
+ toolSchemaTokens: toolSchemaTokenEstimate,
2233
+ toolCount: toolsForTurn.length,
2234
+ };
2235
+ if (cfg.verbose) {
2236
+ console.error(`[turn-debug] prompt_bytes=${promptBytesEstimate} tools=${toolsForTurn.length} tool_schema_bytes=${toolSchemaBytesEstimate} tool_schema_tokens~=${toolSchemaTokenEstimate}`);
2237
+ }
2183
2238
  // ── Response cache: check for cached response ──────────────
2184
2239
  // Only cache tool-less turns (final answers, explanations) since
2185
2240
  // tool-calling turns have side effects that shouldn't be replayed.
@@ -2252,42 +2307,67 @@ export async function createSession(opts) {
2252
2307
  });
2253
2308
  }
2254
2309
  else {
2255
- const routeEndpoint = primaryRoute?.endpoint;
2256
- const activeClient = getClientForEndpoint(routeEndpoint);
2257
- const endpointKey = routeEndpoint ? normalizeEndpoint(routeEndpoint) : undefined;
2258
- if (endpointKey && !probedEndpoints.has(endpointKey)) {
2259
- if (typeof activeClient.probeConnection === 'function') {
2260
- try {
2261
- await activeClient.probeConnection();
2310
+ const isLikelyAuthError = (errMsg) => {
2311
+ const lower = errMsg.toLowerCase();
2312
+ return (lower.includes('refresh_token_reused') ||
2313
+ lower.includes('missing bearer') ||
2314
+ lower.includes('missing api key') ||
2315
+ lower.includes('invalid api key') ||
2316
+ lower.includes('authentication failed') ||
2317
+ lower.includes('unauthorized') ||
2318
+ lower.includes('forbidden') ||
2319
+ lower.includes('invalid token'));
2320
+ };
2321
+ const providerFailures = [];
2322
+ for (const target of apiProviderTargets.length
2323
+ ? apiProviderTargets
2324
+ : [{
2325
+ name: primaryRoute?.name ?? 'default',
2326
+ endpoint: primaryRoute?.endpoint,
2327
+ model: primaryRoute?.model ?? model,
2328
+ fallbackModels: routeApiFallbackModels,
2329
+ }]) {
2330
+ const routeEndpoint = target.endpoint;
2331
+ const activeClient = getClientForEndpoint(routeEndpoint);
2332
+ if (routeEndpoint) {
2333
+ await clientPool.probeIfNeeded(routeEndpoint);
2334
+ }
2335
+ const routeModel = target.model || model;
2336
+ const modelFallbackMap = {};
2337
+ if (target.fallbackModels?.length) {
2338
+ modelFallbackMap[routeModel] = target.fallbackModels;
2339
+ }
2340
+ try {
2341
+ resp = await resilientCall([
2342
+ {
2343
+ name: target.name ?? 'default',
2344
+ execute: (m) => activeClient.chatStream({ ...chatOptsBase, model: m }),
2345
+ },
2346
+ ], routeModel, {
2347
+ maxRetries: 0,
2348
+ modelFallbacks: modelFallbackMap,
2349
+ onRetry: (info) => {
2350
+ if (cfg.verbose) {
2351
+ console.error(`[routing] retry: provider=${info.provider} model=${info.model} attempt=${info.attempt}/${info.maxAttempts} reason=${info.reason}`);
2352
+ }
2353
+ },
2354
+ });
2355
+ break;
2356
+ }
2357
+ catch (providerErr) {
2358
+ const errMsg = String(providerErr?.message ?? providerErr ?? 'unknown error');
2359
+ const compactErr = errMsg.replace(/\s+/g, ' ').trim();
2360
+ providerFailures.push(`${target.name}: ${compactErr}`);
2361
+ if (cfg.verbose && isLikelyAuthError(errMsg)) {
2362
+ console.warn(`[routing] auth/provider failure on ${target.name}; trying next provider fallback`);
2262
2363
  }
2263
- catch {
2264
- // best-effort: if probe fails we still try the call
2364
+ if (isContextWindowExceededError(providerErr)) {
2365
+ throw providerErr;
2265
2366
  }
2266
- probedEndpoints.add(endpointKey);
2267
2367
  }
2268
2368
  }
2269
- const routeModel = primaryRoute?.model ?? model;
2270
- if (routeApiFallbackModels.length > 0) {
2271
- const modelFallbackMap = {
2272
- [routeModel]: routeApiFallbackModels,
2273
- };
2274
- resp = await resilientCall([
2275
- {
2276
- name: primaryRoute?.name ?? 'default',
2277
- execute: (m) => activeClient.chatStream({ ...chatOptsBase, model: m }),
2278
- },
2279
- ], routeModel, {
2280
- maxRetries: 1,
2281
- modelFallbacks: modelFallbackMap,
2282
- onRetry: (info) => {
2283
- if (cfg.verbose) {
2284
- console.error(`[routing] retry: provider=${info.provider} model=${info.model} attempt=${info.attempt}/${info.maxAttempts} reason=${info.reason}`);
2285
- }
2286
- },
2287
- });
2288
- }
2289
- else {
2290
- resp = await activeClient.chatStream({ ...chatOptsBase, model: routeModel });
2369
+ if (!resp) {
2370
+ throw new Error(`All routed providers failed for this turn. ${providerFailures.join(' | ')}`);
2291
2371
  }
2292
2372
  }
2293
2373
  } // end if (!resp) — cache miss path
@@ -2924,6 +3004,8 @@ export async function createSession(opts) {
2924
3004
  const absPath = args.path.startsWith('/')
2925
3005
  ? args.path
2926
3006
  : path.resolve(projectDir, args.path);
3007
+ // Track in turn transaction for potential atomic rollback.
3008
+ turnTransaction.track(absPath);
2927
3009
  // ── Pre-dispatch: block edits to files in a mutation spiral ──
2928
3010
  if (fileMutationBlocked.has(absPath)) {
2929
3011
  const basename = path.basename(absPath);
@@ -3059,6 +3141,7 @@ export async function createSession(opts) {
3059
3141
  let content = '';
3060
3142
  let reusedCachedReadOnlyExec = false;
3061
3143
  let reusedCachedReadTool = false;
3144
+ let toolFallbackNote = null;
3062
3145
  if (name === 'exec' && repeatedReadOnlyExecSigs.has(sig)) {
3063
3146
  const cached = execObservationCacheBySig.get(sig);
3064
3147
  if (cached) {
@@ -3092,7 +3175,92 @@ export async function createSession(opts) {
3092
3175
  toolName: name,
3093
3176
  onToolStream: emitToolStream,
3094
3177
  };
3095
- const value = await builtInFn(callCtx, args);
3178
+ let value;
3179
+ try {
3180
+ value = await builtInFn(callCtx, args);
3181
+ }
3182
+ catch (err) {
3183
+ const msg = String(err?.message ?? err ?? '');
3184
+ // Fallback #1: edit_file mismatch -> targeted edit_range based on closest-match hint.
3185
+ const isEditMismatch = name === 'edit_file' && /edit_file:\s*old_text not found/i.test(msg);
3186
+ if (isEditMismatch && typeof args?.path === 'string') {
3187
+ const best = msg.match(/Closest match at line\s+(\d+)\s*\((\d+)% similarity\)/i);
3188
+ const bestLine = best ? Number.parseInt(best[1], 10) : NaN;
3189
+ const similarity = best ? Number.parseInt(best[2], 10) : NaN;
3190
+ const oldTextForRange = String(args?.old_text ?? '');
3191
+ const oldLineCount = Math.max(1, oldTextForRange.split(/\r?\n/).length);
3192
+ const endLine = Number.isFinite(bestLine)
3193
+ ? bestLine + oldLineCount - 1
3194
+ : Number.NaN;
3195
+ const editRangeFn = tools['edit_range'];
3196
+ if (editRangeFn &&
3197
+ Number.isFinite(bestLine) &&
3198
+ Number.isFinite(endLine) &&
3199
+ Number.isFinite(similarity) &&
3200
+ similarity >= 70) {
3201
+ const fallbackArgs = {
3202
+ path: args.path,
3203
+ start_line: bestLine,
3204
+ end_line: endLine,
3205
+ replacement: args.new_text,
3206
+ };
3207
+ if (cfg.verbose) {
3208
+ console.warn(`[edit_file] auto-fallback to edit_range at ${bestLine}-${endLine} (${similarity}% similarity)`);
3209
+ }
3210
+ value = await editRangeFn(callCtx, fallbackArgs);
3211
+ args = fallbackArgs;
3212
+ toolFallbackNote = 'auto edit_range fallback';
3213
+ }
3214
+ else {
3215
+ throw err;
3216
+ }
3217
+ }
3218
+ else {
3219
+ const isWriteRefusal = name === 'write_file' &&
3220
+ !args?.overwrite &&
3221
+ !args?.force &&
3222
+ /write_file:\s*refusing to overwrite existing non-empty file/i.test(msg);
3223
+ if (!isWriteRefusal)
3224
+ throw err;
3225
+ // Fallback #2 (preferred): rewrite existing file via edit_range first.
3226
+ const editRangeFn = tools['edit_range'];
3227
+ let usedEditRangeFallback = false;
3228
+ if (editRangeFn && typeof args?.path === 'string') {
3229
+ try {
3230
+ const absWritePath = args.path.startsWith('/')
3231
+ ? args.path
3232
+ : path.resolve(projectDir, args.path);
3233
+ const curText = await fs.readFile(absWritePath, 'utf8');
3234
+ const totalLines = Math.max(1, curText.split(/\r?\n/).length);
3235
+ const fallbackArgs = {
3236
+ path: args.path,
3237
+ start_line: 1,
3238
+ end_line: totalLines,
3239
+ replacement: args.content,
3240
+ };
3241
+ if (cfg.verbose) {
3242
+ console.warn(`[write_file] auto-fallback to edit_range for existing file (${totalLines} lines)`);
3243
+ }
3244
+ value = await editRangeFn(callCtx, fallbackArgs);
3245
+ args = fallbackArgs;
3246
+ toolFallbackNote = 'auto edit_range fallback';
3247
+ usedEditRangeFallback = true;
3248
+ }
3249
+ catch {
3250
+ // fall through to explicit overwrite retry below
3251
+ }
3252
+ }
3253
+ if (!usedEditRangeFallback) {
3254
+ const retryArgs = { ...args, overwrite: true };
3255
+ if (cfg.verbose) {
3256
+ console.warn('[write_file] auto-retrying with overwrite=true after explicit overwrite refusal');
3257
+ }
3258
+ value = await builtInFn(callCtx, retryArgs);
3259
+ args = retryArgs;
3260
+ toolFallbackNote = 'auto overwrite fallback';
3261
+ }
3262
+ }
3263
+ }
3096
3264
  content = typeof value === 'string' ? value : JSON.stringify(value);
3097
3265
  if (READ_FILE_CACHE_TOOLS.has(name) &&
3098
3266
  typeof content === 'string' &&
@@ -3178,6 +3346,9 @@ export async function createSession(opts) {
3178
3346
  let summary = reusedCachedReadOnlyExec
3179
3347
  ? 'cached read-only exec observation (unchanged)'
3180
3348
  : toolResultSummary(name, args, content, true);
3349
+ if (toolFallbackNote) {
3350
+ summary = `${summary} (${toolFallbackNote})`;
3351
+ }
3181
3352
  const resultEvent = {
3182
3353
  id: callId,
3183
3354
  name,
@@ -3326,6 +3497,7 @@ export async function createSession(opts) {
3326
3497
  return { id: callId, content: truncated.content };
3327
3498
  };
3328
3499
  const results = [];
3500
+ const turnTransaction = new EditTransaction();
3329
3501
  let invalidArgsThisTurn = false;
3330
3502
  // Helper: catch tool errors but re-throw AgentLoopBreak (those must break the outer loop)
3331
3503
  const catchToolError = async (e, tc) => {
@@ -3480,6 +3652,11 @@ export async function createSession(opts) {
3480
3652
  });
3481
3653
  }
3482
3654
  }
3655
+ // Store the turn transaction for potential post-turn rollback.
3656
+ if (turnTransaction.hasChanges) {
3657
+ turnTransaction.commit();
3658
+ lastTurnTransaction = turnTransaction;
3659
+ }
3483
3660
  // Bail immediately if cancelled during tool execution
3484
3661
  if (ac.signal.aborted)
3485
3662
  break;
@@ -3751,6 +3928,25 @@ export async function createSession(opts) {
3751
3928
  return currentContextTokens > 0 ? currentContextTokens : estimateTokensFromMessages(messages);
3752
3929
  },
3753
3930
  ask,
3931
+ rollbackLastTurnEdits: async () => {
3932
+ if (!lastTurnTransaction || !lastTurnTransaction.hasChanges) {
3933
+ return { ok: false, error: 'No file edits to roll back.' };
3934
+ }
3935
+ const tx = lastTurnTransaction;
3936
+ lastTurnTransaction = undefined;
3937
+ const callCtx = { cwd: projectDir, noConfirm: true, dryRun: false };
3938
+ const results = await tx.rollback(callCtx);
3939
+ return { ok: true, results };
3940
+ },
3941
+ rollback: () => {
3942
+ const cp = conversationBranch.rollback();
3943
+ if (!cp)
3944
+ return null;
3945
+ const removed = messages.length - cp.messageCount;
3946
+ messages.length = cp.messageCount;
3947
+ return { preview: cp.preview, removedMessages: removed };
3948
+ },
3949
+ listCheckpoints: () => conversationBranch.list(),
3754
3950
  setModel,
3755
3951
  setEndpoint,
3756
3952
  listModels,
@@ -3763,8 +3959,11 @@ export async function createSession(opts) {
3763
3959
  captureOn,
3764
3960
  captureOff,
3765
3961
  captureLast,
3962
+ captureSetRedact,
3963
+ captureGetRedact,
3964
+ captureOpen,
3766
3965
  get capturePath() {
3767
- return capturePath;
3966
+ return capture.path;
3768
3967
  },
3769
3968
  getSystemPrompt: () => messages[0]?.role === 'system' ? String(messages[0].content) : activeSystemPromptBase,
3770
3969
  setSystemPrompt,
@@ -3791,6 +3990,9 @@ export async function createSession(opts) {
3791
3990
  get lastTurnMetrics() {
3792
3991
  return lastTurnMetrics;
3793
3992
  },
3993
+ get lastTurnDebug() {
3994
+ return lastTurnDebug;
3995
+ },
3794
3996
  get lastServerHealth() {
3795
3997
  return lastServerHealth;
3796
3998
  },