@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.
- package/dist/agent/capture.js +98 -0
- package/dist/agent/capture.js.map +1 -0
- package/dist/agent/client-pool.js +115 -0
- package/dist/agent/client-pool.js.map +1 -0
- package/dist/agent/conversation-branch.js +50 -0
- package/dist/agent/conversation-branch.js.map +1 -0
- package/dist/agent/tools-schema.js +16 -3
- package/dist/agent/tools-schema.js.map +1 -1
- package/dist/agent.js +320 -118
- package/dist/agent.js.map +1 -1
- package/dist/bot/basic-commands.js +8 -0
- package/dist/bot/basic-commands.js.map +1 -1
- package/dist/bot/budget-command.js +74 -0
- package/dist/bot/budget-command.js.map +1 -0
- package/dist/bot/capture-commands.js +82 -0
- package/dist/bot/capture-commands.js.map +1 -0
- package/dist/bot/command-logic.js +6 -0
- package/dist/bot/command-logic.js.map +1 -1
- package/dist/bot/commands.js +90 -1
- package/dist/bot/commands.js.map +1 -1
- package/dist/bot/cost-command.js +80 -0
- package/dist/bot/cost-command.js.map +1 -0
- package/dist/bot/diff-command.js +48 -0
- package/dist/bot/diff-command.js.map +1 -0
- package/dist/bot/discord-commands.js +36 -1
- package/dist/bot/discord-commands.js.map +1 -1
- package/dist/bot/metrics-command.js +51 -0
- package/dist/bot/metrics-command.js.map +1 -0
- package/dist/bot/rollback-command.js +33 -0
- package/dist/bot/rollback-command.js.map +1 -0
- package/dist/bot/telegram.js +9 -1
- package/dist/bot/telegram.js.map +1 -1
- package/dist/cli/commands/editing.js +11 -2
- package/dist/cli/commands/editing.js.map +1 -1
- package/dist/config.js +27 -0
- package/dist/config.js.map +1 -1
- package/dist/progress/turn-progress.js +203 -129
- package/dist/progress/turn-progress.js.map +1 -1
- package/dist/routing/hysteresis.js +69 -0
- package/dist/routing/hysteresis.js.map +1 -0
- package/dist/tools/transaction.js +60 -0
- package/dist/tools/transaction.js.map +1 -0
- 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
|
|
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
|
|
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
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
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(
|
|
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
|
-
|
|
1355
|
-
|
|
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 =
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
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
|
-
|
|
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 = `${
|
|
1530
|
+
userContent = `${prelude}\n\n${instruction}`;
|
|
1521
1531
|
}
|
|
1522
1532
|
else {
|
|
1523
|
-
userContent = [{ type: 'text', text:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
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
|
-
|
|
2264
|
-
|
|
2364
|
+
if (isContextWindowExceededError(providerErr)) {
|
|
2365
|
+
throw providerErr;
|
|
2265
2366
|
}
|
|
2266
|
-
probedEndpoints.add(endpointKey);
|
|
2267
2367
|
}
|
|
2268
2368
|
}
|
|
2269
|
-
|
|
2270
|
-
|
|
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
|
-
|
|
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
|
|
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
|
},
|