micro-models-agent 0.63.0 → 1.1.0

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 (186) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/dist/cli/cache-line.js +30 -0
  3. package/dist/cli/command-suggest.js +38 -0
  4. package/dist/cli/commands.js +285 -60
  5. package/dist/cli/completer.js +16 -16
  6. package/dist/cli/json-payload.js +32 -0
  7. package/dist/cli/main.js +165 -77
  8. package/dist/cli/plugin-commands.js +5 -4
  9. package/dist/cli/relaunch.js +37 -0
  10. package/dist/cli/repl-commands.js +441 -307
  11. package/dist/cli/repl.js +360 -83
  12. package/dist/cli/run-result.js +12 -6
  13. package/dist/cli/security-commands.js +64 -60
  14. package/dist/cli/setup-order.js +57 -0
  15. package/dist/cli/setup-prompt.js +49 -0
  16. package/dist/cli/setup.js +52 -48
  17. package/dist/config/budget.js +48 -0
  18. package/dist/config/config.js +132 -70
  19. package/dist/config/defaults.js +37 -11
  20. package/dist/config/domains.js +9 -50
  21. package/dist/config/utils.js +56 -0
  22. package/dist/core/agent/audit-gate.js +49 -0
  23. package/dist/core/agent/compaction.js +89 -0
  24. package/dist/core/agent/constants.js +61 -0
  25. package/dist/core/agent/context-renderer.js +40 -0
  26. package/dist/core/agent/hallucination-gate.js +87 -0
  27. package/dist/core/agent/loop-state.js +53 -0
  28. package/dist/core/agent/prefix-monitor.js +101 -0
  29. package/dist/core/agent/reasoning-resolver.js +56 -0
  30. package/dist/core/agent/token-tracker.js +96 -0
  31. package/dist/core/agent/tool-batch.js +237 -0
  32. package/dist/core/agent/tool-output.js +62 -0
  33. package/dist/core/agent-moe.js +214 -69
  34. package/dist/core/agent.js +506 -546
  35. package/dist/core/bootstrap.js +297 -98
  36. package/dist/core/crash-handler.js +2 -1
  37. package/dist/core/prompt-builder.js +3 -0
  38. package/dist/core/prompt-overflow.js +307 -0
  39. package/dist/core/session-logger.js +34 -2
  40. package/dist/i18n/en.json +8 -4
  41. package/dist/i18n/ru.json +8 -4
  42. package/dist/index.js +5 -1
  43. package/dist/llm/cache-usage.js +76 -0
  44. package/dist/llm/image-utils.js +20 -16
  45. package/dist/llm/llm-errors.js +41 -0
  46. package/dist/llm/model-loader.js +30 -0
  47. package/dist/llm/openai-compat.js +287 -101
  48. package/dist/llm/orchestrator.js +140 -68
  49. package/dist/llm/provider-budget.js +68 -0
  50. package/dist/llm/provider.js +0 -1
  51. package/dist/llm/stream-state.js +26 -0
  52. package/dist/llm/token-counter.js +28 -0
  53. package/dist/logger/app-logger.js +12 -15
  54. package/dist/main.js +1755 -841
  55. package/dist/migration/detect.js +3 -1
  56. package/dist/modules/browser/actions.js +0 -3
  57. package/dist/modules/browser/bridge-client.js +2 -0
  58. package/dist/modules/browser/bridge-server.mjs +37 -4
  59. package/dist/modules/browser/driver.js +46 -4
  60. package/dist/modules/certification/cli.js +85 -42
  61. package/dist/modules/certification/loader.js +15 -1
  62. package/dist/modules/certification/manifest.js +126 -15
  63. package/dist/modules/certification/runner.js +4 -26
  64. package/dist/modules/certification/scenarios.js +184 -5
  65. package/dist/modules/certification/syntax-scenarios.js +51 -0
  66. package/dist/modules/context/chunk-query.js +25 -5
  67. package/dist/modules/context/fact-extractor.js +6 -2
  68. package/dist/modules/context/manager.js +23 -7
  69. package/dist/modules/execution/audit-runners.js +7 -1
  70. package/dist/modules/execution/auditor.js +3 -3
  71. package/dist/modules/execution/execution-plugin.js +22 -15
  72. package/dist/modules/execution/input-from.js +46 -0
  73. package/dist/modules/execution/module.js +107 -18
  74. package/dist/modules/execution/moe-executor.js +166 -54
  75. package/dist/modules/execution/plan-actions.js +524 -0
  76. package/dist/modules/execution/plan-steps.js +23 -0
  77. package/dist/modules/execution/plan-store.js +15 -3
  78. package/dist/modules/execution/plan-tool.js +6 -488
  79. package/dist/modules/execution/plan-validator.js +24 -0
  80. package/dist/modules/execution/stuck-detector.js +3 -18
  81. package/dist/modules/execution/tracker.js +14 -5
  82. package/dist/modules/execution/transient-error.js +30 -0
  83. package/dist/modules/execution/verifier.js +94 -7
  84. package/dist/modules/execution/windows-commands.js +11 -0
  85. package/dist/modules/hallucination/confidence.js +36 -23
  86. package/dist/modules/hallucination/consistency.js +3 -0
  87. package/dist/modules/hallucination/detector.js +8 -3
  88. package/dist/modules/hallucination/factual.js +26 -7
  89. package/dist/modules/hallucination/llm-judge.js +12 -2
  90. package/dist/modules/indexer/map-command.js +35 -0
  91. package/dist/modules/indexer/map-select.js +87 -0
  92. package/dist/modules/indexer/module.js +34 -22
  93. package/dist/modules/indexer/symbols.js +189 -0
  94. package/dist/modules/indexer/walker.js +96 -42
  95. package/dist/modules/lsp/check-tool.js +2 -1
  96. package/dist/modules/lsp/client.js +49 -32
  97. package/dist/modules/lsp/config.js +55 -2
  98. package/dist/modules/lsp/module.js +38 -5
  99. package/dist/modules/lsp/probe.js +4 -3
  100. package/dist/modules/lsp/project-root.js +41 -1
  101. package/dist/modules/lsp/startup-check.js +12 -4
  102. package/dist/modules/mcp/client.js +153 -104
  103. package/dist/modules/mcp/module.js +165 -41
  104. package/dist/modules/memory/module.js +4 -3
  105. package/dist/modules/plugins/builtin/lint-on-write.js +36 -6
  106. package/dist/modules/plugins/manager.js +47 -84
  107. package/dist/modules/pricing/index.js +17 -7
  108. package/dist/modules/pricing/prices.js +30 -12
  109. package/dist/modules/processes/index.js +1 -0
  110. package/dist/modules/processes/kill-tree.js +56 -0
  111. package/dist/modules/processes/registry.js +2 -54
  112. package/dist/modules/providers/cache.js +23 -0
  113. package/dist/modules/providers/factory.js +28 -0
  114. package/dist/modules/providers/fallback.js +7 -5
  115. package/dist/modules/providers/health.js +2 -1
  116. package/dist/modules/providers/index.js +1 -0
  117. package/dist/modules/providers/manager.js +17 -2
  118. package/dist/modules/providers/presets.js +79 -6
  119. package/dist/modules/reasoning/policy.js +40 -0
  120. package/dist/modules/reasoning/probe.js +111 -0
  121. package/dist/modules/security/audit-notifier.js +42 -27
  122. package/dist/modules/security/command-validator.js +25 -20
  123. package/dist/modules/security/encryption.js +6 -12
  124. package/dist/modules/security/network-validator.js +76 -5
  125. package/dist/modules/security/path-validator.js +77 -34
  126. package/dist/modules/security/rate-limiter.js +11 -0
  127. package/dist/modules/security/security-policies.js +1 -1
  128. package/dist/modules/security/session-encryption.js +13 -2
  129. package/dist/modules/security/session-isolation.js +2 -9
  130. package/dist/modules/session/manager.js +11 -0
  131. package/dist/modules/session/module.js +11 -3
  132. package/dist/modules/session/store.js +41 -5
  133. package/dist/modules/skills/loader.js +7 -1
  134. package/dist/modules/skills/module.js +2 -1
  135. package/dist/modules/updater/changelog-reader.js +94 -0
  136. package/dist/modules/updater/dev-detect.js +17 -0
  137. package/dist/modules/updater/index.js +1 -0
  138. package/dist/modules/updater/module.js +14 -3
  139. package/dist/output/bus.js +32 -0
  140. package/dist/output/channel.js +233 -0
  141. package/dist/output/format.js +14 -0
  142. package/dist/output/index.js +7 -0
  143. package/dist/output/json-sink.js +22 -0
  144. package/dist/output/machine.js +8 -0
  145. package/dist/output/session-sink.js +27 -0
  146. package/dist/output/types.js +1 -0
  147. package/dist/tools/approve.js +6 -2
  148. package/dist/tools/attach-image.js +11 -11
  149. package/dist/tools/auto-fixer.js +198 -0
  150. package/dist/tools/bash.js +142 -89
  151. package/dist/tools/chunk-query.js +10 -6
  152. package/dist/tools/download-file.js +1 -1
  153. package/dist/tools/edit-file.js +20 -2
  154. package/dist/tools/executor.js +54 -9
  155. package/dist/tools/glob-tool.js +7 -0
  156. package/dist/tools/grep-tool.js +15 -1
  157. package/dist/tools/index.js +3 -1
  158. package/dist/tools/list-dir.js +3 -1
  159. package/dist/tools/load-skill.js +2 -1
  160. package/dist/tools/mcp-call.js +1 -1
  161. package/dist/tools/move-file.js +5 -4
  162. package/dist/tools/path-utils.js +7 -0
  163. package/dist/tools/pipeline-run.js +1 -1
  164. package/dist/tools/prompt-io.js +28 -0
  165. package/dist/tools/question.js +12 -12
  166. package/dist/tools/scope-request.js +91 -0
  167. package/dist/tools/session-info.js +44 -0
  168. package/dist/tools/set-thinking.js +71 -0
  169. package/dist/tools/subagent.js +50 -9
  170. package/dist/tools/syntax-validator.js +177 -0
  171. package/dist/tools/user-input.js +16 -9
  172. package/dist/tools/write-file.js +17 -1
  173. package/dist/ui/diff.js +10 -0
  174. package/dist/ui/line-editor.js +179 -26
  175. package/dist/ui/line-math.js +20 -3
  176. package/dist/ui/md-formatter.js +100 -10
  177. package/dist/ui/output.js +5 -4
  178. package/dist/ui/plan-view.js +2 -7
  179. package/dist/ui/renderer.js +89 -85
  180. package/dist/ui/spinner.js +14 -4
  181. package/dist/utils/error.js +4 -0
  182. package/dist/utils/index.js +4 -0
  183. package/dist/utils/retry.js +17 -0
  184. package/dist/utils/sleep.js +23 -0
  185. package/dist/utils/truncate.js +9 -0
  186. package/package.json +1 -1
