micro-models-agent 0.39.0 → 0.40.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 (96) hide show
  1. package/bin/mma.mjs +41 -41
  2. package/dist/cli/commands.js +116 -3
  3. package/dist/cli/main.js +35 -8
  4. package/dist/cli/repl-commands.js +633 -0
  5. package/dist/cli/repl.js +110 -611
  6. package/dist/cli/setup.js +32 -12
  7. package/dist/config/config.js +46 -30
  8. package/dist/config/defaults.js +10 -1
  9. package/dist/config/security.js +15 -8
  10. package/dist/core/agent-moe.js +24 -12
  11. package/dist/core/agent.js +281 -47
  12. package/dist/core/bootstrap.js +52 -36
  13. package/dist/core/session-logger.js +35 -2
  14. package/dist/core/workspace.js +76 -0
  15. package/dist/i18n/en.json +79 -15
  16. package/dist/i18n/index.js +12 -9
  17. package/dist/i18n/ru.json +79 -15
  18. package/dist/index.js +13 -13
  19. package/dist/llm/openai-compat.js +39 -10
  20. package/dist/logger/app-logger.js +83 -16
  21. package/dist/logger/file-log.js +151 -0
  22. package/dist/main.js +537 -284
  23. package/dist/modules/browser/bridge-server.mjs +113 -105
  24. package/dist/modules/browser/session.js +108 -60
  25. package/dist/modules/certification/cli.js +176 -0
  26. package/dist/modules/certification/fact-checker.js +84 -0
  27. package/dist/modules/certification/loader.js +111 -0
  28. package/dist/modules/certification/manifest.js +50 -0
  29. package/dist/modules/certification/runner.js +162 -0
  30. package/dist/modules/certification/scenarios.js +124 -0
  31. package/dist/modules/certification/types.js +1 -0
  32. package/dist/modules/context/manager.js +119 -10
  33. package/dist/modules/execution/auditor.js +33 -39
  34. package/dist/modules/execution/index.js +8 -6
  35. package/dist/modules/execution/module.js +474 -32
  36. package/dist/modules/execution/moe-executor.js +97 -40
  37. package/dist/modules/execution/plan-coverage.js +68 -0
  38. package/dist/modules/execution/plan-persister.js +46 -0
  39. package/dist/modules/execution/plan-store.js +159 -0
  40. package/dist/modules/execution/planner.js +63 -13
  41. package/dist/modules/execution/stuck-detector.js +252 -39
  42. package/dist/modules/execution/tracker.js +21 -7
  43. package/dist/modules/execution/verifier.js +46 -17
  44. package/dist/modules/hallucination/confidence.js +7 -2
  45. package/dist/modules/hallucination/consistency.js +8 -42
  46. package/dist/modules/hallucination/detector.js +26 -21
  47. package/dist/modules/hallucination/factual.js +170 -150
  48. package/dist/modules/hallucination/index.js +5 -4
  49. package/dist/modules/hallucination/js-identifiers.js +72 -0
  50. package/dist/modules/hallucination/llm-judge.js +103 -0
  51. package/dist/modules/index.js +5 -5
  52. package/dist/modules/lsp/client.js +235 -0
  53. package/dist/modules/lsp/config.js +81 -0
  54. package/dist/modules/lsp/index.js +3 -0
  55. package/dist/modules/lsp/module.js +68 -0
  56. package/dist/modules/lsp/types.js +1 -0
  57. package/dist/modules/mcp/client.js +8 -2
  58. package/dist/modules/memory/store.js +4 -0
  59. package/dist/modules/plugins/builtin/lint-on-write.js +143 -38
  60. package/dist/modules/processes/index.js +1 -2
  61. package/dist/modules/processes/registry.js +125 -35
  62. package/dist/modules/processes/runner.js +9 -110
  63. package/dist/modules/security/audit-log.js +30 -10
  64. package/dist/modules/security/command-validator.js +42 -16
  65. package/dist/modules/security/content-scanner.js +9 -8
  66. package/dist/modules/security/network-validator.js +2 -2
  67. package/dist/modules/security/path-validator.js +64 -10
  68. package/dist/modules/security/security-policies.js +221 -67
  69. package/dist/modules/security/session-encryption.js +42 -25
  70. package/dist/modules/session/manager.js +15 -10
  71. package/dist/modules/session/store.js +62 -8
  72. package/dist/modules/skills/index.js +2 -3
  73. package/dist/modules/skills/module.js +10 -23
  74. package/dist/tools/bash.js +287 -90
  75. package/dist/tools/create-dir.js +0 -1
  76. package/dist/tools/delete-file.js +0 -1
  77. package/dist/tools/edit-file.js +10 -8
  78. package/dist/tools/executor.js +57 -7
  79. package/dist/tools/grep-tool.js +51 -29
  80. package/dist/tools/index.js +55 -40
  81. package/dist/tools/load-skill.js +14 -18
  82. package/dist/tools/move-file.js +3 -2
  83. package/dist/tools/pipeline-run.js +1 -1
  84. package/dist/tools/read-file.js +15 -5
  85. package/dist/tools/search-history.js +42 -22
  86. package/dist/tools/subagent.js +21 -12
  87. package/dist/tools/web-browse.js +54 -25
  88. package/dist/tools/web-fetch.js +60 -34
  89. package/dist/tools/web-search.js +39 -20
  90. package/dist/tools/write-file.js +13 -10
  91. package/dist/ui/diff.js +9 -16
  92. package/dist/ui/renderer.js +69 -6
  93. package/package.json +48 -45
  94. package/dist/modules/context/history.js +0 -15
  95. package/dist/modules/processes/detect.js +0 -34
  96. package/dist/modules/skills/matcher.js +0 -27
