micro-models-agent 0.40.1 → 0.41.2

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 (171) hide show
  1. package/bin/mma.mjs +41 -41
  2. package/dist/cli/commands.js +9 -19
  3. package/dist/cli/completer.js +36 -37
  4. package/dist/cli/index.js +2 -2
  5. package/dist/cli/main.js +48 -23
  6. package/dist/cli/plugin-commands.js +36 -0
  7. package/dist/cli/repl-commands.js +40 -12
  8. package/dist/cli/repl.js +217 -87
  9. package/dist/cli/run-result.js +22 -0
  10. package/dist/cli/security-commands.js +5 -7
  11. package/dist/cli/setup.js +8 -26
  12. package/dist/config/config.js +52 -5
  13. package/dist/config/defaults.js +29 -5
  14. package/dist/config/experts.js +1 -1
  15. package/dist/config/index.js +3 -3
  16. package/dist/config/security.js +3 -10
  17. package/dist/core/agent-moe.js +2 -10
  18. package/dist/core/agent.js +273 -82
  19. package/dist/core/bootstrap.js +80 -13
  20. package/dist/core/index.js +2 -2
  21. package/dist/core/prompt-builder.js +23 -2
  22. package/dist/core/session-logger.js +46 -4
  23. package/dist/core/version.js +24 -0
  24. package/dist/i18n/en.json +75 -2
  25. package/dist/i18n/ru.json +74 -1
  26. package/dist/index.js +1 -1
  27. package/dist/llm/image-utils.js +4 -5
  28. package/dist/llm/index.js +4 -4
  29. package/dist/llm/model-loader.js +6 -6
  30. package/dist/llm/openai-compat.js +40 -34
  31. package/dist/llm/orchestrator.js +33 -29
  32. package/dist/llm/response.js +9 -9
  33. package/dist/logger/app-logger.js +1 -1
  34. package/dist/logger/index.js +1 -1
  35. package/dist/main.js +2489 -2186
  36. package/dist/migration/backup.js +13 -13
  37. package/dist/migration/detect.js +11 -11
  38. package/dist/migration/index.js +2 -2
  39. package/dist/modules/artifacts/store.js +61 -0
  40. package/dist/modules/browser/actions.js +34 -4
  41. package/dist/modules/browser/bridge-client.js +199 -0
  42. package/dist/modules/browser/bridge-path.js +10 -0
  43. package/dist/modules/browser/bridge-server.mjs +202 -202
  44. package/dist/modules/browser/cookie-store.js +6 -6
  45. package/dist/modules/browser/driver.js +136 -0
  46. package/dist/modules/browser/index.js +7 -5
  47. package/dist/modules/browser/module.js +8 -7
  48. package/dist/modules/browser/session.js +87 -84
  49. package/dist/modules/browser/snapshot.js +92 -58
  50. package/dist/modules/browser/types.js +4 -1
  51. package/dist/modules/certification/cli.js +2 -4
  52. package/dist/modules/certification/fact-checker.js +1 -3
  53. package/dist/modules/certification/loader.js +3 -9
  54. package/dist/modules/certification/runner.js +1 -4
  55. package/dist/modules/context/chunk-query.js +100 -0
  56. package/dist/modules/context/fact-extractor.js +162 -0
  57. package/dist/modules/context/history.js +15 -0
  58. package/dist/modules/context/index.js +1 -1
  59. package/dist/modules/context/manager.js +160 -86
  60. package/dist/modules/execution/audit-runners.js +152 -0
  61. package/dist/modules/execution/auditor.js +177 -25
  62. package/dist/modules/execution/execution-plugin.js +272 -0
  63. package/dist/modules/execution/module.js +201 -544
  64. package/dist/modules/execution/moe-executor.js +25 -0
  65. package/dist/modules/execution/plan-store.js +1 -3
  66. package/dist/modules/execution/plan-tool.js +508 -0
  67. package/dist/modules/execution/plan-validator.js +10 -10
  68. package/dist/modules/execution/planner.js +6 -1
  69. package/dist/modules/execution/stuck-detector.js +173 -10
  70. package/dist/modules/execution/verifier.js +86 -42
  71. package/dist/modules/execution/windows-commands.js +41 -0
  72. package/dist/modules/hallucination/confidence.js +8 -1
  73. package/dist/modules/hallucination/detector.js +2 -5
  74. package/dist/modules/hallucination/factual.js +3 -64
  75. package/dist/modules/hallucination/index.js +1 -1
  76. package/dist/modules/hallucination/js-identifiers.js +190 -0
  77. package/dist/modules/hallucination/llm-judge.js +1 -3
  78. package/dist/modules/indexer/cache.js +9 -7
  79. package/dist/modules/indexer/index.js +3 -3
  80. package/dist/modules/indexer/module.js +95 -42
  81. package/dist/modules/indexer/project-profile.js +183 -0
  82. package/dist/modules/indexer/walker.js +17 -17
  83. package/dist/modules/lsp/check-tool.js +58 -0
  84. package/dist/modules/lsp/client.js +74 -31
  85. package/dist/modules/lsp/command.js +60 -0
  86. package/dist/modules/lsp/config.js +87 -33
  87. package/dist/modules/lsp/index.js +3 -3
  88. package/dist/modules/lsp/module.js +185 -21
  89. package/dist/modules/lsp/probe.js +76 -0
  90. package/dist/modules/lsp/project-root.js +32 -0
  91. package/dist/modules/lsp/startup-check.js +141 -0
  92. package/dist/modules/mcp/module.js +2 -6
  93. package/dist/modules/memory/index.js +1 -1
  94. package/dist/modules/memory/module.js +71 -23
  95. package/dist/modules/memory/search.js +11 -9
  96. package/dist/modules/memory/store.js +13 -13
  97. package/dist/modules/pipelines/engine.js +10 -10
  98. package/dist/modules/pipelines/index.js +3 -3
  99. package/dist/modules/pipelines/parser.js +17 -14
  100. package/dist/modules/pipelines/template.js +1 -1
  101. package/dist/modules/plugins/builtin/lint-on-write.js +21 -16
  102. package/dist/modules/plugins/builtin/notify.js +3 -2
  103. package/dist/modules/plugins/index.js +1 -1
  104. package/dist/modules/plugins/loader.js +59 -17
  105. package/dist/modules/plugins/manager.js +73 -17
  106. package/dist/modules/processes/detect.js +34 -0
  107. package/dist/modules/processes/index.js +1 -1
  108. package/dist/modules/processes/registry.js +135 -46
  109. package/dist/modules/registry.js +4 -2
  110. package/dist/modules/security/audit-notifier.js +39 -39
  111. package/dist/modules/security/command-validator.js +2 -8
  112. package/dist/modules/security/data-sanitizer.js +1 -9
  113. package/dist/modules/security/encryption.js +58 -56
  114. package/dist/modules/security/network-validator.js +1 -9
  115. package/dist/modules/security/path-validator.js +1 -3
  116. package/dist/modules/security/security-policies.js +3 -19
  117. package/dist/modules/security/session-encryption.js +1 -1
  118. package/dist/modules/security/session-isolation.js +8 -8
  119. package/dist/modules/session/index.js +3 -3
  120. package/dist/modules/session/module.js +5 -5
  121. package/dist/modules/session/store.js +3 -9
  122. package/dist/modules/skills/matcher.js +27 -0
  123. package/dist/modules/skills/module.js +1 -2
  124. package/dist/modules/updater/checker.js +70 -6
  125. package/dist/modules/updater/index.js +2 -1
  126. package/dist/modules/updater/module.js +116 -0
  127. package/dist/modules/user-profile/compressor.js +2 -2
  128. package/dist/modules/user-profile/index.js +1 -1
  129. package/dist/modules/user-profile/profile.js +9 -9
  130. package/dist/tools/attach-image.js +1 -1
  131. package/dist/tools/bash.js +178 -19
  132. package/dist/tools/browser.js +46 -29
  133. package/dist/tools/chunk-query.js +99 -0
  134. package/dist/tools/download-file.js +116 -0
  135. package/dist/tools/enable-tools.js +58 -0
  136. package/dist/tools/executor.js +4 -5
  137. package/dist/tools/file-info.js +13 -12
  138. package/dist/tools/filter-tools.js +9 -2
  139. package/dist/tools/glob-tool.js +11 -11
  140. package/dist/tools/grep-tool.js +1 -3
  141. package/dist/tools/hidden-tools-block.js +37 -0
  142. package/dist/tools/index.js +13 -2
  143. package/dist/tools/list-dir.js +18 -17
  144. package/dist/tools/load-skill.js +1 -3
  145. package/dist/tools/path-utils.js +4 -4
  146. package/dist/tools/pipeline-run.js +25 -25
  147. package/dist/tools/process-kill.js +11 -11
  148. package/dist/tools/process-list.js +20 -22
  149. package/dist/tools/process-log.js +22 -18
  150. package/dist/tools/question.js +1 -3
  151. package/dist/tools/read-file.js +10 -2
  152. package/dist/tools/recall.js +44 -37
  153. package/dist/tools/registry.js +15 -4
  154. package/dist/tools/remember.js +29 -29
  155. package/dist/tools/scope-check.js +9 -9
  156. package/dist/tools/subagent.js +54 -9
  157. package/dist/tools/user-input.js +1 -1
  158. package/dist/tools/web-browse.js +3 -3
  159. package/dist/tools/web-fetch.js +3 -3
  160. package/dist/tools/web-search.js +3 -3
  161. package/dist/tools/write-file.js +1 -3
  162. package/dist/ui/box.js +1 -5
  163. package/dist/ui/index.js +6 -6
  164. package/dist/ui/line-editor.js +703 -0
  165. package/dist/ui/line-math.js +69 -0
  166. package/dist/ui/md-formatter.js +33 -33
  167. package/dist/ui/output.js +5 -5
  168. package/dist/ui/plan-view.js +103 -0
  169. package/dist/ui/renderer.js +15 -10
  170. package/dist/ui/table.js +1 -1
  171. package/package.json +48 -48
