codemini-cli 0.5.10 → 0.5.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/OPERATIONS.md +242 -242
  2. package/README.md +588 -588
  3. package/codemini-web/dist/assets/{highlighted-body-OFNGDK62-7HL7yft8.js → highlighted-body-OFNGDK62-B-G99D0A.js} +1 -1
  4. package/codemini-web/dist/assets/{index-BK75hMb2.js → index-DIGUEzan.js} +108 -108
  5. package/codemini-web/dist/assets/index-Dkq1DdDX.css +2 -0
  6. package/codemini-web/dist/assets/mermaid-GHXKKRXX-va2Kl89u.js +1 -0
  7. package/codemini-web/dist/index.html +35 -23
  8. package/codemini-web/lib/approval-manager.js +32 -32
  9. package/codemini-web/lib/runtime-bridge.js +17 -11
  10. package/codemini-web/server.js +534 -205
  11. package/deployment.md +212 -212
  12. package/package.json +2 -2
  13. package/skills/brainstorm/SKILL.md +77 -77
  14. package/skills/codemini.skills.json +40 -40
  15. package/skills/grill-me/SKILL.md +30 -30
  16. package/skills/superpowers-lite/SKILL.md +82 -82
  17. package/src/cli.js +74 -74
  18. package/src/commands/chat.js +210 -210
  19. package/src/commands/run.js +313 -313
  20. package/src/commands/skill.js +438 -304
  21. package/src/commands/web.js +57 -57
  22. package/src/core/agent-loop.js +980 -980
  23. package/src/core/ast.js +309 -307
  24. package/src/core/chat-runtime.js +6261 -6253
  25. package/src/core/command-evaluator.js +72 -72
  26. package/src/core/command-loader.js +311 -311
  27. package/src/core/command-policy.js +301 -301
  28. package/src/core/command-risk.js +156 -156
  29. package/src/core/config-store.js +286 -285
  30. package/src/core/constants.js +18 -1
  31. package/src/core/context-compact.js +365 -365
  32. package/src/core/default-system-prompt.js +114 -107
  33. package/src/core/dream-audit.js +105 -105
  34. package/src/core/dream-consolidate.js +229 -229
  35. package/src/core/dream-evaluator.js +185 -185
  36. package/src/core/fff-adapter.js +383 -383
  37. package/src/core/memory-store.js +543 -543
  38. package/src/core/project-index.js +737 -548
  39. package/src/core/project-instructions.js +98 -98
  40. package/src/core/provider/anthropic.js +514 -514
  41. package/src/core/provider/openai-compatible.js +501 -501
  42. package/src/core/reflect-skill.js +178 -178
  43. package/src/core/reply-language.js +40 -40
  44. package/src/core/session-store.js +474 -474
  45. package/src/core/shell-profile.js +237 -237
  46. package/src/core/shell.js +323 -323
  47. package/src/core/soul.js +69 -69
  48. package/src/core/system-prompt-composer.js +52 -52
  49. package/src/core/tool-args.js +199 -154
  50. package/src/core/tool-output.js +184 -184
  51. package/src/core/tool-result-store.js +206 -206
  52. package/src/core/tools.js +3024 -2893
  53. package/src/core/version.js +11 -11
  54. package/src/tui/chat-app.js +5173 -5171
  55. package/src/tui/tool-activity/presenters/misc.js +30 -30
  56. package/src/tui/tool-activity/presenters/system.js +20 -20
  57. package/templates/project-requirements/report-shell.html +582 -582
  58. package/codemini-web/dist/assets/index-BSdIdn3L.css +0 -2
  59. package/codemini-web/dist/assets/mermaid-GHXKKRXX-Dg9qh8mg.js +0 -1
@@ -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
+ }