@visorcraft/idlehands 1.1.6 → 1.1.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/README.md +32 -0
- package/dist/agent/formatting.js +251 -0
- package/dist/agent/formatting.js.map +1 -0
- package/dist/agent/review-artifact.js +147 -0
- package/dist/agent/review-artifact.js.map +1 -0
- package/dist/agent/tool-calls.js +226 -0
- package/dist/agent/tool-calls.js.map +1 -0
- package/dist/agent.js +314 -695
- package/dist/agent.js.map +1 -1
- package/dist/anton/controller.js +1 -1
- package/dist/anton/controller.js.map +1 -1
- package/dist/anton/lock.js +0 -3
- package/dist/anton/lock.js.map +1 -1
- package/dist/anton/parser.js +0 -1
- package/dist/anton/parser.js.map +1 -1
- package/dist/anton/reporter.js +1 -1
- package/dist/anton/reporter.js.map +1 -1
- package/dist/bot/commands.js +3 -2
- package/dist/bot/commands.js.map +1 -1
- package/dist/bot/confirm-telegram.js +2 -1
- package/dist/bot/confirm-telegram.js.map +1 -1
- package/dist/bot/discord-routing.js +179 -0
- package/dist/bot/discord-routing.js.map +1 -0
- package/dist/bot/discord-streaming.js +171 -0
- package/dist/bot/discord-streaming.js.map +1 -0
- package/dist/bot/discord.js +25 -221
- package/dist/bot/discord.js.map +1 -1
- package/dist/bot/format.js +2 -25
- package/dist/bot/format.js.map +1 -1
- package/dist/bot/telegram.js +56 -12
- package/dist/bot/telegram.js.map +1 -1
- package/dist/cli/args.js +4 -1
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/build-repl-context.js.map +1 -1
- package/dist/cli/command-registry.js +2 -1
- package/dist/cli/command-registry.js.map +1 -1
- package/dist/cli/command-utils.js +27 -0
- package/dist/cli/command-utils.js.map +1 -0
- package/dist/cli/commands/anton.js +3 -2
- package/dist/cli/commands/anton.js.map +1 -1
- package/dist/cli/commands/model.js +8 -7
- package/dist/cli/commands/model.js.map +1 -1
- package/dist/cli/commands/project.js +5 -4
- package/dist/cli/commands/project.js.map +1 -1
- package/dist/cli/commands/session.js +118 -8
- package/dist/cli/commands/session.js.map +1 -1
- package/dist/cli/commands/tools.js +4 -3
- package/dist/cli/commands/tools.js.map +1 -1
- package/dist/cli/input.js +2 -1
- package/dist/cli/input.js.map +1 -1
- package/dist/cli/repl-dispatch.js +85 -0
- package/dist/cli/repl-dispatch.js.map +1 -0
- package/dist/cli/runtime-cmds.js +7 -7
- package/dist/cli/runtime-cmds.js.map +1 -1
- package/dist/cli/service.js +0 -14
- package/dist/cli/service.js.map +1 -1
- package/dist/cli/setup.js +25 -5
- package/dist/cli/setup.js.map +1 -1
- package/dist/cli/watch.js +2 -1
- package/dist/cli/watch.js.map +1 -1
- package/dist/client.js +51 -4
- package/dist/client.js.map +1 -1
- package/dist/config.js +79 -0
- package/dist/config.js.map +1 -1
- package/dist/context.js +101 -10
- package/dist/context.js.map +1 -1
- package/dist/harnesses.js +1 -1
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks/index.js +5 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/hooks/loader.js +58 -0
- package/dist/hooks/loader.js.map +1 -0
- package/dist/hooks/manager.js +180 -0
- package/dist/hooks/manager.js.map +1 -0
- package/dist/hooks/plugins/example-console.js +24 -0
- package/dist/hooks/plugins/example-console.js.map +1 -0
- package/dist/hooks/scaffold.js +53 -0
- package/dist/hooks/scaffold.js.map +1 -0
- package/dist/hooks/types.js +8 -0
- package/dist/hooks/types.js.map +1 -0
- package/dist/index.js +16 -64
- package/dist/index.js.map +1 -1
- package/dist/progress/agent-hooks.js +37 -0
- package/dist/progress/agent-hooks.js.map +1 -0
- package/dist/progress/ir.js +7 -0
- package/dist/progress/ir.js.map +1 -0
- package/dist/progress/progress-message-renderer.js +63 -0
- package/dist/progress/progress-message-renderer.js.map +1 -0
- package/dist/progress/serialize-discord.js +60 -0
- package/dist/progress/serialize-discord.js.map +1 -0
- package/dist/progress/serialize-telegram.js +55 -0
- package/dist/progress/serialize-telegram.js.map +1 -0
- package/dist/progress/serialize-tui.js +39 -0
- package/dist/progress/serialize-tui.js.map +1 -0
- package/dist/progress/tool-summary.js +58 -0
- package/dist/progress/tool-summary.js.map +1 -0
- package/dist/progress/tool-tail.js +48 -0
- package/dist/progress/tool-tail.js.map +1 -0
- package/dist/progress/turn-progress.js +215 -0
- package/dist/progress/turn-progress.js.map +1 -0
- package/dist/replay.js +2 -5
- package/dist/replay.js.map +1 -1
- package/dist/safety.js +0 -1
- package/dist/safety.js.map +1 -1
- package/dist/spinner.js +8 -0
- package/dist/spinner.js.map +1 -1
- package/dist/tools.js +422 -29
- package/dist/tools.js.map +1 -1
- package/dist/tui/branch-picker.js.map +1 -1
- package/dist/tui/command-handler.js.map +1 -1
- package/dist/tui/controller.js +417 -33
- package/dist/tui/controller.js.map +1 -1
- package/dist/tui/keymap.js +15 -0
- package/dist/tui/keymap.js.map +1 -1
- package/dist/tui/render.js +115 -3
- package/dist/tui/render.js.map +1 -1
- package/dist/tui/state.js +82 -1
- package/dist/tui/state.js.map +1 -1
- package/dist/upgrade.js.map +1 -1
- package/dist/utils.js +17 -0
- package/dist/utils.js.map +1 -1
- package/package.json +1 -1
package/dist/agent.js
CHANGED
|
@@ -3,6 +3,7 @@ import { enforceContextBudget, stripThinking, estimateTokensFromMessages, estima
|
|
|
3
3
|
import * as tools from './tools.js';
|
|
4
4
|
import { selectHarness } from './harnesses.js';
|
|
5
5
|
import { BASE_MAX_TOKENS, deriveContextWindow, deriveGenerationParams, supportsVisionModel } from './model-customization.js';
|
|
6
|
+
import { HookManager, loadHookPlugins } from './hooks/index.js';
|
|
6
7
|
import { checkExecSafety, checkPathSafety } from './safety.js';
|
|
7
8
|
import { loadProjectContext } from './context.js';
|
|
8
9
|
import { loadGitContext, isGitDirty, stashWorkingTree } from './git.js';
|
|
@@ -13,123 +14,18 @@ import { LensStore } from './lens.js';
|
|
|
13
14
|
import { SYS_CONTEXT_SCHEMA, collectSnapshot } from './sys/context.js';
|
|
14
15
|
import { MCPManager } from './mcp.js';
|
|
15
16
|
import { LspManager, detectInstalledLspServers } from './lsp.js';
|
|
17
|
+
import { generateMinimalDiff, toolResultSummary, execCommandFromSig, formatDurationMs, looksLikePlanningNarration, capTextByApproxTokens, isLikelyBinaryBuffer, sanitizePathsInMessage, digestToolResult, } from './agent/formatting.js';
|
|
18
|
+
import { parseToolCallsFromContent, getMissingRequiredParams, stripMarkdownFences } from './agent/tool-calls.js';
|
|
19
|
+
export { parseToolCallsFromContent };
|
|
20
|
+
import { reviewArtifactKeys, looksLikeCodeReviewRequest, looksLikeReviewRetrievalRequest, retrievalAllowsStaleArtifact, parseReviewArtifactStalePolicy, parseReviewArtifact, reviewArtifactStaleReason, gitHead, normalizeModelsResponse, } from './agent/review-artifact.js';
|
|
16
21
|
import fs from 'node:fs/promises';
|
|
17
22
|
import path from 'node:path';
|
|
18
|
-
import {
|
|
19
|
-
import { stateDir, BASH_PATH as BASH } from './utils.js';
|
|
23
|
+
import { stateDir, timestampedId } from './utils.js';
|
|
20
24
|
function makeAbortController() {
|
|
21
25
|
// Node 24: AbortController is global.
|
|
22
26
|
return new AbortController();
|
|
23
27
|
}
|
|
24
|
-
/** Generate a minimal unified diff for Phase 7 rich display (max 20 lines, truncated). */
|
|
25
|
-
function generateMinimalDiff(before, after, filePath) {
|
|
26
|
-
const bLines = before.split('\n');
|
|
27
|
-
const aLines = after.split('\n');
|
|
28
|
-
const out = [];
|
|
29
|
-
out.push(`--- a/${filePath}`);
|
|
30
|
-
out.push(`+++ b/${filePath}`);
|
|
31
|
-
// Simple line-by-line diff (find changed region)
|
|
32
|
-
let diffStart = 0;
|
|
33
|
-
while (diffStart < bLines.length && diffStart < aLines.length && bLines[diffStart] === aLines[diffStart])
|
|
34
|
-
diffStart++;
|
|
35
|
-
let bEnd = bLines.length - 1;
|
|
36
|
-
let aEnd = aLines.length - 1;
|
|
37
|
-
while (bEnd > diffStart && aEnd > diffStart && bLines[bEnd] === aLines[aEnd]) {
|
|
38
|
-
bEnd--;
|
|
39
|
-
aEnd--;
|
|
40
|
-
}
|
|
41
|
-
const contextBefore = Math.max(0, diffStart - 2);
|
|
42
|
-
const contextAfter = Math.min(Math.max(bLines.length, aLines.length) - 1, Math.max(bEnd, aEnd) + 2);
|
|
43
|
-
const bEndContext = Math.min(bLines.length - 1, contextAfter);
|
|
44
|
-
const aEndContext = Math.min(aLines.length - 1, contextAfter);
|
|
45
|
-
out.push(`@@ -${contextBefore + 1},${bEndContext - contextBefore + 1} +${contextBefore + 1},${aEndContext - contextBefore + 1} @@`);
|
|
46
|
-
let lineCount = 0;
|
|
47
|
-
const MAX_LINES = 20;
|
|
48
|
-
// Context before change
|
|
49
|
-
for (let i = contextBefore; i < diffStart && lineCount < MAX_LINES; i++) {
|
|
50
|
-
out.push(` ${bLines[i]}`);
|
|
51
|
-
lineCount++;
|
|
52
|
-
}
|
|
53
|
-
// Removed lines
|
|
54
|
-
for (let i = diffStart; i <= bEnd && i < bLines.length && lineCount < MAX_LINES; i++) {
|
|
55
|
-
out.push(`-${bLines[i]}`);
|
|
56
|
-
lineCount++;
|
|
57
|
-
}
|
|
58
|
-
// Added lines
|
|
59
|
-
for (let i = diffStart; i <= aEnd && i < aLines.length && lineCount < MAX_LINES; i++) {
|
|
60
|
-
out.push(`+${aLines[i]}`);
|
|
61
|
-
lineCount++;
|
|
62
|
-
}
|
|
63
|
-
// Context after change
|
|
64
|
-
const afterStart = Math.max(bEnd, aEnd) + 1;
|
|
65
|
-
for (let i = afterStart; i <= contextAfter && i < Math.max(bLines.length, aLines.length) && lineCount < MAX_LINES; i++) {
|
|
66
|
-
const line = i < aLines.length ? aLines[i] : bLines[i] ?? '';
|
|
67
|
-
out.push(` ${line}`);
|
|
68
|
-
lineCount++;
|
|
69
|
-
}
|
|
70
|
-
const totalChanges = (bEnd - diffStart + 1) + (aEnd - diffStart + 1);
|
|
71
|
-
if (lineCount >= MAX_LINES && totalChanges > MAX_LINES) {
|
|
72
|
-
out.push(`[+${totalChanges - MAX_LINES} more lines]`);
|
|
73
|
-
}
|
|
74
|
-
return out.join('\n');
|
|
75
|
-
}
|
|
76
|
-
/** Generate a one-line summary of a tool result for hooks/display. */
|
|
77
|
-
function toolResultSummary(name, args, content, success) {
|
|
78
|
-
if (!success)
|
|
79
|
-
return content.slice(0, 120);
|
|
80
|
-
switch (name) {
|
|
81
|
-
case 'read_file':
|
|
82
|
-
case 'read_files': {
|
|
83
|
-
const lines = content.split('\n').length;
|
|
84
|
-
return `${lines} lines read`;
|
|
85
|
-
}
|
|
86
|
-
case 'write_file':
|
|
87
|
-
return `wrote ${args.path || 'file'}`;
|
|
88
|
-
case 'edit_file':
|
|
89
|
-
return content.startsWith('ERROR') ? content.slice(0, 120) : `applied edit`;
|
|
90
|
-
case 'insert_file':
|
|
91
|
-
return `inserted at line ${args.line ?? '?'}`;
|
|
92
|
-
case 'exec': {
|
|
93
|
-
try {
|
|
94
|
-
const r = JSON.parse(content);
|
|
95
|
-
const lines = (r.out || '').split('\n').filter(Boolean).length;
|
|
96
|
-
return `rc=${r.rc}, ${lines} lines`;
|
|
97
|
-
}
|
|
98
|
-
catch {
|
|
99
|
-
return content.slice(0, 80);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
case 'list_dir': {
|
|
103
|
-
const entries = content.split('\n').filter(Boolean).length;
|
|
104
|
-
return `${entries} entries`;
|
|
105
|
-
}
|
|
106
|
-
case 'search_files': {
|
|
107
|
-
const matches = (content.match(/^\d+:/gm) || []).length;
|
|
108
|
-
return `${matches} matches`;
|
|
109
|
-
}
|
|
110
|
-
case 'spawn_task': {
|
|
111
|
-
const line = content.split(/\r?\n/).find((l) => l.includes('status='));
|
|
112
|
-
return line ? line.trim() : 'sub-agent task finished';
|
|
113
|
-
}
|
|
114
|
-
case 'vault_search':
|
|
115
|
-
return `vault results`;
|
|
116
|
-
default:
|
|
117
|
-
return content.slice(0, 80);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
28
|
const CACHED_EXEC_OBSERVATION_HINT = '[idlehands hint] Reused cached output for repeated read-only exec call (unchanged observation).';
|
|
121
|
-
function execCommandFromSig(sig) {
|
|
122
|
-
if (!sig.startsWith('exec:'))
|
|
123
|
-
return '';
|
|
124
|
-
const raw = sig.slice('exec:'.length);
|
|
125
|
-
try {
|
|
126
|
-
const parsed = JSON.parse(raw);
|
|
127
|
-
return typeof parsed?.command === 'string' ? parsed.command : '';
|
|
128
|
-
}
|
|
129
|
-
catch {
|
|
130
|
-
return '';
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
29
|
function looksLikeReadOnlyExecCommand(command) {
|
|
134
30
|
const cmd = String(command || '').trim().toLowerCase();
|
|
135
31
|
if (!cmd)
|
|
@@ -202,7 +98,7 @@ Rules:
|
|
|
202
98
|
- Never use spawn_task to bypass confirmation/safety restrictions (for example blocked package installs). If a command is blocked, adapt the plan or ask the user for approval mode changes.
|
|
203
99
|
- Read the target file before editing. You need the exact text for search/replace.
|
|
204
100
|
- Use read_file with search=... to jump to relevant code; avoid reading whole files.
|
|
205
|
-
-
|
|
101
|
+
- Prefer apply_patch or edit_range for code edits (token-efficient). Use edit_file only when exact old_text replacement is necessary.
|
|
206
102
|
- Use insert_file for insertions (prepend/append/line).
|
|
207
103
|
- Use exec to run commands, tests, builds; check results before reporting success.
|
|
208
104
|
- When running commands in a subdirectory, use exec's cwd parameter — NOT "cd /path && cmd". Each exec call is a fresh shell; cd does not persist.
|
|
@@ -229,7 +125,7 @@ const DEFAULT_SUB_AGENT_RESULT_TOKEN_CAP = 4000;
|
|
|
229
125
|
const APPROVAL_MODE_SET = new Set(['plan', 'reject', 'default', 'auto-edit', 'yolo']);
|
|
230
126
|
const LSP_TOOL_NAMES = ['lsp_diagnostics', 'lsp_symbols', 'lsp_hover', 'lsp_definition', 'lsp_references'];
|
|
231
127
|
const LSP_TOOL_NAME_SET = new Set(LSP_TOOL_NAMES);
|
|
232
|
-
const FILE_MUTATION_TOOL_SET = new Set(['edit_file', 'write_file', 'insert_file']);
|
|
128
|
+
const FILE_MUTATION_TOOL_SET = new Set(['edit_file', 'edit_range', 'apply_patch', 'write_file', 'insert_file']);
|
|
233
129
|
function normalizeApprovalMode(value) {
|
|
234
130
|
if (typeof value !== 'string')
|
|
235
131
|
return undefined;
|
|
@@ -245,66 +141,6 @@ const APPROVAL_MODE_RANK = { plan: 0, reject: 1, default: 2, 'auto-edit': 3, yol
|
|
|
245
141
|
function capApprovalMode(requested, parentMode) {
|
|
246
142
|
return APPROVAL_MODE_RANK[requested] <= APPROVAL_MODE_RANK[parentMode] ? requested : parentMode;
|
|
247
143
|
}
|
|
248
|
-
function formatDurationMs(ms) {
|
|
249
|
-
if (!Number.isFinite(ms) || ms <= 0)
|
|
250
|
-
return '0.0s';
|
|
251
|
-
return `${(ms / 1000).toFixed(1)}s`;
|
|
252
|
-
}
|
|
253
|
-
function looksLikePlanningNarration(text, finishReason) {
|
|
254
|
-
const s = String(text ?? '').trim().toLowerCase();
|
|
255
|
-
if (!s)
|
|
256
|
-
return false;
|
|
257
|
-
// Incomplete streamed answer: likely still needs another turn.
|
|
258
|
-
if (finishReason === 'length')
|
|
259
|
-
return true;
|
|
260
|
-
// Strong completion cues: treat as final answer.
|
|
261
|
-
if (/(^|\n)\s*(done|completed|finished|final answer|summary:)\b/.test(s))
|
|
262
|
-
return false;
|
|
263
|
-
// Typical "thinking out loud"/plan chatter that should continue with tools.
|
|
264
|
-
return /\b(let me|i(?:'|’)ll|i will|i'm going to|i am going to|next i(?:'|’)ll|first i(?:'|’)ll|i need to|i should|checking|reviewing|exploring|starting by)\b/.test(s);
|
|
265
|
-
}
|
|
266
|
-
function approxTokenCharCap(maxTokens) {
|
|
267
|
-
const safe = Math.max(64, Math.floor(maxTokens));
|
|
268
|
-
return safe * 4;
|
|
269
|
-
}
|
|
270
|
-
function capTextByApproxTokens(text, maxTokens) {
|
|
271
|
-
const raw = String(text ?? '');
|
|
272
|
-
const maxChars = approxTokenCharCap(maxTokens);
|
|
273
|
-
if (raw.length <= maxChars)
|
|
274
|
-
return { text: raw, truncated: false };
|
|
275
|
-
const clipped = raw.slice(0, maxChars);
|
|
276
|
-
return {
|
|
277
|
-
text: `${clipped}\n\n[sub-agent] result truncated to ~${maxTokens} tokens (${raw.length} chars original)`,
|
|
278
|
-
truncated: true,
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
function isLikelyBinaryBuffer(buf) {
|
|
282
|
-
const n = Math.min(buf.length, 512);
|
|
283
|
-
for (let i = 0; i < n; i++) {
|
|
284
|
-
if (buf[i] === 0)
|
|
285
|
-
return true;
|
|
286
|
-
}
|
|
287
|
-
return false;
|
|
288
|
-
}
|
|
289
|
-
/**
|
|
290
|
-
* Strip absolute paths from a message to prevent cross-project leaks in vault.
|
|
291
|
-
* Paths within cwd are replaced with relative equivalents; other absolute paths
|
|
292
|
-
* are replaced with just the basename.
|
|
293
|
-
*/
|
|
294
|
-
function sanitizePathsInMessage(message, cwd) {
|
|
295
|
-
const normCwd = cwd.replace(/\/+$/, '');
|
|
296
|
-
// Match absolute Unix paths (at least 2 segments)
|
|
297
|
-
return message.replace(/\/(?:home|tmp|var|usr|opt|etc|root)\/[^\s"',;)\]}>]+/g, (match) => {
|
|
298
|
-
const normMatch = match.replace(/\/+$/, '');
|
|
299
|
-
if (normMatch.startsWith(normCwd + '/')) {
|
|
300
|
-
// Within cwd — make relative
|
|
301
|
-
return normMatch.slice(normCwd.length + 1);
|
|
302
|
-
}
|
|
303
|
-
// Outside cwd — strip to basename
|
|
304
|
-
const base = path.basename(normMatch);
|
|
305
|
-
return base || match;
|
|
306
|
-
});
|
|
307
|
-
}
|
|
308
144
|
async function buildSubAgentContextBlock(cwd, rawFiles) {
|
|
309
145
|
const values = Array.isArray(rawFiles) ? rawFiles : [];
|
|
310
146
|
const files = values
|
|
@@ -384,155 +220,155 @@ function buildToolsSchema(opts) {
|
|
|
384
220
|
properties,
|
|
385
221
|
required
|
|
386
222
|
});
|
|
223
|
+
const str = () => ({ type: 'string' });
|
|
224
|
+
const bool = () => ({ type: 'boolean' });
|
|
225
|
+
const int = (min, max) => ({ type: 'integer', ...(min !== undefined && { minimum: min }), ...(max !== undefined && { maximum: max }) });
|
|
387
226
|
const schemas = [
|
|
227
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
228
|
+
// Token-safe reads (require limit; allow plain output without per-line numbers)
|
|
229
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
388
230
|
{
|
|
389
231
|
type: 'function',
|
|
390
232
|
function: {
|
|
391
233
|
name: 'read_file',
|
|
392
|
-
description: 'Read
|
|
234
|
+
description: 'Read a bounded slice of a file.',
|
|
393
235
|
parameters: obj({
|
|
394
|
-
path:
|
|
395
|
-
offset:
|
|
396
|
-
limit:
|
|
397
|
-
search:
|
|
398
|
-
context:
|
|
399
|
-
|
|
400
|
-
|
|
236
|
+
path: str(),
|
|
237
|
+
offset: int(1, 1_000_000),
|
|
238
|
+
limit: int(1, 240),
|
|
239
|
+
search: str(),
|
|
240
|
+
context: int(0, 80),
|
|
241
|
+
format: { type: 'string', enum: ['plain', 'numbered', 'sparse'] },
|
|
242
|
+
max_bytes: int(256, 20_000),
|
|
243
|
+
}, ['path', 'limit']),
|
|
244
|
+
},
|
|
401
245
|
},
|
|
402
246
|
{
|
|
403
247
|
type: 'function',
|
|
404
248
|
function: {
|
|
405
249
|
name: 'read_files',
|
|
406
|
-
description: 'Batch read
|
|
250
|
+
description: 'Batch read bounded file slices.',
|
|
407
251
|
parameters: obj({
|
|
408
252
|
requests: {
|
|
409
253
|
type: 'array',
|
|
410
254
|
items: obj({
|
|
411
|
-
path:
|
|
412
|
-
offset:
|
|
413
|
-
limit:
|
|
414
|
-
search:
|
|
415
|
-
context:
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
255
|
+
path: str(),
|
|
256
|
+
offset: int(1, 1_000_000),
|
|
257
|
+
limit: int(1, 240),
|
|
258
|
+
search: str(),
|
|
259
|
+
context: int(0, 80),
|
|
260
|
+
format: { type: 'string', enum: ['plain', 'numbered', 'sparse'] },
|
|
261
|
+
max_bytes: int(256, 20_000),
|
|
262
|
+
}, ['path', 'limit']),
|
|
263
|
+
},
|
|
264
|
+
}, ['requests']),
|
|
265
|
+
},
|
|
420
266
|
},
|
|
267
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
268
|
+
// Writes/edits
|
|
269
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
421
270
|
{
|
|
422
271
|
type: 'function',
|
|
423
272
|
function: {
|
|
424
273
|
name: 'write_file',
|
|
425
|
-
description: 'Write
|
|
426
|
-
parameters: obj({ path:
|
|
427
|
-
}
|
|
274
|
+
description: 'Write file (atomic, backup).',
|
|
275
|
+
parameters: obj({ path: str(), content: str() }, ['path', 'content']),
|
|
276
|
+
},
|
|
428
277
|
},
|
|
429
278
|
{
|
|
430
279
|
type: 'function',
|
|
431
280
|
function: {
|
|
432
|
-
name: '
|
|
433
|
-
description: '
|
|
281
|
+
name: 'apply_patch',
|
|
282
|
+
description: 'Apply unified diff patch (multi-file).',
|
|
434
283
|
parameters: obj({
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
}
|
|
284
|
+
patch: str(),
|
|
285
|
+
files: { type: 'array', items: str() },
|
|
286
|
+
strip: int(0, 5),
|
|
287
|
+
}, ['patch', 'files']),
|
|
288
|
+
},
|
|
441
289
|
},
|
|
442
290
|
{
|
|
443
291
|
type: 'function',
|
|
444
292
|
function: {
|
|
445
|
-
name: '
|
|
446
|
-
description: '
|
|
293
|
+
name: 'edit_range',
|
|
294
|
+
description: 'Replace a line range in a file.',
|
|
447
295
|
parameters: obj({
|
|
448
|
-
path:
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
296
|
+
path: str(),
|
|
297
|
+
start_line: int(1),
|
|
298
|
+
end_line: int(1),
|
|
299
|
+
replacement: str(),
|
|
300
|
+
}, ['path', 'start_line', 'end_line', 'replacement']),
|
|
301
|
+
},
|
|
453
302
|
},
|
|
303
|
+
{
|
|
304
|
+
type: 'function',
|
|
305
|
+
function: {
|
|
306
|
+
name: 'edit_file',
|
|
307
|
+
description: 'Legacy exact replace (requires old_text). Prefer apply_patch/edit_range.',
|
|
308
|
+
parameters: obj({ path: str(), old_text: str(), new_text: str(), replace_all: bool() }, ['path', 'old_text', 'new_text']),
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
type: 'function',
|
|
313
|
+
function: {
|
|
314
|
+
name: 'insert_file',
|
|
315
|
+
description: 'Insert text at line (0=prepend, -1=append).',
|
|
316
|
+
parameters: obj({ path: str(), line: int(), text: str() }, ['path', 'line', 'text']),
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
320
|
+
// Bounded listings/search (expose existing caps)
|
|
321
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
454
322
|
{
|
|
455
323
|
type: 'function',
|
|
456
324
|
function: {
|
|
457
325
|
name: 'list_dir',
|
|
458
|
-
description: 'List directory
|
|
459
|
-
parameters: obj({
|
|
460
|
-
|
|
461
|
-
recursive: { type: 'boolean' },
|
|
462
|
-
}, ['path'])
|
|
463
|
-
}
|
|
326
|
+
description: 'List directory entries.',
|
|
327
|
+
parameters: obj({ path: str(), recursive: bool(), max_entries: int(1, 500) }, ['path']),
|
|
328
|
+
},
|
|
464
329
|
},
|
|
465
330
|
{
|
|
466
331
|
type: 'function',
|
|
467
332
|
function: {
|
|
468
333
|
name: 'search_files',
|
|
469
|
-
description: 'Search
|
|
470
|
-
parameters: obj({
|
|
471
|
-
|
|
472
|
-
path: { type: 'string' },
|
|
473
|
-
include: { type: 'string' },
|
|
474
|
-
}, ['pattern', 'path'])
|
|
475
|
-
}
|
|
334
|
+
description: 'Search regex in files.',
|
|
335
|
+
parameters: obj({ pattern: str(), path: str(), include: str(), max_results: int(1, 100) }, ['pattern', 'path']),
|
|
336
|
+
},
|
|
476
337
|
},
|
|
338
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
339
|
+
// Exec (minified schema)
|
|
340
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
477
341
|
{
|
|
478
342
|
type: 'function',
|
|
479
343
|
function: {
|
|
480
344
|
name: 'exec',
|
|
481
|
-
description: 'Run
|
|
482
|
-
parameters: obj({
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
timeout: { type: 'integer', description: 'Timeout in seconds (default: 30, max: 120). Use 60-120 for npm install, builds, or test suites.' }
|
|
486
|
-
}, ['command'])
|
|
487
|
-
}
|
|
488
|
-
}
|
|
345
|
+
description: 'Run bash -c; returns JSON rc/out/err.',
|
|
346
|
+
parameters: obj({ command: str(), cwd: str(), timeout: int(1, 120) }, ['command']),
|
|
347
|
+
},
|
|
348
|
+
},
|
|
489
349
|
];
|
|
490
350
|
if (opts?.allowSpawnTask !== false) {
|
|
491
351
|
schemas.push({
|
|
492
352
|
type: 'function',
|
|
493
353
|
function: {
|
|
494
354
|
name: 'spawn_task',
|
|
495
|
-
description: '
|
|
355
|
+
description: 'Run a sub-agent task (no parent history).',
|
|
496
356
|
parameters: obj({
|
|
497
|
-
task:
|
|
498
|
-
context_files: {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
max_iterations: { type: 'integer', description: 'Optional max turn cap for the sub-agent' },
|
|
506
|
-
max_tokens: { type: 'integer', description: 'Optional max completion tokens for the sub-agent' },
|
|
507
|
-
timeout_sec: { type: 'integer', description: 'Optional timeout for this sub-agent run (seconds)' },
|
|
508
|
-
system_prompt: { type: 'string', description: 'Optional sub-agent system prompt override for this task' },
|
|
357
|
+
task: str(),
|
|
358
|
+
context_files: { type: 'array', items: str() },
|
|
359
|
+
model: str(),
|
|
360
|
+
endpoint: str(),
|
|
361
|
+
max_iterations: int(),
|
|
362
|
+
max_tokens: int(),
|
|
363
|
+
timeout_sec: int(),
|
|
364
|
+
system_prompt: str(),
|
|
509
365
|
approval_mode: { type: 'string', enum: ['plan', 'reject', 'default', 'auto-edit', 'yolo'] },
|
|
510
|
-
}, ['task'])
|
|
511
|
-
}
|
|
366
|
+
}, ['task']),
|
|
367
|
+
},
|
|
512
368
|
});
|
|
513
369
|
}
|
|
514
370
|
if (opts?.activeVaultTools) {
|
|
515
|
-
schemas.push({
|
|
516
|
-
type: 'function',
|
|
517
|
-
function: {
|
|
518
|
-
name: 'vault_search',
|
|
519
|
-
description: 'Search vault entries (notes and previous tool outputs) to reuse prior high-signal findings.',
|
|
520
|
-
parameters: obj({
|
|
521
|
-
query: { type: 'string' },
|
|
522
|
-
limit: { type: 'integer' }
|
|
523
|
-
}, ['query'])
|
|
524
|
-
}
|
|
525
|
-
}, {
|
|
526
|
-
type: 'function',
|
|
527
|
-
function: {
|
|
528
|
-
name: 'vault_note',
|
|
529
|
-
description: 'Persist a concise, high-signal note into the Trifecta vault.',
|
|
530
|
-
parameters: obj({
|
|
531
|
-
key: { type: 'string' },
|
|
532
|
-
value: { type: 'string' }
|
|
533
|
-
}, ['key', 'value'])
|
|
534
|
-
}
|
|
535
|
-
});
|
|
371
|
+
schemas.push({ type: 'function', function: { name: 'vault_search', description: 'Search vault.', parameters: obj({ query: str(), limit: int() }, ['query']) } }, { type: 'function', function: { name: 'vault_note', description: 'Write vault note.', parameters: obj({ key: str(), value: str() }, ['key', 'value']) } });
|
|
536
372
|
}
|
|
537
373
|
// Phase 9: sys_context tool is only available in sys mode.
|
|
538
374
|
if (opts?.sysMode) {
|
|
@@ -543,54 +379,36 @@ function buildToolsSchema(opts) {
|
|
|
543
379
|
type: 'function',
|
|
544
380
|
function: {
|
|
545
381
|
name: 'lsp_diagnostics',
|
|
546
|
-
description: 'Get
|
|
547
|
-
parameters: obj({
|
|
548
|
-
path: { type: 'string', description: 'File path (omit for project-wide diagnostics)' },
|
|
549
|
-
severity: { type: 'integer', description: '1=Error, 2=Warning, 3=Info, 4=Hint (default: config threshold)' },
|
|
550
|
-
}, [])
|
|
382
|
+
description: 'Get LSP diagnostics (errors/warnings) for file or project.',
|
|
383
|
+
parameters: obj({ path: str(), severity: int() }, [])
|
|
551
384
|
}
|
|
552
385
|
}, {
|
|
553
386
|
type: 'function',
|
|
554
387
|
function: {
|
|
555
388
|
name: 'lsp_symbols',
|
|
556
|
-
description: 'List
|
|
557
|
-
parameters: obj({
|
|
558
|
-
path: { type: 'string' },
|
|
559
|
-
}, ['path'])
|
|
389
|
+
description: 'List symbols (functions, classes, vars) in a file.',
|
|
390
|
+
parameters: obj({ path: str() }, ['path'])
|
|
560
391
|
}
|
|
561
392
|
}, {
|
|
562
393
|
type: 'function',
|
|
563
394
|
function: {
|
|
564
395
|
name: 'lsp_hover',
|
|
565
|
-
description: 'Get type
|
|
566
|
-
parameters: obj({
|
|
567
|
-
path: { type: 'string' },
|
|
568
|
-
line: { type: 'integer' },
|
|
569
|
-
character: { type: 'integer' },
|
|
570
|
-
}, ['path', 'line', 'character'])
|
|
396
|
+
description: 'Get type/docs for symbol at position.',
|
|
397
|
+
parameters: obj({ path: str(), line: int(), character: int() }, ['path', 'line', 'character'])
|
|
571
398
|
}
|
|
572
399
|
}, {
|
|
573
400
|
type: 'function',
|
|
574
401
|
function: {
|
|
575
402
|
name: 'lsp_definition',
|
|
576
|
-
description: 'Go to definition of
|
|
577
|
-
parameters: obj({
|
|
578
|
-
path: { type: 'string' },
|
|
579
|
-
line: { type: 'integer' },
|
|
580
|
-
character: { type: 'integer' },
|
|
581
|
-
}, ['path', 'line', 'character'])
|
|
403
|
+
description: 'Go to definition of symbol at position.',
|
|
404
|
+
parameters: obj({ path: str(), line: int(), character: int() }, ['path', 'line', 'character'])
|
|
582
405
|
}
|
|
583
406
|
}, {
|
|
584
407
|
type: 'function',
|
|
585
408
|
function: {
|
|
586
409
|
name: 'lsp_references',
|
|
587
|
-
description: 'Find all references to
|
|
588
|
-
parameters: obj({
|
|
589
|
-
path: { type: 'string' },
|
|
590
|
-
line: { type: 'integer' },
|
|
591
|
-
character: { type: 'integer' },
|
|
592
|
-
max_results: { type: 'integer', description: 'Cap results (default 50)' },
|
|
593
|
-
}, ['path', 'line', 'character'])
|
|
410
|
+
description: 'Find all references to symbol at position.',
|
|
411
|
+
parameters: obj({ path: str(), line: int(), character: int(), max_results: int() }, ['path', 'line', 'character'])
|
|
594
412
|
}
|
|
595
413
|
});
|
|
596
414
|
}
|
|
@@ -599,203 +417,6 @@ function buildToolsSchema(opts) {
|
|
|
599
417
|
}
|
|
600
418
|
return schemas;
|
|
601
419
|
}
|
|
602
|
-
/** @internal Exported for testing. Parses tool calls from model content when tool_calls array is empty. */
|
|
603
|
-
export function parseToolCallsFromContent(content) {
|
|
604
|
-
// Fallback parser: if model printed JSON tool_calls in content.
|
|
605
|
-
const trimmed = content.trim();
|
|
606
|
-
const tryParse = (s) => {
|
|
607
|
-
try {
|
|
608
|
-
return JSON.parse(s);
|
|
609
|
-
}
|
|
610
|
-
catch {
|
|
611
|
-
return null;
|
|
612
|
-
}
|
|
613
|
-
};
|
|
614
|
-
// Case 1: whole content is JSON
|
|
615
|
-
const whole = tryParse(trimmed);
|
|
616
|
-
if (whole?.tool_calls && Array.isArray(whole.tool_calls))
|
|
617
|
-
return whole.tool_calls;
|
|
618
|
-
if (whole?.name && whole?.arguments) {
|
|
619
|
-
return [
|
|
620
|
-
{
|
|
621
|
-
id: 'call_0',
|
|
622
|
-
type: 'function',
|
|
623
|
-
function: { name: String(whole.name), arguments: JSON.stringify(whole.arguments) }
|
|
624
|
-
}
|
|
625
|
-
];
|
|
626
|
-
}
|
|
627
|
-
// Case 2: raw JSON array of tool calls (model writes [{name, arguments}, ...])
|
|
628
|
-
const arrStart = trimmed.indexOf('[');
|
|
629
|
-
const arrEnd = trimmed.lastIndexOf(']');
|
|
630
|
-
if (arrStart !== -1 && arrEnd !== -1 && arrEnd > arrStart) {
|
|
631
|
-
const arrSub = tryParse(trimmed.slice(arrStart, arrEnd + 1));
|
|
632
|
-
if (Array.isArray(arrSub) && arrSub.length > 0 && arrSub[0]?.name) {
|
|
633
|
-
return arrSub.map((item, i) => ({
|
|
634
|
-
id: `call_${i}`,
|
|
635
|
-
type: 'function',
|
|
636
|
-
function: {
|
|
637
|
-
name: String(item.name),
|
|
638
|
-
arguments: typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments ?? {})
|
|
639
|
-
}
|
|
640
|
-
}));
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
// Case 3: find a JSON object substring (handles tool_calls wrapper OR single tool-call)
|
|
644
|
-
const start = trimmed.indexOf('{');
|
|
645
|
-
const end = trimmed.lastIndexOf('}');
|
|
646
|
-
if (start !== -1 && end !== -1 && end > start) {
|
|
647
|
-
const sub = tryParse(trimmed.slice(start, end + 1));
|
|
648
|
-
if (sub?.tool_calls && Array.isArray(sub.tool_calls))
|
|
649
|
-
return sub.tool_calls;
|
|
650
|
-
if (sub?.name && sub?.arguments) {
|
|
651
|
-
return [
|
|
652
|
-
{
|
|
653
|
-
id: 'call_0',
|
|
654
|
-
type: 'function',
|
|
655
|
-
function: { name: String(sub.name), arguments: typeof sub.arguments === 'string' ? sub.arguments : JSON.stringify(sub.arguments) }
|
|
656
|
-
}
|
|
657
|
-
];
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
// Case 4: XML tool calls — used by Qwen, Hermes, and other models whose chat
|
|
661
|
-
// templates emit <tool_call><function=name><parameter=key>value</parameter></function></tool_call>.
|
|
662
|
-
// When llama-server's XML→JSON conversion fails (common with large write_file content),
|
|
663
|
-
// the raw XML leaks into the content field. This recovers it.
|
|
664
|
-
const xmlCalls = parseXmlToolCalls(trimmed);
|
|
665
|
-
if (xmlCalls?.length)
|
|
666
|
-
return xmlCalls;
|
|
667
|
-
// Case 5: Lightweight function-tag calls (seen in some Qwen content-mode outputs):
|
|
668
|
-
// <function=tool_name>
|
|
669
|
-
// {...json args...}
|
|
670
|
-
// </function>
|
|
671
|
-
// or single-line <function=tool_name>{...}</function>
|
|
672
|
-
const fnTagCalls = parseFunctionTagToolCalls(trimmed);
|
|
673
|
-
if (fnTagCalls?.length)
|
|
674
|
-
return fnTagCalls;
|
|
675
|
-
return null;
|
|
676
|
-
}
|
|
677
|
-
/**
|
|
678
|
-
* Parse XML-style tool calls from content.
|
|
679
|
-
* Format: <tool_call><function=name><parameter=key>value</parameter>...</function></tool_call>
|
|
680
|
-
* Handles multiple tool call blocks and arbitrary parameter names/values.
|
|
681
|
-
*/
|
|
682
|
-
function parseXmlToolCalls(content) {
|
|
683
|
-
// Quick bailout: no point parsing if there's no <tool_call> marker
|
|
684
|
-
if (!content.includes('<tool_call>'))
|
|
685
|
-
return null;
|
|
686
|
-
const calls = [];
|
|
687
|
-
// Match each <tool_call>...</tool_call> block.
|
|
688
|
-
// Using a manual scan instead of a single greedy regex to handle nested angle brackets
|
|
689
|
-
// in parameter values (e.g. TypeScript generics, JSX, comparison operators).
|
|
690
|
-
let searchFrom = 0;
|
|
691
|
-
while (searchFrom < content.length) {
|
|
692
|
-
const blockStart = content.indexOf('<tool_call>', searchFrom);
|
|
693
|
-
if (blockStart === -1)
|
|
694
|
-
break;
|
|
695
|
-
const blockEnd = content.indexOf('</tool_call>', blockStart);
|
|
696
|
-
if (blockEnd === -1)
|
|
697
|
-
break; // Truncated — can't recover partial tool calls
|
|
698
|
-
const block = content.slice(blockStart + '<tool_call>'.length, blockEnd);
|
|
699
|
-
searchFrom = blockEnd + '</tool_call>'.length;
|
|
700
|
-
// Extract function name: <function=name>...</function>
|
|
701
|
-
const fnMatch = block.match(/<function=(\w[\w.-]*)>/);
|
|
702
|
-
if (!fnMatch)
|
|
703
|
-
continue;
|
|
704
|
-
const fnName = fnMatch[1];
|
|
705
|
-
const fnStart = block.indexOf(fnMatch[0]) + fnMatch[0].length;
|
|
706
|
-
const fnEnd = block.lastIndexOf('</function>');
|
|
707
|
-
const fnBody = fnEnd !== -1 ? block.slice(fnStart, fnEnd) : block.slice(fnStart);
|
|
708
|
-
// Extract parameters: <parameter=key>value</parameter>
|
|
709
|
-
// Uses bracket-matching (depth counting) so that parameter values containing
|
|
710
|
-
// literal <parameter=...>...</parameter> (e.g. writing XML files) are handled
|
|
711
|
-
// correctly instead of being truncated at the inner close tag.
|
|
712
|
-
const args = {};
|
|
713
|
-
const openRe = /<parameter=(\w[\w.-]*)>/g;
|
|
714
|
-
const closeTag = '</parameter>';
|
|
715
|
-
let paramMatch;
|
|
716
|
-
while ((paramMatch = openRe.exec(fnBody)) !== null) {
|
|
717
|
-
const paramName = paramMatch[1];
|
|
718
|
-
const valueStart = paramMatch.index + paramMatch[0].length;
|
|
719
|
-
// Bracket-match: find the </parameter> that balances this open tag.
|
|
720
|
-
// Depth starts at 1; nested <parameter=...> increments, </parameter> decrements.
|
|
721
|
-
let depth = 1;
|
|
722
|
-
let scanPos = valueStart;
|
|
723
|
-
let closeIdx = -1;
|
|
724
|
-
while (scanPos < fnBody.length && depth > 0) {
|
|
725
|
-
const nextOpen = fnBody.indexOf('<parameter=', scanPos);
|
|
726
|
-
const nextClose = fnBody.indexOf(closeTag, scanPos);
|
|
727
|
-
if (nextClose === -1)
|
|
728
|
-
break; // No more close tags — truncated
|
|
729
|
-
if (nextOpen !== -1 && nextOpen < nextClose) {
|
|
730
|
-
// An open tag comes before the next close — increase depth
|
|
731
|
-
depth++;
|
|
732
|
-
scanPos = nextOpen + 1; // advance past '<' to avoid re-matching
|
|
733
|
-
}
|
|
734
|
-
else {
|
|
735
|
-
// Close tag comes first — decrease depth
|
|
736
|
-
depth--;
|
|
737
|
-
if (depth === 0) {
|
|
738
|
-
closeIdx = nextClose;
|
|
739
|
-
}
|
|
740
|
-
scanPos = nextClose + closeTag.length;
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
if (closeIdx === -1) {
|
|
744
|
-
// No matching close tag — take rest of body as value (truncated output)
|
|
745
|
-
args[paramName] = fnBody.slice(valueStart).trim();
|
|
746
|
-
break;
|
|
747
|
-
}
|
|
748
|
-
// Trim exactly the template-added leading/trailing newline, preserve internal whitespace
|
|
749
|
-
let value = fnBody.slice(valueStart, closeIdx);
|
|
750
|
-
if (value.startsWith('\n'))
|
|
751
|
-
value = value.slice(1);
|
|
752
|
-
if (value.endsWith('\n'))
|
|
753
|
-
value = value.slice(0, -1);
|
|
754
|
-
args[paramName] = value;
|
|
755
|
-
// Advance the regex past the close tag so the next openRe.exec starts after it
|
|
756
|
-
openRe.lastIndex = closeIdx + closeTag.length;
|
|
757
|
-
}
|
|
758
|
-
if (fnName && Object.keys(args).length > 0) {
|
|
759
|
-
calls.push({
|
|
760
|
-
id: `call_xml_${calls.length}`,
|
|
761
|
-
type: 'function',
|
|
762
|
-
function: {
|
|
763
|
-
name: fnName,
|
|
764
|
-
arguments: JSON.stringify(args)
|
|
765
|
-
}
|
|
766
|
-
});
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
return calls.length > 0 ? calls : null;
|
|
770
|
-
}
|
|
771
|
-
/** Check for missing required params by tool name — universal pre-dispatch validation */
|
|
772
|
-
function getMissingRequiredParams(toolName, args) {
|
|
773
|
-
const required = {
|
|
774
|
-
read_file: ['path'],
|
|
775
|
-
read_files: ['requests'],
|
|
776
|
-
write_file: ['path', 'content'],
|
|
777
|
-
edit_file: ['path', 'old_text', 'new_text'],
|
|
778
|
-
insert_file: ['path', 'line', 'text'],
|
|
779
|
-
list_dir: ['path'],
|
|
780
|
-
search_files: ['pattern', 'path'],
|
|
781
|
-
exec: ['command'],
|
|
782
|
-
spawn_task: ['task'],
|
|
783
|
-
sys_context: [],
|
|
784
|
-
vault_search: ['query'],
|
|
785
|
-
vault_note: ['key', 'value']
|
|
786
|
-
};
|
|
787
|
-
const req = required[toolName];
|
|
788
|
-
if (!req)
|
|
789
|
-
return [];
|
|
790
|
-
return req.filter(p => args[p] === undefined || args[p] === null);
|
|
791
|
-
}
|
|
792
|
-
/** Strip markdown code fences (```json ... ```) from tool argument strings */
|
|
793
|
-
function stripMarkdownFences(s) {
|
|
794
|
-
const trimmed = s.trim();
|
|
795
|
-
// Match ```json\n...\n``` or ```\n...\n```
|
|
796
|
-
const m = /^```(?:json)?\s*\n?([\s\S]*?)\n?```\s*$/.exec(trimmed);
|
|
797
|
-
return m ? m[1] : s;
|
|
798
|
-
}
|
|
799
420
|
function isReadOnlyTool(name) {
|
|
800
421
|
return name === 'read_file' || name === 'read_files' || name === 'list_dir' || name === 'search_files' || name === 'vault_search' || name === 'sys_context';
|
|
801
422
|
}
|
|
@@ -804,6 +425,10 @@ function planModeSummary(name, args) {
|
|
|
804
425
|
switch (name) {
|
|
805
426
|
case 'write_file':
|
|
806
427
|
return `write ${args.path ?? 'unknown'} (${typeof args.content === 'string' ? args.content.split('\n').length : '?'} lines)`;
|
|
428
|
+
case 'apply_patch':
|
|
429
|
+
return `apply patch to ${Array.isArray(args.files) ? args.files.length : '?'} file(s)`;
|
|
430
|
+
case 'edit_range':
|
|
431
|
+
return `edit ${args.path ?? 'unknown'} lines ${args.start_line ?? '?'}-${args.end_line ?? '?'}`;
|
|
807
432
|
case 'edit_file':
|
|
808
433
|
return `edit ${args.path ?? 'unknown'} (replace ${typeof args.old_text === 'string' ? args.old_text.split('\n').length : '?'} lines)`;
|
|
809
434
|
case 'insert_file':
|
|
@@ -838,148 +463,6 @@ function userDisallowsDelegation(content) {
|
|
|
838
463
|
/\b(?:spawn[_\-\s]?task|sub[\-\s]?agents?|delegate|delegation)\b[^\n.]{0,50}\b(?:do not|don't|dont|not allowed|forbidden|no)\b/.test(text);
|
|
839
464
|
return negationNearDelegation;
|
|
840
465
|
}
|
|
841
|
-
function reviewArtifactKeys(projectDir) {
|
|
842
|
-
const { projectId } = projectIndexKeys(projectDir);
|
|
843
|
-
return {
|
|
844
|
-
projectId,
|
|
845
|
-
latestKey: `artifact:review:latest:${projectId}`,
|
|
846
|
-
byIdPrefix: `artifact:review:item:${projectId}:`,
|
|
847
|
-
};
|
|
848
|
-
}
|
|
849
|
-
function looksLikeCodeReviewRequest(text) {
|
|
850
|
-
const t = text.toLowerCase();
|
|
851
|
-
if (!t.trim())
|
|
852
|
-
return false;
|
|
853
|
-
if (/^\s*\/review\b/.test(t))
|
|
854
|
-
return true;
|
|
855
|
-
if (/\b(?:code\s+review|security\s+review|review\s+the\s+(?:code|diff|changes|repo|repository|pr)|audit\s+the\s+code)\b/.test(t))
|
|
856
|
-
return true;
|
|
857
|
-
return /\breview\b/.test(t) && /\b(?:code|repo|repository|diff|changes|pull\s*request|pr)\b/.test(t);
|
|
858
|
-
}
|
|
859
|
-
function looksLikeReviewRetrievalRequest(text) {
|
|
860
|
-
const t = text.toLowerCase();
|
|
861
|
-
if (!t.trim())
|
|
862
|
-
return false;
|
|
863
|
-
if (/^\s*\/review\s+(?:print|show|replay|latest|last|full)\b/.test(t))
|
|
864
|
-
return true;
|
|
865
|
-
if (!/\breview\b/.test(t))
|
|
866
|
-
return false;
|
|
867
|
-
if (/\bprint\s+stale\s+review\s+anyway\b/.test(t))
|
|
868
|
-
return true;
|
|
869
|
-
if (/\b(?:print|show|display|repeat|paste|send|output|give)\b[^\n.]{0,80}\breview\b[^\n.]{0,40}\b(?:again|back)\b/.test(t))
|
|
870
|
-
return true;
|
|
871
|
-
if (/\b(?:print|show|display|repeat|paste|send|output|give)\b[^\n.]{0,80}\b(?:full|entire|complete|whole)\b[^\n.]{0,80}\breview\b/.test(t))
|
|
872
|
-
return true;
|
|
873
|
-
if (/\b(?:full|entire|complete|whole)\b[^\n.]{0,30}\bcode\s+review\b/.test(t) && /\b(?:print|show|display|repeat|paste|send|output|give)\b/.test(t))
|
|
874
|
-
return true;
|
|
875
|
-
if (/\b(?:print|show|display|repeat|paste|send|output|give)\b[^\n.]{0,80}\b(?:last|latest|previous)\b[^\n.]{0,40}\breview\b/.test(t))
|
|
876
|
-
return true;
|
|
877
|
-
return false;
|
|
878
|
-
}
|
|
879
|
-
function retrievalAllowsStaleArtifact(text) {
|
|
880
|
-
const t = text.toLowerCase();
|
|
881
|
-
if (!t.trim())
|
|
882
|
-
return false;
|
|
883
|
-
if (/\bprint\s+stale\s+review\s+anyway\b/.test(t))
|
|
884
|
-
return true;
|
|
885
|
-
if (/\b(?:force|override|ignore)\b[^\n.]{0,80}\b(?:stale|old|previous)\b[^\n.]{0,80}\breview\b/.test(t))
|
|
886
|
-
return true;
|
|
887
|
-
if (/\b(?:stale|old|previous)\b[^\n.]{0,80}\breview\b[^\n.]{0,80}\b(?:anyway|still|force|override|ignore)\b/.test(t))
|
|
888
|
-
return true;
|
|
889
|
-
return false;
|
|
890
|
-
}
|
|
891
|
-
function parseReviewArtifactStalePolicy(raw) {
|
|
892
|
-
const v = typeof raw === 'string' ? raw.toLowerCase().trim() : '';
|
|
893
|
-
if (v === 'block')
|
|
894
|
-
return 'block';
|
|
895
|
-
return 'warn';
|
|
896
|
-
}
|
|
897
|
-
function parseReviewArtifact(raw) {
|
|
898
|
-
try {
|
|
899
|
-
const parsed = JSON.parse(raw);
|
|
900
|
-
if (!parsed || typeof parsed !== 'object')
|
|
901
|
-
return null;
|
|
902
|
-
if (parsed.kind !== 'code_review')
|
|
903
|
-
return null;
|
|
904
|
-
if (typeof parsed.id !== 'string' || !parsed.id)
|
|
905
|
-
return null;
|
|
906
|
-
if (typeof parsed.createdAt !== 'string' || !parsed.createdAt)
|
|
907
|
-
return null;
|
|
908
|
-
if (typeof parsed.model !== 'string')
|
|
909
|
-
return null;
|
|
910
|
-
if (typeof parsed.projectId !== 'string' || !parsed.projectId)
|
|
911
|
-
return null;
|
|
912
|
-
if (typeof parsed.projectDir !== 'string' || !parsed.projectDir)
|
|
913
|
-
return null;
|
|
914
|
-
if (typeof parsed.prompt !== 'string')
|
|
915
|
-
return null;
|
|
916
|
-
if (typeof parsed.content !== 'string')
|
|
917
|
-
return null;
|
|
918
|
-
return parsed;
|
|
919
|
-
}
|
|
920
|
-
catch {
|
|
921
|
-
return null;
|
|
922
|
-
}
|
|
923
|
-
}
|
|
924
|
-
function gitHead(cwd) {
|
|
925
|
-
const inside = spawnSync(BASH, ['-lc', 'git rev-parse --is-inside-work-tree'], {
|
|
926
|
-
cwd,
|
|
927
|
-
encoding: 'utf8',
|
|
928
|
-
timeout: 1000,
|
|
929
|
-
});
|
|
930
|
-
if (inside.status !== 0 || !String(inside.stdout || '').trim().startsWith('true'))
|
|
931
|
-
return undefined;
|
|
932
|
-
const head = spawnSync(BASH, ['-lc', 'git rev-parse HEAD'], {
|
|
933
|
-
cwd,
|
|
934
|
-
encoding: 'utf8',
|
|
935
|
-
timeout: 1000,
|
|
936
|
-
});
|
|
937
|
-
if (head.status !== 0)
|
|
938
|
-
return undefined;
|
|
939
|
-
const sha = String(head.stdout || '').trim();
|
|
940
|
-
return sha || undefined;
|
|
941
|
-
}
|
|
942
|
-
function shortSha(sha) {
|
|
943
|
-
if (!sha)
|
|
944
|
-
return 'unknown';
|
|
945
|
-
return sha.slice(0, 8);
|
|
946
|
-
}
|
|
947
|
-
function reviewArtifactStaleReason(artifact, cwd) {
|
|
948
|
-
const currentHead = gitHead(cwd);
|
|
949
|
-
const currentDirty = isGitDirty(cwd);
|
|
950
|
-
if (artifact.gitHead && currentHead && artifact.gitHead !== currentHead) {
|
|
951
|
-
return `Stored review was generated at commit ${shortSha(artifact.gitHead)}; repository is now at ${shortSha(currentHead)}.`;
|
|
952
|
-
}
|
|
953
|
-
if (artifact.gitDirty === false && currentDirty) {
|
|
954
|
-
return 'Stored review was generated on a clean tree; working tree now has uncommitted changes.';
|
|
955
|
-
}
|
|
956
|
-
return '';
|
|
957
|
-
}
|
|
958
|
-
function normalizeModelsResponse(raw) {
|
|
959
|
-
if (Array.isArray(raw)) {
|
|
960
|
-
return {
|
|
961
|
-
data: raw
|
|
962
|
-
.map((m) => {
|
|
963
|
-
if (!m)
|
|
964
|
-
return null;
|
|
965
|
-
if (typeof m === 'string')
|
|
966
|
-
return { id: m };
|
|
967
|
-
if (typeof m.id === 'string' && m.id)
|
|
968
|
-
return m;
|
|
969
|
-
return null;
|
|
970
|
-
})
|
|
971
|
-
.filter(Boolean)
|
|
972
|
-
};
|
|
973
|
-
}
|
|
974
|
-
if (raw && Array.isArray(raw.data)) {
|
|
975
|
-
return {
|
|
976
|
-
data: raw.data
|
|
977
|
-
.map((m) => (m && typeof m.id === 'string' && m.id ? m : null))
|
|
978
|
-
.filter(Boolean)
|
|
979
|
-
};
|
|
980
|
-
}
|
|
981
|
-
return { data: [] };
|
|
982
|
-
}
|
|
983
466
|
export async function createSession(opts) {
|
|
984
467
|
const cfg = opts.config;
|
|
985
468
|
let client = opts.runtime?.client ?? new OpenAIClient(cfg.endpoint, opts.apiKey, cfg.verbose);
|
|
@@ -989,6 +472,15 @@ export async function createSession(opts) {
|
|
|
989
472
|
if (typeof cfg.response_timeout === 'number' && cfg.response_timeout > 0) {
|
|
990
473
|
client.setResponseTimeout(cfg.response_timeout);
|
|
991
474
|
}
|
|
475
|
+
if (typeof client.setConnectionTimeout === 'function' && typeof cfg.connection_timeout === 'number' && cfg.connection_timeout > 0) {
|
|
476
|
+
client.setConnectionTimeout(cfg.connection_timeout);
|
|
477
|
+
}
|
|
478
|
+
if (typeof client.setInitialConnectionCheck === 'function' && typeof cfg.initial_connection_check === 'boolean') {
|
|
479
|
+
client.setInitialConnectionCheck(cfg.initial_connection_check);
|
|
480
|
+
}
|
|
481
|
+
if (typeof client.setInitialConnectionProbeTimeout === 'function' && typeof cfg.initial_connection_timeout === 'number' && cfg.initial_connection_timeout > 0) {
|
|
482
|
+
client.setInitialConnectionProbeTimeout(cfg.initial_connection_timeout);
|
|
483
|
+
}
|
|
992
484
|
// Health check + model list (cheap, avoids wasting GPU on chat warmups if unreachable)
|
|
993
485
|
let modelsList = normalizeModelsResponse(await client.models().catch(() => null));
|
|
994
486
|
let model = cfg.model && cfg.model.trim().length
|
|
@@ -1004,6 +496,44 @@ export async function createSession(opts) {
|
|
|
1004
496
|
modelMeta,
|
|
1005
497
|
});
|
|
1006
498
|
let supportsVision = supportsVisionModel(model, modelMeta, harness);
|
|
499
|
+
const sessionId = `session-${timestampedId()}`;
|
|
500
|
+
const hookCfg = cfg.hooks ?? {};
|
|
501
|
+
const hookManager = opts.runtime?.hookManager ?? new HookManager({
|
|
502
|
+
enabled: hookCfg.enabled !== false,
|
|
503
|
+
strict: hookCfg.strict === true,
|
|
504
|
+
warnMs: hookCfg.warn_ms,
|
|
505
|
+
allowedCapabilities: Array.isArray(hookCfg.allow_capabilities) ? hookCfg.allow_capabilities : undefined,
|
|
506
|
+
context: () => ({
|
|
507
|
+
sessionId,
|
|
508
|
+
cwd: cfg.dir ?? process.cwd(),
|
|
509
|
+
model,
|
|
510
|
+
harness: harness.id,
|
|
511
|
+
endpoint: cfg.endpoint,
|
|
512
|
+
}),
|
|
513
|
+
});
|
|
514
|
+
const emitDetached = (promise, eventName) => {
|
|
515
|
+
void promise.catch((error) => {
|
|
516
|
+
if (!process.env.IDLEHANDS_QUIET_WARNINGS) {
|
|
517
|
+
console.warn(`[hooks] async ${eventName} dispatch failed: ${error?.message ?? String(error)}`);
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
};
|
|
521
|
+
if (!opts.runtime?.hookManager && hookManager.isEnabled()) {
|
|
522
|
+
const loadedPlugins = await loadHookPlugins({
|
|
523
|
+
pluginPaths: Array.isArray(hookCfg.plugin_paths) ? hookCfg.plugin_paths : [],
|
|
524
|
+
cwd: cfg.dir ?? process.cwd(),
|
|
525
|
+
strict: hookCfg.strict === true,
|
|
526
|
+
});
|
|
527
|
+
for (const loaded of loadedPlugins) {
|
|
528
|
+
await hookManager.registerPlugin(loaded.plugin, loaded.path);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
await hookManager.emit('session_start', {
|
|
532
|
+
model,
|
|
533
|
+
harness: harness.id,
|
|
534
|
+
endpoint: cfg.endpoint,
|
|
535
|
+
cwd: cfg.dir ?? process.cwd(),
|
|
536
|
+
});
|
|
1007
537
|
if (!cfg.i_know_what_im_doing && contextWindow > 131072) {
|
|
1008
538
|
console.warn('[warn] context_window is above 131072; this can increase memory usage and hurt throughput. Use --i-know-what-im-doing to proceed.');
|
|
1009
539
|
}
|
|
@@ -1034,7 +564,7 @@ export async function createSession(opts) {
|
|
|
1034
564
|
? Number(cfg.mcp_call_timeout_sec)
|
|
1035
565
|
: (Number.isFinite(cfg.mcp?.call_timeout_sec) ? Number(cfg.mcp?.call_timeout_sec) : 30);
|
|
1036
566
|
const builtInToolNames = [
|
|
1037
|
-
'read_file', 'read_files', 'write_file', 'edit_file', 'insert_file',
|
|
567
|
+
'read_file', 'read_files', 'write_file', 'apply_patch', 'edit_range', 'edit_file', 'insert_file',
|
|
1038
568
|
'list_dir', 'search_files', 'exec', 'vault_search', 'vault_note', 'sys_context',
|
|
1039
569
|
...(spawnTaskEnabled ? ['spawn_task'] : []),
|
|
1040
570
|
];
|
|
@@ -1276,6 +806,7 @@ export async function createSession(opts) {
|
|
|
1276
806
|
];
|
|
1277
807
|
sessionMetaPending = sessionMeta;
|
|
1278
808
|
lastEditedPath = undefined;
|
|
809
|
+
initialConnectionProbeDone = false;
|
|
1279
810
|
mcpToolsLoaded = !mcpLazySchemaMode;
|
|
1280
811
|
};
|
|
1281
812
|
const restore = (next) => {
|
|
@@ -1298,6 +829,7 @@ export async function createSession(opts) {
|
|
|
1298
829
|
};
|
|
1299
830
|
let reqCounter = 0;
|
|
1300
831
|
let inFlight = null;
|
|
832
|
+
let initialConnectionProbeDone = false;
|
|
1301
833
|
let lastEditedPath;
|
|
1302
834
|
// Plan mode state (Phase 8)
|
|
1303
835
|
let planSteps = [];
|
|
@@ -1798,6 +1330,7 @@ export async function createSession(opts) {
|
|
|
1798
1330
|
return fresh.data.map((m) => m.id).filter(Boolean);
|
|
1799
1331
|
};
|
|
1800
1332
|
const setModel = (name) => {
|
|
1333
|
+
const previousModel = model;
|
|
1801
1334
|
model = name;
|
|
1802
1335
|
harness = selectHarness(model, cfg.harness && cfg.harness.trim() ? cfg.harness.trim() : undefined);
|
|
1803
1336
|
const nextMeta = modelsList?.data?.find((m) => m.id === model);
|
|
@@ -1815,6 +1348,11 @@ export async function createSession(opts) {
|
|
|
1815
1348
|
configuredTopP: cfg.top_p,
|
|
1816
1349
|
baseMaxTokens: BASE_MAX_TOKENS,
|
|
1817
1350
|
}));
|
|
1351
|
+
emitDetached(hookManager.emit('model_changed', {
|
|
1352
|
+
previousModel,
|
|
1353
|
+
nextModel: model,
|
|
1354
|
+
harness: harness.id,
|
|
1355
|
+
}), 'model_changed');
|
|
1818
1356
|
};
|
|
1819
1357
|
const setEndpoint = async (endpoint, modelName) => {
|
|
1820
1358
|
const normalized = endpoint.replace(/\/+$/, '');
|
|
@@ -2002,11 +1540,49 @@ export async function createSession(opts) {
|
|
|
2002
1540
|
const hookObj = typeof hooks === 'function' ? { onToken: hooks } : hooks ?? {};
|
|
2003
1541
|
let turns = 0;
|
|
2004
1542
|
let toolCalls = 0;
|
|
1543
|
+
const askId = `ask-${timestampedId()}`;
|
|
1544
|
+
const emitToolCall = async (call) => {
|
|
1545
|
+
hookObj.onToolCall?.(call);
|
|
1546
|
+
await hookManager.emit('tool_call', { askId, turn: turns, call });
|
|
1547
|
+
};
|
|
1548
|
+
const emitToolStream = (stream) => {
|
|
1549
|
+
try {
|
|
1550
|
+
void hookObj.onToolStream?.(stream);
|
|
1551
|
+
}
|
|
1552
|
+
catch {
|
|
1553
|
+
// best effort
|
|
1554
|
+
}
|
|
1555
|
+
try {
|
|
1556
|
+
void hookManager.emit('tool_stream', { askId, turn: turns, stream });
|
|
1557
|
+
}
|
|
1558
|
+
catch {
|
|
1559
|
+
// best effort
|
|
1560
|
+
}
|
|
1561
|
+
};
|
|
1562
|
+
const emitToolResult = async (result) => {
|
|
1563
|
+
await hookObj.onToolResult?.(result);
|
|
1564
|
+
await hookManager.emit('tool_result', { askId, turn: turns, result });
|
|
1565
|
+
};
|
|
1566
|
+
const emitTurnEnd = async (stats) => {
|
|
1567
|
+
await hookObj.onTurnEnd?.(stats);
|
|
1568
|
+
await hookManager.emit('turn_end', { askId, stats });
|
|
1569
|
+
};
|
|
1570
|
+
const finalizeAsk = async (text) => {
|
|
1571
|
+
await hookManager.emit('ask_end', { askId, text, turns, toolCalls });
|
|
1572
|
+
return { text, turns, toolCalls };
|
|
1573
|
+
};
|
|
2005
1574
|
const rawInstructionText = userContentToText(instruction).trim();
|
|
1575
|
+
await hookManager.emit('ask_start', { askId, instruction: rawInstructionText });
|
|
2006
1576
|
const projectDir = cfg.dir ?? process.cwd();
|
|
2007
1577
|
const reviewKeys = reviewArtifactKeys(projectDir);
|
|
2008
1578
|
const retrievalRequested = looksLikeReviewRetrievalRequest(rawInstructionText);
|
|
2009
1579
|
const shouldPersistReviewArtifact = looksLikeCodeReviewRequest(rawInstructionText) && !retrievalRequested;
|
|
1580
|
+
if (!retrievalRequested && cfg.initial_connection_check !== false && !initialConnectionProbeDone) {
|
|
1581
|
+
if (typeof client.probeConnection === 'function') {
|
|
1582
|
+
await client.probeConnection();
|
|
1583
|
+
initialConnectionProbeDone = true;
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
2010
1586
|
if (retrievalRequested) {
|
|
2011
1587
|
const latest = vault
|
|
2012
1588
|
? await vault.getLatestByKey(reviewKeys.latestKey, 'system').catch(() => null)
|
|
@@ -2023,37 +1599,37 @@ export async function createSession(opts) {
|
|
|
2023
1599
|
'Reply with "print stale review anyway" to override, or request a fresh review.';
|
|
2024
1600
|
messages.push({ role: 'assistant', content: blocked });
|
|
2025
1601
|
hookObj.onToken?.(blocked);
|
|
2026
|
-
await
|
|
1602
|
+
await emitTurnEnd({
|
|
2027
1603
|
turn: turns,
|
|
2028
1604
|
toolCalls,
|
|
2029
1605
|
promptTokens: cumulativeUsage.prompt,
|
|
2030
1606
|
completionTokens: cumulativeUsage.completion,
|
|
2031
1607
|
});
|
|
2032
|
-
return
|
|
1608
|
+
return await finalizeAsk(blocked);
|
|
2033
1609
|
}
|
|
2034
1610
|
const text = stale
|
|
2035
1611
|
? `${artifact.content}\n\n[artifact note] ${stale}`
|
|
2036
1612
|
: artifact.content;
|
|
2037
1613
|
messages.push({ role: 'assistant', content: text });
|
|
2038
1614
|
hookObj.onToken?.(text);
|
|
2039
|
-
await
|
|
1615
|
+
await emitTurnEnd({
|
|
2040
1616
|
turn: turns,
|
|
2041
1617
|
toolCalls,
|
|
2042
1618
|
promptTokens: cumulativeUsage.prompt,
|
|
2043
1619
|
completionTokens: cumulativeUsage.completion,
|
|
2044
1620
|
});
|
|
2045
|
-
return
|
|
1621
|
+
return await finalizeAsk(text);
|
|
2046
1622
|
}
|
|
2047
1623
|
const miss = 'No stored full code review found yet. Ask me to run a code review first, then I can replay it verbatim.';
|
|
2048
1624
|
messages.push({ role: 'assistant', content: miss });
|
|
2049
1625
|
hookObj.onToken?.(miss);
|
|
2050
|
-
await
|
|
1626
|
+
await emitTurnEnd({
|
|
2051
1627
|
turn: turns,
|
|
2052
1628
|
toolCalls,
|
|
2053
1629
|
promptTokens: cumulativeUsage.prompt,
|
|
2054
1630
|
completionTokens: cumulativeUsage.completion,
|
|
2055
1631
|
});
|
|
2056
|
-
return
|
|
1632
|
+
return await finalizeAsk(miss);
|
|
2057
1633
|
}
|
|
2058
1634
|
const persistReviewArtifact = async (finalText) => {
|
|
2059
1635
|
if (!vault || !shouldPersistReviewArtifact)
|
|
@@ -2062,7 +1638,7 @@ export async function createSession(opts) {
|
|
|
2062
1638
|
if (!clean)
|
|
2063
1639
|
return;
|
|
2064
1640
|
const createdAt = new Date().toISOString();
|
|
2065
|
-
const id = `review-${
|
|
1641
|
+
const id = `review-${timestampedId()}`;
|
|
2066
1642
|
const artifact = {
|
|
2067
1643
|
id,
|
|
2068
1644
|
kind: 'code_review',
|
|
@@ -2098,6 +1674,7 @@ export async function createSession(opts) {
|
|
|
2098
1674
|
// identical tool call signature counts across this ask() run
|
|
2099
1675
|
const sigCounts = new Map();
|
|
2100
1676
|
const toolNameByCallId = new Map();
|
|
1677
|
+
const toolArgsByCallId = new Map();
|
|
2101
1678
|
// Loop-break helper state: bump mutationVersion whenever a tool mutates files.
|
|
2102
1679
|
// We also record the mutationVersion at which a given signature was last seen.
|
|
2103
1680
|
let mutationVersion = 0;
|
|
@@ -2139,6 +1716,42 @@ export async function createSession(opts) {
|
|
|
2139
1716
|
}
|
|
2140
1717
|
return msg;
|
|
2141
1718
|
};
|
|
1719
|
+
const compactToolMessageForHistory = async (toolCallId, rawContent) => {
|
|
1720
|
+
const toolName = toolNameByCallId.get(toolCallId) ?? 'tool';
|
|
1721
|
+
const toolArgs = toolArgsByCallId.get(toolCallId) ?? {};
|
|
1722
|
+
const rawMsg = { role: 'tool', tool_call_id: toolCallId, content: rawContent };
|
|
1723
|
+
// Persist full-fidelity output immediately so live context can stay small.
|
|
1724
|
+
if (vault && typeof vault.archiveToolResult === 'function') {
|
|
1725
|
+
try {
|
|
1726
|
+
await vault.archiveToolResult(rawMsg, toolName);
|
|
1727
|
+
}
|
|
1728
|
+
catch (e) {
|
|
1729
|
+
console.warn(`[warn] vault archive failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
let compact = rawContent;
|
|
1733
|
+
if (lens) {
|
|
1734
|
+
try {
|
|
1735
|
+
const lensCompact = await lens.summarizeToolOutput(rawContent, toolName, typeof toolArgs.path === 'string' ? String(toolArgs.path) : undefined);
|
|
1736
|
+
if (typeof lensCompact === 'string' && lensCompact.length && lensCompact.length < compact.length) {
|
|
1737
|
+
compact = lensCompact;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
catch {
|
|
1741
|
+
// ignore lens failures; fallback to raw
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
const success = !String(rawContent).startsWith('ERROR:');
|
|
1745
|
+
const digested = digestToolResult(toolName, toolArgs, compact, success);
|
|
1746
|
+
if (digested !== rawContent) {
|
|
1747
|
+
return {
|
|
1748
|
+
role: 'tool',
|
|
1749
|
+
tool_call_id: toolCallId,
|
|
1750
|
+
content: `${digested}\n[full output archived in vault: tool=${toolName}, call_id=${toolCallId}]`,
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
return rawMsg;
|
|
1754
|
+
};
|
|
2142
1755
|
const persistFailure = async (error, contextLine) => {
|
|
2143
1756
|
if (!vault)
|
|
2144
1757
|
return;
|
|
@@ -2193,13 +1806,13 @@ export async function createSession(opts) {
|
|
|
2193
1806
|
if (inFlight?.signal?.aborted)
|
|
2194
1807
|
break;
|
|
2195
1808
|
turns++;
|
|
1809
|
+
await hookManager.emit('turn_start', { askId, turn: turns });
|
|
2196
1810
|
const wallElapsed = (Date.now() - wallStart) / 1000;
|
|
2197
1811
|
if (wallElapsed > cfg.timeout) {
|
|
2198
1812
|
throw new Error(`session timeout exceeded (${cfg.timeout}s) after ${wallElapsed.toFixed(1)}s`);
|
|
2199
1813
|
}
|
|
2200
1814
|
await maybeAutoDetectModelChange();
|
|
2201
1815
|
const beforeMsgs = messages;
|
|
2202
|
-
const beforeTokens = estimateTokensFromMessages(beforeMsgs);
|
|
2203
1816
|
const compacted = enforceContextBudget({
|
|
2204
1817
|
messages: beforeMsgs,
|
|
2205
1818
|
contextWindow,
|
|
@@ -2208,7 +1821,6 @@ export async function createSession(opts) {
|
|
|
2208
1821
|
compactAt: cfg.compact_at ?? 0.8,
|
|
2209
1822
|
toolSchemaTokens: estimateToolSchemaTokens(getToolsSchema()),
|
|
2210
1823
|
});
|
|
2211
|
-
const compactedDropped = beforeMsgs.length > compacted.length || estimateTokensFromMessages(compacted) < beforeTokens;
|
|
2212
1824
|
const compactedByRefs = new Set(compacted);
|
|
2213
1825
|
const dropped = beforeMsgs.filter((m) => !compactedByRefs.has(m));
|
|
2214
1826
|
if (dropped.length && vault) {
|
|
@@ -2233,9 +1845,9 @@ export async function createSession(opts) {
|
|
|
2233
1845
|
const callerSignal = hookObj.signal;
|
|
2234
1846
|
const onCallerAbort = () => ac.abort();
|
|
2235
1847
|
callerSignal?.addEventListener('abort', onCallerAbort, { once: true });
|
|
2236
|
-
// Per-request timeout: the lesser of response_timeout (default
|
|
1848
|
+
// Per-request timeout: the lesser of response_timeout (default 600s) or the remaining session wall time.
|
|
2237
1849
|
// This prevents a single slow request from consuming the entire session budget.
|
|
2238
|
-
const perReqCap = cfg.response_timeout && cfg.response_timeout > 0 ? cfg.response_timeout :
|
|
1850
|
+
const perReqCap = cfg.response_timeout && cfg.response_timeout > 0 ? cfg.response_timeout : 600;
|
|
2239
1851
|
const wallRemaining = Math.max(0, cfg.timeout - (Date.now() - wallStart) / 1000);
|
|
2240
1852
|
const reqTimeout = Math.min(perReqCap, Math.max(10, wallRemaining));
|
|
2241
1853
|
const timer = setTimeout(() => ac.abort(), reqTimeout * 1000);
|
|
@@ -2389,7 +2001,7 @@ export async function createSession(opts) {
|
|
|
2389
2001
|
role: 'user',
|
|
2390
2002
|
content: '[system] Your previous response was empty (no text, no tool calls). Continue by either calling a tool with valid JSON arguments or giving a final answer.',
|
|
2391
2003
|
});
|
|
2392
|
-
await
|
|
2004
|
+
await emitTurnEnd({
|
|
2393
2005
|
turn: turns,
|
|
2394
2006
|
toolCalls,
|
|
2395
2007
|
promptTokens: cumulativeUsage.prompt,
|
|
@@ -2451,7 +2063,11 @@ export async function createSession(opts) {
|
|
|
2451
2063
|
if (visible && hookObj.onToken)
|
|
2452
2064
|
hookObj.onToken('\n');
|
|
2453
2065
|
toolCalls += toolCallsArr.length;
|
|
2454
|
-
|
|
2066
|
+
const assistantToolCallText = visible || '';
|
|
2067
|
+
const compactAssistantToolCallText = assistantToolCallText.length > 900
|
|
2068
|
+
? `${assistantToolCallText.slice(0, 900)}\n[history-compacted: assistant narration truncated before tool execution]`
|
|
2069
|
+
: assistantToolCallText;
|
|
2070
|
+
messages.push({ role: 'assistant', content: compactAssistantToolCallText, tool_calls: toolCallsArr });
|
|
2455
2071
|
// sigCounts is scoped to the entire ask() run (see above)
|
|
2456
2072
|
// Bridge ConfirmationProvider → legacy confirm callback for tools.
|
|
2457
2073
|
// If a ConfirmationProvider is given, wrap it; otherwise fall back to raw callback.
|
|
@@ -2574,7 +2190,7 @@ export async function createSession(opts) {
|
|
|
2574
2190
|
`Hint: you repeated the same tool call ${loopThreshold} times with identical arguments. ` +
|
|
2575
2191
|
`If the call succeeded, move on to the next step. ` +
|
|
2576
2192
|
`If it failed, check that all required parameters are present and correct. ` +
|
|
2577
|
-
`For write_file/edit_file, ensure
|
|
2193
|
+
`For write_file/edit_file/apply_patch/edit_range, ensure required args are present (content/old_text/new_text/patch/files/start_line/end_line/replacement).`);
|
|
2578
2194
|
}
|
|
2579
2195
|
}
|
|
2580
2196
|
// Update consecutive tracking: save this turn's signatures for next turn comparison.
|
|
@@ -2603,6 +2219,8 @@ export async function createSession(opts) {
|
|
|
2603
2219
|
const hasMcpTool = mcpManager?.hasTool(name) === true;
|
|
2604
2220
|
if (!builtInFn && !isLspTool && !hasMcpTool && !isSpawnTask)
|
|
2605
2221
|
throw new Error(`unknown tool: ${name}`);
|
|
2222
|
+
// Keep parsed args by call-id so we can digest/archive tool outputs with context.
|
|
2223
|
+
toolArgsByCallId.set(callId, args && typeof args === 'object' && !Array.isArray(args) ? args : {});
|
|
2606
2224
|
// Pre-dispatch check for missing required params.
|
|
2607
2225
|
// Universal: catches omitted params early with a clear, instructive error
|
|
2608
2226
|
// before the tool itself throws a less helpful message.
|
|
@@ -2638,8 +2256,8 @@ export async function createSession(opts) {
|
|
|
2638
2256
|
const searchTerm = typeof args.search === 'string' ? args.search : '';
|
|
2639
2257
|
// Fix 1: Hard cumulative budget — refuse reads past hard cap
|
|
2640
2258
|
if (cumulativeReadOnlyCalls > READ_BUDGET_HARD) {
|
|
2641
|
-
|
|
2642
|
-
|
|
2259
|
+
await emitToolCall({ id: callId, name, args });
|
|
2260
|
+
await emitToolResult({ id: callId, name, success: false, summary: 'read budget exhausted', result: '' });
|
|
2643
2261
|
return { id: callId, content: `STOP: Read budget exhausted (${cumulativeReadOnlyCalls}/${READ_BUDGET_HARD} calls). Do NOT read more files. Use search_files or exec: grep -rn "pattern" path/ to find what you need.` };
|
|
2644
2262
|
}
|
|
2645
2263
|
// Fix 2: Directory scan detection — counts unique files per dir (re-reads are OK)
|
|
@@ -2654,8 +2272,8 @@ export async function createSession(opts) {
|
|
|
2654
2272
|
blockedDirs.add(parentDir);
|
|
2655
2273
|
}
|
|
2656
2274
|
if (blockedDirs.has(parentDir) && uniqueCount > 8) {
|
|
2657
|
-
|
|
2658
|
-
|
|
2275
|
+
await emitToolCall({ id: callId, name, args });
|
|
2276
|
+
await emitToolResult({ id: callId, name, success: false, summary: 'dir scan blocked', result: '' });
|
|
2659
2277
|
return { id: callId, content: `STOP: Directory scan detected — you've read ${uniqueCount} unique files from ${parentDir}/. Use search_files(pattern, '${parentDir}') or exec: grep -rn "pattern" ${parentDir}/ instead of reading files individually.` };
|
|
2660
2278
|
}
|
|
2661
2279
|
}
|
|
@@ -2666,8 +2284,8 @@ export async function createSession(opts) {
|
|
|
2666
2284
|
searchTermFiles.set(key, new Set());
|
|
2667
2285
|
searchTermFiles.get(key).add(filePath);
|
|
2668
2286
|
if (searchTermFiles.get(key).size >= 3) {
|
|
2669
|
-
|
|
2670
|
-
|
|
2287
|
+
await emitToolCall({ id: callId, name, args });
|
|
2288
|
+
await emitToolResult({ id: callId, name, success: false, summary: 'use search_files', result: '' });
|
|
2671
2289
|
return { id: callId, content: `STOP: You've searched ${searchTermFiles.get(key).size} files for "${searchTerm}" one at a time. This is what search_files does in one call. Use: search_files(pattern="${searchTerm}", path=".") or exec: grep -rn "${searchTerm}" .` };
|
|
2672
2290
|
}
|
|
2673
2291
|
}
|
|
@@ -2689,12 +2307,12 @@ export async function createSession(opts) {
|
|
|
2689
2307
|
// Notify via confirmProvider.showBlocked if available
|
|
2690
2308
|
opts.confirmProvider?.showBlocked?.({ tool: name, args, reason: `plan mode: ${summary}` });
|
|
2691
2309
|
// Hook: onToolCall + onToolResult for plan-blocked actions
|
|
2692
|
-
|
|
2693
|
-
|
|
2310
|
+
await emitToolCall({ id: callId, name, args });
|
|
2311
|
+
await emitToolResult({ id: callId, name, success: true, summary: `⏸ ${summary} (blocked)`, result: blockedMsg });
|
|
2694
2312
|
return { id: callId, content: blockedMsg };
|
|
2695
2313
|
}
|
|
2696
2314
|
// Hook: onToolCall (Phase 8.5)
|
|
2697
|
-
|
|
2315
|
+
await emitToolCall({ id: callId, name, args });
|
|
2698
2316
|
if (cfg.step_mode) {
|
|
2699
2317
|
const stepPrompt = `Step mode: execute ${name}(${JSON.stringify(args).slice(0, 200)}) ? [Y/n]`;
|
|
2700
2318
|
const ok = confirmBridge ? await confirmBridge(stepPrompt, { tool: name, args }) : true;
|
|
@@ -2717,7 +2335,13 @@ export async function createSession(opts) {
|
|
|
2717
2335
|
content = await runSpawnTask(args);
|
|
2718
2336
|
}
|
|
2719
2337
|
else if (builtInFn) {
|
|
2720
|
-
const
|
|
2338
|
+
const callCtx = {
|
|
2339
|
+
...ctx,
|
|
2340
|
+
toolCallId: callId,
|
|
2341
|
+
toolName: name,
|
|
2342
|
+
onToolStream: emitToolStream,
|
|
2343
|
+
};
|
|
2344
|
+
const value = await builtInFn(callCtx, args);
|
|
2721
2345
|
content = typeof value === 'string' ? value : JSON.stringify(value);
|
|
2722
2346
|
if (name === 'exec') {
|
|
2723
2347
|
// Successful exec clears blocked-loop counters.
|
|
@@ -2815,7 +2439,7 @@ export async function createSession(opts) {
|
|
|
2815
2439
|
}
|
|
2816
2440
|
catch { }
|
|
2817
2441
|
}
|
|
2818
|
-
|
|
2442
|
+
await emitToolResult(resultEvent);
|
|
2819
2443
|
// Proactive LSP diagnostics after file mutations
|
|
2820
2444
|
if (lspManager?.hasServers() && lspCfg?.proactive_diagnostics !== false) {
|
|
2821
2445
|
if (FILE_MUTATION_TOOL_SET.has(name)) {
|
|
@@ -2843,7 +2467,7 @@ export async function createSession(opts) {
|
|
|
2843
2467
|
};
|
|
2844
2468
|
const results = [];
|
|
2845
2469
|
// Helper: catch tool errors but re-throw AgentLoopBreak (those must break the outer loop)
|
|
2846
|
-
const catchToolError = (e, tc) => {
|
|
2470
|
+
const catchToolError = async (e, tc) => {
|
|
2847
2471
|
if (e instanceof AgentLoopBreak)
|
|
2848
2472
|
throw e;
|
|
2849
2473
|
const msg = e?.message ?? String(e);
|
|
@@ -2877,7 +2501,7 @@ export async function createSession(opts) {
|
|
|
2877
2501
|
}
|
|
2878
2502
|
// Hook: onToolResult for errors (Phase 8.5)
|
|
2879
2503
|
const callId = resolveCallId(tc);
|
|
2880
|
-
|
|
2504
|
+
await emitToolResult({ id: callId, name: tc.function.name, success: false, summary: msg || 'unknown error', result: `ERROR: ${msg || 'unknown error'}` });
|
|
2881
2505
|
// Never return undefined error text; it makes bench failures impossible to debug.
|
|
2882
2506
|
return { id: callId, content: `ERROR: ${msg || 'unknown tool error'}` };
|
|
2883
2507
|
};
|
|
@@ -2916,7 +2540,7 @@ export async function createSession(opts) {
|
|
|
2916
2540
|
results.push(await runOne(tc));
|
|
2917
2541
|
}
|
|
2918
2542
|
catch (e) {
|
|
2919
|
-
results.push(catchToolError(e, tc));
|
|
2543
|
+
results.push(await catchToolError(e, tc));
|
|
2920
2544
|
}
|
|
2921
2545
|
}
|
|
2922
2546
|
}
|
|
@@ -2930,7 +2554,7 @@ export async function createSession(opts) {
|
|
|
2930
2554
|
results.push(await runOne(tc));
|
|
2931
2555
|
}
|
|
2932
2556
|
catch (e) {
|
|
2933
|
-
results.push(catchToolError(e, tc));
|
|
2557
|
+
results.push(await catchToolError(e, tc));
|
|
2934
2558
|
}
|
|
2935
2559
|
}
|
|
2936
2560
|
}
|
|
@@ -2938,7 +2562,8 @@ export async function createSession(opts) {
|
|
|
2938
2562
|
if (ac.signal.aborted)
|
|
2939
2563
|
break;
|
|
2940
2564
|
for (const r of results) {
|
|
2941
|
-
|
|
2565
|
+
const compactToolMsg = await compactToolMessageForHistory(r.id, r.content);
|
|
2566
|
+
messages.push(compactToolMsg);
|
|
2942
2567
|
}
|
|
2943
2568
|
if (readOnlyExecTurnHints.length) {
|
|
2944
2569
|
const previews = readOnlyExecTurnHints
|
|
@@ -2972,7 +2597,7 @@ export async function createSession(opts) {
|
|
|
2972
2597
|
});
|
|
2973
2598
|
}
|
|
2974
2599
|
// Hook: onTurnEnd (Phase 8.5)
|
|
2975
|
-
await
|
|
2600
|
+
await emitTurnEnd({
|
|
2976
2601
|
turn: turns,
|
|
2977
2602
|
toolCalls,
|
|
2978
2603
|
promptTokens: cumulativeUsage.prompt,
|
|
@@ -3015,7 +2640,7 @@ export async function createSession(opts) {
|
|
|
3015
2640
|
`Original task:\n${clippedReminder}\n\n` +
|
|
3016
2641
|
`Call the needed tools directly. If everything is truly complete, provide the final answer.`
|
|
3017
2642
|
});
|
|
3018
|
-
await
|
|
2643
|
+
await emitTurnEnd({
|
|
3019
2644
|
turn: turns,
|
|
3020
2645
|
toolCalls,
|
|
3021
2646
|
promptTokens: cumulativeUsage.prompt,
|
|
@@ -3035,7 +2660,7 @@ export async function createSession(opts) {
|
|
|
3035
2660
|
role: 'user',
|
|
3036
2661
|
content: '[system] Continue executing the task. Use tools now (do not just narrate plans). If complete, give the final answer.'
|
|
3037
2662
|
});
|
|
3038
|
-
await
|
|
2663
|
+
await emitTurnEnd({
|
|
3039
2664
|
turn: turns,
|
|
3040
2665
|
toolCalls,
|
|
3041
2666
|
promptTokens: cumulativeUsage.prompt,
|
|
@@ -3053,7 +2678,7 @@ export async function createSession(opts) {
|
|
|
3053
2678
|
// final assistant message
|
|
3054
2679
|
messages.push({ role: 'assistant', content: assistantText });
|
|
3055
2680
|
await persistReviewArtifact(assistantText).catch(() => { });
|
|
3056
|
-
await
|
|
2681
|
+
await emitTurnEnd({
|
|
3057
2682
|
turn: turns,
|
|
3058
2683
|
toolCalls,
|
|
3059
2684
|
promptTokens: cumulativeUsage.prompt,
|
|
@@ -3065,7 +2690,7 @@ export async function createSession(opts) {
|
|
|
3065
2690
|
ppTps,
|
|
3066
2691
|
tgTps,
|
|
3067
2692
|
});
|
|
3068
|
-
return
|
|
2693
|
+
return await finalizeAsk(assistantText);
|
|
3069
2694
|
}
|
|
3070
2695
|
const reason = `max iterations exceeded (${maxIters})`;
|
|
3071
2696
|
const diag = lastSuccessfulTestRun
|
|
@@ -3091,6 +2716,12 @@ export async function createSession(opts) {
|
|
|
3091
2716
|
})();
|
|
3092
2717
|
const err = new Error(`BUG: threw undefined in agent.ask() (turn=${turns}). lastMsg=${lastMsg?.role ?? 'unknown'}:${lastMsgPreview}`);
|
|
3093
2718
|
await persistFailure(err, `ask turn ${turns}`);
|
|
2719
|
+
await hookManager.emit('ask_error', {
|
|
2720
|
+
askId,
|
|
2721
|
+
error: err.message,
|
|
2722
|
+
turns,
|
|
2723
|
+
toolCalls,
|
|
2724
|
+
});
|
|
3094
2725
|
throw err;
|
|
3095
2726
|
}
|
|
3096
2727
|
await persistFailure(e, `ask turn ${turns}`);
|
|
@@ -3100,8 +2731,21 @@ export async function createSession(opts) {
|
|
|
3100
2731
|
}
|
|
3101
2732
|
// Never rethrow undefined; normalize to Error for debuggability.
|
|
3102
2733
|
if (e === undefined) {
|
|
3103
|
-
|
|
2734
|
+
const normalized = new Error('BUG: threw undefined (normalized at ask() boundary)');
|
|
2735
|
+
await hookManager.emit('ask_error', {
|
|
2736
|
+
askId,
|
|
2737
|
+
error: normalized.message,
|
|
2738
|
+
turns,
|
|
2739
|
+
toolCalls,
|
|
2740
|
+
});
|
|
2741
|
+
throw normalized;
|
|
3104
2742
|
}
|
|
2743
|
+
await hookManager.emit('ask_error', {
|
|
2744
|
+
askId,
|
|
2745
|
+
error: e instanceof Error ? e.message : String(e),
|
|
2746
|
+
turns,
|
|
2747
|
+
toolCalls,
|
|
2748
|
+
});
|
|
3105
2749
|
throw e;
|
|
3106
2750
|
}
|
|
3107
2751
|
};
|
|
@@ -3148,6 +2792,7 @@ export async function createSession(opts) {
|
|
|
3148
2792
|
replay,
|
|
3149
2793
|
vault,
|
|
3150
2794
|
lens,
|
|
2795
|
+
hookManager,
|
|
3151
2796
|
get lastEditedPath() {
|
|
3152
2797
|
return lastEditedPath;
|
|
3153
2798
|
},
|
|
@@ -3192,30 +2837,4 @@ async function autoPickModel(client, cached) {
|
|
|
3192
2837
|
clearTimeout(timer);
|
|
3193
2838
|
}
|
|
3194
2839
|
}
|
|
3195
|
-
function parseFunctionTagToolCalls(content) {
|
|
3196
|
-
const m = content.match(/<function=([\w.-]+)>([\s\S]*?)<\/function>/i);
|
|
3197
|
-
if (!m)
|
|
3198
|
-
return null;
|
|
3199
|
-
const name = m[1];
|
|
3200
|
-
const body = (m[2] ?? '').trim();
|
|
3201
|
-
// If body contains JSON object, use it as arguments; else empty object.
|
|
3202
|
-
let args = '{}';
|
|
3203
|
-
const jsonStart = body.indexOf('{');
|
|
3204
|
-
const jsonEnd = body.lastIndexOf('}');
|
|
3205
|
-
if (jsonStart !== -1 && jsonEnd > jsonStart) {
|
|
3206
|
-
const sub = body.slice(jsonStart, jsonEnd + 1);
|
|
3207
|
-
try {
|
|
3208
|
-
JSON.parse(sub);
|
|
3209
|
-
args = sub;
|
|
3210
|
-
}
|
|
3211
|
-
catch {
|
|
3212
|
-
// keep {}
|
|
3213
|
-
}
|
|
3214
|
-
}
|
|
3215
|
-
return [{
|
|
3216
|
-
id: 'call_0',
|
|
3217
|
-
type: 'function',
|
|
3218
|
-
function: { name, arguments: args }
|
|
3219
|
-
}];
|
|
3220
|
-
}
|
|
3221
2840
|
//# sourceMappingURL=agent.js.map
|