@@ -1,15 +1,18 @@
1
- import { join } from "path";
2
1
  import { t } from "../i18n/index";
3
2
  import { pc } from "../ui/colors";
4
3
  import { PromptBuilder } from "./prompt-builder";
5
4
  import { processRegistry } from "../modules/processes";
6
5
  import { SessionLogger } from "./session-logger";
7
6
  import { runWithMoE } from "./agent-moe";
8
- import { MemoryStore } from "../modules/memory/store";
9
7
  import { StepVerifier } from "../modules/execution/verifier";
10
8
  const TOOL_RESULT_MAX_TOKENS_RATIO = 0.3;
11
9
  const TOOL_RESULT_ABSOLUTE_MAX_CHARS = 15000;
12
10
  const QUALITY_TRIGGER_THRESHOLD = 40;
11
+ /** Minimum iterations between quality-triggered forced compactions. Without
12
+ * this, a low-quality context re-triggers compaction on EVERY iteration
13
+ * (observed: 56 compactions in ~28 min) and the compaction itself can't
14
+ * restore quality, so the agent burns the whole budget compacting. */
15
+ const FORCED_COMPACTION_COOLDOWN = 3;
13
16
  /** True when the text looks like a raw JSON tool payload (garbage to display). */
14
17
  function isToolCallJson(text) {
15
18
  const trimmed = text.trim();
@@ -24,6 +27,19 @@ function isToolCallJson(text) {
24
27
  }
25
28
  return false;
26
29
  }
