codemini-cli 0.5.10 → 0.5.11
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/OPERATIONS.md +242 -242
- package/README.md +588 -588
- package/codemini-web/dist/assets/{highlighted-body-OFNGDK62-7HL7yft8.js → highlighted-body-OFNGDK62-CANOG7Xg.js} +1 -1
- package/codemini-web/dist/assets/{index-BK75hMb2.js → index-B71xykPM.js} +108 -108
- package/codemini-web/dist/assets/index-Dkq1DdDX.css +2 -0
- package/codemini-web/dist/assets/mermaid-GHXKKRXX-Z_w7M93P.js +1 -0
- package/codemini-web/dist/index.html +23 -23
- package/codemini-web/lib/approval-manager.js +32 -32
- package/codemini-web/lib/runtime-bridge.js +17 -11
- package/codemini-web/server.js +534 -205
- package/deployment.md +212 -212
- package/package.json +1 -1
- package/skills/brainstorm/SKILL.md +77 -77
- package/skills/codemini.skills.json +40 -40
- package/skills/grill-me/SKILL.md +30 -30
- package/skills/superpowers-lite/SKILL.md +82 -82
- package/src/cli.js +74 -74
- package/src/commands/chat.js +210 -210
- package/src/commands/run.js +313 -313
- package/src/commands/skill.js +438 -304
- package/src/commands/web.js +57 -57
- package/src/core/agent-loop.js +980 -980
- package/src/core/ast.js +309 -307
- package/src/core/chat-runtime.js +6261 -6253
- package/src/core/command-evaluator.js +72 -72
- package/src/core/command-loader.js +311 -311
- package/src/core/command-policy.js +301 -301
- package/src/core/command-risk.js +156 -156
- package/src/core/config-store.js +289 -289
- package/src/core/constants.js +18 -1
- package/src/core/context-compact.js +365 -365
- package/src/core/default-system-prompt.js +114 -107
- package/src/core/dream-audit.js +105 -105
- package/src/core/dream-consolidate.js +229 -229
- package/src/core/dream-evaluator.js +185 -185
- package/src/core/fff-adapter.js +383 -383
- package/src/core/memory-store.js +543 -543
- package/src/core/project-index.js +737 -548
- package/src/core/project-instructions.js +98 -98
- package/src/core/provider/anthropic.js +514 -514
- package/src/core/provider/openai-compatible.js +501 -501
- package/src/core/reflect-skill.js +178 -178
- package/src/core/reply-language.js +40 -40
- package/src/core/session-store.js +474 -474
- package/src/core/shell-profile.js +237 -237
- package/src/core/shell.js +323 -323
- package/src/core/soul.js +69 -69
- package/src/core/system-prompt-composer.js +52 -52
- package/src/core/tool-args.js +199 -154
- package/src/core/tool-output.js +184 -184
- package/src/core/tool-result-store.js +206 -206
- package/src/core/tools.js +3024 -2893
- package/src/core/version.js +11 -11
- package/src/tui/chat-app.js +5171 -5171
- package/src/tui/tool-activity/presenters/misc.js +30 -30
- package/src/tui/tool-activity/presenters/system.js +20 -20
- package/templates/project-requirements/report-shell.html +582 -582
- package/codemini-web/dist/assets/index-BSdIdn3L.css +0 -2
- package/codemini-web/dist/assets/mermaid-GHXKKRXX-Dg9qh8mg.js +0 -1
package/src/core/agent-loop.js
CHANGED
|
@@ -1,980 +1,980 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import { trimInline as _trimInline, normalizePath } from './string-utils.js';
|
|
3
|
-
import { captureToInbox, listInbox } from './memory-store.js';
|
|
4
|
-
import { requiresApprovalEvaluation } from './command-risk.js';
|
|
5
|
-
import { getToolOutputSanitizeOptions, sanitizeTextForModel } from './tool-output.js';
|
|
6
|
-
import { normalizeToolArguments } from './tool-args.js';
|
|
7
|
-
import { storeResultIfNeeded, summarizeToolResult } from './tool-result-store.js';
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* 安全解析 JSON 字符串。
|
|
11
|
-
* 解析失败时返回带 _raw 和 _invalid_json 标记的对象,
|
|
12
|
-
* 调用方可据此决定是回退到原始文本还是报告错误。
|
|
13
|
-
*/
|
|
14
|
-
function safeJsonParse(raw) {
|
|
15
|
-
if (!raw || typeof raw !== 'string') return {};
|
|
16
|
-
try {
|
|
17
|
-
return JSON.parse(raw);
|
|
18
|
-
} catch (parseError) {
|
|
19
|
-
return {
|
|
20
|
-
_raw: String(raw),
|
|
21
|
-
_invalid_json: true,
|
|
22
|
-
_parseError: parseError.message
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function buildDeleteApprovalDetails(source, rawPath) {
|
|
28
|
-
const existing =
|
|
29
|
-
source?.approval && typeof source.approval === 'object' && !Array.isArray(source.approval)
|
|
30
|
-
? source.approval
|
|
31
|
-
: {};
|
|
32
|
-
const approvalPath = String(existing.path || rawPath || '').trim();
|
|
33
|
-
const approvalName = String(existing.name || (approvalPath ? path.basename(approvalPath) : '') || '').trim();
|
|
34
|
-
const approvalType = String(existing.type || '').trim();
|
|
35
|
-
|
|
36
|
-
const approval = {};
|
|
37
|
-
if (approvalPath) approval.path = approvalPath;
|
|
38
|
-
if (approvalName) approval.name = approvalName;
|
|
39
|
-
if (approvalType) approval.type = approvalType;
|
|
40
|
-
return Object.keys(approval).length > 0 ? approval : undefined;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function buildDeleteCancellationResult(args) {
|
|
44
|
-
const approval =
|
|
45
|
-
args?.approval && typeof args.approval === 'object' && !Array.isArray(args.approval)
|
|
46
|
-
? args.approval
|
|
47
|
-
: undefined;
|
|
48
|
-
const pathValue = String(approval?.path || args?.path || '').trim();
|
|
49
|
-
const nameValue = String(approval?.name || (pathValue ? path.basename(pathValue) : '') || '').trim();
|
|
50
|
-
const typeValue = String(approval?.type || '').trim();
|
|
51
|
-
return {
|
|
52
|
-
ok: false,
|
|
53
|
-
...(pathValue ? { path: pathValue } : {}),
|
|
54
|
-
...(nameValue ? { name: nameValue } : {}),
|
|
55
|
-
...(typeValue ? { type: typeValue } : {}),
|
|
56
|
-
deleted: false,
|
|
57
|
-
cancelled: true,
|
|
58
|
-
reason: 'User denied deletion approval'
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function emptyToolResultMarker(toolName) {
|
|
63
|
-
const name = String(toolName || 'tool').trim() || 'tool';
|
|
64
|
-
return `(${name} completed with no output)`;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function clipToolResult(result, maxChars = 12000) {
|
|
68
|
-
const raw = sanitizeTextForModel(typeof result === 'string' ? result : JSON.stringify(result));
|
|
69
|
-
if (!maxChars || raw.length <= maxChars) return raw;
|
|
70
|
-
return `${raw.slice(0, maxChars)}\n... [tool result truncated ${raw.length - maxChars} chars]`;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function compactToolResult(result, toolName, args, maxChars = 12000) {
|
|
74
|
-
if (result === null || result === undefined) return 'no output';
|
|
75
|
-
if (typeof result === 'string') {
|
|
76
|
-
const sanitized = sanitizeTextForModel(result);
|
|
77
|
-
if (sanitized.length <= maxChars) return sanitized;
|
|
78
|
-
return `${sanitized.slice(0, maxChars)}\n... [tool result truncated ${sanitized.length - maxChars} chars, original: ${sanitized.length}]`;
|
|
79
|
-
}
|
|
80
|
-
if (typeof result !== 'object') return String(result);
|
|
81
|
-
|
|
82
|
-
const obj = result;
|
|
83
|
-
const rawLen = JSON.stringify(obj).length;
|
|
84
|
-
|
|
85
|
-
// Read file result: { path, phase, content, ... }
|
|
86
|
-
if ('path' in obj && 'phase' in obj && obj.phase === 'content') {
|
|
87
|
-
const header = `[File: ${obj.path}, lines ${obj.start_line || 1}-${obj.end_line || '?'}${obj.total_lines ? ` of ${obj.total_lines}` : ''}${obj.truncated ? ', truncated' : ''}]`;
|
|
88
|
-
const content = obj.content || obj.text || '';
|
|
89
|
-
if (typeof content !== 'string' || content.length <= maxChars) {
|
|
90
|
-
const body = typeof content === 'string' ? content : JSON.stringify(content);
|
|
91
|
-
return body.length <= maxChars ? `${header}\n${body}` : `${header}\n${body.slice(0, maxChars)}\n... [omitted ${body.length - maxChars} chars, original: ${rawLen}]`;
|
|
92
|
-
}
|
|
93
|
-
// Keep head + tail
|
|
94
|
-
const headLen = Math.floor(maxChars * 0.6);
|
|
95
|
-
const tailLen = Math.floor(maxChars * 0.3);
|
|
96
|
-
return `${header}\n${content.slice(0, headLen)}\n... [omitted ${content.length - headLen - tailLen} chars] ...\n${content.slice(-tailLen)}\n[original: ${rawLen} chars]`;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// File edit/write result: { path, action, ... }
|
|
100
|
-
if ('path' in obj && 'action' in obj) {
|
|
101
|
-
const summary = summarizeToolResult(obj);
|
|
102
|
-
const diff = obj.diff || obj.patch || obj.content_preview || '';
|
|
103
|
-
if (diff && typeof diff === 'string' && diff.length <= 800) {
|
|
104
|
-
return `${summary}\n${diff}`;
|
|
105
|
-
}
|
|
106
|
-
if (diff) {
|
|
107
|
-
return `${summary}\n${diff.slice(0, 800)}\n... [diff truncated, original: ${rawLen}]`;
|
|
108
|
-
}
|
|
109
|
-
return `${summary} [original: ${rawLen} chars]`;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Shell command result: { stdout, stderr, code, ... }
|
|
113
|
-
if ('stdout' in obj || 'stderr' in obj || 'code' in obj) {
|
|
114
|
-
const command = String(obj.command || '').slice(0, 200);
|
|
115
|
-
const stdout = String(obj.stdout || '').slice(0, 500);
|
|
116
|
-
const stderr = String(obj.stderr || '').slice(0, 500);
|
|
117
|
-
const code = obj.code ?? 0;
|
|
118
|
-
const parts = [`[exit: ${code}]`];
|
|
119
|
-
if (command) parts.push(`command: ${command}`);
|
|
120
|
-
if (stdout) parts.push(`stdout:\n${stdout}`);
|
|
121
|
-
if (stderr) parts.push(`stderr:\n${stderr}`);
|
|
122
|
-
if (rawLen > 2000) parts.push(`[original: ${rawLen} chars]`);
|
|
123
|
-
return parts.join('\n');
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Array results (file lists, grep results, etc.)
|
|
127
|
-
if (Array.isArray(obj)) {
|
|
128
|
-
const maxItems = 50;
|
|
129
|
-
if (obj.length <= maxItems) {
|
|
130
|
-
const serialized = JSON.stringify(obj);
|
|
131
|
-
return serialized.length <= maxChars ? serialized : clipToolResult(obj, maxChars);
|
|
132
|
-
}
|
|
133
|
-
const kept = obj.slice(0, maxItems);
|
|
134
|
-
const items = typeof kept[0] === 'string'
|
|
135
|
-
? kept.join('\n')
|
|
136
|
-
: kept.map((item) => JSON.stringify(item)).join('\n');
|
|
137
|
-
return `${items}\n... and ${obj.length - maxItems} more items [total: ${obj.length}, original: ${rawLen} chars]`;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// Patch result: { files: [...] }
|
|
141
|
-
if ('files' in obj && Array.isArray(obj.files)) {
|
|
142
|
-
return `patched ${obj.files.length} file(s): ${obj.files.slice(0, 10).join(', ')}${obj.files.length > 10 ? ` ... and ${obj.files.length - 10} more` : ''} [original: ${rawLen}]`;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Task results
|
|
146
|
-
if ('created' in obj && Array.isArray(obj.created)) {
|
|
147
|
-
return `created ${obj.created.length} task(s)`;
|
|
148
|
-
}
|
|
149
|
-
if ('tasks' in obj && Array.isArray(obj.tasks)) {
|
|
150
|
-
return `${obj.tasks.length} task(s)`;
|
|
151
|
-
}
|
|
152
|
-
if ('newTodos' in obj && Array.isArray(obj.newTodos)) {
|
|
153
|
-
return obj.newTodos.length > 0 ? `updated ${obj.newTodos.length} todo item(s)` : 'cleared todo list';
|
|
154
|
-
}
|
|
155
|
-
if ('newPlan' in obj) {
|
|
156
|
-
return obj.newPlan ? `updated plan state (${String(obj.newPlan.status || 'draft')})` : 'cleared plan state';
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Fallback: clip with reduced limit
|
|
160
|
-
return clipToolResult(obj, Math.min(maxChars, 4000));
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// ─── P1a: Read-only tool classification ──────────────────────────────
|
|
164
|
-
|
|
165
|
-
const READ_ONLY_TOOLS = new Set([
|
|
166
|
-
'read', 'grep', 'glob', 'list',
|
|
167
|
-
'ast_query', 'read_ast_node',
|
|
168
|
-
'web_fetch', 'web_search',
|
|
169
|
-
'list_background_tasks', 'get_background_task',
|
|
170
|
-
'read_plan'
|
|
171
|
-
]);
|
|
172
|
-
|
|
173
|
-
// ─── Auto-capture tool errors to dream loop inbox ────────────────────
|
|
174
|
-
|
|
175
|
-
const DREAM_AUTO_CAPTURE_TOOLS = new Set([
|
|
176
|
-
'edit', 'write', 'run', 'delete'
|
|
177
|
-
]);
|
|
178
|
-
|
|
179
|
-
const DREAM_AUTO_CAPTURE_COOLDOWN_MS = 60_000;
|
|
180
|
-
const lastAutoCaptureByTool = new Map();
|
|
181
|
-
|
|
182
|
-
function isAutoCaptureEnabled(config = {}) {
|
|
183
|
-
return config?.memory?.enabled !== false && config?.memory?.auto_capture !== false;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function shouldAutoCaptureError(toolName, message) {
|
|
187
|
-
if (!DREAM_AUTO_CAPTURE_TOOLS.has(toolName)) return false;
|
|
188
|
-
const now = Date.now();
|
|
189
|
-
const lastTime = lastAutoCaptureByTool.get(toolName) || 0;
|
|
190
|
-
if (now - lastTime < DREAM_AUTO_CAPTURE_COOLDOWN_MS) return false;
|
|
191
|
-
const noisePatterns = [
|
|
192
|
-
/file already exists/i,
|
|
193
|
-
/no such file/i,
|
|
194
|
-
/not found$/i,
|
|
195
|
-
/already exists$/i,
|
|
196
|
-
/cancelled/i,
|
|
197
|
-
/aborted/i,
|
|
198
|
-
/blocked by (?:safe mode|policy|dangerous command)/i,
|
|
199
|
-
/exit 127/i,
|
|
200
|
-
/command not found/i,
|
|
201
|
-
/permission denied/i,
|
|
202
|
-
/args\?\s/i,
|
|
203
|
-
/path.*outside workspace/i,
|
|
204
|
-
/escapes workspace/i
|
|
205
|
-
];
|
|
206
|
-
if (noisePatterns.some((p) => p.test(message))) return false;
|
|
207
|
-
lastAutoCaptureByTool.set(toolName, now);
|
|
208
|
-
return true;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
async function captureToolFailure(toolName, message, args, config = {}) {
|
|
212
|
-
if (!isAutoCaptureEnabled(config)) return;
|
|
213
|
-
const summary = `[${toolName}] ${String(message).slice(0, 120)}`;
|
|
214
|
-
const details = args
|
|
215
|
-
? `Tool: ${toolName}\nError: ${message}\nArgs: ${JSON.stringify(args).slice(0, 300)}`
|
|
216
|
-
: `Tool: ${toolName}\nError: ${message}`;
|
|
217
|
-
await captureToInbox({
|
|
218
|
-
scope: 'repo',
|
|
219
|
-
type: 'failure',
|
|
220
|
-
summary,
|
|
221
|
-
details,
|
|
222
|
-
source: 'auto-capture'
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
async function checkAutoDreamThreshold(config) {
|
|
227
|
-
const threshold = Number(config?.memory?.auto_dream_threshold || 10);
|
|
228
|
-
if (threshold <= 0) return false;
|
|
229
|
-
try {
|
|
230
|
-
const entries = await listInbox();
|
|
231
|
-
return entries.length >= threshold;
|
|
232
|
-
} catch {
|
|
233
|
-
return false;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// ─── Exported helpers ────────────────────────────────────────────────
|
|
238
|
-
|
|
239
|
-
function extractFileChange(toolName, result) {
|
|
240
|
-
if (!result || typeof result !== 'object') return null;
|
|
241
|
-
const FILE_TOOLS = new Set(['edit', 'write', 'delete']);
|
|
242
|
-
if (!FILE_TOOLS.has(toolName)) return null;
|
|
243
|
-
|
|
244
|
-
/* delete */
|
|
245
|
-
if ('deleted' in result && result.deleted) {
|
|
246
|
-
return { path: String(result.path || ''), action: 'delete', linesAdded: 0, linesRemoved: 0 };
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/* edit / write */
|
|
250
|
-
if ('path' in result && 'action' in result) {
|
|
251
|
-
const action = String(result.action || '');
|
|
252
|
-
const isCreate = action === 'create';
|
|
253
|
-
const added = Number(result.lines_added || 0);
|
|
254
|
-
const removed = Number(result.lines_removed || 0);
|
|
255
|
-
return {
|
|
256
|
-
path: String(result.path || ''),
|
|
257
|
-
action: isCreate ? 'create' : 'edit',
|
|
258
|
-
linesAdded: added,
|
|
259
|
-
linesRemoved: removed
|
|
260
|
-
};
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
return null;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
export const trimInline = _trimInline;
|
|
267
|
-
|
|
268
|
-
function normalizeAssistantText(value) {
|
|
269
|
-
return String(value || '').trim();
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function hasTrailingToolContext(messages = []) {
|
|
273
|
-
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
274
|
-
const message = messages[index];
|
|
275
|
-
if (!message || typeof message !== 'object') continue;
|
|
276
|
-
if (message.role === 'tool') return true;
|
|
277
|
-
if (message.role === 'assistant' || message.role === 'user') return false;
|
|
278
|
-
}
|
|
279
|
-
return false;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
function isGenericCompletionText(text) {
|
|
283
|
-
const normalized = normalizeAssistantText(text).toLowerCase();
|
|
284
|
-
if (!normalized) return false;
|
|
285
|
-
const genericPhrases = new Set([
|
|
286
|
-
'done',
|
|
287
|
-
'completed',
|
|
288
|
-
'complete',
|
|
289
|
-
'finished',
|
|
290
|
-
'task completed',
|
|
291
|
-
'all done',
|
|
292
|
-
'ok',
|
|
293
|
-
'okay',
|
|
294
|
-
'已完成',
|
|
295
|
-
'已完成任务',
|
|
296
|
-
'完成',
|
|
297
|
-
'任务完成'
|
|
298
|
-
]);
|
|
299
|
-
return genericPhrases.has(normalized);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
function shouldAskForConcreteFinalAnswer(text, messages = []) {
|
|
303
|
-
if (!hasTrailingToolContext(messages)) return false;
|
|
304
|
-
const normalized = normalizeAssistantText(text);
|
|
305
|
-
if (!normalized) return true;
|
|
306
|
-
return isGenericCompletionText(normalized);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
function isBroadRepositoryAnalysisTask(text) {
|
|
310
|
-
const normalized = String(text || '').trim().toLowerCase();
|
|
311
|
-
if (!normalized) return false;
|
|
312
|
-
return (
|
|
313
|
-
/optimi|improve|analy[sz]e|audit|review|overview|architecture|codebase|repository|repo/.test(normalized) ||
|
|
314
|
-
/项目.*优化|项目.*问题|可优化|分析这个项目|看看.*项目|代码库|仓库/.test(String(text || ''))
|
|
315
|
-
);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
function parseProjectIndexSummary(text) {
|
|
319
|
-
const sourceRoots = [];
|
|
320
|
-
const entryCandidates = [];
|
|
321
|
-
const candidateFiles = [];
|
|
322
|
-
for (const line of String(text || '').split(/\r?\n/)) {
|
|
323
|
-
const trimmed = line.trim();
|
|
324
|
-
if (trimmed.startsWith('source_roots:')) {
|
|
325
|
-
sourceRoots.push(
|
|
326
|
-
...String(trimmed.slice('source_roots:'.length))
|
|
327
|
-
.split(',')
|
|
328
|
-
.map((value) => value.trim())
|
|
329
|
-
.filter(Boolean)
|
|
330
|
-
);
|
|
331
|
-
} else if (trimmed.startsWith('entry_candidates:')) {
|
|
332
|
-
entryCandidates.push(
|
|
333
|
-
...String(trimmed.slice('entry_candidates:'.length))
|
|
334
|
-
.split(',')
|
|
335
|
-
.map((value) => value.trim())
|
|
336
|
-
.filter(Boolean)
|
|
337
|
-
);
|
|
338
|
-
} else if (trimmed.startsWith('- ')) {
|
|
339
|
-
const match = trimmed.match(/^- ([^ ]+)/);
|
|
340
|
-
if (match?.[1]) candidateFiles.push(match[1].trim());
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
return { sourceRoots, entryCandidates, candidateFiles };
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function createAnalysisGuardState(userPrompt) {
|
|
347
|
-
return {
|
|
348
|
-
active: isBroadRepositoryAnalysisTask(userPrompt),
|
|
349
|
-
indexQueried: false,
|
|
350
|
-
sourceRoots: new Set(),
|
|
351
|
-
entryCandidates: new Set(),
|
|
352
|
-
candidateFiles: new Set(),
|
|
353
|
-
relevantSourceReads: new Set(),
|
|
354
|
-
blockedExplorations: 0
|
|
355
|
-
};
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function topLevelPath(value) {
|
|
359
|
-
const normalized = normalizePath(value).trim();
|
|
360
|
-
return normalized.split('/')[0] || '';
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
function isRelevantSourcePath(filePath, state) {
|
|
364
|
-
const normalized = normalizePath(filePath).trim();
|
|
365
|
-
if (!normalized) return false;
|
|
366
|
-
if (state.candidateFiles.has(normalized) || state.entryCandidates.has(normalized)) return true;
|
|
367
|
-
for (const root of state.sourceRoots) {
|
|
368
|
-
if (normalized === root || normalized.startsWith(`${root}/`)) return true;
|
|
369
|
-
}
|
|
370
|
-
return false;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
function blockedExplorationReason(toolName, args, state) {
|
|
374
|
-
if (!state.active) return '';
|
|
375
|
-
|
|
376
|
-
// Always note when query_project_index is used, but never force it
|
|
377
|
-
if (toolName === 'query_project_index') return '';
|
|
378
|
-
|
|
379
|
-
const target = normalizePath(String(args?.path || args?.pattern || args?.query || '')).trim();
|
|
380
|
-
const top = topLevelPath(target);
|
|
381
|
-
if (!top) return '';
|
|
382
|
-
|
|
383
|
-
if (['skills', 'souls', 'templates', '.codemini', '.codemini-global'].includes(top)) {
|
|
384
|
-
return `Skip ${top}/ for broad repository analysis unless the user explicitly asks for it. Inspect relevant source files first.`;
|
|
385
|
-
}
|
|
386
|
-
return '';
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
function noteAnalysisEvidence(state, toolName, args, toolResult) {
|
|
390
|
-
if (!state.active) return;
|
|
391
|
-
if (toolName === 'query_project_index') {
|
|
392
|
-
state.indexQueried = true;
|
|
393
|
-
const summary = parseProjectIndexSummary(JSON.stringify(toolResult));
|
|
394
|
-
for (const root of summary.sourceRoots) state.sourceRoots.add(root);
|
|
395
|
-
for (const entry of summary.entryCandidates) state.entryCandidates.add(entry);
|
|
396
|
-
for (const file of summary.candidateFiles) state.candidateFiles.add(file);
|
|
397
|
-
const projectMap = toolResult?.project_map || {};
|
|
398
|
-
for (const root of projectMap.source_roots || []) state.sourceRoots.add(String(root));
|
|
399
|
-
for (const entry of projectMap.entry_candidates || []) state.entryCandidates.add(String(entry));
|
|
400
|
-
for (const match of toolResult?.matches || []) {
|
|
401
|
-
if (match?.file) state.candidateFiles.add(String(match.file));
|
|
402
|
-
}
|
|
403
|
-
return;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
if (toolName === 'read') {
|
|
407
|
-
const filePath = String(toolResult?.path || args?.path || '').split(':')[0];
|
|
408
|
-
if (isRelevantSourcePath(filePath, state)) {
|
|
409
|
-
state.relevantSourceReads.add(filePath);
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
function needsMoreAnalysisEvidence(state) {
|
|
415
|
-
if (!state.active) return false;
|
|
416
|
-
if (!state.indexQueried) return true;
|
|
417
|
-
return state.relevantSourceReads.size < 2;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
function normalizeToolCallName(name) {
|
|
421
|
-
return String(name || '').trim();
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
function formatToolDisplayName(name, args) {
|
|
425
|
-
if (name === 'grep') {
|
|
426
|
-
const query = trimInline(args?.pattern || args?.query || args?.symbol || '', 96);
|
|
427
|
-
return query ? `grep("${query}")` : 'grep';
|
|
428
|
-
}
|
|
429
|
-
if (name === 'glob') {
|
|
430
|
-
const pattern = trimInline(args?.pattern || '', 96);
|
|
431
|
-
return pattern ? `glob("${pattern}")` : 'glob';
|
|
432
|
-
}
|
|
433
|
-
if (name === 'list') {
|
|
434
|
-
const target = trimInline(args?.path || '.', 96) || '.';
|
|
435
|
-
return `list(${target})`;
|
|
436
|
-
}
|
|
437
|
-
if (name === 'read' || name === 'write') {
|
|
438
|
-
const target = trimInline(args?.path || '.', 96) || '.';
|
|
439
|
-
if (name === 'read') {
|
|
440
|
-
const start = Number(args?.start_line);
|
|
441
|
-
const end = Number(args?.end_line);
|
|
442
|
-
const hasRange = Number.isFinite(start) && start > 0;
|
|
443
|
-
const suffix = hasRange ? `:${start}-${Number.isFinite(end) && end >= start ? end : start}` : '';
|
|
444
|
-
return `read(${target}${suffix})`;
|
|
445
|
-
}
|
|
446
|
-
return `write(${target})`;
|
|
447
|
-
}
|
|
448
|
-
if (name === 'run') {
|
|
449
|
-
const command = trimInline(args?.command || '', 96);
|
|
450
|
-
return command ? `run(${command})` : name;
|
|
451
|
-
}
|
|
452
|
-
if (name === 'web_fetch') {
|
|
453
|
-
const url = trimInline(args?.url || args?.href || '', 96);
|
|
454
|
-
return url ? `web_fetch(${url})` : name;
|
|
455
|
-
}
|
|
456
|
-
if (name === 'web_search') {
|
|
457
|
-
const query = trimInline(args?.query || args?.q || '', 96);
|
|
458
|
-
return query ? `web_search(${query})` : name;
|
|
459
|
-
}
|
|
460
|
-
if (name === 'edit') {
|
|
461
|
-
const target = trimInline(args?.path || args?.file || '.', 96) || '.';
|
|
462
|
-
return `edit(${target})`;
|
|
463
|
-
}
|
|
464
|
-
if (name === 'delete') {
|
|
465
|
-
const target = trimInline(args?.path || args?.target || '.', 96) || '.';
|
|
466
|
-
return `delete(${target})`;
|
|
467
|
-
}
|
|
468
|
-
if (name === 'update_todos') {
|
|
469
|
-
return 'update_todos';
|
|
470
|
-
}
|
|
471
|
-
if (name === 'read_plan' || name === 'update_plan') {
|
|
472
|
-
return name;
|
|
473
|
-
}
|
|
474
|
-
if (name === 'list_background_tasks') {
|
|
475
|
-
return name;
|
|
476
|
-
}
|
|
477
|
-
if (name === 'get_background_task' || name === 'stop_background_task') {
|
|
478
|
-
const taskId = trimInline(args?.task_id || args?.taskId || '', 96);
|
|
479
|
-
return taskId ? `${name}(${taskId})` : name;
|
|
480
|
-
}
|
|
481
|
-
return name;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
// ─── Format a single tool result using per-tool formatter or fallback ──
|
|
485
|
-
|
|
486
|
-
function formatToolResult(toolResult, toolName, args, toolFormatters, toolResultMaxChars) {
|
|
487
|
-
const sanitizeOptions = getToolOutputSanitizeOptions(toolName);
|
|
488
|
-
if (toolFormatters && typeof toolFormatters[toolName] === 'function') {
|
|
489
|
-
const formatted = toolFormatters[toolName](toolResult, args);
|
|
490
|
-
if (typeof formatted === 'string') {
|
|
491
|
-
const sanitized = sanitizeTextForModel(formatted, sanitizeOptions);
|
|
492
|
-
return sanitized.trim() ? sanitized : emptyToolResultMarker(toolName);
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
const fallback = compactToolResult(toolResult, toolName, args, toolResultMaxChars);
|
|
496
|
-
const sanitizedFallback = sanitizeTextForModel(fallback, sanitizeOptions);
|
|
497
|
-
return String(sanitizedFallback || '').trim() ? sanitizedFallback : emptyToolResultMarker(toolName);
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
// ─── Main agent loop ────────────────────────────────────────────────
|
|
501
|
-
|
|
502
|
-
export async function runAgentLoop({
|
|
503
|
-
systemPrompt,
|
|
504
|
-
userPrompt,
|
|
505
|
-
model,
|
|
506
|
-
requestCompletion,
|
|
507
|
-
toolHandlers = {},
|
|
508
|
-
toolDefinitions = [],
|
|
509
|
-
maxSteps = 8,
|
|
510
|
-
initialMessages = [],
|
|
511
|
-
onEvent,
|
|
512
|
-
executionMode = 'auto',
|
|
513
|
-
alwaysAllowTools = [],
|
|
514
|
-
requestToolApproval,
|
|
515
|
-
toolResultMaxChars = 12000,
|
|
516
|
-
toolFormatters = {},
|
|
517
|
-
deferredDefinitions = {},
|
|
518
|
-
signal,
|
|
519
|
-
skipAnalysisNudge = false,
|
|
520
|
-
config = {}
|
|
521
|
-
}) {
|
|
522
|
-
const messages = [];
|
|
523
|
-
if (systemPrompt) {
|
|
524
|
-
messages.push({ role: 'system', content: systemPrompt });
|
|
525
|
-
}
|
|
526
|
-
if (Array.isArray(initialMessages) && initialMessages.length > 0) {
|
|
527
|
-
messages.push(...initialMessages);
|
|
528
|
-
}
|
|
529
|
-
if (userPrompt) {
|
|
530
|
-
messages.push({ role: 'user', content: userPrompt });
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
let finalText = '';
|
|
534
|
-
let lastAssistantText = '';
|
|
535
|
-
let pendingSummaryNudges = 0;
|
|
536
|
-
const analysisGuard = createAnalysisGuardState(userPrompt);
|
|
537
|
-
const alwaysAllowSet = new Set((Array.isArray(alwaysAllowTools) ? alwaysAllowTools : []).map((t) => String(t)));
|
|
538
|
-
let lastAutoDreamCheckStep = 0;
|
|
539
|
-
|
|
540
|
-
// Mutable tool list — grows as tool_search loads deferred tools
|
|
541
|
-
const activeTools = [...toolDefinitions];
|
|
542
|
-
|
|
543
|
-
async function maybeRunAutoDream(stepNumber = 0, { force = false } = {}) {
|
|
544
|
-
if (executionMode === 'plan') return;
|
|
545
|
-
const interval = Math.max(1, Number(config?.memory?.auto_dream_check_interval_steps || 20));
|
|
546
|
-
const normalizedStep = Math.max(1, Number(stepNumber || 1));
|
|
547
|
-
if (!force && lastAutoDreamCheckStep > 0 && normalizedStep - lastAutoDreamCheckStep < interval) return;
|
|
548
|
-
if (force && lastAutoDreamCheckStep === normalizedStep) return;
|
|
549
|
-
lastAutoDreamCheckStep = normalizedStep;
|
|
550
|
-
const autoDreamResult = await checkAutoDreamThreshold(config);
|
|
551
|
-
if (!autoDreamResult) return;
|
|
552
|
-
const dreamTool = toolHandlers['dream_consolidate'];
|
|
553
|
-
if (typeof dreamTool !== 'function') return;
|
|
554
|
-
if (onEvent) onEvent({ type: 'dream:auto', message: 'inbox threshold reached' });
|
|
555
|
-
try {
|
|
556
|
-
const report = await dreamTool({});
|
|
557
|
-
if (onEvent) {
|
|
558
|
-
onEvent({ type: 'dream:complete', report });
|
|
559
|
-
}
|
|
560
|
-
} catch (error) {
|
|
561
|
-
if (onEvent) {
|
|
562
|
-
onEvent({
|
|
563
|
-
type: 'dream:complete',
|
|
564
|
-
report: { ok: false, error: String(error?.message || error || 'unknown dream error') }
|
|
565
|
-
});
|
|
566
|
-
}
|
|
567
|
-
// Auto-dream is best-effort; don't block the loop
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
for (let step = 0; step < maxSteps; step += 1) {
|
|
572
|
-
// 检查是否已被用户中止
|
|
573
|
-
if (signal?.aborted) {
|
|
574
|
-
if (onEvent) onEvent({ type: 'aborted', step: step + 1 });
|
|
575
|
-
break;
|
|
576
|
-
}
|
|
577
|
-
if (onEvent) onEvent({ type: 'step:start', step: step + 1 });
|
|
578
|
-
await maybeRunAutoDream(step + 1);
|
|
579
|
-
const completion = await requestCompletion({
|
|
580
|
-
model,
|
|
581
|
-
messages,
|
|
582
|
-
tools: activeTools,
|
|
583
|
-
signal
|
|
584
|
-
});
|
|
585
|
-
|
|
586
|
-
// 流式请求完成后再次检查中止状态
|
|
587
|
-
if (signal?.aborted) {
|
|
588
|
-
if (onEvent) onEvent({ type: 'aborted', step: step + 1 });
|
|
589
|
-
break;
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
if (completion?.incomplete) {
|
|
593
|
-
continue;
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
const toolCalls = Array.isArray(completion.toolCalls) ? completion.toolCalls : [];
|
|
597
|
-
const assistantText = completion.text || '';
|
|
598
|
-
lastAssistantText = assistantText || lastAssistantText;
|
|
599
|
-
|
|
600
|
-
const assistantMessage = completion?.assistantMessage
|
|
601
|
-
? {
|
|
602
|
-
...completion.assistantMessage,
|
|
603
|
-
role: 'assistant',
|
|
604
|
-
content: completion.assistantMessage.content ?? completion?.content ?? assistantText
|
|
605
|
-
}
|
|
606
|
-
: { role: 'assistant', content: completion?.content ?? assistantText };
|
|
607
|
-
if (!Array.isArray(assistantMessage.tool_calls) && toolCalls.length > 0) {
|
|
608
|
-
assistantMessage.tool_calls = toolCalls.map((tc) => ({
|
|
609
|
-
id: tc.id,
|
|
610
|
-
type: 'function',
|
|
611
|
-
function: { name: tc.name, arguments: tc.arguments || '{}' }
|
|
612
|
-
}));
|
|
613
|
-
}
|
|
614
|
-
messages.push(assistantMessage);
|
|
615
|
-
if (onEvent) {
|
|
616
|
-
onEvent({
|
|
617
|
-
type: 'assistant:response',
|
|
618
|
-
step: step + 1,
|
|
619
|
-
text: assistantText,
|
|
620
|
-
toolCalls: toolCalls.map((tc) => tc.name),
|
|
621
|
-
assistantMessage
|
|
622
|
-
});
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
if (toolCalls.length === 0) {
|
|
626
|
-
if (!skipAnalysisNudge && needsMoreAnalysisEvidence(analysisGuard) && pendingSummaryNudges < 2) {
|
|
627
|
-
pendingSummaryNudges += 1;
|
|
628
|
-
messages.push({
|
|
629
|
-
role: 'user',
|
|
630
|
-
content:
|
|
631
|
-
'You have not inspected enough relevant source files yet. Query the project index if needed, then inspect the next relevant source files before concluding. Do not stop after unrelated directories, tests, skills, souls, or templates.'
|
|
632
|
-
});
|
|
633
|
-
continue;
|
|
634
|
-
}
|
|
635
|
-
if (!skipAnalysisNudge && shouldAskForConcreteFinalAnswer(assistantText, messages.slice(0, -1)) && pendingSummaryNudges < 2) {
|
|
636
|
-
pendingSummaryNudges += 1;
|
|
637
|
-
messages.push({
|
|
638
|
-
role: 'user',
|
|
639
|
-
content:
|
|
640
|
-
'You have already inspected tool results. Before stopping, check whether the task is actually complete. If it is, provide a concise final answer with specific findings or concrete next steps. If it is not, continue with the next tool call.'
|
|
641
|
-
});
|
|
642
|
-
continue;
|
|
643
|
-
}
|
|
644
|
-
finalText = assistantText;
|
|
645
|
-
await maybeRunAutoDream(step + 1, { force: true });
|
|
646
|
-
return { text: finalText, messages, steps: step + 1 };
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
pendingSummaryNudges = 0;
|
|
650
|
-
|
|
651
|
-
if (executionMode === 'plan') {
|
|
652
|
-
const plannedLines = callsToPlanSummary(toolCalls);
|
|
653
|
-
finalText = [
|
|
654
|
-
assistantText || '',
|
|
655
|
-
'',
|
|
656
|
-
`[plan mode] ${toolCalls.length} tool call(s) were planned but not executed.`,
|
|
657
|
-
plannedLines.length > 0 ? 'Planned exploration:' : '',
|
|
658
|
-
...plannedLines
|
|
659
|
-
]
|
|
660
|
-
.filter(Boolean)
|
|
661
|
-
.join('\n');
|
|
662
|
-
await maybeRunAutoDream(step + 1, { force: true });
|
|
663
|
-
return { text: finalText.trim(), messages, steps: step + 1 };
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
// ─── P1a: Partition into read-only (parallel) and write (serial) ──
|
|
667
|
-
|
|
668
|
-
const callsWithMeta = toolCalls.map((call) => {
|
|
669
|
-
const toolName = normalizeToolCallName(call.name);
|
|
670
|
-
const args = normalizeToolArguments(toolName, safeJsonParse(call.arguments), call.arguments);
|
|
671
|
-
const displayName = formatToolDisplayName(toolName, args);
|
|
672
|
-
const isReadOnly = READ_ONLY_TOOLS.has(toolName);
|
|
673
|
-
return { call, args, toolName, displayName, isReadOnly };
|
|
674
|
-
});
|
|
675
|
-
|
|
676
|
-
// Approval checks first — must be done synchronously before any execution
|
|
677
|
-
const approvalResults = new Map();
|
|
678
|
-
for (const { call, toolName, displayName, args } of callsWithMeta) {
|
|
679
|
-
let approved = true;
|
|
680
|
-
let approvalArgs = args;
|
|
681
|
-
let preflightErrorContent = '';
|
|
682
|
-
const isSafeModeRun = toolName === 'run'
|
|
683
|
-
&& config?.policy?.safe_mode !== false
|
|
684
|
-
&& requiresApprovalEvaluation(args?.command || '', config?.shell?.default);
|
|
685
|
-
const needsApproval = toolName === 'delete' || isSafeModeRun
|
|
686
|
-
|| (executionMode === 'normal' && !alwaysAllowSet.has(toolName));
|
|
687
|
-
if (needsApproval) {
|
|
688
|
-
approved = false;
|
|
689
|
-
const handler = toolHandlers[toolName];
|
|
690
|
-
if (toolName === 'delete' && typeof handler?.prepareApproval === 'function') {
|
|
691
|
-
try {
|
|
692
|
-
const approval = await handler.prepareApproval(args);
|
|
693
|
-
const normalizedApproval = buildDeleteApprovalDetails({ approval }, args?.path);
|
|
694
|
-
if (normalizedApproval) {
|
|
695
|
-
approvalArgs = { ...args, approval: normalizedApproval };
|
|
696
|
-
}
|
|
697
|
-
} catch (error) {
|
|
698
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
699
|
-
preflightErrorContent = clipToolResult({ error: message }, toolResultMaxChars);
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
/* Run tool: safe mode LLM-based command evaluation */
|
|
703
|
-
if (toolName === 'run' && isSafeModeRun && !preflightErrorContent) {
|
|
704
|
-
try {
|
|
705
|
-
const { evaluateCommandWithLLM } = await import('./command-evaluator.js');
|
|
706
|
-
const evaluation = await evaluateCommandWithLLM({
|
|
707
|
-
command: args?.command || '',
|
|
708
|
-
config,
|
|
709
|
-
workspaceRoot: config?.workspaceRoot || process.cwd()
|
|
710
|
-
});
|
|
711
|
-
approvalArgs = { ...args, _risk: evaluation.risk, _evaluation: evaluation };
|
|
712
|
-
/* LLM says low-risk + allow → auto-approve, skip confirmation panel */
|
|
713
|
-
if (evaluation.risk === 'low' && evaluation.recommendation === 'allow') {
|
|
714
|
-
approvalResults.set(call.id, { approved: true, args: approvalArgs });
|
|
715
|
-
continue;
|
|
716
|
-
}
|
|
717
|
-
} catch (_) {
|
|
718
|
-
approvalArgs = { ...args, _risk: 'high', _evaluation: null };
|
|
719
|
-
}
|
|
720
|
-
if (typeof handler?.prepareApproval === 'function') {
|
|
721
|
-
try {
|
|
722
|
-
const approval = await handler.prepareApproval(approvalArgs);
|
|
723
|
-
approvalArgs = { ...approvalArgs, approval };
|
|
724
|
-
} catch (_) { /* skip */ }
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
if (preflightErrorContent) {
|
|
728
|
-
approvalResults.set(call.id, {
|
|
729
|
-
approved: false,
|
|
730
|
-
args: approvalArgs,
|
|
731
|
-
errorContent: preflightErrorContent
|
|
732
|
-
});
|
|
733
|
-
continue;
|
|
734
|
-
}
|
|
735
|
-
if (typeof requestToolApproval === 'function') {
|
|
736
|
-
const decision = await requestToolApproval({
|
|
737
|
-
id: call.id,
|
|
738
|
-
name: toolName,
|
|
739
|
-
displayName,
|
|
740
|
-
arguments: approvalArgs,
|
|
741
|
-
approvalDetails: toolName === 'delete' ? approvalArgs.approval
|
|
742
|
-
: (toolName === 'run' ? approvalArgs.approval : undefined)
|
|
743
|
-
});
|
|
744
|
-
approved = Boolean(decision?.approved);
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
approvalResults.set(call.id, { approved, args: approvalArgs });
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
// Collect results keyed by call.id, then write to messages in original order
|
|
751
|
-
const resultEntries = new Map(); // call.id -> { content, error? }
|
|
752
|
-
|
|
753
|
-
// Helper to execute a single tool call
|
|
754
|
-
async function executeOne({ call, args, toolName, displayName, isReadOnly }) {
|
|
755
|
-
const startedAt = Date.now();
|
|
756
|
-
const approvalState = approvalResults.get(call.id) || { approved: true, args };
|
|
757
|
-
const effectiveArgs = approvalState.args || args;
|
|
758
|
-
|
|
759
|
-
if (approvalState.errorContent) {
|
|
760
|
-
const summary = trimInline(approvalState.errorContent, 120);
|
|
761
|
-
if (onEvent) {
|
|
762
|
-
onEvent({ type: 'tool:error', name: displayName, id: call.id, arguments: effectiveArgs, durationMs: 0, summary });
|
|
763
|
-
}
|
|
764
|
-
return {
|
|
765
|
-
callId: call.id,
|
|
766
|
-
content: approvalState.errorContent,
|
|
767
|
-
error: true,
|
|
768
|
-
durationMs: 0,
|
|
769
|
-
summary,
|
|
770
|
-
status: 'error'
|
|
771
|
-
};
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
if (!approvalState.approved) {
|
|
775
|
-
if (onEvent) onEvent({ type: 'tool:blocked', name: displayName, id: call.id, arguments: effectiveArgs });
|
|
776
|
-
const blockedPayload =
|
|
777
|
-
toolName === 'delete'
|
|
778
|
-
? buildDeleteCancellationResult(effectiveArgs)
|
|
779
|
-
: { blocked: true, reason: 'Tool call requires approval in normal mode' };
|
|
780
|
-
return {
|
|
781
|
-
callId: call.id,
|
|
782
|
-
content: JSON.stringify(blockedPayload),
|
|
783
|
-
blocked: true,
|
|
784
|
-
summary: 'Tool call requires approval',
|
|
785
|
-
status: 'blocked'
|
|
786
|
-
};
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
if (onEvent) onEvent({ type: 'tool:start', name: displayName, id: call.id, arguments: effectiveArgs });
|
|
790
|
-
const handler = toolHandlers[toolName];
|
|
791
|
-
if (!handler) {
|
|
792
|
-
const available = Object.keys(toolHandlers).join(', ');
|
|
793
|
-
const msg = `Unknown tool: "${toolName}". Available tools: ${available || '(none)'}`;
|
|
794
|
-
const summary = trimInline(msg, 200);
|
|
795
|
-
if (onEvent) {
|
|
796
|
-
onEvent({ type: 'tool:error', name: displayName, id: call.id, arguments: effectiveArgs, durationMs: 0, summary });
|
|
797
|
-
}
|
|
798
|
-
return {
|
|
799
|
-
callId: call.id,
|
|
800
|
-
content: JSON.stringify({ error: msg }),
|
|
801
|
-
error: true,
|
|
802
|
-
durationMs: 0,
|
|
803
|
-
summary,
|
|
804
|
-
status: 'error'
|
|
805
|
-
};
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
const blockedReason = blockedExplorationReason(toolName, effectiveArgs, analysisGuard);
|
|
809
|
-
if (blockedReason) {
|
|
810
|
-
analysisGuard.blockedExplorations += 1;
|
|
811
|
-
const content = clipToolResult({ error: blockedReason }, toolResultMaxChars);
|
|
812
|
-
const summary = trimInline(blockedReason, 120);
|
|
813
|
-
if (onEvent) {
|
|
814
|
-
onEvent({ type: 'tool:error', name: displayName, id: call.id, arguments: effectiveArgs, durationMs: 0, summary });
|
|
815
|
-
}
|
|
816
|
-
return {
|
|
817
|
-
callId: call.id,
|
|
818
|
-
content,
|
|
819
|
-
error: true,
|
|
820
|
-
durationMs: 0,
|
|
821
|
-
summary,
|
|
822
|
-
status: 'error'
|
|
823
|
-
};
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
let toolResult;
|
|
827
|
-
try {
|
|
828
|
-
toolResult = await handler(effectiveArgs);
|
|
829
|
-
} catch (error) {
|
|
830
|
-
const durationMs = Date.now() - startedAt;
|
|
831
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
832
|
-
const summary = trimInline(message, 120);
|
|
833
|
-
if (onEvent) {
|
|
834
|
-
onEvent({ type: 'tool:error', name: displayName, id: call.id, arguments: effectiveArgs, durationMs, summary });
|
|
835
|
-
}
|
|
836
|
-
if (isAutoCaptureEnabled(config) && shouldAutoCaptureError(toolName, message)) {
|
|
837
|
-
await captureToolFailure(toolName, message, effectiveArgs, config).catch(() => {});
|
|
838
|
-
}
|
|
839
|
-
return {
|
|
840
|
-
callId: call.id,
|
|
841
|
-
content: clipToolResult({ error: message }, toolResultMaxChars),
|
|
842
|
-
error: true,
|
|
843
|
-
durationMs,
|
|
844
|
-
summary,
|
|
845
|
-
status: 'error'
|
|
846
|
-
};
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
const durationMs = Date.now() - startedAt;
|
|
850
|
-
const summary = summarizeToolResult(toolResult);
|
|
851
|
-
/* 提取文件改动统计 */
|
|
852
|
-
const fileChange = extractFileChange(toolName, toolResult);
|
|
853
|
-
if (onEvent) {
|
|
854
|
-
onEvent({ type: 'tool:end', name: displayName, id: call.id, arguments: effectiveArgs, durationMs, summary, fileChange });
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
// Auto-capture non-throwing tool failures (e.g. shell non-zero exit)
|
|
858
|
-
if (toolResult && typeof toolResult === 'object') {
|
|
859
|
-
const exitCode = toolResult.code ?? toolResult.exitCode;
|
|
860
|
-
const stderr = String(toolResult.stderr || '');
|
|
861
|
-
if (typeof exitCode === 'number' && exitCode !== 0 && stderr) {
|
|
862
|
-
const failMsg = `exit ${exitCode}: ${stderr.slice(0, 120)}`;
|
|
863
|
-
if (isAutoCaptureEnabled(config) && shouldAutoCaptureError(toolName, failMsg)) {
|
|
864
|
-
await captureToolFailure(toolName, failMsg, effectiveArgs, config).catch(() => {});
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
if (toolResult.error) {
|
|
868
|
-
const errMsg = String(toolResult.error).slice(0, 120);
|
|
869
|
-
if (isAutoCaptureEnabled(config) && shouldAutoCaptureError(toolName, errMsg)) {
|
|
870
|
-
await captureToolFailure(toolName, errMsg, effectiveArgs, config).catch(() => {});
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
// P1b: Use per-tool formatter if available, else fallback
|
|
876
|
-
let formatted = formatToolResult(toolResult, toolName, effectiveArgs, toolFormatters, toolResultMaxChars);
|
|
877
|
-
noteAnalysisEvidence(analysisGuard, toolName, effectiveArgs, toolResult);
|
|
878
|
-
|
|
879
|
-
// P2: If tool_search loaded deferred tools, inject their schemas into activeTools
|
|
880
|
-
if (toolName === 'tool_search' && toolResult && Array.isArray(toolResult.schemas)) {
|
|
881
|
-
for (const schema of toolResult.schemas) {
|
|
882
|
-
const name = schema?.function?.name;
|
|
883
|
-
if (name && !activeTools.some((t) => t?.function?.name === name)) {
|
|
884
|
-
activeTools.push(schema);
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
// P0: Persist to disk if still large
|
|
890
|
-
formatted = await storeResultIfNeeded(call.id, formatted, toolResult);
|
|
891
|
-
|
|
892
|
-
return { callId: call.id, content: formatted, durationMs, summary, status: 'done' };
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
// Separate read-only and write calls, preserving order
|
|
896
|
-
const readOnlyCalls = callsWithMeta.filter((c) => c.isReadOnly && approvalResults.get(c.call.id)?.approved);
|
|
897
|
-
const writeCalls = callsWithMeta.filter((c) => !c.isReadOnly || !approvalResults.get(c.call.id)?.approved);
|
|
898
|
-
|
|
899
|
-
// Execute read-only calls in parallel
|
|
900
|
-
if (readOnlyCalls.length > 0) {
|
|
901
|
-
const readOnlyResults = await Promise.all(readOnlyCalls.map((c) => executeOne(c)));
|
|
902
|
-
for (const r of readOnlyResults) {
|
|
903
|
-
resultEntries.set(r.callId, r);
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
// Execute write calls serially
|
|
908
|
-
for (const c of writeCalls) {
|
|
909
|
-
const r = await executeOne(c);
|
|
910
|
-
resultEntries.set(r.callId, r);
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
// Write results to messages in original tool call order
|
|
914
|
-
for (const { call, displayName, args } of callsWithMeta) {
|
|
915
|
-
const entry = resultEntries.get(call.id);
|
|
916
|
-
if (!entry) continue;
|
|
917
|
-
|
|
918
|
-
if (entry.blocked) {
|
|
919
|
-
attachToolCallSessionMeta(assistantMessage, call.id, { summary: entry.summary || '', status: entry.status || 'blocked' });
|
|
920
|
-
messages.push({ role: 'tool', tool_call_id: call.id, content: entry.content, tool_summary: entry.summary || '', tool_status: entry.status || 'blocked' });
|
|
921
|
-
if (onEvent) {
|
|
922
|
-
onEvent({ type: 'tool:result', name: displayName, id: call.id, arguments: args, content: entry.content, blocked: true });
|
|
923
|
-
}
|
|
924
|
-
continue;
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
if (entry.error) {
|
|
928
|
-
attachToolCallSessionMeta(assistantMessage, call.id, { durationMs: entry.durationMs, summary: entry.summary || '', status: entry.status || 'error' });
|
|
929
|
-
messages.push({ role: 'tool', tool_call_id: call.id, content: entry.content, tool_duration_ms: entry.durationMs, tool_summary: entry.summary || '', tool_status: entry.status || 'error' });
|
|
930
|
-
if (onEvent) {
|
|
931
|
-
onEvent({ type: 'tool:result', name: displayName, id: call.id, arguments: args, content: entry.content, error: true });
|
|
932
|
-
}
|
|
933
|
-
continue;
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
attachToolCallSessionMeta(assistantMessage, call.id, { durationMs: entry.durationMs, summary: entry.summary || '', status: entry.status || 'done' });
|
|
937
|
-
messages.push({ role: 'tool', tool_call_id: call.id, content: entry.content, tool_duration_ms: entry.durationMs, tool_summary: entry.summary || '', tool_status: entry.status || 'done' });
|
|
938
|
-
if (onEvent) {
|
|
939
|
-
onEvent({ type: 'tool:result', name: displayName, id: call.id, arguments: args, content: entry.content });
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
// 如果被用户中止,返回已有内容并标记
|
|
945
|
-
if (signal?.aborted) {
|
|
946
|
-
const fallback = lastAssistantText || '';
|
|
947
|
-
return {
|
|
948
|
-
text: fallback,
|
|
949
|
-
messages,
|
|
950
|
-
steps: maxSteps,
|
|
951
|
-
aborted: true
|
|
952
|
-
};
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
const fallback = lastAssistantText || 'Stopped before final response.';
|
|
956
|
-
await maybeRunAutoDream(maxSteps, { force: true });
|
|
957
|
-
return {
|
|
958
|
-
text: `${fallback}\n\n[stopped] Reached max tool steps (${maxSteps}). Try a narrower prompt or increase execution.max_steps.`,
|
|
959
|
-
messages,
|
|
960
|
-
steps: maxSteps
|
|
961
|
-
};
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
function callsToPlanSummary(toolCalls = []) {
|
|
965
|
-
return toolCalls
|
|
966
|
-
.slice(0, 8)
|
|
967
|
-
.map((call) => {
|
|
968
|
-
const args = safeJsonParse(call?.arguments);
|
|
969
|
-
return `- ${formatToolDisplayName(normalizeToolCallName(call?.name), args)}`;
|
|
970
|
-
});
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
function attachToolCallSessionMeta(assistantMessage, callId, meta = {}) {
|
|
974
|
-
if (!assistantMessage || !Array.isArray(assistantMessage.tool_calls)) return;
|
|
975
|
-
const call = assistantMessage.tool_calls.find((tc) => String(tc?.id || '') === String(callId || ''));
|
|
976
|
-
if (!call) return;
|
|
977
|
-
if (Number.isFinite(Number(meta.durationMs))) call.durationMs = Number(meta.durationMs);
|
|
978
|
-
if (typeof meta.summary === 'string' && meta.summary.trim()) call.summary = meta.summary.trim();
|
|
979
|
-
if (typeof meta.status === 'string' && meta.status.trim()) call.status = meta.status.trim();
|
|
980
|
-
}
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { trimInline as _trimInline, normalizePath } from './string-utils.js';
|
|
3
|
+
import { captureToInbox, listInbox } from './memory-store.js';
|
|
4
|
+
import { requiresApprovalEvaluation } from './command-risk.js';
|
|
5
|
+
import { getToolOutputSanitizeOptions, sanitizeTextForModel } from './tool-output.js';
|
|
6
|
+
import { normalizeToolArguments } from './tool-args.js';
|
|
7
|
+
import { storeResultIfNeeded, summarizeToolResult } from './tool-result-store.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 安全解析 JSON 字符串。
|
|
11
|
+
* 解析失败时返回带 _raw 和 _invalid_json 标记的对象,
|
|
12
|
+
* 调用方可据此决定是回退到原始文本还是报告错误。
|
|
13
|
+
*/
|
|
14
|
+
function safeJsonParse(raw) {
|
|
15
|
+
if (!raw || typeof raw !== 'string') return {};
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(raw);
|
|
18
|
+
} catch (parseError) {
|
|
19
|
+
return {
|
|
20
|
+
_raw: String(raw),
|
|
21
|
+
_invalid_json: true,
|
|
22
|
+
_parseError: parseError.message
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function buildDeleteApprovalDetails(source, rawPath) {
|
|
28
|
+
const existing =
|
|
29
|
+
source?.approval && typeof source.approval === 'object' && !Array.isArray(source.approval)
|
|
30
|
+
? source.approval
|
|
31
|
+
: {};
|
|
32
|
+
const approvalPath = String(existing.path || rawPath || '').trim();
|
|
33
|
+
const approvalName = String(existing.name || (approvalPath ? path.basename(approvalPath) : '') || '').trim();
|
|
34
|
+
const approvalType = String(existing.type || '').trim();
|
|
35
|
+
|
|
36
|
+
const approval = {};
|
|
37
|
+
if (approvalPath) approval.path = approvalPath;
|
|
38
|
+
if (approvalName) approval.name = approvalName;
|
|
39
|
+
if (approvalType) approval.type = approvalType;
|
|
40
|
+
return Object.keys(approval).length > 0 ? approval : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function buildDeleteCancellationResult(args) {
|
|
44
|
+
const approval =
|
|
45
|
+
args?.approval && typeof args.approval === 'object' && !Array.isArray(args.approval)
|
|
46
|
+
? args.approval
|
|
47
|
+
: undefined;
|
|
48
|
+
const pathValue = String(approval?.path || args?.path || '').trim();
|
|
49
|
+
const nameValue = String(approval?.name || (pathValue ? path.basename(pathValue) : '') || '').trim();
|
|
50
|
+
const typeValue = String(approval?.type || '').trim();
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
...(pathValue ? { path: pathValue } : {}),
|
|
54
|
+
...(nameValue ? { name: nameValue } : {}),
|
|
55
|
+
...(typeValue ? { type: typeValue } : {}),
|
|
56
|
+
deleted: false,
|
|
57
|
+
cancelled: true,
|
|
58
|
+
reason: 'User denied deletion approval'
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function emptyToolResultMarker(toolName) {
|
|
63
|
+
const name = String(toolName || 'tool').trim() || 'tool';
|
|
64
|
+
return `(${name} completed with no output)`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function clipToolResult(result, maxChars = 12000) {
|
|
68
|
+
const raw = sanitizeTextForModel(typeof result === 'string' ? result : JSON.stringify(result));
|
|
69
|
+
if (!maxChars || raw.length <= maxChars) return raw;
|
|
70
|
+
return `${raw.slice(0, maxChars)}\n... [tool result truncated ${raw.length - maxChars} chars]`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function compactToolResult(result, toolName, args, maxChars = 12000) {
|
|
74
|
+
if (result === null || result === undefined) return 'no output';
|
|
75
|
+
if (typeof result === 'string') {
|
|
76
|
+
const sanitized = sanitizeTextForModel(result);
|
|
77
|
+
if (sanitized.length <= maxChars) return sanitized;
|
|
78
|
+
return `${sanitized.slice(0, maxChars)}\n... [tool result truncated ${sanitized.length - maxChars} chars, original: ${sanitized.length}]`;
|
|
79
|
+
}
|
|
80
|
+
if (typeof result !== 'object') return String(result);
|
|
81
|
+
|
|
82
|
+
const obj = result;
|
|
83
|
+
const rawLen = JSON.stringify(obj).length;
|
|
84
|
+
|
|
85
|
+
// Read file result: { path, phase, content, ... }
|
|
86
|
+
if ('path' in obj && 'phase' in obj && obj.phase === 'content') {
|
|
87
|
+
const header = `[File: ${obj.path}, lines ${obj.start_line || 1}-${obj.end_line || '?'}${obj.total_lines ? ` of ${obj.total_lines}` : ''}${obj.truncated ? ', truncated' : ''}]`;
|
|
88
|
+
const content = obj.content || obj.text || '';
|
|
89
|
+
if (typeof content !== 'string' || content.length <= maxChars) {
|
|
90
|
+
const body = typeof content === 'string' ? content : JSON.stringify(content);
|
|
91
|
+
return body.length <= maxChars ? `${header}\n${body}` : `${header}\n${body.slice(0, maxChars)}\n... [omitted ${body.length - maxChars} chars, original: ${rawLen}]`;
|
|
92
|
+
}
|
|
93
|
+
// Keep head + tail
|
|
94
|
+
const headLen = Math.floor(maxChars * 0.6);
|
|
95
|
+
const tailLen = Math.floor(maxChars * 0.3);
|
|
96
|
+
return `${header}\n${content.slice(0, headLen)}\n... [omitted ${content.length - headLen - tailLen} chars] ...\n${content.slice(-tailLen)}\n[original: ${rawLen} chars]`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// File edit/write result: { path, action, ... }
|
|
100
|
+
if ('path' in obj && 'action' in obj) {
|
|
101
|
+
const summary = summarizeToolResult(obj);
|
|
102
|
+
const diff = obj.diff || obj.patch || obj.content_preview || '';
|
|
103
|
+
if (diff && typeof diff === 'string' && diff.length <= 800) {
|
|
104
|
+
return `${summary}\n${diff}`;
|
|
105
|
+
}
|
|
106
|
+
if (diff) {
|
|
107
|
+
return `${summary}\n${diff.slice(0, 800)}\n... [diff truncated, original: ${rawLen}]`;
|
|
108
|
+
}
|
|
109
|
+
return `${summary} [original: ${rawLen} chars]`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Shell command result: { stdout, stderr, code, ... }
|
|
113
|
+
if ('stdout' in obj || 'stderr' in obj || 'code' in obj) {
|
|
114
|
+
const command = String(obj.command || '').slice(0, 200);
|
|
115
|
+
const stdout = String(obj.stdout || '').slice(0, 500);
|
|
116
|
+
const stderr = String(obj.stderr || '').slice(0, 500);
|
|
117
|
+
const code = obj.code ?? 0;
|
|
118
|
+
const parts = [`[exit: ${code}]`];
|
|
119
|
+
if (command) parts.push(`command: ${command}`);
|
|
120
|
+
if (stdout) parts.push(`stdout:\n${stdout}`);
|
|
121
|
+
if (stderr) parts.push(`stderr:\n${stderr}`);
|
|
122
|
+
if (rawLen > 2000) parts.push(`[original: ${rawLen} chars]`);
|
|
123
|
+
return parts.join('\n');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Array results (file lists, grep results, etc.)
|
|
127
|
+
if (Array.isArray(obj)) {
|
|
128
|
+
const maxItems = 50;
|
|
129
|
+
if (obj.length <= maxItems) {
|
|
130
|
+
const serialized = JSON.stringify(obj);
|
|
131
|
+
return serialized.length <= maxChars ? serialized : clipToolResult(obj, maxChars);
|
|
132
|
+
}
|
|
133
|
+
const kept = obj.slice(0, maxItems);
|
|
134
|
+
const items = typeof kept[0] === 'string'
|
|
135
|
+
? kept.join('\n')
|
|
136
|
+
: kept.map((item) => JSON.stringify(item)).join('\n');
|
|
137
|
+
return `${items}\n... and ${obj.length - maxItems} more items [total: ${obj.length}, original: ${rawLen} chars]`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Patch result: { files: [...] }
|
|
141
|
+
if ('files' in obj && Array.isArray(obj.files)) {
|
|
142
|
+
return `patched ${obj.files.length} file(s): ${obj.files.slice(0, 10).join(', ')}${obj.files.length > 10 ? ` ... and ${obj.files.length - 10} more` : ''} [original: ${rawLen}]`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Task results
|
|
146
|
+
if ('created' in obj && Array.isArray(obj.created)) {
|
|
147
|
+
return `created ${obj.created.length} task(s)`;
|
|
148
|
+
}
|
|
149
|
+
if ('tasks' in obj && Array.isArray(obj.tasks)) {
|
|
150
|
+
return `${obj.tasks.length} task(s)`;
|
|
151
|
+
}
|
|
152
|
+
if ('newTodos' in obj && Array.isArray(obj.newTodos)) {
|
|
153
|
+
return obj.newTodos.length > 0 ? `updated ${obj.newTodos.length} todo item(s)` : 'cleared todo list';
|
|
154
|
+
}
|
|
155
|
+
if ('newPlan' in obj) {
|
|
156
|
+
return obj.newPlan ? `updated plan state (${String(obj.newPlan.status || 'draft')})` : 'cleared plan state';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Fallback: clip with reduced limit
|
|
160
|
+
return clipToolResult(obj, Math.min(maxChars, 4000));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── P1a: Read-only tool classification ──────────────────────────────
|
|
164
|
+
|
|
165
|
+
const READ_ONLY_TOOLS = new Set([
|
|
166
|
+
'read', 'grep', 'glob', 'list',
|
|
167
|
+
'ast_query', 'read_ast_node',
|
|
168
|
+
'web_fetch', 'web_search',
|
|
169
|
+
'list_background_tasks', 'get_background_task',
|
|
170
|
+
'read_plan'
|
|
171
|
+
]);
|
|
172
|
+
|
|
173
|
+
// ─── Auto-capture tool errors to dream loop inbox ────────────────────
|
|
174
|
+
|
|
175
|
+
const DREAM_AUTO_CAPTURE_TOOLS = new Set([
|
|
176
|
+
'edit', 'write', 'run', 'delete'
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
const DREAM_AUTO_CAPTURE_COOLDOWN_MS = 60_000;
|
|
180
|
+
const lastAutoCaptureByTool = new Map();
|
|
181
|
+
|
|
182
|
+
function isAutoCaptureEnabled(config = {}) {
|
|
183
|
+
return config?.memory?.enabled !== false && config?.memory?.auto_capture !== false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function shouldAutoCaptureError(toolName, message) {
|
|
187
|
+
if (!DREAM_AUTO_CAPTURE_TOOLS.has(toolName)) return false;
|
|
188
|
+
const now = Date.now();
|
|
189
|
+
const lastTime = lastAutoCaptureByTool.get(toolName) || 0;
|
|
190
|
+
if (now - lastTime < DREAM_AUTO_CAPTURE_COOLDOWN_MS) return false;
|
|
191
|
+
const noisePatterns = [
|
|
192
|
+
/file already exists/i,
|
|
193
|
+
/no such file/i,
|
|
194
|
+
/not found$/i,
|
|
195
|
+
/already exists$/i,
|
|
196
|
+
/cancelled/i,
|
|
197
|
+
/aborted/i,
|
|
198
|
+
/blocked by (?:safe mode|policy|dangerous command)/i,
|
|
199
|
+
/exit 127/i,
|
|
200
|
+
/command not found/i,
|
|
201
|
+
/permission denied/i,
|
|
202
|
+
/args\?\s/i,
|
|
203
|
+
/path.*outside workspace/i,
|
|
204
|
+
/escapes workspace/i
|
|
205
|
+
];
|
|
206
|
+
if (noisePatterns.some((p) => p.test(message))) return false;
|
|
207
|
+
lastAutoCaptureByTool.set(toolName, now);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function captureToolFailure(toolName, message, args, config = {}) {
|
|
212
|
+
if (!isAutoCaptureEnabled(config)) return;
|
|
213
|
+
const summary = `[${toolName}] ${String(message).slice(0, 120)}`;
|
|
214
|
+
const details = args
|
|
215
|
+
? `Tool: ${toolName}\nError: ${message}\nArgs: ${JSON.stringify(args).slice(0, 300)}`
|
|
216
|
+
: `Tool: ${toolName}\nError: ${message}`;
|
|
217
|
+
await captureToInbox({
|
|
218
|
+
scope: 'repo',
|
|
219
|
+
type: 'failure',
|
|
220
|
+
summary,
|
|
221
|
+
details,
|
|
222
|
+
source: 'auto-capture'
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function checkAutoDreamThreshold(config) {
|
|
227
|
+
const threshold = Number(config?.memory?.auto_dream_threshold || 10);
|
|
228
|
+
if (threshold <= 0) return false;
|
|
229
|
+
try {
|
|
230
|
+
const entries = await listInbox();
|
|
231
|
+
return entries.length >= threshold;
|
|
232
|
+
} catch {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ─── Exported helpers ────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
function extractFileChange(toolName, result) {
|
|
240
|
+
if (!result || typeof result !== 'object') return null;
|
|
241
|
+
const FILE_TOOLS = new Set(['edit', 'write', 'delete']);
|
|
242
|
+
if (!FILE_TOOLS.has(toolName)) return null;
|
|
243
|
+
|
|
244
|
+
/* delete */
|
|
245
|
+
if ('deleted' in result && result.deleted) {
|
|
246
|
+
return { path: String(result.path || ''), action: 'delete', linesAdded: 0, linesRemoved: 0 };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* edit / write */
|
|
250
|
+
if ('path' in result && 'action' in result) {
|
|
251
|
+
const action = String(result.action || '');
|
|
252
|
+
const isCreate = action === 'create';
|
|
253
|
+
const added = Number(result.lines_added || 0);
|
|
254
|
+
const removed = Number(result.lines_removed || 0);
|
|
255
|
+
return {
|
|
256
|
+
path: String(result.path || ''),
|
|
257
|
+
action: isCreate ? 'create' : 'edit',
|
|
258
|
+
linesAdded: added,
|
|
259
|
+
linesRemoved: removed
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export const trimInline = _trimInline;
|
|
267
|
+
|
|
268
|
+
function normalizeAssistantText(value) {
|
|
269
|
+
return String(value || '').trim();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function hasTrailingToolContext(messages = []) {
|
|
273
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
274
|
+
const message = messages[index];
|
|
275
|
+
if (!message || typeof message !== 'object') continue;
|
|
276
|
+
if (message.role === 'tool') return true;
|
|
277
|
+
if (message.role === 'assistant' || message.role === 'user') return false;
|
|
278
|
+
}
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function isGenericCompletionText(text) {
|
|
283
|
+
const normalized = normalizeAssistantText(text).toLowerCase();
|
|
284
|
+
if (!normalized) return false;
|
|
285
|
+
const genericPhrases = new Set([
|
|
286
|
+
'done',
|
|
287
|
+
'completed',
|
|
288
|
+
'complete',
|
|
289
|
+
'finished',
|
|
290
|
+
'task completed',
|
|
291
|
+
'all done',
|
|
292
|
+
'ok',
|
|
293
|
+
'okay',
|
|
294
|
+
'已完成',
|
|
295
|
+
'已完成任务',
|
|
296
|
+
'完成',
|
|
297
|
+
'任务完成'
|
|
298
|
+
]);
|
|
299
|
+
return genericPhrases.has(normalized);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function shouldAskForConcreteFinalAnswer(text, messages = []) {
|
|
303
|
+
if (!hasTrailingToolContext(messages)) return false;
|
|
304
|
+
const normalized = normalizeAssistantText(text);
|
|
305
|
+
if (!normalized) return true;
|
|
306
|
+
return isGenericCompletionText(normalized);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function isBroadRepositoryAnalysisTask(text) {
|
|
310
|
+
const normalized = String(text || '').trim().toLowerCase();
|
|
311
|
+
if (!normalized) return false;
|
|
312
|
+
return (
|
|
313
|
+
/optimi|improve|analy[sz]e|audit|review|overview|architecture|codebase|repository|repo/.test(normalized) ||
|
|
314
|
+
/项目.*优化|项目.*问题|可优化|分析这个项目|看看.*项目|代码库|仓库/.test(String(text || ''))
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function parseProjectIndexSummary(text) {
|
|
319
|
+
const sourceRoots = [];
|
|
320
|
+
const entryCandidates = [];
|
|
321
|
+
const candidateFiles = [];
|
|
322
|
+
for (const line of String(text || '').split(/\r?\n/)) {
|
|
323
|
+
const trimmed = line.trim();
|
|
324
|
+
if (trimmed.startsWith('source_roots:')) {
|
|
325
|
+
sourceRoots.push(
|
|
326
|
+
...String(trimmed.slice('source_roots:'.length))
|
|
327
|
+
.split(',')
|
|
328
|
+
.map((value) => value.trim())
|
|
329
|
+
.filter(Boolean)
|
|
330
|
+
);
|
|
331
|
+
} else if (trimmed.startsWith('entry_candidates:')) {
|
|
332
|
+
entryCandidates.push(
|
|
333
|
+
...String(trimmed.slice('entry_candidates:'.length))
|
|
334
|
+
.split(',')
|
|
335
|
+
.map((value) => value.trim())
|
|
336
|
+
.filter(Boolean)
|
|
337
|
+
);
|
|
338
|
+
} else if (trimmed.startsWith('- ')) {
|
|
339
|
+
const match = trimmed.match(/^- ([^ ]+)/);
|
|
340
|
+
if (match?.[1]) candidateFiles.push(match[1].trim());
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return { sourceRoots, entryCandidates, candidateFiles };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function createAnalysisGuardState(userPrompt) {
|
|
347
|
+
return {
|
|
348
|
+
active: isBroadRepositoryAnalysisTask(userPrompt),
|
|
349
|
+
indexQueried: false,
|
|
350
|
+
sourceRoots: new Set(),
|
|
351
|
+
entryCandidates: new Set(),
|
|
352
|
+
candidateFiles: new Set(),
|
|
353
|
+
relevantSourceReads: new Set(),
|
|
354
|
+
blockedExplorations: 0
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function topLevelPath(value) {
|
|
359
|
+
const normalized = normalizePath(value).trim();
|
|
360
|
+
return normalized.split('/')[0] || '';
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function isRelevantSourcePath(filePath, state) {
|
|
364
|
+
const normalized = normalizePath(filePath).trim();
|
|
365
|
+
if (!normalized) return false;
|
|
366
|
+
if (state.candidateFiles.has(normalized) || state.entryCandidates.has(normalized)) return true;
|
|
367
|
+
for (const root of state.sourceRoots) {
|
|
368
|
+
if (normalized === root || normalized.startsWith(`${root}/`)) return true;
|
|
369
|
+
}
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function blockedExplorationReason(toolName, args, state) {
|
|
374
|
+
if (!state.active) return '';
|
|
375
|
+
|
|
376
|
+
// Always note when query_project_index is used, but never force it
|
|
377
|
+
if (toolName === 'query_project_index') return '';
|
|
378
|
+
|
|
379
|
+
const target = normalizePath(String(args?.path || args?.pattern || args?.query || '')).trim();
|
|
380
|
+
const top = topLevelPath(target);
|
|
381
|
+
if (!top) return '';
|
|
382
|
+
|
|
383
|
+
if (['skills', 'souls', 'templates', '.codemini', '.codemini-global'].includes(top)) {
|
|
384
|
+
return `Skip ${top}/ for broad repository analysis unless the user explicitly asks for it. Inspect relevant source files first.`;
|
|
385
|
+
}
|
|
386
|
+
return '';
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function noteAnalysisEvidence(state, toolName, args, toolResult) {
|
|
390
|
+
if (!state.active) return;
|
|
391
|
+
if (toolName === 'query_project_index') {
|
|
392
|
+
state.indexQueried = true;
|
|
393
|
+
const summary = parseProjectIndexSummary(JSON.stringify(toolResult));
|
|
394
|
+
for (const root of summary.sourceRoots) state.sourceRoots.add(root);
|
|
395
|
+
for (const entry of summary.entryCandidates) state.entryCandidates.add(entry);
|
|
396
|
+
for (const file of summary.candidateFiles) state.candidateFiles.add(file);
|
|
397
|
+
const projectMap = toolResult?.project_map || {};
|
|
398
|
+
for (const root of projectMap.source_roots || []) state.sourceRoots.add(String(root));
|
|
399
|
+
for (const entry of projectMap.entry_candidates || []) state.entryCandidates.add(String(entry));
|
|
400
|
+
for (const match of toolResult?.matches || []) {
|
|
401
|
+
if (match?.file) state.candidateFiles.add(String(match.file));
|
|
402
|
+
}
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (toolName === 'read') {
|
|
407
|
+
const filePath = String(toolResult?.path || args?.path || '').split(':')[0];
|
|
408
|
+
if (isRelevantSourcePath(filePath, state)) {
|
|
409
|
+
state.relevantSourceReads.add(filePath);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function needsMoreAnalysisEvidence(state) {
|
|
415
|
+
if (!state.active) return false;
|
|
416
|
+
if (!state.indexQueried) return true;
|
|
417
|
+
return state.relevantSourceReads.size < 2;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function normalizeToolCallName(name) {
|
|
421
|
+
return String(name || '').trim();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function formatToolDisplayName(name, args) {
|
|
425
|
+
if (name === 'grep') {
|
|
426
|
+
const query = trimInline(args?.pattern || args?.query || args?.symbol || '', 96);
|
|
427
|
+
return query ? `grep("${query}")` : 'grep';
|
|
428
|
+
}
|
|
429
|
+
if (name === 'glob') {
|
|
430
|
+
const pattern = trimInline(args?.pattern || '', 96);
|
|
431
|
+
return pattern ? `glob("${pattern}")` : 'glob';
|
|
432
|
+
}
|
|
433
|
+
if (name === 'list') {
|
|
434
|
+
const target = trimInline(args?.path || '.', 96) || '.';
|
|
435
|
+
return `list(${target})`;
|
|
436
|
+
}
|
|
437
|
+
if (name === 'read' || name === 'write') {
|
|
438
|
+
const target = trimInline(args?.path || '.', 96) || '.';
|
|
439
|
+
if (name === 'read') {
|
|
440
|
+
const start = Number(args?.start_line);
|
|
441
|
+
const end = Number(args?.end_line);
|
|
442
|
+
const hasRange = Number.isFinite(start) && start > 0;
|
|
443
|
+
const suffix = hasRange ? `:${start}-${Number.isFinite(end) && end >= start ? end : start}` : '';
|
|
444
|
+
return `read(${target}${suffix})`;
|
|
445
|
+
}
|
|
446
|
+
return `write(${target})`;
|
|
447
|
+
}
|
|
448
|
+
if (name === 'run') {
|
|
449
|
+
const command = trimInline(args?.command || '', 96);
|
|
450
|
+
return command ? `run(${command})` : name;
|
|
451
|
+
}
|
|
452
|
+
if (name === 'web_fetch') {
|
|
453
|
+
const url = trimInline(args?.url || args?.href || '', 96);
|
|
454
|
+
return url ? `web_fetch(${url})` : name;
|
|
455
|
+
}
|
|
456
|
+
if (name === 'web_search') {
|
|
457
|
+
const query = trimInline(args?.query || args?.q || '', 96);
|
|
458
|
+
return query ? `web_search(${query})` : name;
|
|
459
|
+
}
|
|
460
|
+
if (name === 'edit') {
|
|
461
|
+
const target = trimInline(args?.path || args?.file || '.', 96) || '.';
|
|
462
|
+
return `edit(${target})`;
|
|
463
|
+
}
|
|
464
|
+
if (name === 'delete') {
|
|
465
|
+
const target = trimInline(args?.path || args?.target || '.', 96) || '.';
|
|
466
|
+
return `delete(${target})`;
|
|
467
|
+
}
|
|
468
|
+
if (name === 'update_todos') {
|
|
469
|
+
return 'update_todos';
|
|
470
|
+
}
|
|
471
|
+
if (name === 'read_plan' || name === 'update_plan') {
|
|
472
|
+
return name;
|
|
473
|
+
}
|
|
474
|
+
if (name === 'list_background_tasks') {
|
|
475
|
+
return name;
|
|
476
|
+
}
|
|
477
|
+
if (name === 'get_background_task' || name === 'stop_background_task') {
|
|
478
|
+
const taskId = trimInline(args?.task_id || args?.taskId || '', 96);
|
|
479
|
+
return taskId ? `${name}(${taskId})` : name;
|
|
480
|
+
}
|
|
481
|
+
return name;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ─── Format a single tool result using per-tool formatter or fallback ──
|
|
485
|
+
|
|
486
|
+
function formatToolResult(toolResult, toolName, args, toolFormatters, toolResultMaxChars) {
|
|
487
|
+
const sanitizeOptions = getToolOutputSanitizeOptions(toolName);
|
|
488
|
+
if (toolFormatters && typeof toolFormatters[toolName] === 'function') {
|
|
489
|
+
const formatted = toolFormatters[toolName](toolResult, args);
|
|
490
|
+
if (typeof formatted === 'string') {
|
|
491
|
+
const sanitized = sanitizeTextForModel(formatted, sanitizeOptions);
|
|
492
|
+
return sanitized.trim() ? sanitized : emptyToolResultMarker(toolName);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
const fallback = compactToolResult(toolResult, toolName, args, toolResultMaxChars);
|
|
496
|
+
const sanitizedFallback = sanitizeTextForModel(fallback, sanitizeOptions);
|
|
497
|
+
return String(sanitizedFallback || '').trim() ? sanitizedFallback : emptyToolResultMarker(toolName);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ─── Main agent loop ────────────────────────────────────────────────
|
|
501
|
+
|
|
502
|
+
export async function runAgentLoop({
|
|
503
|
+
systemPrompt,
|
|
504
|
+
userPrompt,
|
|
505
|
+
model,
|
|
506
|
+
requestCompletion,
|
|
507
|
+
toolHandlers = {},
|
|
508
|
+
toolDefinitions = [],
|
|
509
|
+
maxSteps = 8,
|
|
510
|
+
initialMessages = [],
|
|
511
|
+
onEvent,
|
|
512
|
+
executionMode = 'auto',
|
|
513
|
+
alwaysAllowTools = [],
|
|
514
|
+
requestToolApproval,
|
|
515
|
+
toolResultMaxChars = 12000,
|
|
516
|
+
toolFormatters = {},
|
|
517
|
+
deferredDefinitions = {},
|
|
518
|
+
signal,
|
|
519
|
+
skipAnalysisNudge = false,
|
|
520
|
+
config = {}
|
|
521
|
+
}) {
|
|
522
|
+
const messages = [];
|
|
523
|
+
if (systemPrompt) {
|
|
524
|
+
messages.push({ role: 'system', content: systemPrompt });
|
|
525
|
+
}
|
|
526
|
+
if (Array.isArray(initialMessages) && initialMessages.length > 0) {
|
|
527
|
+
messages.push(...initialMessages);
|
|
528
|
+
}
|
|
529
|
+
if (userPrompt) {
|
|
530
|
+
messages.push({ role: 'user', content: userPrompt });
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
let finalText = '';
|
|
534
|
+
let lastAssistantText = '';
|
|
535
|
+
let pendingSummaryNudges = 0;
|
|
536
|
+
const analysisGuard = createAnalysisGuardState(userPrompt);
|
|
537
|
+
const alwaysAllowSet = new Set((Array.isArray(alwaysAllowTools) ? alwaysAllowTools : []).map((t) => String(t)));
|
|
538
|
+
let lastAutoDreamCheckStep = 0;
|
|
539
|
+
|
|
540
|
+
// Mutable tool list — grows as tool_search loads deferred tools
|
|
541
|
+
const activeTools = [...toolDefinitions];
|
|
542
|
+
|
|
543
|
+
async function maybeRunAutoDream(stepNumber = 0, { force = false } = {}) {
|
|
544
|
+
if (executionMode === 'plan') return;
|
|
545
|
+
const interval = Math.max(1, Number(config?.memory?.auto_dream_check_interval_steps || 20));
|
|
546
|
+
const normalizedStep = Math.max(1, Number(stepNumber || 1));
|
|
547
|
+
if (!force && lastAutoDreamCheckStep > 0 && normalizedStep - lastAutoDreamCheckStep < interval) return;
|
|
548
|
+
if (force && lastAutoDreamCheckStep === normalizedStep) return;
|
|
549
|
+
lastAutoDreamCheckStep = normalizedStep;
|
|
550
|
+
const autoDreamResult = await checkAutoDreamThreshold(config);
|
|
551
|
+
if (!autoDreamResult) return;
|
|
552
|
+
const dreamTool = toolHandlers['dream_consolidate'];
|
|
553
|
+
if (typeof dreamTool !== 'function') return;
|
|
554
|
+
if (onEvent) onEvent({ type: 'dream:auto', message: 'inbox threshold reached' });
|
|
555
|
+
try {
|
|
556
|
+
const report = await dreamTool({});
|
|
557
|
+
if (onEvent) {
|
|
558
|
+
onEvent({ type: 'dream:complete', report });
|
|
559
|
+
}
|
|
560
|
+
} catch (error) {
|
|
561
|
+
if (onEvent) {
|
|
562
|
+
onEvent({
|
|
563
|
+
type: 'dream:complete',
|
|
564
|
+
report: { ok: false, error: String(error?.message || error || 'unknown dream error') }
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
// Auto-dream is best-effort; don't block the loop
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
for (let step = 0; step < maxSteps; step += 1) {
|
|
572
|
+
// 检查是否已被用户中止
|
|
573
|
+
if (signal?.aborted) {
|
|
574
|
+
if (onEvent) onEvent({ type: 'aborted', step: step + 1 });
|
|
575
|
+
break;
|
|
576
|
+
}
|
|
577
|
+
if (onEvent) onEvent({ type: 'step:start', step: step + 1 });
|
|
578
|
+
await maybeRunAutoDream(step + 1);
|
|
579
|
+
const completion = await requestCompletion({
|
|
580
|
+
model,
|
|
581
|
+
messages,
|
|
582
|
+
tools: activeTools,
|
|
583
|
+
signal
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
// 流式请求完成后再次检查中止状态
|
|
587
|
+
if (signal?.aborted) {
|
|
588
|
+
if (onEvent) onEvent({ type: 'aborted', step: step + 1 });
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (completion?.incomplete) {
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const toolCalls = Array.isArray(completion.toolCalls) ? completion.toolCalls : [];
|
|
597
|
+
const assistantText = completion.text || '';
|
|
598
|
+
lastAssistantText = assistantText || lastAssistantText;
|
|
599
|
+
|
|
600
|
+
const assistantMessage = completion?.assistantMessage
|
|
601
|
+
? {
|
|
602
|
+
...completion.assistantMessage,
|
|
603
|
+
role: 'assistant',
|
|
604
|
+
content: completion.assistantMessage.content ?? completion?.content ?? assistantText
|
|
605
|
+
}
|
|
606
|
+
: { role: 'assistant', content: completion?.content ?? assistantText };
|
|
607
|
+
if (!Array.isArray(assistantMessage.tool_calls) && toolCalls.length > 0) {
|
|
608
|
+
assistantMessage.tool_calls = toolCalls.map((tc) => ({
|
|
609
|
+
id: tc.id,
|
|
610
|
+
type: 'function',
|
|
611
|
+
function: { name: tc.name, arguments: tc.arguments || '{}' }
|
|
612
|
+
}));
|
|
613
|
+
}
|
|
614
|
+
messages.push(assistantMessage);
|
|
615
|
+
if (onEvent) {
|
|
616
|
+
onEvent({
|
|
617
|
+
type: 'assistant:response',
|
|
618
|
+
step: step + 1,
|
|
619
|
+
text: assistantText,
|
|
620
|
+
toolCalls: toolCalls.map((tc) => tc.name),
|
|
621
|
+
assistantMessage
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (toolCalls.length === 0) {
|
|
626
|
+
if (!skipAnalysisNudge && needsMoreAnalysisEvidence(analysisGuard) && pendingSummaryNudges < 2) {
|
|
627
|
+
pendingSummaryNudges += 1;
|
|
628
|
+
messages.push({
|
|
629
|
+
role: 'user',
|
|
630
|
+
content:
|
|
631
|
+
'You have not inspected enough relevant source files yet. Query the project index if needed, then inspect the next relevant source files before concluding. Do not stop after unrelated directories, tests, skills, souls, or templates.'
|
|
632
|
+
});
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
if (!skipAnalysisNudge && shouldAskForConcreteFinalAnswer(assistantText, messages.slice(0, -1)) && pendingSummaryNudges < 2) {
|
|
636
|
+
pendingSummaryNudges += 1;
|
|
637
|
+
messages.push({
|
|
638
|
+
role: 'user',
|
|
639
|
+
content:
|
|
640
|
+
'You have already inspected tool results. Before stopping, check whether the task is actually complete. If it is, provide a concise final answer with specific findings or concrete next steps. If it is not, continue with the next tool call.'
|
|
641
|
+
});
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
finalText = assistantText;
|
|
645
|
+
await maybeRunAutoDream(step + 1, { force: true });
|
|
646
|
+
return { text: finalText, messages, steps: step + 1 };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
pendingSummaryNudges = 0;
|
|
650
|
+
|
|
651
|
+
if (executionMode === 'plan') {
|
|
652
|
+
const plannedLines = callsToPlanSummary(toolCalls);
|
|
653
|
+
finalText = [
|
|
654
|
+
assistantText || '',
|
|
655
|
+
'',
|
|
656
|
+
`[plan mode] ${toolCalls.length} tool call(s) were planned but not executed.`,
|
|
657
|
+
plannedLines.length > 0 ? 'Planned exploration:' : '',
|
|
658
|
+
...plannedLines
|
|
659
|
+
]
|
|
660
|
+
.filter(Boolean)
|
|
661
|
+
.join('\n');
|
|
662
|
+
await maybeRunAutoDream(step + 1, { force: true });
|
|
663
|
+
return { text: finalText.trim(), messages, steps: step + 1 };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// ─── P1a: Partition into read-only (parallel) and write (serial) ──
|
|
667
|
+
|
|
668
|
+
const callsWithMeta = toolCalls.map((call) => {
|
|
669
|
+
const toolName = normalizeToolCallName(call.name);
|
|
670
|
+
const args = normalizeToolArguments(toolName, safeJsonParse(call.arguments), call.arguments);
|
|
671
|
+
const displayName = formatToolDisplayName(toolName, args);
|
|
672
|
+
const isReadOnly = READ_ONLY_TOOLS.has(toolName);
|
|
673
|
+
return { call, args, toolName, displayName, isReadOnly };
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
// Approval checks first — must be done synchronously before any execution
|
|
677
|
+
const approvalResults = new Map();
|
|
678
|
+
for (const { call, toolName, displayName, args } of callsWithMeta) {
|
|
679
|
+
let approved = true;
|
|
680
|
+
let approvalArgs = args;
|
|
681
|
+
let preflightErrorContent = '';
|
|
682
|
+
const isSafeModeRun = toolName === 'run'
|
|
683
|
+
&& config?.policy?.safe_mode !== false
|
|
684
|
+
&& requiresApprovalEvaluation(args?.command || '', config?.shell?.default);
|
|
685
|
+
const needsApproval = toolName === 'delete' || isSafeModeRun
|
|
686
|
+
|| (executionMode === 'normal' && !alwaysAllowSet.has(toolName));
|
|
687
|
+
if (needsApproval) {
|
|
688
|
+
approved = false;
|
|
689
|
+
const handler = toolHandlers[toolName];
|
|
690
|
+
if (toolName === 'delete' && typeof handler?.prepareApproval === 'function') {
|
|
691
|
+
try {
|
|
692
|
+
const approval = await handler.prepareApproval(args);
|
|
693
|
+
const normalizedApproval = buildDeleteApprovalDetails({ approval }, args?.path);
|
|
694
|
+
if (normalizedApproval) {
|
|
695
|
+
approvalArgs = { ...args, approval: normalizedApproval };
|
|
696
|
+
}
|
|
697
|
+
} catch (error) {
|
|
698
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
699
|
+
preflightErrorContent = clipToolResult({ error: message }, toolResultMaxChars);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
/* Run tool: safe mode LLM-based command evaluation */
|
|
703
|
+
if (toolName === 'run' && isSafeModeRun && !preflightErrorContent) {
|
|
704
|
+
try {
|
|
705
|
+
const { evaluateCommandWithLLM } = await import('./command-evaluator.js');
|
|
706
|
+
const evaluation = await evaluateCommandWithLLM({
|
|
707
|
+
command: args?.command || '',
|
|
708
|
+
config,
|
|
709
|
+
workspaceRoot: config?.workspaceRoot || process.cwd()
|
|
710
|
+
});
|
|
711
|
+
approvalArgs = { ...args, _risk: evaluation.risk, _evaluation: evaluation };
|
|
712
|
+
/* LLM says low-risk + allow → auto-approve, skip confirmation panel */
|
|
713
|
+
if (evaluation.risk === 'low' && evaluation.recommendation === 'allow') {
|
|
714
|
+
approvalResults.set(call.id, { approved: true, args: approvalArgs });
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
} catch (_) {
|
|
718
|
+
approvalArgs = { ...args, _risk: 'high', _evaluation: null };
|
|
719
|
+
}
|
|
720
|
+
if (typeof handler?.prepareApproval === 'function') {
|
|
721
|
+
try {
|
|
722
|
+
const approval = await handler.prepareApproval(approvalArgs);
|
|
723
|
+
approvalArgs = { ...approvalArgs, approval };
|
|
724
|
+
} catch (_) { /* skip */ }
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
if (preflightErrorContent) {
|
|
728
|
+
approvalResults.set(call.id, {
|
|
729
|
+
approved: false,
|
|
730
|
+
args: approvalArgs,
|
|
731
|
+
errorContent: preflightErrorContent
|
|
732
|
+
});
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
if (typeof requestToolApproval === 'function') {
|
|
736
|
+
const decision = await requestToolApproval({
|
|
737
|
+
id: call.id,
|
|
738
|
+
name: toolName,
|
|
739
|
+
displayName,
|
|
740
|
+
arguments: approvalArgs,
|
|
741
|
+
approvalDetails: toolName === 'delete' ? approvalArgs.approval
|
|
742
|
+
: (toolName === 'run' ? approvalArgs.approval : undefined)
|
|
743
|
+
});
|
|
744
|
+
approved = Boolean(decision?.approved);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
approvalResults.set(call.id, { approved, args: approvalArgs });
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Collect results keyed by call.id, then write to messages in original order
|
|
751
|
+
const resultEntries = new Map(); // call.id -> { content, error? }
|
|
752
|
+
|
|
753
|
+
// Helper to execute a single tool call
|
|
754
|
+
async function executeOne({ call, args, toolName, displayName, isReadOnly }) {
|
|
755
|
+
const startedAt = Date.now();
|
|
756
|
+
const approvalState = approvalResults.get(call.id) || { approved: true, args };
|
|
757
|
+
const effectiveArgs = approvalState.args || args;
|
|
758
|
+
|
|
759
|
+
if (approvalState.errorContent) {
|
|
760
|
+
const summary = trimInline(approvalState.errorContent, 120);
|
|
761
|
+
if (onEvent) {
|
|
762
|
+
onEvent({ type: 'tool:error', name: displayName, id: call.id, arguments: effectiveArgs, durationMs: 0, summary });
|
|
763
|
+
}
|
|
764
|
+
return {
|
|
765
|
+
callId: call.id,
|
|
766
|
+
content: approvalState.errorContent,
|
|
767
|
+
error: true,
|
|
768
|
+
durationMs: 0,
|
|
769
|
+
summary,
|
|
770
|
+
status: 'error'
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (!approvalState.approved) {
|
|
775
|
+
if (onEvent) onEvent({ type: 'tool:blocked', name: displayName, id: call.id, arguments: effectiveArgs });
|
|
776
|
+
const blockedPayload =
|
|
777
|
+
toolName === 'delete'
|
|
778
|
+
? buildDeleteCancellationResult(effectiveArgs)
|
|
779
|
+
: { blocked: true, reason: 'Tool call requires approval in normal mode' };
|
|
780
|
+
return {
|
|
781
|
+
callId: call.id,
|
|
782
|
+
content: JSON.stringify(blockedPayload),
|
|
783
|
+
blocked: true,
|
|
784
|
+
summary: 'Tool call requires approval',
|
|
785
|
+
status: 'blocked'
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (onEvent) onEvent({ type: 'tool:start', name: displayName, id: call.id, arguments: effectiveArgs });
|
|
790
|
+
const handler = toolHandlers[toolName];
|
|
791
|
+
if (!handler) {
|
|
792
|
+
const available = Object.keys(toolHandlers).join(', ');
|
|
793
|
+
const msg = `Unknown tool: "${toolName}". Available tools: ${available || '(none)'}`;
|
|
794
|
+
const summary = trimInline(msg, 200);
|
|
795
|
+
if (onEvent) {
|
|
796
|
+
onEvent({ type: 'tool:error', name: displayName, id: call.id, arguments: effectiveArgs, durationMs: 0, summary });
|
|
797
|
+
}
|
|
798
|
+
return {
|
|
799
|
+
callId: call.id,
|
|
800
|
+
content: JSON.stringify({ error: msg }),
|
|
801
|
+
error: true,
|
|
802
|
+
durationMs: 0,
|
|
803
|
+
summary,
|
|
804
|
+
status: 'error'
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const blockedReason = blockedExplorationReason(toolName, effectiveArgs, analysisGuard);
|
|
809
|
+
if (blockedReason) {
|
|
810
|
+
analysisGuard.blockedExplorations += 1;
|
|
811
|
+
const content = clipToolResult({ error: blockedReason }, toolResultMaxChars);
|
|
812
|
+
const summary = trimInline(blockedReason, 120);
|
|
813
|
+
if (onEvent) {
|
|
814
|
+
onEvent({ type: 'tool:error', name: displayName, id: call.id, arguments: effectiveArgs, durationMs: 0, summary });
|
|
815
|
+
}
|
|
816
|
+
return {
|
|
817
|
+
callId: call.id,
|
|
818
|
+
content,
|
|
819
|
+
error: true,
|
|
820
|
+
durationMs: 0,
|
|
821
|
+
summary,
|
|
822
|
+
status: 'error'
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
let toolResult;
|
|
827
|
+
try {
|
|
828
|
+
toolResult = await handler(effectiveArgs);
|
|
829
|
+
} catch (error) {
|
|
830
|
+
const durationMs = Date.now() - startedAt;
|
|
831
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
832
|
+
const summary = trimInline(message, 120);
|
|
833
|
+
if (onEvent) {
|
|
834
|
+
onEvent({ type: 'tool:error', name: displayName, id: call.id, arguments: effectiveArgs, durationMs, summary });
|
|
835
|
+
}
|
|
836
|
+
if (isAutoCaptureEnabled(config) && shouldAutoCaptureError(toolName, message)) {
|
|
837
|
+
await captureToolFailure(toolName, message, effectiveArgs, config).catch(() => {});
|
|
838
|
+
}
|
|
839
|
+
return {
|
|
840
|
+
callId: call.id,
|
|
841
|
+
content: clipToolResult({ error: message }, toolResultMaxChars),
|
|
842
|
+
error: true,
|
|
843
|
+
durationMs,
|
|
844
|
+
summary,
|
|
845
|
+
status: 'error'
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const durationMs = Date.now() - startedAt;
|
|
850
|
+
const summary = summarizeToolResult(toolResult);
|
|
851
|
+
/* 提取文件改动统计 */
|
|
852
|
+
const fileChange = extractFileChange(toolName, toolResult);
|
|
853
|
+
if (onEvent) {
|
|
854
|
+
onEvent({ type: 'tool:end', name: displayName, id: call.id, arguments: effectiveArgs, durationMs, summary, fileChange });
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Auto-capture non-throwing tool failures (e.g. shell non-zero exit)
|
|
858
|
+
if (toolResult && typeof toolResult === 'object') {
|
|
859
|
+
const exitCode = toolResult.code ?? toolResult.exitCode;
|
|
860
|
+
const stderr = String(toolResult.stderr || '');
|
|
861
|
+
if (typeof exitCode === 'number' && exitCode !== 0 && stderr) {
|
|
862
|
+
const failMsg = `exit ${exitCode}: ${stderr.slice(0, 120)}`;
|
|
863
|
+
if (isAutoCaptureEnabled(config) && shouldAutoCaptureError(toolName, failMsg)) {
|
|
864
|
+
await captureToolFailure(toolName, failMsg, effectiveArgs, config).catch(() => {});
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
if (toolResult.error) {
|
|
868
|
+
const errMsg = String(toolResult.error).slice(0, 120);
|
|
869
|
+
if (isAutoCaptureEnabled(config) && shouldAutoCaptureError(toolName, errMsg)) {
|
|
870
|
+
await captureToolFailure(toolName, errMsg, effectiveArgs, config).catch(() => {});
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// P1b: Use per-tool formatter if available, else fallback
|
|
876
|
+
let formatted = formatToolResult(toolResult, toolName, effectiveArgs, toolFormatters, toolResultMaxChars);
|
|
877
|
+
noteAnalysisEvidence(analysisGuard, toolName, effectiveArgs, toolResult);
|
|
878
|
+
|
|
879
|
+
// P2: If tool_search loaded deferred tools, inject their schemas into activeTools
|
|
880
|
+
if (toolName === 'tool_search' && toolResult && Array.isArray(toolResult.schemas)) {
|
|
881
|
+
for (const schema of toolResult.schemas) {
|
|
882
|
+
const name = schema?.function?.name;
|
|
883
|
+
if (name && !activeTools.some((t) => t?.function?.name === name)) {
|
|
884
|
+
activeTools.push(schema);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// P0: Persist to disk if still large
|
|
890
|
+
formatted = await storeResultIfNeeded(call.id, formatted, toolResult);
|
|
891
|
+
|
|
892
|
+
return { callId: call.id, content: formatted, durationMs, summary, status: 'done' };
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Separate read-only and write calls, preserving order
|
|
896
|
+
const readOnlyCalls = callsWithMeta.filter((c) => c.isReadOnly && approvalResults.get(c.call.id)?.approved);
|
|
897
|
+
const writeCalls = callsWithMeta.filter((c) => !c.isReadOnly || !approvalResults.get(c.call.id)?.approved);
|
|
898
|
+
|
|
899
|
+
// Execute read-only calls in parallel
|
|
900
|
+
if (readOnlyCalls.length > 0) {
|
|
901
|
+
const readOnlyResults = await Promise.all(readOnlyCalls.map((c) => executeOne(c)));
|
|
902
|
+
for (const r of readOnlyResults) {
|
|
903
|
+
resultEntries.set(r.callId, r);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Execute write calls serially
|
|
908
|
+
for (const c of writeCalls) {
|
|
909
|
+
const r = await executeOne(c);
|
|
910
|
+
resultEntries.set(r.callId, r);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Write results to messages in original tool call order
|
|
914
|
+
for (const { call, displayName, args } of callsWithMeta) {
|
|
915
|
+
const entry = resultEntries.get(call.id);
|
|
916
|
+
if (!entry) continue;
|
|
917
|
+
|
|
918
|
+
if (entry.blocked) {
|
|
919
|
+
attachToolCallSessionMeta(assistantMessage, call.id, { summary: entry.summary || '', status: entry.status || 'blocked' });
|
|
920
|
+
messages.push({ role: 'tool', tool_call_id: call.id, content: entry.content, tool_summary: entry.summary || '', tool_status: entry.status || 'blocked' });
|
|
921
|
+
if (onEvent) {
|
|
922
|
+
onEvent({ type: 'tool:result', name: displayName, id: call.id, arguments: args, content: entry.content, blocked: true });
|
|
923
|
+
}
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
if (entry.error) {
|
|
928
|
+
attachToolCallSessionMeta(assistantMessage, call.id, { durationMs: entry.durationMs, summary: entry.summary || '', status: entry.status || 'error' });
|
|
929
|
+
messages.push({ role: 'tool', tool_call_id: call.id, content: entry.content, tool_duration_ms: entry.durationMs, tool_summary: entry.summary || '', tool_status: entry.status || 'error' });
|
|
930
|
+
if (onEvent) {
|
|
931
|
+
onEvent({ type: 'tool:result', name: displayName, id: call.id, arguments: args, content: entry.content, error: true });
|
|
932
|
+
}
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
attachToolCallSessionMeta(assistantMessage, call.id, { durationMs: entry.durationMs, summary: entry.summary || '', status: entry.status || 'done' });
|
|
937
|
+
messages.push({ role: 'tool', tool_call_id: call.id, content: entry.content, tool_duration_ms: entry.durationMs, tool_summary: entry.summary || '', tool_status: entry.status || 'done' });
|
|
938
|
+
if (onEvent) {
|
|
939
|
+
onEvent({ type: 'tool:result', name: displayName, id: call.id, arguments: args, content: entry.content });
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// 如果被用户中止,返回已有内容并标记
|
|
945
|
+
if (signal?.aborted) {
|
|
946
|
+
const fallback = lastAssistantText || '';
|
|
947
|
+
return {
|
|
948
|
+
text: fallback,
|
|
949
|
+
messages,
|
|
950
|
+
steps: maxSteps,
|
|
951
|
+
aborted: true
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const fallback = lastAssistantText || 'Stopped before final response.';
|
|
956
|
+
await maybeRunAutoDream(maxSteps, { force: true });
|
|
957
|
+
return {
|
|
958
|
+
text: `${fallback}\n\n[stopped] Reached max tool steps (${maxSteps}). Try a narrower prompt or increase execution.max_steps.`,
|
|
959
|
+
messages,
|
|
960
|
+
steps: maxSteps
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function callsToPlanSummary(toolCalls = []) {
|
|
965
|
+
return toolCalls
|
|
966
|
+
.slice(0, 8)
|
|
967
|
+
.map((call) => {
|
|
968
|
+
const args = safeJsonParse(call?.arguments);
|
|
969
|
+
return `- ${formatToolDisplayName(normalizeToolCallName(call?.name), args)}`;
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function attachToolCallSessionMeta(assistantMessage, callId, meta = {}) {
|
|
974
|
+
if (!assistantMessage || !Array.isArray(assistantMessage.tool_calls)) return;
|
|
975
|
+
const call = assistantMessage.tool_calls.find((tc) => String(tc?.id || '') === String(callId || ''));
|
|
976
|
+
if (!call) return;
|
|
977
|
+
if (Number.isFinite(Number(meta.durationMs))) call.durationMs = Number(meta.durationMs);
|
|
978
|
+
if (typeof meta.summary === 'string' && meta.summary.trim()) call.summary = meta.summary.trim();
|
|
979
|
+
if (typeof meta.status === 'string' && meta.status.trim()) call.status = meta.status.trim();
|
|
980
|
+
}
|