@@ -1,15 +1,35 @@
1
+ import { join } from "path";
1
2
  import { t } from "../i18n/index";
2
3
  import { pc } from "../ui/colors";
3
4
  import { PromptBuilder } from "./prompt-builder";
4
5
  import { processRegistry } from "../modules/processes";
5
6
  import { SessionLogger } from "./session-logger";
6
7
  import { runWithMoE } from "./agent-moe";
8
+ import { MemoryStore } from "../modules/memory/store";
9
+ import { StepVerifier } from "../modules/execution/verifier";
7
10
  const TOOL_RESULT_MAX_TOKENS_RATIO = 0.3;
8
11
  const TOOL_RESULT_ABSOLUTE_MAX_CHARS = 15000;
12
+ const QUALITY_TRIGGER_THRESHOLD = 40;
13
+ /** True when the text looks like a raw JSON tool payload (garbage to display). */
14
+ function isToolCallJson(text) {
15
+ const trimmed = text.trim();
16
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
17
+ try {
18
+ JSON.parse(trimmed);
19
+ return true;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ return false;
26
+ }
9
27
  export class Agent {
10
28
  deps;
11
29
  systemPromptAdded = false;
12
30
  shutdownRequested = false;
31
+ abortController = null;
32
+ lastCompactionShown = 0;
13
33
  constructor(deps) {
14
34
  this.deps = deps;
15
35
  }
@@ -31,19 +51,45 @@ export class Agent {
31
51
  if (dynamic.length > 0) {
32
52
  builder.addBlocks(dynamic);
33
53
  }
34
- const pluginBlocks = (this.deps.pluginManager.runOnBuildPrompt?.() ?? [])
35
- .filter((s) => Boolean(s && s.trim() !== ""))
36
- .map((content) => ({
37
- content,
38
- priority: "low",
39
- essential: false,
40
- estimatedTokens: this.deps.llmProvider.countTokens(content),
41
- }));
54
+ const pluginBlocks = (this.deps.pluginManager.runOnBuildPrompt?.() ?? []).flatMap((content) => content && content.trim() !== ""
55
+ ? [
56
+ {
57
+ content,
58
+ priority: "low",
59
+ essential: false,
60
+ estimatedTokens: this.deps.llmProvider.countTokens(content),
61
+ },
62
+ ]
63
+ : []);
42
64
  if (pluginBlocks.length > 0) {
43
65
  builder.addBlocks(pluginBlocks);
44
66
  }
45
67
  return builder.build();
46
68
  }
69
+ getSystemPromptInfo() {
70
+ const { prompt, excluded } = this.buildSystemPrompt();
71
+ const tokenCount = this.deps.llmProvider.countTokens(prompt);
72
+ return { text: prompt, tokenCount, excluded };
73
+ }
74
+ /**
75
+ * Some OpenAI-compatible backends (llama.cpp) omit `usage` from responses,
76
+ * leaving apiPromptTokens/apiCompletionTokens at 0. Fall back to local
77
+ * estimates so JSON results still carry meaningful token metrics.
78
+ */
79
+ resolveUsageTokens(apiPromptTokens, apiCompletionTokens, estimatedPromptTokens, completionChars) {
80
+ if (apiPromptTokens > 0 || apiCompletionTokens > 0) {
81
+ return {
82
+ prompt: apiPromptTokens,
83
+ completion: apiCompletionTokens,
84
+ total: apiPromptTokens + apiCompletionTokens,
85
+ };
86
+ }
87
+ // ~4 chars per token is a reasonable heuristic when the backend gives
88
+ // us nothing (matches the pre-tiktoken fallback elsewhere in the code).
89
+ const prompt = Math.max(1, estimatedPromptTokens);
90
+ const completion = Math.max(0, Math.ceil(completionChars / 4));
91
+ return { prompt, completion, total: prompt + completion };
92
+ }
47
93
  refreshSystemPrompt() {
48
94
  const { prompt } = this.buildSystemPrompt();
49
95
  const current = this.deps.contextManager
@@ -71,8 +117,8 @@ export class Agent {
71
117
  }
72
118
  async run(input, onChunk, onMeta, onTool, onPhase) {
73
119
  this.setScope();
74
- const { config, llmProvider, toolExecutor, pluginManager, contextManager, logger, sessionManager, } = this.deps;
75
- const slog = new SessionLogger(sessionManager);
120
+ const { config, llmProvider, toolExecutor, pluginManager, contextManager, logger, sessionManager, baseDir, } = this.deps;
121
+ const slog = new SessionLogger(sessionManager, logger);
76
122
  if (sessionManager && !sessionManager.getActive()) {
77
123
  sessionManager.create();
78
124
  logger.debug(`Session started: ${sessionManager.getActive()}`);
@@ -90,6 +136,18 @@ export class Agent {
90
136
  logger,
91
137
  sessionManager: sessionManager?.getActiveMeta(),
92
138
  });
139
+ if (config.session?.baselineCheck !== false) {
140
+ const verifier = new StepVerifier(baseDir);
141
+ verifier
142
+ .runTypeCheck()
143
+ .then((tc) => {
144
+ if (!tc.passed) {
145
+ logger.warn(`Baseline typecheck has issues: ${tc.message?.slice(0, 500)}`);
146
+ onMeta?.(pc.yellow(`\n⚠ Baseline typecheck has issues\n`));
147
+ }
148
+ })
149
+ .catch(() => { });
150
+ }
93
151
  }
94
152
  contextManager.addMessage({ role: "user", content: input });
95
153
  slog.logUser(input);
@@ -105,25 +163,43 @@ export class Agent {
105
163
  return this.executeSingleAgentLoop(input, onChunk, onMeta, onTool, onPhase);
106
164
  }
107
165
  async executeSingleAgentLoop(input, onChunk, onMeta, onTool, onPhase) {
108
- const { config, llmProvider, toolExecutor, pluginManager, contextManager, hallucinationDetector, logger, sessionManager, } = this.deps;
109
- const slog = new SessionLogger(sessionManager);
166
+ const { config, llmProvider, toolExecutor, pluginManager, contextManager, hallucinationDetector, logger, sessionManager, baseDir, } = this.deps;
167
+ const slog = new SessionLogger(sessionManager, logger);
168
+ this.abortController = new AbortController();
110
169
  let iteration = 0;
111
170
  let lastText = "";
112
171
  let hallucinationRetries = 0;
113
172
  let lastToolSignature = "";
114
173
  let apiPromptTokens = 0;
115
174
  let apiCompletionTokens = 0;
175
+ let apiCompletionChars = 0;
116
176
  const MAX_HALLUCINATION_RETRIES = 3;
117
177
  let consecutiveToolFailures = 0;
118
178
  const MAX_CONSECUTIVE_TOOL_FAILURES = 5;
179
+ let auditRetries = 0;
180
+ const MAX_AUDIT_RETRIES = 3;
181
+ let emptyResponseRetries = 0;
182
+ const MAX_EMPTY_RESPONSE_RETRIES = 2;
183
+ let emptyResponseExhausted = false;
184
+ let repeatedToolCount = 0;
185
+ const MAX_REPEATED_TOOL_CALLS = 2;
186
+ // 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);
190
+ contextManager.setToolTokens(toolTokenEstimate);
119
191
  while (iteration < config.maxToolIterations && !this.shutdownRequested) {
120
192
  iteration++;
193
+ contextManager.noteIteration();
121
194
  pluginManager.runOnBeforeThink({
122
195
  iteration,
123
196
  logger,
124
197
  lastUserMessage: input,
125
198
  contextManager,
126
199
  onMeta,
200
+ sessionLog: {
201
+ plan: (event, detail, iter) => slog.logPlan(event, detail, iter),
202
+ },
127
203
  });
128
204
  if (contextManager.needsCompaction()) {
129
205
  contextManager.compact();
@@ -132,6 +208,13 @@ export class Agent {
132
208
  }
133
209
  const currentTokens = contextManager.getEstimatedTokens();
134
210
  const budget = contextManager.getBudget();
211
+ const quality = contextManager.getQuality();
212
+ if (quality < QUALITY_TRIGGER_THRESHOLD &&
213
+ contextManager.getCompactionCount() > 0) {
214
+ contextManager.compact();
215
+ logger.warn(`Low context quality (${quality}%) — forced compaction`);
216
+ slog.logCompaction(`quality-triggered compaction (${quality}% < ${QUALITY_TRIGGER_THRESHOLD}%), iteration ${iteration}`, iteration, currentTokens, budget.history);
217
+ }
135
218
  if (currentTokens > budget.history) {
136
219
  contextManager.compact();
137
220
  logger.warn(`Context overflow (${currentTokens} > ${budget.history}), forced compaction`);
@@ -139,8 +222,7 @@ export class Agent {
139
222
  }
140
223
  this.refreshSystemPrompt();
141
224
  const history = contextManager.getActiveHistory();
142
- const allTools = toolExecutor.getToolDefinitions(this.deps.toolTags);
143
- slog.logToolDefs(allTools.length, allTools.map((t) => t.name), iteration);
225
+ slog.logToolDefs(allToolsForBudget.length, allToolsForBudget.map((t) => t.name), iteration);
144
226
  let textContent = "";
145
227
  let reasoningContent = "";
146
228
  const toolCalls = [];
@@ -148,8 +230,12 @@ export class Agent {
148
230
  let emittedReasoning = false;
149
231
  const textChunks = [];
150
232
  this.emitPhase(iteration, "thinking", onPhase);
233
+ const llmStart = Date.now();
234
+ logger.logLLMRequest(config.model, history.length, input, "agent");
151
235
  try {
152
- for await (const chunk of llmProvider.chat(history, allTools)) {
236
+ for await (const chunk of llmProvider.chat(history, allToolsForBudget, this.abortController?.signal)) {
237
+ if (this.shutdownRequested)
238
+ break;
153
239
  if (chunk.type === "text" && chunk.content) {
154
240
  if (emittedReasoning && !textContent) {
155
241
  onMeta?.("\n\n");
@@ -189,6 +275,11 @@ export class Agent {
189
275
  }
190
276
  }
191
277
  catch (err) {
278
+ if (this.shutdownRequested || err?.name === "AbortError") {
279
+ logger.info("LLM call aborted (interrupt)");
280
+ break;
281
+ }
282
+ logger.logLLMResponse(config.model, textContent.length, Date.now() - llmStart, err.message, "agent");
192
283
  logger.error(`LLM call failed: ${err.message}`);
193
284
  slog.logError(err.message);
194
285
  pluginManager.runOnError({ iteration, logger }, err);
@@ -202,10 +293,22 @@ export class Agent {
202
293
  finally {
203
294
  this.emitPhase(iteration, "done", onPhase);
204
295
  }
205
- // Display buffered text only if no tool call in this response.
206
- // When a tool call is present, text is just the model describing
207
- // its tool call (e.g. raw JSON args) — suppress it.
208
- if (!sawToolCall && textChunks.length > 0) {
296
+ // Track response length so token metrics stay meaningful even when
297
+ // the backend omits `usage` from the response.
298
+ apiCompletionChars += (textContent || reasoningContent).length;
299
+ logger.logLLMResponse(config.model, (textContent || reasoningContent).length, Date.now() - llmStart, undefined, "agent");
300
+ if (this.shutdownRequested) {
301
+ break;
302
+ }
303
+ // Show the model's commentary text. When a tool call accompanies the
304
+ // response, keep the text too (opencode-like narration), unless it is
305
+ // a raw JSON payload that small models sometimes emit instead of
306
+ // describing the call. `toolComments: false` restores the old behavior
307
+ // of suppressing text next to a tool call.
308
+ const toolComments = this.deps.config.ui?.toolComments ?? true;
309
+ const showText = textChunks.length > 0 &&
310
+ (!sawToolCall || (toolComments && !isToolCallJson(textContent)));
311
+ if (showText) {
209
312
  for (const chunk of textChunks) {
210
313
  const textOut = pluginManager.runOnText({ iteration, logger }, chunk);
211
314
  onChunk?.(textOut);
@@ -227,8 +330,19 @@ export class Agent {
227
330
  .map((tc) => `${tc.name}:${JSON.stringify(tc.arguments)}`)
228
331
  .join("|");
229
332
  if (signature && signature === lastToolSignature) {
230
- logger.debug("Exit-on-complete: repeated identical tool call, stopping");
231
- break;
333
+ // A repeated identical tool call is often the model re-running
334
+ // a command after a confusing result. Give it one more chance
335
+ // to produce a final text answer instead of stopping with
336
+ // text: "" (observed on 08-r4: bash re-run → empty result).
337
+ repeatedToolCount++;
338
+ if (repeatedToolCount >= MAX_REPEATED_TOOL_CALLS) {
339
+ logger.debug("Exit-on-complete: repeated identical tool call, stopping");
340
+ break;
341
+ }
342
+ contextManager.addMessage({
343
+ role: "user",
344
+ content: `<system-summary>You just called the same tool with identical arguments. If the task is done, answer with a final text response NOW. If the command failed, try a different approach.</system-summary>`,
345
+ });
232
346
  }
233
347
  lastToolSignature = signature;
234
348
  }
@@ -260,26 +374,35 @@ export class Agent {
260
374
  pluginManager.runOnToolStart({ iteration, logger }, { id: call.id, name: call.name, arguments: call.arguments });
261
375
  onTool?.({ type: "start", tool: call.name, args: call.arguments });
262
376
  slog.logToolCall(call, iteration);
263
- const result = await toolExecutor.execute(call);
377
+ const tokensBeforeTool = contextManager.getEstimatedTokens();
378
+ const result = await toolExecutor.execute(call, this.abortController?.signal);
264
379
  const duration = Date.now() - startTime;
265
380
  if (!result.success)
266
381
  anyToolFailed = true;
382
+ if (result.success && call.arguments.path) {
383
+ const filePath = String(call.arguments.path);
384
+ if (call.name === "write_file" || call.name === "edit_file") {
385
+ hallucinationDetector
386
+ .getConsistencyCheck()
387
+ .trackCreatedFile(filePath);
388
+ }
389
+ else if (call.name === "delete_file") {
390
+ hallucinationDetector
391
+ .getConsistencyCheck()
392
+ .trackDeletedFile(filePath);
393
+ }
394
+ }
267
395
  pluginManager.runOnToolEnd({ iteration, logger }, { id: call.id, name: call.name, arguments: call.arguments }, result, duration);
268
396
  if (result.display) {
269
397
  onMeta?.("\n" + result.display + "\n");
270
398
  }
271
- const metaOut = pluginManager.runOnMeta({ iteration, logger }, result.output);
272
- onMeta?.("\n" + pc.dim(metaOut) + "\n");
399
+ else {
400
+ const metaOut = pluginManager.runOnMeta({ iteration, logger }, result.output);
401
+ onMeta?.("\n" + pc.dim(metaOut) + "\n");
402
+ }
273
403
  if (result.diff) {
274
404
  onMeta?.("\n" + result.diff + "\n");
275
405
  }
276
- onTool?.({
277
- type: "end",
278
- tool: call.name,
279
- args: call.arguments,
280
- duration,
281
- error: !result.success,
282
- });
283
406
  const currentTokens = contextManager.getEstimatedTokens();
284
407
  const budget = contextManager.getBudget();
285
408
  const truncatedOutput = this.truncateToolOutput(result.output, budget, currentTokens);
@@ -288,6 +411,17 @@ export class Agent {
288
411
  content: truncatedOutput,
289
412
  name: call.name,
290
413
  tool_call_id: call.id,
414
+ success: result.success,
415
+ arguments: call.arguments,
416
+ });
417
+ const tokensAfterTool = contextManager.getEstimatedTokens();
418
+ onTool?.({
419
+ type: "end",
420
+ tool: call.name,
421
+ args: call.arguments,
422
+ duration,
423
+ error: !result.success,
424
+ ctxDelta: tokensAfterTool - tokensBeforeTool,
291
425
  });
292
426
  summaries.push(`[Tool: ${call.name} (${JSON.stringify(call.arguments)}) → ${truncatedOutput.slice(0, 200)}]`);
293
427
  if (config.session.autoSave) {
@@ -305,7 +439,9 @@ export class Agent {
305
439
  consecutiveToolFailures = 0;
306
440
  }
307
441
  if (consecutiveToolFailures >= MAX_CONSECUTIVE_TOOL_FAILURES) {
308
- const recoveryMsg = t("exec.consecutive_failures_recovery", { count: consecutiveToolFailures });
442
+ const recoveryMsg = t("exec.consecutive_failures_recovery", {
443
+ count: consecutiveToolFailures,
444
+ });
309
445
  logger.warn(`Consecutive tool failures: ${consecutiveToolFailures}`);
310
446
  const taskSnippet = input.length > 200 ? input.slice(0, 200) + "..." : input;
311
447
  const taskReminder = t("exec.task_reminder", { task: taskSnippet });
@@ -313,14 +449,44 @@ export class Agent {
313
449
  role: "user",
314
450
  content: `<system-summary>${recoveryMsg}\n${taskReminder}</system-summary>`,
315
451
  });
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
+ }
459
+ }
316
460
  }
317
461
  contextManager.addMessage({
318
462
  role: "user",
319
463
  content: `<system-summary>${summaries.join("\n")}</system-summary>`,
320
464
  });
465
+ const ui = this.deps.config.ui;
466
+ if (ui?.showContextStats) {
467
+ const ctxTokens = contextManager.getEstimatedTokens();
468
+ const ctxBudget = contextManager.getBudget();
469
+ const ctxPct = Math.min(100, Math.round((ctxTokens / ctxBudget.history) * 100));
470
+ const barLen = 10;
471
+ const filled = Math.round((ctxPct / 100) * barLen);
472
+ const ctxBar = pc.green("█".repeat(filled)) + pc.dim("░".repeat(barLen - filled));
473
+ const pctColor = ctxPct >= 75 ? pc.yellow : pc.dim;
474
+ const compCount = contextManager.getCompactionCount();
475
+ const quality = contextManager.getQuality();
476
+ const qualityColor = quality >= 70 ? pc.green : quality >= 40 ? pc.yellow : pc.red;
477
+ onMeta?.(`\n ${ctxBar} ${pctColor(`${ctxPct}%`)} ${pc.dim(`ctx: ${ctxTokens}/${ctxBudget.history}`)} ${pc.dim(`compactions: ${compCount}`)} ${qualityColor(`quality: ${quality}%`)}\n`);
478
+ }
479
+ else if (ui?.showCompaction) {
480
+ const compCount = contextManager.getCompactionCount();
481
+ if (compCount > this.lastCompactionShown) {
482
+ this.lastCompactionShown = compCount;
483
+ onMeta?.(pc.dim(`\n ⟳ Context compacted (${compCount})\n`));
484
+ }
485
+ }
321
486
  continue;
322
487
  }
323
- const hallucinationResult = hallucinationDetector.validate(textContent);
488
+ hallucinationDetector.getConfidenceCheck().setPreviousResponse(lastText);
489
+ const hallucinationResult = await hallucinationDetector.validate(textContent);
324
490
  if (hallucinationResult.status === "block") {
325
491
  logger.warn(`Response blocked: ${hallucinationResult.reason}`);
326
492
  return {
@@ -334,17 +500,27 @@ export class Agent {
334
500
  }
335
501
  if (hallucinationResult.status === "warn") {
336
502
  logger.warn(`Hallucination warning: ${hallucinationResult.reason}`);
337
- const warnPrefix = t("hall.uncertainty_prefix");
338
- if (onChunk) {
339
- onChunk(warnPrefix);
503
+ const warnLine = `${t("hall.uncertainty_prefix").trim()} ${hallucinationResult.reason ?? ""}`;
504
+ if (onMeta) {
505
+ onMeta(`\n${pc.yellow(warnLine)}\n`);
506
+ }
507
+ else if (onChunk) {
508
+ onChunk(`\n${warnLine}\n`);
340
509
  }
341
- lastText = warnPrefix + lastText;
342
510
  }
343
511
  if (hallucinationResult.status === "retry") {
344
- if (this.deps.exitOnComplete) {
512
+ if (this.deps.exitOnComplete && textContent?.trim()) {
345
513
  logger.debug("Exit-on-complete: stopping on first response");
514
+ // Save the response BEFORE breaking — lastText is still the
515
+ // previous (tool-only) iteration's text, so without this the
516
+ // final answer is lost (reported text: "").
517
+ lastText = textContent;
346
518
  break;
347
519
  }
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: "".
348
524
  if (hallucinationRetries >= MAX_HALLUCINATION_RETRIES) {
349
525
  logger.warn(`Hallucination retries exhausted (${MAX_HALLUCINATION_RETRIES}), returning error`);
350
526
  return {
@@ -372,11 +548,40 @@ export class Agent {
372
548
  }
373
549
  if (textContent) {
374
550
  contextManager.addMessage({ role: "assistant", content: textContent });
375
- slog.saveAssistantMessage(textContent);
551
+ if (config.session.autoSave) {
552
+ slog.saveAssistantMessage(textContent);
553
+ }
376
554
  slog.logAssistant(textContent, reasoningContent, undefined, iteration);
377
555
  }
378
556
  lastText = textContent;
557
+ {
558
+ const decisionPatterns = [
559
+ ...textContent.matchAll(/(?:plan|decided|decision|решено|план|решение):\s*(.+?)(?:\n|$)/gi),
560
+ ];
561
+ for (const match of decisionPatterns) {
562
+ hallucinationDetector
563
+ .getConsistencyCheck()
564
+ .trackDecision(match[1].trim(), "agent_response");
565
+ }
566
+ }
379
567
  if (!sawToolCall) {
568
+ // Guard: the model returned an EMPTY final response (no text, no
569
+ // tool calls — often just reasoning content after context
570
+ // compaction). Nudge it to produce a real answer instead of
571
+ // silently finishing with text: "".
572
+ if (!textContent?.trim()) {
573
+ if (emptyResponseRetries < MAX_EMPTY_RESPONSE_RETRIES) {
574
+ emptyResponseRetries++;
575
+ logger.warn(`Empty response on iteration ${iteration} (retry ${emptyResponseRetries}/${MAX_EMPTY_RESPONSE_RETRIES})`);
576
+ contextManager.addMessage({
577
+ role: "user",
578
+ content: `<system-summary>Your previous response was empty. Answer the user's task now with a final text response or call a tool. Do not reply with reasoning only.</system-summary>`,
579
+ });
580
+ continue;
581
+ }
582
+ emptyResponseExhausted = true;
583
+ logger.warn(`Empty response retries exhausted after ${MAX_EMPTY_RESPONSE_RETRIES} attempts`);
584
+ }
380
585
  if (this.deps.finalAudit) {
381
586
  const audit = await this.deps.finalAudit();
382
587
  if (audit && !audit.passed) {
@@ -390,7 +595,10 @@ export class Agent {
390
595
  })}</system-summary>`,
391
596
  });
392
597
  slog.logAudit(audit.summary, iteration);
393
- if (iteration >= config.maxToolIterations - 1) {
598
+ auditRetries++;
599
+ if (auditRetries >= MAX_AUDIT_RETRIES ||
600
+ iteration >= config.maxToolIterations - 1) {
601
+ logger.warn(`Final audit still incomplete after ${auditRetries} retries — finishing anyway`);
394
602
  break;
395
603
  }
396
604
  continue;
@@ -401,6 +609,7 @@ export class Agent {
401
609
  }
402
610
  const tokensUsed = contextManager.getEstimatedTokens();
403
611
  const budget = contextManager.getBudget();
612
+ const usageTokens = this.resolveUsageTokens(apiPromptTokens, apiCompletionTokens, tokensUsed, apiCompletionChars);
404
613
  if (iteration >= config.maxToolIterations) {
405
614
  return {
406
615
  success: false,
@@ -409,26 +618,49 @@ export class Agent {
409
618
  iterationCount: iteration,
410
619
  contextUsed: tokensUsed,
411
620
  contextLimit: budget.history,
412
- promptTokens: apiPromptTokens,
413
- completionTokens: apiCompletionTokens,
414
- totalTokens: apiPromptTokens + apiCompletionTokens,
621
+ promptTokens: usageTokens.prompt,
622
+ completionTokens: usageTokens.completion,
623
+ totalTokens: usageTokens.total,
624
+ compactionCount: contextManager.getCompactionCount(),
625
+ contextQuality: contextManager.getQuality(),
415
626
  };
416
627
  }
417
628
  return {
418
- success: true,
629
+ success: emptyResponseExhausted ? false : true,
419
630
  text: lastText,
631
+ error: emptyResponseExhausted ? t("error.empty_response") : undefined,
420
632
  iterationCount: iteration,
421
633
  contextUsed: tokensUsed,
422
634
  contextLimit: budget.history,
423
- promptTokens: apiPromptTokens,
424
- completionTokens: apiCompletionTokens,
425
- totalTokens: apiPromptTokens + apiCompletionTokens,
635
+ promptTokens: usageTokens.prompt,
636
+ completionTokens: usageTokens.completion,
637
+ totalTokens: usageTokens.total,
638
+ compactionCount: contextManager.getCompactionCount(),
639
+ contextQuality: contextManager.getQuality(),
426
640
  };
427
641
  }
428
642
  clearContext() {
429
643
  this.deps.contextManager.clear();
430
644
  this.systemPromptAdded = false;
431
645
  }
646
+ async reconfigure(config) {
647
+ const { OpenAICompatProvider } = await import("../llm/openai-compat");
648
+ const { TokenCounter } = await import("../llm/token-counter");
649
+ const newProvider = new OpenAICompatProvider({
650
+ model: config.model,
651
+ baseUrl: config.provider.baseUrl,
652
+ apiKey: config.provider.apiKey,
653
+ contextWindow: config.contextWindow,
654
+ retry: config.retry,
655
+ rateLimits: config.security?.rateLimits,
656
+ });
657
+ this.deps.llmProvider = newProvider;
658
+ this.deps.toolExecutor.updateProvider(newProvider);
659
+ this.deps.toolExecutor.ctx.llmProvider = newProvider;
660
+ const newTokenCounter = new TokenCounter(config.model);
661
+ this.deps.contextManager.resize(config.contextWindow, config.contextBudget, newTokenCounter);
662
+ this.deps.config = config;
663
+ }
432
664
  setContext(messages) {
433
665
  const { contextManager } = this.deps;
434
666
  contextManager.clear();
@@ -447,12 +679,14 @@ export class Agent {
447
679
  }
448
680
  shutdown() {
449
681
  this.shutdownRequested = true;
682
+ this.abortController?.abort();
450
683
  const { pluginManager, logger, sessionManager, contextManager } = this.deps;
451
684
  contextManager.onCompact = null;
452
685
  const killed = processRegistry.killAll();
453
686
  if (killed > 0) {
454
687
  logger.info(`Killed ${killed} background process(es) on shutdown`);
455
688
  }
689
+ logger.closeSessionLog();
456
690
  pluginManager.runOnSessionEnd({
457
691
  logger,
458
692
  sessionManager: sessionManager?.getActiveMeta(),