30
+ /**
31
+ * Max chars a tool result may occupy, given remaining budget and whether
32
+ * the tool bounds its own output. Tools that declare `boundedOutput` (e.g.
33
+ * read_file with its line limit) are never truncated by the budget — a
34
+ * near-full context used to cut them to ~2K chars, making the model believe
35
+ * files were truncated and re-read them forever.
36
+ */
37
+ export function toolOutputCharLimit(remainingBudget, historyBudget, bounded) {
38
+ if (bounded)
39
+ return Number.MAX_SAFE_INTEGER;
40
+ const maxCharsByRatio = Math.floor(historyBudget * TOOL_RESULT_MAX_TOKENS_RATIO * 2);
41
+ return Math.min(remainingBudget, maxCharsByRatio, TOOL_RESULT_ABSOLUTE_MAX_CHARS);
42
+ }
27
43
  export class Agent {
28
44
  deps;
29
45
  systemPromptAdded = false;
@@ -43,8 +59,7 @@ export class Agent {
43
59
  }
44
60
  }
45
61
  buildSystemPrompt() {
46
- const systemBudget = Math.floor(this.deps.config.contextWindow *
47
- this.deps.config.contextBudget.systemPrompt);
62
+ const systemBudget = Math.floor(this.deps.config.contextWindow * this.deps.config.contextBudget.systemPrompt);
48
63
  const builder = new PromptBuilder(systemBudget);
49
64
  builder.addBlocks(this.deps.promptBlocks);
50
65
  const dynamic = this.deps.getDynamicPromptBlocks?.() ?? [];
@@ -64,7 +79,45 @@ export class Agent {
64
79
  if (pluginBlocks.length > 0) {
65
80
  builder.addBlocks(pluginBlocks);
66
81
  }
67
- return builder.build();
82
+ const result = builder.build();
83
+ return {
84
+ prompt: result.prompt,
85
+ excluded: result.excluded,
86
+ blocks: result.blocks,
87
+ };
88
+ }
89
+ /**
90
+ * Log the current context state to the session log. Called every iteration
91
+ * so the session.jsonl shows a full timeline of how the context grows,
92
+ * compacts, and what it consists of. The `start` snapshot additionally
93
+ * carries the system-prompt block breakdown (which blocks, priorities,
94
+ * token estimates, what was excluded by the budget).
95
+ */
96
+ logContextStat(kind, iteration, slog, blocks) {
97
+ const cm = this.deps.contextManager;
98
+ if (typeof cm.getSnapshot !== "function")
99
+ return;
100
+ const snap = cm.getSnapshot();
101
+ const history = cm.getActiveHistory();
102
+ const systemMsg = history.find((m) => m.role === "system");
103
+ slog.logContext({
104
+ kind,
105
+ iteration,
106
+ window: snap.window,
107
+ systemBudget: snap.budget.systemPrompt,
108
+ reserveBudget: snap.budget.responseReserve,
109
+ historyBudget: snap.budget.history,
110
+ systemTokens: kind === "start" && systemMsg && typeof systemMsg.content === "string"
111
+ ? this.deps.llmProvider.countTokens(systemMsg.content)
112
+ : undefined,
113
+ toolTokens: snap.toolTokens,
114
+ tokens: snap.tokens,
115
+ quality: snap.quality,
116
+ messageCount: snap.messageCount,
117
+ compactionCount: snap.compactionCount,
118
+ iterationsSinceCompaction: snap.iterationsSinceCompaction,
119
+ blocks,
120
+ });
68
121
  }
69
122
  getSystemPromptInfo() {
70
123
  const { prompt, excluded } = this.buildSystemPrompt();
@@ -92,27 +145,26 @@ export class Agent {
92
145
  }
93
146
  refreshSystemPrompt() {
94
147
  const { prompt } = this.buildSystemPrompt();
95
- const current = this.deps.contextManager
96
- .getActiveHistory()
97
- .find((m) => m.role === "system");
148
+ const current = this.deps.contextManager.getActiveHistory().find((m) => m.role === "system");
98
149
  if (!current || current.content !== prompt) {
99
150
  this.deps.contextManager.updateSystemPrompt?.(prompt);
100
151
  }
101
152
  }
102
- truncateToolOutput(output, budget, currentTokens) {
103
- const remainingBudget = budget.history - currentTokens;
104
- const maxCharsByBudget = Math.floor(remainingBudget * 0.5 * 2);
105
- const maxCharsByRatio = Math.floor(budget.history * TOOL_RESULT_MAX_TOKENS_RATIO * 2);
106
- const maxChars = Math.min(maxCharsByBudget, maxCharsByRatio, TOOL_RESULT_ABSOLUTE_MAX_CHARS);
153
+ truncateToolOutput(output, budget, currentTokens, bounded) {
154
+ const remainingBudget = Math.max(0, budget.history - currentTokens);
155
+ const maxChars = toolOutputCharLimit(remainingBudget, budget.history, bounded);
107
156
  if (output.length <= maxChars)
108
157
  return output;
109
158
  const truncated = output.slice(0, maxChars);
110
159
  const removedChars = output.length - maxChars;
111
- return (truncated +
112
- `\n\n${t("tool.truncated", { tokens: Math.ceil(removedChars / 2) })}`);
160
+ return truncated + `\n\n${t("tool.truncated", { tokens: Math.ceil(removedChars / 2) })}`;
113
161
  }
114
162
  emitPhase(iteration, phase, onPhase) {
115
- this.deps.pluginManager.runOnPhase?.({ iteration, logger: this.deps.logger }, phase);
163
+ this.deps.pluginManager.runOnPhase?.({
164
+ iteration,
165
+ logger: this.deps.logger,
166
+ contextManager: this.deps.contextManager,
167
+ }, phase);
116
168
  onPhase?.(phase);
117
169
  }
118
170
  async run(input, onChunk, onMeta, onTool, onPhase) {
@@ -125,6 +177,10 @@ export class Agent {
125
177
  }
126
178
  if (!this.systemPromptAdded &&
127
179
  !contextManager.getActiveHistory().some((m) => m.role === "system")) {
180
+ const lazy = this.deps.lazyPromptBlocks ? await this.deps.lazyPromptBlocks() : [];
181
+ if (lazy.length > 0) {
182
+ this.deps.promptBlocks.push(...lazy);
183
+ }
128
184
  const { prompt: systemPrompt, excluded } = this.buildSystemPrompt();
129
185
  contextManager.addMessage({ role: "system", content: systemPrompt });
130
186
  this.systemPromptAdded = true;
@@ -135,6 +191,7 @@ export class Agent {
135
191
  pluginManager.runOnSessionStart({
136
192
  logger,
137
193
  sessionManager: sessionManager?.getActiveMeta(),
194
+ contextManager,
138
195
  });
139
196
  if (config.session?.baselineCheck !== false) {
140
197
  const verifier = new StepVerifier(baseDir);
@@ -149,6 +206,13 @@ export class Agent {
149
206
  .catch(() => { });
150
207
  }
151
208
  }
209
+ // A new user message starts a fresh compaction interval — iterations
210
+ // from the previous turn must not trigger a compaction on iteration 1
211
+ // of this one (observed: turn 2 compacted at iteration 8, deleting the
212
+ // just-sent task along with 40+ old turns).
213
+ if (typeof contextManager.resetUserTurn === "function") {
214
+ contextManager.resetUserTurn();
215
+ }
152
216
  contextManager.addMessage({ role: "user", content: input });
153
217
  slog.logUser(input);
154
218
  if (config.moe?.enabled) {
@@ -168,6 +232,12 @@ export class Agent {
168
232
  this.abortController = new AbortController();
169
233
  let iteration = 0;
170
234
  let lastText = "";
235
+ // The actual most-recent model output (tool commentary, retried answers
236
+ // included). Repetition is compared against THIS, not the last *accepted*
237
+ // text — a frozen accepted answer made consecutive retries compare
238
+ // against a stale baseline and flag every re-answer as repetitive.
239
+ let lastModelText = "";
240
+ let lastForcedCompactionIteration = -FORCED_COMPACTION_COOLDOWN;
171
241
  let hallucinationRetries = 0;
172
242
  let lastToolSignature = "";
173
243
  let apiPromptTokens = 0;
@@ -176,21 +246,40 @@ export class Agent {
176
246
  const MAX_HALLUCINATION_RETRIES = 3;
177
247
  let consecutiveToolFailures = 0;
178
248
  const MAX_CONSECUTIVE_TOOL_FAILURES = 5;
249
+ // Per-tool failure counts and which tools already produced a memory rule.
250
+ const toolFailureCounts = new Map();
251
+ const memoryRuleRecorded = new Set();
252
+ const MIN_REPEATED_TOOL_FAILURES = 3;
179
253
  let auditRetries = 0;
180
254
  const MAX_AUDIT_RETRIES = 3;
181
255
  let emptyResponseRetries = 0;
182
256
  const MAX_EMPTY_RESPONSE_RETRIES = 2;
183
257
  let emptyResponseExhausted = false;
258
+ let auditFailed = false;
259
+ let lastAuditSummary = "";
260
+ // Set when the audit gate rejects a final answer and re-prompts: the next
261
+ // non-tool response is then a re-answer of an already-completed task, so
262
+ // a repetition verdict is expected and must not burn a hallucination retry.
263
+ let suppressRepetitionRetry = false;
184
264
  let repeatedToolCount = 0;
185
265
  const MAX_REPEATED_TOOL_CALLS = 2;
186
266
  // Account for tool definitions in context budget (they're sent via body.tools, not messages)
187
- const allToolsForBudget = toolExecutor.getToolDefinitions(this.deps.toolTags);
188
- const toolTokenEstimate = allToolsForBudget.reduce((sum, t) => sum +
189
- Math.ceil((t.description.length + JSON.stringify(t.parameters).length) / 4), 0);
267
+ // Tool definitions are recomputed each iteration so `enable_tools`
268
+ // (which mutates the shared activeToolTags array) can grow the
269
+ // LLM-visible tool set mid-run. Bound per-iteration to keep the budget
270
+ // estimate and boundedOutput set in sync with what is actually sent.
271
+ let allToolsForBudget = toolExecutor.getToolDefinitions(this.deps.toolTags);
272
+ let boundedToolNames = new Set(allToolsForBudget.filter((t) => t.boundedOutput).map((t) => t.name));
273
+ let toolTokenEstimate = allToolsForBudget.reduce((sum, t) => sum + Math.ceil((t.description.length + JSON.stringify(t.parameters).length) / 4), 0);
190
274
  contextManager.setToolTokens(toolTokenEstimate);
191
275
  while (iteration < config.maxToolIterations && !this.shutdownRequested) {
192
276
  iteration++;
193
277
  contextManager.noteIteration();
278
+ // Re-read the mutable tag set in case enable_tools was called.
279
+ allToolsForBudget = toolExecutor.getToolDefinitions(this.deps.toolTags);
280
+ boundedToolNames = new Set(allToolsForBudget.filter((t) => t.boundedOutput).map((t) => t.name));
281
+ toolTokenEstimate = allToolsForBudget.reduce((sum, t) => sum + Math.ceil((t.description.length + JSON.stringify(t.parameters).length) / 4), 0);
282
+ contextManager.setToolTokens(toolTokenEstimate);
194
283
  pluginManager.runOnBeforeThink({
195
284
  iteration,
196
285
  logger,
@@ -202,27 +291,50 @@ export class Agent {
202
291
  },
203
292
  });
204
293
  if (contextManager.needsCompaction()) {
205
- contextManager.compact();
206
- logger.debug("Context compacted");
207
- slog.logCompaction(`regular compaction, iteration ${iteration}`, iteration);
294
+ const result = contextManager.compact();
295
+ if (result) {
296
+ logger.debug("Context compacted");
297
+ slog.logCompaction({ reason: "interval", iteration, ...result });
298
+ }
208
299
  }
209
300
  const currentTokens = contextManager.getEstimatedTokens();
210
301
  const budget = contextManager.getBudget();
211
302
  const quality = contextManager.getQuality();
212
303
  if (quality < QUALITY_TRIGGER_THRESHOLD &&
213
- contextManager.getCompactionCount() > 0) {
214
- contextManager.compact();
304
+ contextManager.getCompactionCount() > 0 &&
305
+ iteration - lastForcedCompactionIteration >= FORCED_COMPACTION_COOLDOWN) {
306
+ lastForcedCompactionIteration = iteration;
307
+ const result = contextManager.compact();
215
308
  logger.warn(`Low context quality (${quality}%) — forced compaction`);
216
- slog.logCompaction(`quality-triggered compaction (${quality}% < ${QUALITY_TRIGGER_THRESHOLD}%), iteration ${iteration}`, iteration, currentTokens, budget.history);
309
+ if (result) {
310
+ slog.logCompaction({
311
+ reason: `quality-triggered (${quality}% < ${QUALITY_TRIGGER_THRESHOLD}%)`,
312
+ iteration,
313
+ ...result,
314
+ });
315
+ }
217
316
  }
218
317
  if (currentTokens > budget.history) {
219
- contextManager.compact();
318
+ const result = contextManager.compact();
220
319
  logger.warn(`Context overflow (${currentTokens} > ${budget.history}), forced compaction`);
221
- slog.logCompaction(`forced compaction (${currentTokens} > ${budget.history}), iteration ${iteration}`, iteration, currentTokens, budget.history);
320
+ if (result) {
321
+ slog.logCompaction({
322
+ reason: `overflow (${currentTokens} > ${budget.history})`,
323
+ iteration,
324
+ ...result,
325
+ });
326
+ }
222
327
  }
223
328
  this.refreshSystemPrompt();
224
329
  const history = contextManager.getActiveHistory();
225
330
  slog.logToolDefs(allToolsForBudget.length, allToolsForBudget.map((t) => t.name), iteration);
331
+ if (iteration === 1) {
332
+ const { blocks } = this.buildSystemPrompt();
333
+ this.logContextStat("start", iteration, slog, blocks);
334
+ }
335
+ else {
336
+ this.logContextStat("iteration", iteration, slog);
337
+ }
226
338
  let textContent = "";
227
339
  let reasoningContent = "";
228
340
  const toolCalls = [];
@@ -231,6 +343,8 @@ export class Agent {
231
343
  const textChunks = [];
232
344
  this.emitPhase(iteration, "thinking", onPhase);
233
345
  const llmStart = Date.now();
346
+ const promptBefore = apiPromptTokens;
347
+ const completionBefore = apiCompletionTokens;
234
348
  logger.logLLMRequest(config.model, history.length, input, "agent");
235
349
  try {
236
350
  for await (const chunk of llmProvider.chat(history, allToolsForBudget, this.abortController?.signal)) {
@@ -246,7 +360,7 @@ export class Agent {
246
360
  if (chunk.type === "reasoning" && chunk.content) {
247
361
  reasoningContent += chunk.content;
248
362
  if (config.showReasoning) {
249
- const metaOut = pluginManager.runOnMeta({ iteration, logger }, chunk.content);
363
+ const metaOut = pluginManager.runOnMeta({ iteration, logger, contextManager }, chunk.content);
250
364
  if (metaOut) {
251
365
  onMeta?.(pc.dim(metaOut));
252
366
  }
@@ -282,7 +396,7 @@ export class Agent {
282
396
  logger.logLLMResponse(config.model, textContent.length, Date.now() - llmStart, err.message, "agent");
283
397
  logger.error(`LLM call failed: ${err.message}`);
284
398
  slog.logError(err.message);
285
- pluginManager.runOnError({ iteration, logger }, err);
399
+ pluginManager.runOnError({ iteration, logger, contextManager }, err);
286
400
  return {
287
401
  success: false,
288
402
  text: lastText,
@@ -297,6 +411,26 @@ export class Agent {
297
411
  // the backend omits `usage` from the response.
298
412
  apiCompletionChars += (textContent || reasoningContent).length;
299
413
  logger.logLLMResponse(config.model, (textContent || reasoningContent).length, Date.now() - llmStart, undefined, "agent");
414
+ // Log per-call token usage. llama.cpp streaming often omits `usage`,
415
+ // so fall back to local estimates (context tokens + chars/4) and
416
+ // mark the source — the log must distinguish real API numbers from
417
+ // heuristics.
418
+ {
419
+ const usagePrompt = apiPromptTokens - promptBefore;
420
+ const usageCompletion = apiCompletionTokens - completionBefore;
421
+ const source = usagePrompt > 0 || usageCompletion > 0 ? "api" : "estimate";
422
+ const prompt = source === "api" ? usagePrompt : contextManager.getEstimatedTokens();
423
+ const completion = source === "api"
424
+ ? usageCompletion
425
+ : Math.ceil((textContent || reasoningContent).length / 4);
426
+ slog.logLlmUsage(iteration, {
427
+ promptTokens: prompt,
428
+ completionTokens: completion,
429
+ totalTokens: prompt + completion,
430
+ source,
431
+ durationMs: Date.now() - llmStart,
432
+ });
433
+ }
300
434
  if (this.shutdownRequested) {
301
435
  break;
302
436
  }
@@ -306,11 +440,10 @@ export class Agent {
306
440
  // describing the call. `toolComments: false` restores the old behavior
307
441
  // of suppressing text next to a tool call.
308
442
  const toolComments = this.deps.config.ui?.toolComments ?? true;
309
- const showText = textChunks.length > 0 &&
310
- (!sawToolCall || (toolComments && !isToolCallJson(textContent)));
443
+ const showText = textChunks.length > 0 && (!sawToolCall || (toolComments && !isToolCallJson(textContent)));
311
444
  if (showText) {
312
445
  for (const chunk of textChunks) {
313
- const textOut = pluginManager.runOnText({ iteration, logger }, chunk);
446
+ const textOut = pluginManager.runOnText({ iteration, logger, contextManager }, chunk);
314
447
  onChunk?.(textOut);
315
448
  }
316
449
  }
@@ -324,7 +457,7 @@ export class Agent {
324
457
  else if (reasoningContent) {
325
458
  llmResponse = { type: "reasoning", content: reasoningContent };
326
459
  }
327
- pluginManager.runOnAfterThink({ iteration, logger }, llmResponse);
460
+ pluginManager.runOnAfterThink({ iteration, logger, contextManager }, llmResponse);
328
461
  if (this.deps.exitOnComplete && sawToolCall) {
329
462
  const signature = toolCalls
330
463
  .map((tc) => `${tc.name}:${JSON.stringify(tc.arguments)}`)
@@ -371,7 +504,7 @@ export class Agent {
371
504
  toolName: call.name,
372
505
  args: call.arguments,
373
506
  });
374
- pluginManager.runOnToolStart({ iteration, logger }, { id: call.id, name: call.name, arguments: call.arguments });
507
+ pluginManager.runOnToolStart({ iteration, logger, contextManager }, { id: call.id, name: call.name, arguments: call.arguments });
375
508
  onTool?.({ type: "start", tool: call.name, args: call.arguments });
376
509
  slog.logToolCall(call, iteration);
377
510
  const tokensBeforeTool = contextManager.getEstimatedTokens();
@@ -382,22 +515,18 @@ export class Agent {
382
515
  if (result.success && call.arguments.path) {
383
516
  const filePath = String(call.arguments.path);
384
517
  if (call.name === "write_file" || call.name === "edit_file") {
385
- hallucinationDetector
386
- .getConsistencyCheck()
387
- .trackCreatedFile(filePath);
518
+ hallucinationDetector.getConsistencyCheck().trackCreatedFile(filePath);
388
519
  }
389
520
  else if (call.name === "delete_file") {
390
- hallucinationDetector
391
- .getConsistencyCheck()
392
- .trackDeletedFile(filePath);
521
+ hallucinationDetector.getConsistencyCheck().trackDeletedFile(filePath);
393
522
  }
394
523
  }
395
- pluginManager.runOnToolEnd({ iteration, logger }, { id: call.id, name: call.name, arguments: call.arguments }, result, duration);
524
+ pluginManager.runOnToolEnd({ iteration, logger, contextManager }, { id: call.id, name: call.name, arguments: call.arguments }, result, duration);
396
525
  if (result.display) {
397
526
  onMeta?.("\n" + result.display + "\n");
398
527
  }
399
528
  else {
400
- const metaOut = pluginManager.runOnMeta({ iteration, logger }, result.output);
529
+ const metaOut = pluginManager.runOnMeta({ iteration, logger, contextManager }, result.output);
401
530
  onMeta?.("\n" + pc.dim(metaOut) + "\n");
402
531
  }
403
532
  if (result.diff) {
@@ -405,7 +534,7 @@ export class Agent {
405
534
  }
406
535
  const currentTokens = contextManager.getEstimatedTokens();
407
536
  const budget = contextManager.getBudget();
408
- const truncatedOutput = this.truncateToolOutput(result.output, budget, currentTokens);
537
+ const truncatedOutput = this.truncateToolOutput(result.output, budget, currentTokens, boundedToolNames.has(call.name));
409
538
  contextManager.addMessage({
410
539
  role: "tool",
411
540
  content: truncatedOutput,
@@ -428,8 +557,35 @@ export class Agent {
428
557
  slog.logToolResult(call, result, duration, iteration);
429
558
  }
430
559
  if (contextManager.needsCompaction()) {
431
- contextManager.compact();
432
- logger.debug("Context compacted after tool result");
560
+ const result = contextManager.compact();
561
+ if (result) {
562
+ logger.debug("Context compacted after tool result");
563
+ slog.logCompaction({
564
+ reason: "after_tool",
565
+ iteration,
566
+ ...result,
567
+ });
568
+ }
569
+ }
570
+ // Track per-tool failure counts (NOT just consecutive) so a tool
571
+ // that keeps failing while other tools succeed between attempts
572
+ // is still learned from (observed: LSP spawn npx ENOENT failed
573
+ // 8x in one session, never consecutively, so it never reached
574
+ // memory via the 5-consecutive-failures path).
575
+ if (!result.success) {
576
+ const key = call.name;
577
+ const prev = toolFailureCounts.get(key) ?? { count: 0, error: "" };
578
+ prev.count++;
579
+ prev.error = String(result.output ?? "").slice(0, 200);
580
+ toolFailureCounts.set(key, prev);
581
+ if (prev.count >= MIN_REPEATED_TOOL_FAILURES && !memoryRuleRecorded.has(key)) {
582
+ memoryRuleRecorded.add(key);
583
+ const memStore = this.deps.memoryStore;
584
+ if (memStore) {
585
+ memStore.appendRule("errors", `Tool ${key} failed ${prev.count}x (${prev.error})`, `Repeated ${key} failures suggest a systemic problem (config, environment, or a broken tool), not a one-off`, "Check the error message, verify the tool's dependencies are installed/configured, and consider a different tool");
586
+ logger.warn(`Recorded repeated ${key} failures to memory (${prev.count}x)`);
587
+ }
588
+ }
433
589
  }
434
590
  }
435
591
  if (anyToolFailed) {
@@ -449,13 +605,9 @@ export class Agent {
449
605
  role: "user",
450
606
  content: `<system-summary>${recoveryMsg}\n${taskReminder}</system-summary>`,
451
607
  });
452
- if (sessionManager) {
453
- const activeSession = sessionManager.getActiveMeta();
454
- if (activeSession) {
455
- const memDir = join(baseDir, ".mma", "memory");
456
- const memStore = new MemoryStore(memDir);
457
- memStore.appendRule("errors", `${consecutiveToolFailures} consecutive tool failures`, "Multiple tools failing suggests environment or configuration issue", "Check dependencies, verify file paths, try write_file directly instead of shell commands");
458
- }
608
+ const memStore = this.deps.memoryStore;
609
+ if (memStore) {
610
+ memStore.appendRule("errors", `${consecutiveToolFailures} consecutive tool failures`, "Multiple tools failing suggests environment or configuration issue", "Check dependencies, verify file paths, try write_file directly instead of shell commands");
459
611
  }
460
612
  }
461
613
  contextManager.addMessage({
@@ -483,9 +635,17 @@ export class Agent {
483
635
  onMeta?.(pc.dim(`\n ⟳ Context compacted (${compCount})\n`));
484
636
  }
485
637
  }
638
+ // A tool call means the model kept working instead of just
639
+ // re-answering — the audit-re-answer leniency no longer applies.
640
+ suppressRepetitionRetry = false;
486
641
  continue;
487
642
  }
488
- hallucinationDetector.getConfidenceCheck().setPreviousResponse(lastText);
643
+ hallucinationDetector.getConfidenceCheck().setPreviousResponse(lastModelText);
644
+ // Update AFTER setPreviousResponse so the comparison uses the
645
+ // previous iteration's output, not this one (which would otherwise
646
+ // always overlap with itself).
647
+ if (textContent)
648
+ lastModelText = textContent;
489
649
  const hallucinationResult = await hallucinationDetector.validate(textContent);
490
650
  if (hallucinationResult.status === "block") {
491
651
  logger.warn(`Response blocked: ${hallucinationResult.reason}`);
@@ -517,34 +677,55 @@ export class Agent {
517
677
  lastText = textContent;
518
678
  break;
519
679
  }
520
- // NOTE: with exitOnComplete and an EMPTY text we deliberately do NOT
521
- // break the model produced no usable answer yet (same case as the
522
- // empty-response guard below). Falling through to the retry path
523
- // keeps us from finishing with text: "".
524
- if (hallucinationRetries >= MAX_HALLUCINATION_RETRIES) {
525
- logger.warn(`Hallucination retries exhausted (${MAX_HALLUCINATION_RETRIES}), returning error`);
526
- return {
527
- success: false,
528
- text: lastText,
529
- error: t("error.response_blocked", {
530
- reason: t("hall.max_retries_exhausted"),
531
- }),
532
- iterationCount: iteration,
533
- };
680
+ if (suppressRepetitionRetry && hallucinationResult.kind === "repetition") {
681
+ // The audit gate rejected the previous final answer and
682
+ // re-prompted the model. Its re-answer restating the completed
683
+ // task is naturally "repetitive" that is expected, not
684
+ // degeneration. Downgrade to a warning and let the response
685
+ // flow through to the final audit gate again (which is itself
686
+ // bounded by MAX_AUDIT_RETRIES).
687
+ suppressRepetitionRetry = false;
688
+ logger.warn(`Audit-triggered re-answer repetition — not counted as hallucination retry`);
689
+ const warnLine = `${t("hall.uncertainty_prefix").trim()} ${hallucinationResult.reason ?? ""}`;
690
+ if (onMeta) {
691
+ onMeta(`\n${pc.yellow(warnLine)}\n`);
692
+ }
693
+ else if (onChunk) {
694
+ onChunk(`\n${warnLine}\n`);
695
+ }
696
+ // fall through to the acceptance path below (assistant message,
697
+ // lastText, final audit gate).
534
698
  }
535
- hallucinationRetries++;
536
- logger.warn(`Hallucination retry (${hallucinationRetries}/${MAX_HALLUCINATION_RETRIES}): ${hallucinationResult.reason}`);
537
- if (textContent) {
699
+ else {
700
+ // NOTE: with exitOnComplete and an EMPTY text we deliberately
701
+ // do NOT break — the model produced no usable answer yet (same
702
+ // case as the empty-response guard below). Falling through to
703
+ // the retry path keeps us from finishing with text: "".
704
+ if (hallucinationRetries >= MAX_HALLUCINATION_RETRIES) {
705
+ logger.warn(`Hallucination retries exhausted (${MAX_HALLUCINATION_RETRIES}), returning error`);
706
+ return {
707
+ success: false,
708
+ text: lastText,
709
+ error: t("error.response_blocked", {
710
+ reason: t("hall.max_retries_exhausted"),
711
+ }),
712
+ iterationCount: iteration,
713
+ };
714
+ }
715
+ hallucinationRetries++;
716
+ logger.warn(`Hallucination retry (${hallucinationRetries}/${MAX_HALLUCINATION_RETRIES}): ${hallucinationResult.reason}`);
717
+ if (textContent) {
718
+ contextManager.addMessage({
719
+ role: "assistant",
720
+ content: textContent,
721
+ });
722
+ }
538
723
  contextManager.addMessage({
539
- role: "assistant",
540
- content: textContent,
724
+ role: "user",
725
+ content: `<system-summary>[Retry context: ${hallucinationResult.reason}. Original task: "${input}". You must either call a needed tool or provide a substantive response. Empty replies are not allowed.]</system-summary>`,
541
726
  });
727
+ continue;
542
728
  }
543
- contextManager.addMessage({
544
- role: "user",
545
- content: `<system-summary>[Retry context: ${hallucinationResult.reason}. Original task: "${input}". You must either call a needed tool or provide a substantive response. Empty replies are not allowed.]</system-summary>`,
546
- });
547
- continue;
548
729
  }
549
730
  if (textContent) {
550
731
  contextManager.addMessage({ role: "assistant", content: textContent });
@@ -596,11 +777,16 @@ export class Agent {
596
777
  });
597
778
  slog.logAudit(audit.summary, iteration);
598
779
  auditRetries++;
599
- if (auditRetries >= MAX_AUDIT_RETRIES ||
600
- iteration >= config.maxToolIterations - 1) {
601
- logger.warn(`Final audit still incomplete after ${auditRetries} retries — finishing anyway`);
780
+ lastAuditSummary = audit.summary;
781
+ if (auditRetries >= MAX_AUDIT_RETRIES || iteration >= config.maxToolIterations - 1) {
782
+ logger.warn(`Final audit still incomplete after ${auditRetries} retries — reporting failure`);
783
+ auditFailed = true;
602
784
  break;
603
785
  }
786
+ // The next non-tool response is a forced re-answer of an
787
+ // already-completed task — allow one repetition without
788
+ // burning the hallucination budget (see retry branch).
789
+ suppressRepetitionRetry = true;
604
790
  continue;
605
791
  }
606
792
  }
@@ -626,9 +812,13 @@ export class Agent {
626
812
  };
627
813
  }
628
814
  return {
629
- success: emptyResponseExhausted ? false : true,
815
+ success: emptyResponseExhausted || auditFailed ? false : true,
630
816
  text: lastText,
631
- error: emptyResponseExhausted ? t("error.empty_response") : undefined,
817
+ error: emptyResponseExhausted
818
+ ? t("error.empty_response")
819
+ : auditFailed
820
+ ? t("error.audit_failed", { summary: lastAuditSummary })
821
+ : undefined,
632
822
  iterationCount: iteration,
633
823
  contextUsed: tokensUsed,
634
824
  contextLimit: budget.history,
@@ -690,6 +880,7 @@ export class Agent {
690
880
  pluginManager.runOnSessionEnd({
691
881
  logger,
692
882
  sessionManager: sessionManager?.getActiveMeta(),
883
+ contextManager,
693
884
  });
694
885
  }
695
886
  }