@@ -6,6 +6,7 @@ import { Auditor, findExistingFile } from "./auditor";
6
6
  import { PlanStore } from "./plan-store";
7
7
  import { getMessageText } from "../../llm/provider";
8
8
  import { extractFileLikeTokens, stripUrls } from "../hallucination/js-identifiers";
9
+ import { toForwardSlash } from "../../tools/path-utils";
9
10
  import { existsSync, readFileSync } from "fs";
10
11
  import { resolve } from "path";
11
12
  import { performWebSearch } from "../../tools/web-search";
@@ -19,6 +20,63 @@ import { createExecutionPlugin, STUCK_RECOVERY_COOLDOWN, STUCK_WARN_REPEAT_EVERY
19
20
  * time-based. Injectable via the constructor for tests (0 disables it).
20
21
  */
21
22
  const ERROR_SEARCH_MIN_INTERVAL_MS = 30000;
23
+ /**
24
+ * Tools that may run while a plan step is active WITHOUT a step-alignment
25
+ * check. They either cannot touch the filesystem, or are structural/read-only
26
+ * inspection tools the agent legitimately needs at any point (diagnostics,
27
+ * index/search, memory, process introspection, interactive prompts). Only
28
+ * file-mutating tools are pinned to the current step's file tokens.
29
+ *
30
+ * `lsp_check` and `project_map` belong here explicitly: both are alwaysOn and
31
+ * the system prompt MANDATES `lsp_check` after every write (including rewrites
32
+ * of files owned by already-completed steps). Leaving them off the list made
33
+ * every diagnostic call burn a plan warning and could hard-block a legitimate
34
+ * write on the 3rd call (observed: "3 вызовов подряд вне текущего шага" while
35
+ * the agent was writing the current step's own deliverable).
36
+ */
37
+ const PLAN_ALIGNMENT_EXEMPT_TOOLS = new Set([
38
+ // structural / execution control
39
+ "plan",
40
+ "todo",
41
+ "verify",
42
+ "enable_tools",
43
+ // filesystem reads
44
+ "list_dir",
45
+ "read_file",
46
+ "glob",
47
+ "grep",
48
+ "file_info",
49
+ // read-only diagnostics + code index
50
+ "lsp_check",
51
+ "project_map",
52
+ "chunk_query",
53
+ // web / memory / session reads
54
+ "web_search",
55
+ "web_fetch",
56
+ "web_browse",
57
+ "recall",
58
+ "search_history",
59
+ "session_info",
60
+ // process introspection
61
+ "process_list",
62
+ "process_log",
63
+ // interactive prompts (no filesystem mutation)
64
+ "question",
65
+ "approve",
66
+ // skills
67
+ "load_skill",
68
+ ]);
69
+ /**
70
+ * Canonical form of a file token for plan alignment: forward slashes + lower
71
+ * case. Model tool calls pass absolute Windows paths
72
+ * (`C:\…\test\src\main.ts`) while plan steps name files relatively
73
+ * (`src/main.ts`); comparing the raw strings made every on-track write look
74
+ * off-path (backslash vs forward slash never intersects). Normalizing both
75
+ * sides lets the substring/`endsWith` overlap below match.
76
+ */
77
+ function planPathToken(p) {
78
+ return toForwardSlash(p).toLowerCase();
79
+ }
22
80
  export class ExecutionModule {
23
81
  name = "execution";
24
82
  tracker = null;
@@ -227,6 +285,34 @@ export class ExecutionModule {
227
285
  * Final audit before the agent may declare the task done. Returns null when
228
286
  * no plan is active; otherwise checks plan completion + artifact existence.
229
287
  */
288
+ /**
289
+ * Evidence-based auto-close of pending plan steps whose deliverables exist
290
+ * on disk. Small models routinely finish the actual work but forget the
291
+ * `plan update` bookkeeping (observed ses_mthn3c2a: 2/5 steps marked done
292
+ * while every artifact existed — the audit gate then rejected a correct
293
+ * final answer and burned all retry budget on bookkeeping, not on work).
294
+ *
295
+ * Standard is the SAME one the auditor already applies to done steps: every
296
+ * file-like token in the step description resolves to an existing file.
297
+ * Steps without any file token stay pending (nothing to verify).
298
+ */
299
+ autoCloseEvidenceSteps(plan) {
300
+ let closed = 0;
301
+ for (const step of plan.steps) {
302
+ if (step.status === "done" || step.status === "skipped")
303
+ continue;
304
+ const tokens = extractFileLikeTokens(stripUrls(step.description));
305
+ if (tokens.length === 0)
306
+ continue;
307
+ const allExist = tokens.every((f) => findExistingFile(this.baseDir, f) !== null);
308
+ if (!allExist)
309
+ continue;
310
+ step.status = "done";
311
+ step.note = `auto-closed by final audit: all named files exist (${tokens.join(", ")})`;
312
+ closed++;
313
+ }
314
+ return closed;
315
+ }
230
316
  async runFinalAudit() {
231
317
  // No active tracker: audit the last completed plan instead of bailing.
232
318
  // A plan finished via `plan update` auto-archives and clears the tracker;
@@ -236,9 +322,13 @@ export class ExecutionModule {
236
322
  if (!this.completedPlan)
237
323
  return null;
238
324
  const plan = this.completedPlan;
325
+ const autoClosed = this.autoCloseEvidenceSteps(plan);
239
326
  const audit = await this.auditor.audit(plan);
240
327
  const pendingSteps = plan.steps.flatMap((s) => s.status !== "done" && s.status !== "skipped" ? [`${s.id}. ${s.description}`] : []);
241
328
  const done = plan.steps.filter((s) => s.status === "done").length;
329
+ if (autoClosed > 0) {
330
+ this.store.saveActive(plan);
331
+ }
242
332
  return {
243
333
  passed: audit.passed && pendingSteps.length === 0,
244
334
  done,
@@ -253,6 +343,10 @@ export class ExecutionModule {
253
343
  return null;
254
344
  }
255
345
  const plan = this.tracker.getPlan();
346
+ const autoClosed = this.autoCloseEvidenceSteps(plan);
347
+ if (autoClosed > 0) {
348
+ this.store.saveActive(plan);
349
+ }
256
350
  const audit = await this.auditor.audit(plan);
257
351
  const pendingSteps = plan.steps.flatMap((s) => s.status !== "done" && s.status !== "skipped" ? [`${s.id}. ${s.description}`] : []);
258
352
  const done = plan.steps.filter((s) => s.status === "done").length;
@@ -349,20 +443,9 @@ export class ExecutionModule {
349
443
  const step = this.tracker.getCurrentStep();
350
444
  if (!step)
351
445
  return null;
352
- const allowedAlways = [
353
- "plan",
354
- "todo",
355
- "verify",
356
- "list_dir",
357
- "read_file",
358
- "glob",
359
- "grep",
360
- "file_info",
361
- "load_skill",
362
- ];
363
- if (allowedAlways.includes(call.name))
446
+ if (PLAN_ALIGNMENT_EXEMPT_TOOLS.has(call.name))
364
447
  return null;
365
- const stepPaths = extractFileLikeTokens(stripUrls(step.description)).map((p) => p.toLowerCase());
448
+ const stepPaths = extractFileLikeTokens(stripUrls(step.description)).map(planPathToken);
366
449
  if (stepPaths.length === 0)
367
450
  return null;
368
451
  // Only path-ish arguments are inspected. JSON-stringifying the whole args
@@ -370,7 +453,7 @@ export class ExecutionModule {
370
453
  // off-path check (e.g. `import "./styles.css"` inside the file body) and
371
454
  // raised phantom alignment warnings for on-track writes.
372
455
  const argStr = this.pathArgStrings(call.arguments).join(" ");
373
- const callPaths = extractFileLikeTokens(stripUrls(argStr)).map((p) => p.toLowerCase());
456
+ const callPaths = extractFileLikeTokens(stripUrls(argStr)).map(planPathToken);
374
457
  if (callPaths.length === 0)
375
458
  return null;
376
459
  // Files that already belong to COMPLETED (done/skipped) steps are
@@ -381,15 +464,21 @@ export class ExecutionModule {
381
464
  for (const s of plan.steps) {
382
465
  if (s.status === "done" || s.status === "skipped") {
383
466
  extractFileLikeTokens(stripUrls(s.description))
384
- .map((p) => p.toLowerCase())
467
+ .map(planPathToken)
385
468
  .forEach((p) => finishedPaths.add(p));
386
469
  }
387
470
  }
471
+ // Bidirectional path-token overlap, used for BOTH checks below. A step may
472
+ // name "main.ts" while the tool calls "src/main.ts" (or vice versa), so a
473
+ // one-directional `s.includes(p)` missed the common nested/partial case
474
+ // (observed: rewriting a completed step's src/main.ts was flagged as
475
+ // off-path for the current step and helped hard-block the third call).
476
+ const matchesAny = (p, tokens) => tokens.some((t) => t === p || t.includes(p) || p.includes(t));
477
+ const finished = [...finishedPaths];
388
478
  const offPath = callPaths.some((p) => {
389
- if (finishedPaths.has(p) || [...finishedPaths].some((s) => s.includes(p))) {
479
+ if (matchesAny(p, finished))
390
480
  return false;
391
- }
392
- return !stepPaths.some((s) => p.includes(s) || s.includes(p));
481
+ return !matchesAny(p, stepPaths);
393
482
  });
394
483
  if (!offPath)
395
484
  return null;
@@ -3,27 +3,34 @@ import { filterToolsByTags } from "../../tools/filter-tools";
3
3
  import { StuckDetector } from "./stuck-detector";
4
4
  import { t } from "../../i18n/index";
5
5
  import { ArtifactStore } from "../artifacts/store";
6
- const TRANSIENT_ERROR_PATTERNS = [
7
- "timeout",
8
- "Timeout",
9
- "TIMEOUT",
10
- "5xx",
11
- "500",
12
- "502",
13
- "503",
14
- "network",
15
- "Network",
16
- "ECONNREFUSED",
17
- "ECONNRESET",
18
- "fetch failed",
19
- "abort",
20
- "Abort",
21
- ];
22
- function isTransientError(error) {
23
- return TRANSIENT_ERROR_PATTERNS.some((p) => error.includes(p));
6
+ import { buildInputSection } from "./input-from";
7
+ import { parseScopeRequest } from "../../tools/scope-request";
8
+ import { isTransientError } from "./transient-error";
9
+ import { sleep } from "../../utils/sleep";
10
+ import { backoffDelay } from "../../utils/retry";
11
+ import { errMsg } from "../../utils";
12
+ export const MAX_SCOPE_EXPANSION_CYCLES = 2;
13
+ /** Uniform interrupt error recognized by the agent loop (err.name === "AbortError"). */
14
+ export function newAbortError() {
15
+ const e = new Error(t("tool.aborted", { name: "MoE execution" }));
16
+ e.name = "AbortError";
17
+ return e;
24
18
  }
25
- function sleep(ms) {
26
- return new Promise((r) => setTimeout(r, ms));
19
+ /**
20
+ * Сборка детального сообщения об ошибке подзадачи: базовый текст + подсказки
21
+ * stuck-детектора + совет про load_skill. Дублировался 2× в executeSubtask.
22
+ */
23
+ function buildErrorDetail(stuckDetector, baseError, lastError) {
24
+ const hints = stuckDetector.getActionableHints();
25
+ let errorDetail = baseError;
26
+ if (hints.length > 0) {
27
+ errorDetail += `\n\n${t("exec.hints", { hints: hints.map((h) => `- ${h}`).join("\n") })}`;
28
+ }
29
+ if (lastError) {
30
+ errorDetail +=
31
+ "\n\nIf relevant skills are available, consider loading one with load_skill for expert guidance.";
32
+ }
33
+ return errorDetail;
27
34
  }
28
35
  export function topologicalSort(subtasks) {
29
36
  const sorted = [];
@@ -48,7 +55,7 @@ export function topologicalSort(subtasks) {
48
55
  }
49
56
  return sorted;
50
57
  }
51
- async function executeSubtask(subtask, deps, _sharedContext) {
58
+ async function executeSubtask(subtask, deps, depResults, sharedContext, onUsage) {
52
59
  const startTime = Date.now();
53
60
  const expertConfig = getExpertConfig(deps.config, subtask.expert_tag);
54
61
  if (!expertConfig) {
@@ -95,7 +102,11 @@ async function executeSubtask(subtask, deps, _sharedContext) {
95
102
  const scopePrompt = allowedFiles.length > 0 || readOnlyFiles.length > 0
96
103
  ? `\n\nAllowed files to write: ${allowedFiles.join(", ") || "(none)"}\nRead-only files: ${readOnlyFiles.join(", ") || "(none)"}`
97
104
  : "";
98
- const taskPrompt = `${subtask.description}\n\nExpected output: ${subtask.expected_output}\nSuccess criteria:\n${(subtask.success_criteria || []).map((c) => `- ${c}`).join("\n")}${scopePrompt}`;
105
+ const sharedRules = sharedContext?.rules;
106
+ const sharedContextPrompt = sharedRules && sharedRules.length > 0
107
+ ? `\n\nShared rules:\n${sharedRules.map((r) => `- ${r}`).join("\n")}`
108
+ : "";
109
+ const taskPrompt = `${subtask.description}\n\nExpected output: ${subtask.expected_output}\nSuccess criteria:\n${(subtask.success_criteria || []).map((c) => `- ${c}`).join("\n")}${scopePrompt}${sharedContextPrompt}${buildInputSection(subtask, depResults, deps.config.subagent?.maxSummaryChars ?? 2000)}`;
99
110
  const subagentArgs = {
100
111
  task: taskPrompt,
101
112
  allowed_files: allowedFiles,
@@ -106,22 +117,39 @@ async function executeSubtask(subtask, deps, _sharedContext) {
106
117
  subagentArgs.result_mode = "file";
107
118
  subagentArgs.artifact_name = subtask.id;
108
119
  }
120
+ const subagentStart = Date.now();
121
+ deps.onTool?.({
122
+ type: "start",
123
+ tool: "subagent",
124
+ args: { subtask: subtask.id, description: subtask.description },
125
+ });
109
126
  const result = await subagentTool.handler({
110
127
  config: deps.config,
111
128
  baseDir: deps.baseDir,
112
129
  logger: deps.logger,
113
130
  llmProvider: deps.llmProvider,
114
131
  toolExecutor: deps.toolExecutor,
115
- sessionId: "moe",
132
+ sessionId: deps.sessionId || "moe",
116
133
  }, subagentArgs);
134
+ if (result.usage) {
135
+ onUsage?.(subtask.expert_tag, result.usage);
136
+ }
137
+ deps.onTool?.({
138
+ type: "end",
139
+ tool: "subagent",
140
+ args: { subtask: subtask.id, description: subtask.description },
141
+ duration: Date.now() - subagentStart,
142
+ error: !result.success,
143
+ });
117
144
  if (result.success) {
118
145
  if (deps.config.subagent?.resultMode === "file") {
119
- const pathMatch = result.output.match(/saved to artifact: (.+)/);
120
- const abs = pathMatch ? pathMatch[1].trim() : "";
146
+ // Structured field from the subagent tool — locale-independent
147
+ // (parsing the localized output string broke under ru).
148
+ const abs = result.artifactPath ?? "";
121
149
  if (abs) {
122
150
  const store = new ArtifactStore({
123
151
  baseDir: deps.baseDir,
124
- sessionId: "moe",
152
+ sessionId: deps.sessionId || "moe",
125
153
  });
126
154
  const full = store.read(abs.split(/[\\/]/).pop() || ""); // file name only, scoped to root
127
155
  deps.logger.debug(`MoE subtask "${subtask.id}" attached artifact ${abs} (${full?.length ?? 0} chars) to Router`);
@@ -156,48 +184,38 @@ async function executeSubtask(subtask, deps, _sharedContext) {
156
184
  }
157
185
  }
158
186
  if (isTransientError(lastError) && attempt < maxAttempts) {
159
- const delay = Math.min(1000 * Math.pow(2, attempt - 1), 8000);
187
+ const delay = backoffDelay(attempt - 1, 1000, 8000);
160
188
  deps.logger.debug(`Retrying subtask "${subtask.id}" (attempt ${attempt}/${maxAttempts}) after ${delay}ms: transient error`);
161
189
  await sleep(delay);
162
190
  continue;
163
191
  }
164
192
  // Non-transient failure — build detailed error with hints
165
- const hints = stuckDetector.getActionableHints();
166
- let errorDetail = result.output;
167
- if (hints.length > 0) {
168
- errorDetail += `\n\n${t("exec.hints", { hints: hints.map((h) => `- ${h}`).join("\n") })}`;
169
- }
170
- if (lastError) {
171
- errorDetail +=
172
- "\n\nIf relevant skills are available, consider loading one with load_skill for expert guidance.";
173
- }
193
+ const errorDetail = buildErrorDetail(stuckDetector, result.output, lastError);
174
194
  return {
175
195
  subtaskId: subtask.id,
176
196
  success: false,
177
197
  summary: `Failed: ${subtask.id}`,
178
198
  result: result.output,
179
199
  error: errorDetail,
200
+ scopeRequest: parseScopeRequest(result.output) ?? undefined,
180
201
  durationMs: Date.now() - startTime,
181
202
  };
182
203
  }
183
204
  catch (e) {
184
205
  lastError = e.message;
185
206
  stuckDetector.recordToolError("subagent", e.message);
207
+ // User interrupt (Esc) is NEVER transient — propagate immediately so
208
+ // the wave barrier and the agent loop see the abort instead of retrying.
209
+ if (e?.name === "AbortError") {
210
+ throw e;
211
+ }
186
212
  if (isTransientError(lastError) && attempt < maxAttempts) {
187
- const delay = Math.min(1000 * Math.pow(2, attempt - 1), 8000);
213
+ const delay = backoffDelay(attempt - 1, 1000, 8000);
188
214
  deps.logger.debug(`Retrying subtask "${subtask.id}" (attempt ${attempt}/${maxAttempts}) after ${delay}ms: ${lastError}`);
189
215
  await sleep(delay);
190
216
  continue;
191
217
  }
192
- const hints = stuckDetector.getActionableHints();
193
- let errorDetail = e.message;
194
- if (hints.length > 0) {
195
- errorDetail += `\n\n${t("exec.hints", { hints: hints.map((h) => `- ${h}`).join("\n") })}`;
196
- }
197
- if (lastError) {
198
- errorDetail +=
199
- "\n\nIf relevant skills are available, consider loading one with load_skill for expert guidance.";
200
- }
218
+ const errorDetail = buildErrorDetail(stuckDetector, e.message, lastError);
201
219
  return {
202
220
  subtaskId: subtask.id,
203
221
  success: false,
@@ -225,14 +243,35 @@ async function executeSubtask(subtask, deps, _sharedContext) {
225
243
  }
226
244
  export class MoEExecutor {
227
245
  deps;
246
+ usageByTag = new Map();
228
247
  constructor(deps) {
229
248
  this.deps = deps;
230
249
  }
231
- async executePlan(plan) {
232
- const results = [];
250
+ /** Aggregated token usage per expert_tag across all executed subtasks. */
251
+ getUsageByTag() {
252
+ return Object.fromEntries(this.usageByTag);
253
+ }
254
+ recordUsage(expertTag, usage) {
255
+ const entry = this.usageByTag.get(expertTag) ?? {
256
+ promptTokens: 0,
257
+ completionTokens: 0,
258
+ totalTokens: 0,
259
+ subtasks: 0,
260
+ };
261
+ entry.promptTokens += usage.promptTokens;
262
+ entry.completionTokens += usage.completionTokens;
263
+ entry.totalTokens += usage.totalTokens;
264
+ entry.subtasks += 1;
265
+ this.usageByTag.set(expertTag, entry);
266
+ }
267
+ async executePlan(plan, opts) {
268
+ const skipIds = opts?.skipIds;
269
+ const carried = opts?.carriedResults ?? [];
270
+ const results = carried.map((r) => ({ ...r }));
233
271
  const errors = [];
234
272
  const warnings = [];
235
- const waves = topologicalSort(plan.subtasks);
273
+ const subtasks = skipIds && skipIds.size > 0 ? plan.subtasks.filter((s) => !skipIds.has(s.id)) : plan.subtasks;
274
+ const waves = topologicalSort(subtasks);
236
275
  if (waves.length === 0) {
237
276
  return {
238
277
  success: false,
@@ -243,8 +282,8 @@ export class MoEExecutor {
243
282
  }
244
283
  // Verify all subtasks are included — catch silent drops from unresolved dependencies
245
284
  const sortedCount = waves.flat().length;
246
- if (sortedCount < plan.subtasks.length) {
247
- const missing = plan.subtasks
285
+ if (sortedCount < subtasks.length) {
286
+ const missing = subtasks
248
287
  .filter((s) => !waves.flat().some((w) => w.id === s.id))
249
288
  .map((s) => s.id);
250
289
  return {
@@ -258,6 +297,7 @@ export class MoEExecutor {
258
297
  }
259
298
  for (let waveIdx = 0; waveIdx < waves.length; waveIdx++) {
260
299
  const wave = waves[waveIdx];
300
+ const depResults = new Map(results.map((r) => [r.subtaskId, r]));
261
301
  // Cap concurrent subtasks per wave (rateLimits.maxParallelTasks, default 5).
262
302
  const maxParallel = this.deps.config?.security?.rateLimits?.maxParallelTasks ?? 5;
263
303
  let active = 0;
@@ -273,32 +313,104 @@ export class MoEExecutor {
273
313
  };
274
314
  const wavePromises = wave.map(async (subtask) => {
275
315
  await acquire();
316
+ if (this.deps.signal?.aborted) {
317
+ // Checked after acquiring so subtasks queued behind an in-flight one
318
+ // never start once Esc was pressed mid-wave.
319
+ release();
320
+ throw newAbortError();
321
+ }
322
+ this.deps.onEvent?.({
323
+ type: "moe_subtask",
324
+ data: { subtaskId: subtask.id, expertTag: subtask.expert_tag, status: "start" },
325
+ });
276
326
  let result;
277
327
  try {
278
- result = await executeSubtask(subtask, this.deps, plan.shared_context);
328
+ result = await executeSubtask(subtask, this.deps, depResults, plan.shared_context, (tag, usage) => this.recordUsage(tag, usage));
329
+ if (!result.success && this.deps.onScopeRequest) {
330
+ result = await this.resolveScopeAndRetry(subtask, result, depResults, plan.shared_context);
331
+ }
279
332
  }
280
333
  catch (e) {
334
+ if (e instanceof Error && e.name === "AbortError")
335
+ throw e;
281
336
  result = {
282
337
  subtaskId: subtask.id,
283
338
  success: false,
284
339
  summary: `Unhandled error: ${subtask.id}`,
285
340
  result: "",
286
- error: e instanceof Error ? e.message : String(e),
341
+ error: errMsg(e),
287
342
  durationMs: 0,
288
343
  };
289
344
  }
290
345
  finally {
291
346
  release();
292
347
  }
348
+ this.deps.onEvent?.({
349
+ type: "moe_subtask",
350
+ data: {
351
+ subtaskId: subtask.id,
352
+ expertTag: subtask.expert_tag,
353
+ status: result.success ? "done" : "failed",
354
+ durationMs: result.durationMs,
355
+ summary: result.summary,
356
+ },
357
+ });
293
358
  results.push(result);
294
359
  return result;
295
360
  });
296
361
  await Promise.all(wavePromises);
362
+ // Wave barrier: once interrupted, stop before the next wave instead of
363
+ // starting new sub-agents after Esc.
364
+ if (this.deps.signal?.aborted)
365
+ throw newAbortError();
297
366
  }
298
367
  const failed = results.filter((r) => !r.success);
299
368
  if (failed.length > 0) {
300
369
  errors.push(...failed.map((f) => `[${f.subtaskId}] ${f.error || f.summary}`));
301
370
  }
302
- return { success: failed.length === 0, results, errors, warnings };
371
+ return {
372
+ success: failed.length === 0,
373
+ results,
374
+ errors,
375
+ warnings,
376
+ ...(carried.length > 0 ? { carriedSubtaskIds: carried.map((r) => r.subtaskId) } : {}),
377
+ };
378
+ }
379
+ /**
380
+ * Scope expansion protocol (spec §Post-MVP): a failed sub-agent's
381
+ * <scope-request> goes to the Router; approve extends the subtask scope and
382
+ * re-executes ONLY this subtask, reject fails it (it can still be fixed by
383
+ * a full re-plan). Expansion is capped — never automatic.
384
+ */
385
+ async resolveScopeAndRetry(subtask, failed, depResults, sharedContext) {
386
+ let current = failed;
387
+ for (let cycle = 0; cycle < MAX_SCOPE_EXPANSION_CYCLES; cycle++) {
388
+ const req = current.scopeRequest;
389
+ if (!req)
390
+ return current;
391
+ const decision = await this.deps.onScopeRequest(subtask.id, req);
392
+ if (decision.action !== "approve") {
393
+ this.deps.logger.info?.(`MoE: router rejected scope request for "${subtask.id}" (${req.reason})`);
394
+ return {
395
+ ...current,
396
+ error: `${current.error}\nRouter rejected scope request for files: ${req.files.join(", ")}`,
397
+ };
398
+ }
399
+ subtask.allowed_files = uniq([...(subtask.allowed_files || []), ...decision.write]);
400
+ subtask.read_only_files = uniq([...(subtask.read_only_files || []), ...decision.read]);
401
+ this.deps.logger.info?.(`MoE: approved scope expansion for "${subtask.id}": +write [${decision.write.join(", ")}] +read [${decision.read.join(", ")}]`);
402
+ const retry = await executeSubtask(subtask, this.deps, depResults, sharedContext, (tag, usage) => this.recordUsage(tag, usage));
403
+ if (retry.success || !retry.scopeRequest)
404
+ return retry;
405
+ current = retry;
406
+ }
407
+ return {
408
+ ...current,
409
+ summary: `Failed after ${MAX_SCOPE_EXPANSION_CYCLES} scope expansions: ${subtask.id}`,
410
+ error: `${current.error}\nScope expansion limit (${MAX_SCOPE_EXPANSION_CYCLES}) reached`,
411
+ };
303
412
  }
304
413
  }
414
+ function uniq(arr) {
415
+ return Array.from(new Set(arr));
416
+ }