micro-models-agent 0.63.3 → 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 (185) hide show
  1. package/CHANGELOG.md +148 -1
  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 +7 -4
  41. package/dist/i18n/ru.json +7 -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 +1606 -800
  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/driver.js +46 -4
  59. package/dist/modules/certification/cli.js +85 -42
  60. package/dist/modules/certification/loader.js +15 -1
  61. package/dist/modules/certification/manifest.js +126 -15
  62. package/dist/modules/certification/runner.js +4 -26
  63. package/dist/modules/certification/scenarios.js +184 -5
  64. package/dist/modules/certification/syntax-scenarios.js +51 -0
  65. package/dist/modules/context/chunk-query.js +25 -5
  66. package/dist/modules/context/fact-extractor.js +6 -2
  67. package/dist/modules/context/manager.js +23 -7
  68. package/dist/modules/execution/audit-runners.js +7 -1
  69. package/dist/modules/execution/auditor.js +3 -3
  70. package/dist/modules/execution/execution-plugin.js +22 -15
  71. package/dist/modules/execution/input-from.js +46 -0
  72. package/dist/modules/execution/module.js +107 -18
  73. package/dist/modules/execution/moe-executor.js +166 -54
  74. package/dist/modules/execution/plan-actions.js +524 -0
  75. package/dist/modules/execution/plan-steps.js +23 -0
  76. package/dist/modules/execution/plan-store.js +15 -3
  77. package/dist/modules/execution/plan-tool.js +6 -488
  78. package/dist/modules/execution/plan-validator.js +24 -0
  79. package/dist/modules/execution/stuck-detector.js +3 -18
  80. package/dist/modules/execution/tracker.js +14 -5
  81. package/dist/modules/execution/transient-error.js +30 -0
  82. package/dist/modules/execution/verifier.js +94 -7
  83. package/dist/modules/execution/windows-commands.js +11 -0
  84. package/dist/modules/hallucination/confidence.js +36 -23
  85. package/dist/modules/hallucination/consistency.js +3 -0
  86. package/dist/modules/hallucination/detector.js +8 -3
  87. package/dist/modules/hallucination/factual.js +26 -7
  88. package/dist/modules/hallucination/llm-judge.js +12 -2
  89. package/dist/modules/indexer/map-command.js +35 -0
  90. package/dist/modules/indexer/map-select.js +87 -0
  91. package/dist/modules/indexer/module.js +34 -22
  92. package/dist/modules/indexer/symbols.js +189 -0
  93. package/dist/modules/indexer/walker.js +96 -42
  94. package/dist/modules/lsp/check-tool.js +2 -1
  95. package/dist/modules/lsp/client.js +49 -32
  96. package/dist/modules/lsp/config.js +55 -2
  97. package/dist/modules/lsp/module.js +38 -5
  98. package/dist/modules/lsp/probe.js +4 -3
  99. package/dist/modules/lsp/project-root.js +41 -1
  100. package/dist/modules/lsp/startup-check.js +12 -4
  101. package/dist/modules/mcp/client.js +153 -104
  102. package/dist/modules/mcp/module.js +165 -41
  103. package/dist/modules/memory/module.js +4 -3
  104. package/dist/modules/plugins/builtin/lint-on-write.js +36 -6
  105. package/dist/modules/plugins/manager.js +47 -84
  106. package/dist/modules/pricing/index.js +17 -7
  107. package/dist/modules/pricing/prices.js +30 -12
  108. package/dist/modules/processes/index.js +1 -0
  109. package/dist/modules/processes/kill-tree.js +56 -0
  110. package/dist/modules/processes/registry.js +2 -54
  111. package/dist/modules/providers/cache.js +23 -0
  112. package/dist/modules/providers/factory.js +28 -0
  113. package/dist/modules/providers/fallback.js +7 -5
  114. package/dist/modules/providers/health.js +2 -1
  115. package/dist/modules/providers/index.js +1 -0
  116. package/dist/modules/providers/manager.js +17 -2
  117. package/dist/modules/providers/presets.js +79 -6
  118. package/dist/modules/reasoning/policy.js +40 -0
  119. package/dist/modules/reasoning/probe.js +111 -0
  120. package/dist/modules/security/audit-notifier.js +42 -27
  121. package/dist/modules/security/command-validator.js +25 -20
  122. package/dist/modules/security/encryption.js +6 -12
  123. package/dist/modules/security/network-validator.js +76 -5
  124. package/dist/modules/security/path-validator.js +77 -34
  125. package/dist/modules/security/rate-limiter.js +11 -0
  126. package/dist/modules/security/security-policies.js +1 -1
  127. package/dist/modules/security/session-encryption.js +13 -2
  128. package/dist/modules/security/session-isolation.js +2 -9
  129. package/dist/modules/session/manager.js +11 -0
  130. package/dist/modules/session/module.js +11 -3
  131. package/dist/modules/session/store.js +41 -5
  132. package/dist/modules/skills/loader.js +7 -1
  133. package/dist/modules/skills/module.js +2 -1
  134. package/dist/modules/updater/changelog-reader.js +94 -0
  135. package/dist/modules/updater/dev-detect.js +17 -0
  136. package/dist/modules/updater/index.js +1 -0
  137. package/dist/modules/updater/module.js +14 -3
  138. package/dist/output/bus.js +32 -0
  139. package/dist/output/channel.js +233 -0
  140. package/dist/output/format.js +14 -0
  141. package/dist/output/index.js +7 -0
  142. package/dist/output/json-sink.js +22 -0
  143. package/dist/output/machine.js +8 -0
  144. package/dist/output/session-sink.js +27 -0
  145. package/dist/output/types.js +1 -0
  146. package/dist/tools/approve.js +6 -2
  147. package/dist/tools/attach-image.js +11 -11
  148. package/dist/tools/auto-fixer.js +198 -0
  149. package/dist/tools/bash.js +142 -89
  150. package/dist/tools/chunk-query.js +10 -6
  151. package/dist/tools/download-file.js +1 -1
  152. package/dist/tools/edit-file.js +20 -2
  153. package/dist/tools/executor.js +54 -9
  154. package/dist/tools/glob-tool.js +7 -0
  155. package/dist/tools/grep-tool.js +15 -1
  156. package/dist/tools/index.js +3 -1
  157. package/dist/tools/list-dir.js +3 -1
  158. package/dist/tools/load-skill.js +2 -1
  159. package/dist/tools/mcp-call.js +1 -1
  160. package/dist/tools/move-file.js +5 -4
  161. package/dist/tools/path-utils.js +7 -0
  162. package/dist/tools/pipeline-run.js +1 -1
  163. package/dist/tools/prompt-io.js +28 -0
  164. package/dist/tools/question.js +12 -12
  165. package/dist/tools/scope-request.js +91 -0
  166. package/dist/tools/session-info.js +44 -0
  167. package/dist/tools/set-thinking.js +71 -0
  168. package/dist/tools/subagent.js +50 -9
  169. package/dist/tools/syntax-validator.js +177 -0
  170. package/dist/tools/user-input.js +16 -9
  171. package/dist/tools/write-file.js +17 -1
  172. package/dist/ui/diff.js +10 -0
  173. package/dist/ui/line-editor.js +179 -26
  174. package/dist/ui/line-math.js +20 -3
  175. package/dist/ui/md-formatter.js +100 -10
  176. package/dist/ui/output.js +5 -4
  177. package/dist/ui/plan-view.js +2 -7
  178. package/dist/ui/renderer.js +89 -85
  179. package/dist/ui/spinner.js +14 -4
  180. package/dist/utils/error.js +4 -0
  181. package/dist/utils/index.js +4 -0
  182. package/dist/utils/retry.js +17 -0
  183. package/dist/utils/sleep.js +23 -0
  184. package/dist/utils/truncate.js +9 -0
  185. package/package.json +1 -1
@@ -1,84 +1,179 @@
1
1
  import { ProviderManager } from "../modules/providers/manager";
2
+ import { buildActiveProvider } from "../modules/providers/factory";
2
3
  import { t } from "../i18n/index";
3
4
  import { pc } from "../ui/colors";
5
+ import { join } from "path";
4
6
  import { PromptBuilder } from "./prompt-builder";
5
7
  import { processRegistry } from "../modules/processes";
6
8
  import { SessionLogger } from "./session-logger";
7
9
  import { runWithMoE } from "./agent-moe";
8
10
  import { CostTracker } from "../modules/pricing/index";
9
- const TOOL_RESULT_MAX_TOKENS_RATIO = 0.3;
10
- const TOOL_RESULT_ABSOLUTE_MAX_CHARS = 15000;
11
- const QUALITY_TRIGGER_THRESHOLD = 40;
12
- /** Minimum iterations between quality-triggered forced compactions. Without
13
- * this, a low-quality context re-triggers compaction on EVERY iteration
14
- * (observed: 56 compactions in ~28 min) and the compaction itself can't
15
- * restore quality, so the agent burns the whole budget compacting. */
16
- const FORCED_COMPACTION_COOLDOWN = 3;
17
- const MAX_LLM_ERROR_RETRIES = 2;
18
- /** True when the text looks like a raw JSON tool payload (garbage to display). */
19
- function isToolCallJson(text) {
20
- const trimmed = text.trim();
21
- if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
11
+ import { createPolicyState } from "../modules/reasoning/policy";
12
+ import { estimateTokens } from "../llm/token-counter";
13
+ import { LlmError } from "../llm/llm-errors";
14
+ import { createLoopState, clearIterationSignals } from "./agent/loop-state";
15
+ import { CompactionService } from "./agent/compaction";
16
+ import { TokenTracker } from "./agent/token-tracker";
17
+ import { buildPromptSnapshot, diffPrompt } from "./agent/prefix-monitor";
18
+ import { ReasoningEffortResolver } from "./agent/reasoning-resolver";
19
+ import { ContextRenderer } from "./agent/context-renderer";
20
+ import { ToolBatchExecutor } from "./agent/tool-batch";
21
+ import { HallucinationGate } from "./agent/hallucination-gate";
22
+ import { AuditGate } from "./agent/audit-gate";
23
+ import { resolvePromptOverflow, overflowHint, HINT_BLOCK_TOKENS, } from "./prompt-overflow";
24
+ import { MAX_EMPTY_RESPONSE_RETRIES, MAX_LLM_ERROR_RETRIES, MAX_REPEATED_TOOL_CALLS, MAX_REPEATED_TOOL_CALLS_INTERACTIVE, MUTATION_CYCLE_NUDGE_THRESHOLD, } from "./agent/constants";
25
+ export { toolOutputCharLimit, compactToolError } from "./agent/tool-output";
26
+ /**
27
+ * Read-only introspection tools. Repeating one with identical arguments can
28
+ * never make progress, so the interactive loop hard-stops after a few repeats
29
+ * (observed: `session_info` called in a tight loop after the task was already
30
+ * complete). Mutating tools stay nudge-only so a legitimate edit/build/read
31
+ * cycle is never cut short.
32
+ */
33
+ const LOOP_PRONE_STATUS_TOOLS = new Set([
34
+ "session_info",
35
+ "process_list",
36
+ "process_log",
37
+ "file_info",
38
+ "project_map",
39
+ ]);
40
+ /**
41
+ * Ключ целевого объекта мутации для детекции «перфекционизм-цикла».
42
+ * edit_file/write_file → путь файла; bash → нормализованная команда.
43
+ * Остальные тула (read-only, bookkeeping) не считаются мутациями.
44
+ * Внимание: в цикле toolCall.arguments — сырая JSON-строка от провайдера.
45
+ */
46
+ export function mutationTargetKey(name, rawArgs) {
47
+ let a = null;
48
+ if (typeof rawArgs === "string") {
22
49
  try {
23
- JSON.parse(trimmed);
24
- return true;
50
+ a = JSON.parse(rawArgs);
25
51
  }
26
52
  catch {
27
- return false;
53
+ return null;
28
54
  }
29
55
  }
30
- return false;
31
- }
32
- /**
33
- * Max chars a tool result may occupy, given remaining budget and whether
34
- * the tool bounds its own output. Tools that declare `boundedOutput` (e.g.
35
- * read_file with its line limit) are never truncated by the budget — a
36
- * near-full context used to cut them to ~2K chars, making the model believe
37
- * files were truncated and re-read them forever.
38
- */
39
- export function toolOutputCharLimit(remainingBudget, historyBudget, bounded) {
40
- if (bounded)
41
- return Number.MAX_SAFE_INTEGER;
42
- const maxCharsByRatio = Math.floor(historyBudget * TOOL_RESULT_MAX_TOKENS_RATIO * 2);
43
- return Math.min(remainingBudget, maxCharsByRatio, TOOL_RESULT_ABSOLUTE_MAX_CHARS);
56
+ else if (rawArgs && typeof rawArgs === "object") {
57
+ a = rawArgs;
58
+ }
59
+ if (!a)
60
+ return null;
61
+ if ((name === "edit_file" || name === "write_file") && typeof a.path === "string") {
62
+ return `write:${a.path.replace(/\\/g, "/").toLowerCase()}`;
63
+ }
64
+ if (name === "bash" && typeof a.command === "string") {
65
+ const cmd = a.command.replace(/\s+/g, " ").trim().slice(0, 80).toLowerCase();
66
+ return cmd ? `bash:${cmd}` : null;
67
+ }
68
+ // Bookkeeping churn: plan/todo repeated over the same action with slightly
69
+ // different args burned 10+ iterations in ses_mthn3c2a after an audit
70
+ // rejection (the model "fixed" the plan instead of the work).
71
+ if ((name === "plan" || name === "todo") && typeof a.action === "string") {
72
+ return `book:${name}:${a.action.toLowerCase()}`;
73
+ }
74
+ return null;
44
75
  }
45
76
  export class Agent {
46
77
  deps;
47
78
  systemPromptAdded = false;
48
79
  shutdownRequested = false;
49
80
  abortController = null;
50
- lastCompactionShown = 0;
51
81
  costTracker;
82
+ /** Рендер UI-индикаторов контекста (stats-бар/компакция). Экземпляр
83
+ * переживает несколько запусков цикла — хранит lastCompactionShown. */
84
+ contextRenderer;
85
+ policyState;
86
+ /** Reasoning probe result (B2): starts from deps, refreshed lazily in run(). */
87
+ probePassed;
88
+ /** Shared mutable reasoning state (owned by agent, shared with set_thinking tool). */
89
+ reasoningState;
90
+ /** Current iteration counter (exposed for tool cooldown checks). */
91
+ _currentIteration = 0;
92
+ /** Kind → replacement block for instructions/project-map blocks that
93
+ * exceeded the system budget (resolved once before the first LLM call). */
94
+ promptOverrides = new Map();
95
+ /** Small always-included block explaining the compressed instructions. */
96
+ overflowHintBlock = null;
97
+ /** Guards the one-time async overflow resolution in run(). */
98
+ overflowResolved = false;
52
99
  constructor(deps) {
53
100
  this.deps = deps;
54
101
  this.costTracker = new CostTracker(deps.config.model, deps.config.pricing);
102
+ this.contextRenderer = new ContextRenderer();
103
+ this.policyState = createPolicyState();
104
+ this.probePassed = deps.reasoningProbePassed ?? false;
105
+ const initialLevel = (deps.config.reasoning?.mode !== "auto" ? deps.config.reasoning?.mode : undefined) ?? "medium";
106
+ this.reasoningState = { level: initialLevel, overrideIteration: -100 };
55
107
  }
56
108
  /** Expose context manager for REPL image attachment and other direct access. */
57
109
  get contextManager() {
58
110
  return this.deps.contextManager;
59
111
  }
112
+ /** Current effective reasoning level. */
113
+ get reasoningLevel() {
114
+ return this.reasoningState.level;
115
+ }
116
+ /** Set reasoning level (used by REPL and CLI). "auto" clears a manual override. */
117
+ setReasoningLevel(level) {
118
+ if (level === "auto") {
119
+ this.reasoningState.manual = false;
120
+ return;
121
+ }
122
+ this.reasoningState.level = level;
123
+ this.reasoningState.manual = true;
124
+ }
125
+ /** Current iteration number (for tool cooldown checks). */
126
+ get currentIteration() {
127
+ return this._currentIteration;
128
+ }
60
129
  callerProvenance() {
61
- return {
62
- provider: this.deps.config.provider?.type || "unknown",
63
- model: this.deps.config.model,
64
- };
130
+ const p = this.deps.config.provider;
131
+ let providerName = p?.type || "unknown";
132
+ // When provider.entries exist, use the active entry's label for
133
+ // distinguishable cost provenance (e.g. "lmstudio" vs "ollama").
134
+ if (p?.entries && p.entries.length > 0) {
135
+ const entry = p.active
136
+ ? p.entries.find((e) => e.label === p.active || e.type === p.active)
137
+ : p.entries[0];
138
+ if (entry)
139
+ providerName = entry.label || entry.type || providerName;
140
+ }
141
+ return { provider: providerName, model: this.deps.config.model };
65
142
  }
66
143
  /** Typed module accessor for CLI/REPL commands (e.g. /lsp). */
67
144
  getModule(name) {
68
145
  return this.deps.moduleRegistry?.get(name);
69
146
  }
147
+ /**
148
+ * Запуск тула по имени из CLI/REPL (например, lsp_check). Обходит
149
+ * инкапсуляцию: команды не должны лезть в приватные `deps`.
150
+ */
151
+ runTool(name, args) {
152
+ return this.deps.toolExecutor.executeByName(name, args);
153
+ }
70
154
  setScope() {
71
155
  if (this.deps.scope) {
72
156
  this.deps.toolExecutor.setScope(this.deps.scope);
73
157
  }
74
158
  }
159
+ /**
160
+ * Install (or clear) the terminal-aware prompt surface used by interactive
161
+ * tools. Delegates to the tool executor so the shared ToolContext — the one
162
+ * tools actually receive — carries the same instance.
163
+ */
164
+ setPromptIO(io) {
165
+ this.deps.toolExecutor.setPromptIO(io);
166
+ }
75
167
  buildSystemPrompt() {
76
168
  const systemBudget = Math.floor(this.deps.config.contextWindow * this.deps.config.contextBudget.systemPrompt);
77
169
  const builder = new PromptBuilder(systemBudget);
78
- builder.addBlocks(this.deps.promptBlocks);
170
+ // Overflow replacements (compressed instructions / project map) take
171
+ // precedence over the original oversized blocks.
172
+ const applyOverrides = (blocks) => blocks.map((b) => (b.kind && this.promptOverrides.has(b.kind) ? this.promptOverrides.get(b.kind) : b));
173
+ builder.addBlocks(applyOverrides(this.deps.promptBlocks));
79
174
  const dynamic = this.deps.getDynamicPromptBlocks?.() ?? [];
80
175
  if (dynamic.length > 0) {
81
- builder.addBlocks(dynamic);
176
+ builder.addBlocks(applyOverrides(dynamic));
82
177
  }
83
178
  const pluginBlocks = (this.deps.pluginManager.runOnBuildPrompt?.() ?? []).flatMap((content) => content && content.trim() !== ""
84
179
  ? [
@@ -93,10 +188,14 @@ export class Agent {
93
188
  if (pluginBlocks.length > 0) {
94
189
  builder.addBlocks(pluginBlocks);
95
190
  }
191
+ if (this.overflowHintBlock) {
192
+ builder.addBlock(this.overflowHintBlock);
193
+ }
96
194
  const result = builder.build();
97
195
  return {
98
196
  prompt: result.prompt,
99
197
  excluded: result.excluded,
198
+ excludedBlocks: result.excludedBlocks,
100
199
  blocks: result.blocks,
101
200
  };
102
201
  }
@@ -139,23 +238,46 @@ export class Agent {
139
238
  return { text: prompt, tokenCount, excluded };
140
239
  }
141
240
  /**
142
- * Some OpenAI-compatible backends (llama.cpp) omit `usage` from responses,
143
- * leaving apiPromptTokens/apiCompletionTokens at 0. Fall back to local
144
- * estimates so JSON results still carry meaningful token metrics.
241
+ * One-time (first run) handling of instructions/project-map blocks that did
242
+ * not fit the system-prompt budget: they are summarized by the LLM into the
243
+ * remaining budget (disk-cached) or truncated, and a small hint block tells
244
+ * the model what happened and how to read the full source.
145
245
  */
146
- resolveUsageTokens(apiPromptTokens, apiCompletionTokens, estimatedPromptTokens, completionChars) {
147
- if (apiPromptTokens > 0 || apiCompletionTokens > 0) {
148
- return {
149
- prompt: apiPromptTokens,
150
- completion: apiCompletionTokens,
151
- total: apiPromptTokens + apiCompletionTokens,
152
- };
246
+ async resolvePromptOverflowOnce() {
247
+ const cfg = this.deps.config;
248
+ const dry = this.buildSystemPrompt();
249
+ const overflow = dry.excludedBlocks.filter((b) => Boolean(b.kind));
250
+ if (overflow.length === 0)
251
+ return;
252
+ const systemBudget = Math.floor(cfg.contextWindow * cfg.contextBudget.systemPrompt);
253
+ const res = await resolvePromptOverflow({
254
+ overflow,
255
+ includedTokens: dry.blocks.filter((b) => b.included).reduce((s, b) => s + b.tokens, 0),
256
+ systemBudget,
257
+ provider: cfg.instructions?.summarize === false ? null : this.deps.llmProvider,
258
+ cacheDir: join(this.deps.baseDir, ".mma", "cache", "prompt-summaries"),
259
+ logger: this.deps.logger,
260
+ });
261
+ for (const r of res.replacements) {
262
+ if (r.kind)
263
+ this.promptOverrides.set(r.kind, r);
264
+ }
265
+ this.overflowHintBlock = res.hintBlock;
266
+ const needed = dry.blocks.filter((b) => b.included).reduce((s, b) => s + b.tokens, 0) +
267
+ overflow.reduce((s, b) => s + b.estimatedTokens, 0) +
268
+ HINT_BLOCK_TOKENS;
269
+ const hint = overflowHint(cfg, this.deps.configDir, needed);
270
+ for (const w of res.warnings) {
271
+ this.deps.logger.warn(t("prompt.overflow.exceeded", {
272
+ label: w.label,
273
+ original: String(w.originalTokens),
274
+ needed: String(needed),
275
+ budget: String(systemBudget),
276
+ mode: w.mode === "summary" ? "summarized" : "truncated",
277
+ resolved: String(w.resolvedTokens),
278
+ hint,
279
+ }));
153
280
  }
154
- // ~4 chars per token is a reasonable heuristic when the backend gives
155
- // us nothing (matches the pre-tiktoken fallback elsewhere in the code).
156
- const prompt = Math.max(1, estimatedPromptTokens);
157
- const completion = Math.max(0, Math.ceil(completionChars / 4));
158
- return { prompt, completion, total: prompt + completion };
159
281
  }
160
282
  refreshSystemPrompt() {
161
283
  const { prompt } = this.buildSystemPrompt();
@@ -164,15 +286,6 @@ export class Agent {
164
286
  this.deps.contextManager.updateSystemPrompt?.(prompt);
165
287
  }
166
288
  }
167
- truncateToolOutput(output, budget, currentTokens, bounded) {
168
- const remainingBudget = Math.max(0, budget.history - currentTokens);
169
- const maxChars = toolOutputCharLimit(remainingBudget, budget.history, bounded);
170
- if (output.length <= maxChars)
171
- return output;
172
- const truncated = output.slice(0, maxChars);
173
- const removedChars = output.length - maxChars;
174
- return truncated + `\n\n${t("tool.truncated", { tokens: Math.ceil(removedChars / 2) })}`;
175
- }
176
289
  emitPhase(iteration, phase, onPhase) {
177
290
  this.deps.pluginManager.runOnPhase?.({
178
291
  iteration,
@@ -184,6 +297,9 @@ export class Agent {
184
297
  async run(input, onChunk, onMeta, onTool, onPhase) {
185
298
  // Reset shutdown flag from previous interrupt
186
299
  this.shutdownRequested = false;
300
+ // Fresh controller per turn — the MoE path relies on it for Esc handling
301
+ // (executeSingleAgentLoop re-creates it again for the single-agent path).
302
+ this.abortController = new AbortController();
187
303
  this.setScope();
188
304
  const { config, llmProvider, toolExecutor, pluginManager, contextManager, logger, sessionManager, baseDir, } = this.deps;
189
305
  const slog = new SessionLogger(sessionManager, logger, () => this.callerProvenance());
@@ -205,6 +321,24 @@ export class Agent {
205
321
  if (lazy.length > 0) {
206
322
  this.deps.promptBlocks.push(...lazy);
207
323
  }
324
+ // One-time resolution of instructions/project-map blocks that exceed
325
+ // the system-prompt budget: summarize (cached) or truncate them BEFORE
326
+ // the first real build so the model still receives the instructions.
327
+ if (!this.overflowResolved) {
328
+ this.overflowResolved = true;
329
+ try {
330
+ await this.resolvePromptOverflowOnce();
331
+ }
332
+ catch (err) {
333
+ this.deps.logger.warn(t("prompt.overflow.failed", { error: String(err?.message ?? err) }));
334
+ }
335
+ }
336
+ // B2: await the background reasoning probe next to the lazy prompt
337
+ // blocks so the FIRST LLM call already knows whether the backend
338
+ // respects the reasoning mechanism (bootstrap no longer blocks on it).
339
+ if (this.deps.reasoningProbe) {
340
+ this.probePassed = await this.deps.reasoningProbe();
341
+ }
208
342
  const { prompt: systemPrompt, excluded } = this.buildSystemPrompt();
209
343
  contextManager.addMessage({ role: "system", content: systemPrompt });
210
344
  this.systemPromptAdded = true;
@@ -251,15 +385,44 @@ export class Agent {
251
385
  });
252
386
  };
253
387
  try {
254
- const result = config.moe?.enabled === true
255
- ? await runWithMoE({
388
+ const moeEnabled = config.moe?.enabled === true;
389
+ const moeRunner = this.deps.runWithMoEOverride ?? runWithMoE;
390
+ const result = moeEnabled
391
+ ? await moeRunner({
256
392
  config,
257
393
  llmProvider,
258
394
  toolExecutor,
259
395
  logger,
260
396
  baseDir: this.deps.baseDir,
261
- }, input, () => this.executeSingleAgentLoop(input, onChunk, onMeta, countTool, onPhase), { onMeta, onTool: countTool, onPhase })
262
- : await this.executeSingleAgentLoop(input, onChunk, onMeta, countTool, onPhase);
397
+ }, input, () => this.executeSingleAgentLoop(input, slog, onChunk, onMeta, countTool, onPhase), {
398
+ onMeta,
399
+ onTool: countTool,
400
+ onPhase,
401
+ signal: this.abortController.signal,
402
+ onEvent: (event) => slog.logMoE(event.type, event.data),
403
+ sessionId: this.deps.sessionManager?.getActiveMeta()?.id,
404
+ })
405
+ : await this.executeSingleAgentLoop(input, slog, onChunk, onMeta, countTool, onPhase);
406
+ // In MoE mode the single-agent loop (the only writer of assistant turns)
407
+ // never runs — without this the history ends user→user across turns and
408
+ // session.jsonl is missing the assistant answer. Write the result once,
409
+ // only when MoE actually handled the turn (a fallback already wrote it).
410
+ if (moeEnabled && result.moeHandled) {
411
+ if (result.text) {
412
+ contextManager.addMessage({ role: "assistant", content: result.text });
413
+ if (config.session.autoSave) {
414
+ slog.saveAssistantMessage(result.text);
415
+ }
416
+ slog.logAssistant(result.text, "", undefined, 0);
417
+ }
418
+ if (!result.success && result.error) {
419
+ slog.logError(result.error);
420
+ }
421
+ }
422
+ const provenance = this.callerProvenance();
423
+ result.provider = provenance.provider;
424
+ result.model = provenance.model;
425
+ result.durationMs = Date.now() - startedAt;
263
426
  emitTurnEnd(result);
264
427
  return result;
265
428
  }
@@ -268,44 +431,38 @@ export class Agent {
268
431
  throw err;
269
432
  }
270
433
  }
271
- async executeSingleAgentLoop(input, onChunk, onMeta, onTool, onPhase) {
272
- const { config, llmProvider, toolExecutor, pluginManager, contextManager, hallucinationDetector, logger, sessionManager, baseDir, } = this.deps;
273
- const slog = new SessionLogger(sessionManager, logger, () => this.callerProvenance());
434
+ async executeSingleAgentLoop(input, slog, onChunk, onMeta, onTool, onPhase) {
435
+ const { config, llmProvider, toolExecutor, pluginManager, contextManager, hallucinationDetector, logger, } = this.deps;
274
436
  this.abortController = new AbortController();
275
- let iteration = 0;
276
- let lastText = "";
277
- // The actual most-recent model output (tool commentary, retried answers
278
- // included). Repetition is compared against THIS, not the last *accepted*
279
- // text a frozen accepted answer made consecutive retries compare
280
- // against a stale baseline and flag every re-answer as repetitive.
281
- let lastModelText = "";
282
- let lastForcedCompactionIteration = -FORCED_COMPACTION_COOLDOWN;
283
- let hallucinationRetries = 0;
284
- let lastToolSignature = "";
285
- let apiPromptTokens = 0;
286
- let apiCompletionTokens = 0;
287
- let apiCompletionChars = 0;
288
- const MAX_HALLUCINATION_RETRIES = 3;
289
- let consecutiveToolFailures = 0;
290
- const MAX_CONSECUTIVE_TOOL_FAILURES = 5;
291
- // Per-tool failure counts and which tools already produced a memory rule.
292
- const toolFailureCounts = new Map();
293
- const memoryRuleRecorded = new Set();
294
- const MIN_REPEATED_TOOL_FAILURES = 3;
295
- let auditRetries = 0;
296
- const MAX_AUDIT_RETRIES = 3;
297
- let emptyResponseRetries = 0;
298
- const MAX_EMPTY_RESPONSE_RETRIES = 2;
299
- let emptyResponseExhausted = false;
300
- let auditFailed = false;
301
- let lastAuditSummary = "";
302
- // Set when the audit gate rejects a final answer and re-prompts: the next
303
- // non-tool response is then a re-answer of an already-completed task, so
304
- // a repetition verdict is expected and must not burn a hallucination retry.
305
- let suppressRepetitionRetry = false;
306
- let repeatedToolCount = 0;
307
- const MAX_REPEATED_TOOL_CALLS = 2;
308
- let llmErrorRetries = 0;
437
+ // Единый контейнер состояния цикла — заменяет ~24 разрозненных `let`.
438
+ const state = createLoopState();
439
+ // Модули цикла: каждый инкапсулирует одну ответственность и получает
440
+ // зависимости через конструктор (никаких глобальных синглтонов).
441
+ const compactionService = new CompactionService({
442
+ contextManager,
443
+ logger,
444
+ slog,
445
+ hallucinationDetector,
446
+ });
447
+ const tokenTracker = new TokenTracker(this.costTracker, () => this.callerProvenance().provider);
448
+ const reasoningResolver = new ReasoningEffortResolver();
449
+ const toolBatch = new ToolBatchExecutor({
450
+ config,
451
+ toolExecutor,
452
+ pluginManager,
453
+ contextManager,
454
+ hallucinationDetector,
455
+ compactionService,
456
+ logger,
457
+ memoryStore: this.deps.memoryStore,
458
+ costTracker: this.costTracker,
459
+ planSummary: this.deps.planSummary,
460
+ getAbortSignal: () => this.abortController?.signal,
461
+ isShutdownRequested: () => this.shutdownRequested,
462
+ setScope: () => this.setScope(),
463
+ });
464
+ const hallucinationGate = new HallucinationGate(hallucinationDetector);
465
+ const auditGate = new AuditGate(this.deps.finalAudit);
309
466
  // Account for tool definitions in context budget (they're sent via body.tools, not messages)
310
467
  // Tool definitions are recomputed each iteration so `enable_tools`
311
468
  // (which mutates the shared activeToolTags array) can grow the
@@ -313,10 +470,11 @@ export class Agent {
313
470
  // estimate and boundedOutput set in sync with what is actually sent.
314
471
  let allToolsForBudget = toolExecutor.getToolDefinitions(this.deps.toolTags);
315
472
  let boundedToolNames = new Set(allToolsForBudget.filter((t) => t.boundedOutput).map((t) => t.name));
316
- let toolTokenEstimate = allToolsForBudget.reduce((sum, t) => sum + Math.ceil((t.description.length + JSON.stringify(t.parameters).length) / 4), 0);
473
+ let toolTokenEstimate = allToolsForBudget.reduce((sum, t) => sum + estimateTokens(t.description + JSON.stringify(t.parameters)), 0);
317
474
  contextManager.setToolTokens(toolTokenEstimate);
318
- while (iteration < config.maxToolIterations && !this.shutdownRequested) {
319
- iteration++;
475
+ while (state.iteration < config.maxToolIterations && !this.shutdownRequested) {
476
+ state.iteration++;
477
+ this._currentIteration = state.iteration;
320
478
  contextManager.noteIteration();
321
479
  // Re-read the mutable tag set in case enable_tools was called.
322
480
  allToolsForBudget = toolExecutor.getToolDefinitions(this.deps.toolTags);
@@ -324,7 +482,7 @@ export class Agent {
324
482
  toolTokenEstimate = allToolsForBudget.reduce((sum, t) => sum + Math.ceil((t.description.length + JSON.stringify(t.parameters).length) / 4), 0);
325
483
  contextManager.setToolTokens(toolTokenEstimate);
326
484
  pluginManager.runOnBeforeThink({
327
- iteration,
485
+ iteration: state.iteration,
328
486
  logger,
329
487
  lastUserMessage: input,
330
488
  contextManager,
@@ -333,68 +491,65 @@ export class Agent {
333
491
  plan: (event, detail, iter) => slog.logPlan(event, detail, iter),
334
492
  },
335
493
  });
336
- if (contextManager.needsCompaction()) {
337
- const result = contextManager.compact();
338
- if (result) {
339
- logger.debug("Context compacted");
340
- slog.logCompaction({ reason: "interval", iteration, ...result });
341
- // Seed hallucination checker with files from compaction summary
342
- hallucinationDetector.addKnownFiles(contextManager.getKnownFiles());
343
- }
344
- }
345
- const currentTokens = contextManager.getEstimatedTokens();
346
- const budget = contextManager.getBudget();
347
- const quality = contextManager.getQuality();
348
- if (quality < QUALITY_TRIGGER_THRESHOLD &&
349
- contextManager.getCompactionCount() > 0 &&
350
- iteration - lastForcedCompactionIteration >= FORCED_COMPACTION_COOLDOWN) {
351
- lastForcedCompactionIteration = iteration;
352
- const result = contextManager.compact();
353
- logger.warn(`Low context quality (${quality}%) — forced compaction`);
354
- if (result) {
355
- slog.logCompaction({
356
- reason: `quality-triggered (${quality}% < ${QUALITY_TRIGGER_THRESHOLD}%)`,
357
- iteration,
358
- ...result,
359
- });
360
- hallucinationDetector.addKnownFiles(contextManager.getKnownFiles());
361
- }
362
- }
363
- if (currentTokens > budget.history) {
364
- const result = contextManager.compact();
365
- logger.warn(`Context overflow (${currentTokens} > ${budget.history}), forced compaction`);
366
- if (result) {
367
- slog.logCompaction({
368
- reason: `overflow (${currentTokens} > ${budget.history})`,
369
- iteration,
370
- ...result,
371
- });
372
- hallucinationDetector.addKnownFiles(contextManager.getKnownFiles());
373
- }
374
- }
494
+ // Единая точка компакции: интервал → качество (с cooldown) → переполнение.
495
+ compactionService.compactIfNeeded(state);
375
496
  this.refreshSystemPrompt();
376
497
  const history = contextManager.getActiveHistory();
377
- slog.logToolDefs(allToolsForBudget.length, allToolsForBudget.map((t) => t.name), iteration);
378
- if (iteration === 1) {
498
+ // Монитор стабильности префикса: считаем до запроса, пишем в llm_usage
499
+ // после. Char-оценка (chars/4) достаточна — важна динамика ratio и
500
+ // причина расхождения, а не абсолютная точность токенов.
501
+ const promptSnapshot = buildPromptSnapshot(history, allToolsForBudget);
502
+ state.prefixDelta = diffPrompt(state.prevPrompt, promptSnapshot, estimateTokens);
503
+ state.prevPrompt = promptSnapshot;
504
+ if (state.prefixDelta.cause === "system" ||
505
+ state.prefixDelta.cause === "tools" ||
506
+ state.prefixDelta.cause === "history" ||
507
+ state.prefixDelta.cause === "volatile") {
508
+ state.lastPrefixBreak = state.prefixDelta;
509
+ }
510
+ slog.logToolDefs(allToolsForBudget.length, allToolsForBudget.map((t) => t.name), state.iteration);
511
+ if (state.iteration === 1) {
379
512
  const { blocks } = this.buildSystemPrompt();
380
- this.logContextStat("start", iteration, slog, blocks);
513
+ this.logContextStat("start", state.iteration, slog, blocks);
381
514
  }
382
515
  else {
383
- this.logContextStat("iteration", iteration, slog);
516
+ this.logContextStat("iteration", state.iteration, slog);
384
517
  }
385
518
  let textContent = "";
386
519
  let reasoningContent = "";
387
520
  const toolCalls = [];
388
521
  let sawToolCall = false;
389
522
  let emittedReasoning = false;
390
- const textChunks = [];
391
- this.emitPhase(iteration, "thinking", onPhase);
523
+ this.emitPhase(state.iteration, "thinking", onPhase);
392
524
  const llmStart = Date.now();
393
- const promptBefore = apiPromptTokens;
394
- const completionBefore = apiCompletionTokens;
525
+ const baseline = tokenTracker.beginIteration(state, contextManager.getEstimatedTokens());
395
526
  logger.logLLMRequest(config.model, history.length, input, "agent");
527
+ // Оценка reasoning-effort на итерацию. Сигналы описывают события
528
+ // ПРЕДЫДУЩЕЙ итерации (выставляются на retry-путях перед continue),
529
+ // поэтому политика оценивается ДО очистки сигналов — иначе raise-ветка
530
+ // недостижима.
531
+ const reasoning = reasoningResolver.resolve({
532
+ state,
533
+ policyState: this.policyState,
534
+ reasoningConfig: config.reasoning,
535
+ probePassed: this.probePassed,
536
+ reasoningStrategy: this.deps.reasoningStrategy,
537
+ reasoningState: this.reasoningState,
538
+ });
539
+ // Очистка per-итерационных сигналов после потребления политикой.
540
+ clearIterationSignals(state);
541
+ if (reasoning.level) {
542
+ slog.logReasoningControl({
543
+ iteration: state.iteration,
544
+ level: reasoning.level,
545
+ strategy: reasoning.strategy,
546
+ probePassed: this.probePassed,
547
+ source: reasoning.source,
548
+ });
549
+ contextManager.thinkingLevel = reasoning.level;
550
+ }
396
551
  try {
397
- for await (const chunk of llmProvider.chat(history, allToolsForBudget, this.abortController?.signal)) {
552
+ for await (const chunk of llmProvider.chat(history, allToolsForBudget, this.abortController?.signal, reasoning.level ? { reasoningEffort: reasoning.level, reasoningStrategy: reasoning.strategy } : undefined)) {
398
553
  if (this.shutdownRequested)
399
554
  break;
400
555
  if (chunk.type === "text" && chunk.content) {
@@ -402,12 +557,13 @@ export class Agent {
402
557
  onMeta?.("\n\n");
403
558
  }
404
559
  textContent += chunk.content;
405
- textChunks.push(chunk.content);
560
+ const textOut = pluginManager.runOnText({ iteration: state.iteration, logger, contextManager }, chunk.content);
561
+ onChunk?.(textOut);
406
562
  }
407
563
  if (chunk.type === "reasoning" && chunk.content) {
408
564
  reasoningContent += chunk.content;
409
565
  if (config.showReasoning) {
410
- const metaOut = pluginManager.runOnMeta({ iteration, logger, contextManager }, chunk.content);
566
+ const metaOut = pluginManager.runOnMeta({ iteration: state.iteration, logger, contextManager }, chunk.content);
411
567
  if (metaOut) {
412
568
  onMeta?.(pc.dim(metaOut));
413
569
  }
@@ -430,8 +586,19 @@ export class Agent {
430
586
  });
431
587
  }
432
588
  if (chunk.type === "done" && chunk.usage) {
433
- apiPromptTokens += chunk.usage.promptTokens;
434
- apiCompletionTokens += chunk.usage.completionTokens;
589
+ // chat() вызывается ОДИН раз за итерацию — каждый `done` здесь
590
+ // относится к тому же логическому запросу (usage-only чанк +
591
+ // non-streaming fallback дают два done). ЗАМЕНЯЕМ running-суммы
592
+ // вместо аккумуляции, иначе один реальный запрос посчитается дважды.
593
+ tokenTracker.recordApiUsage(state, baseline, chunk.usage);
594
+ }
595
+ if (chunk.type === "warning" && chunk.content) {
596
+ // Non-fatal provider notice (e.g. answer cut by the completion
597
+ // cap). Rendered through the text stream so it lands after the
598
+ // partial line without disturbing markdown formatting; it is NOT
599
+ // added to the assistant context/history.
600
+ logger.warn(`LLM warning: ${chunk.content}`);
601
+ onChunk?.(`\n\n> ⚠️ ${chunk.content}\n`);
435
602
  }
436
603
  }
437
604
  }
@@ -440,15 +607,16 @@ export class Agent {
440
607
  logger.info("LLM call aborted (interrupt)");
441
608
  break;
442
609
  }
443
- // Recoverable LLM errors (e.g. the completion hit the token limit
444
- // mid-tool_call): feed the localized reason back into context so the
445
- // model can adapt (split the write into smaller chunks) instead of
446
- // the whole session dying. Bounded to avoid infinite loops.
447
- if (err?.recoverableLlm && llmErrorRetries < MAX_LLM_ERROR_RETRIES) {
448
- llmErrorRetries++;
449
- logger.warn(`Recoverable LLM error, feeding back (${llmErrorRetries}/${MAX_LLM_ERROR_RETRIES}): ${err.message}`);
610
+ // Восстановимые ошибки LLM (например, токен-лимит на середине
611
+ // tool_call): отдаём локализованную причину обратно в контекст, чтобы
612
+ // модель адаптировалась (разбить запись на куски) вместо смерти всей
613
+ // сессии. Ограничено во избежание бесконечного цикла.
614
+ if (err instanceof LlmError && err.recoverable && state.llmErrorRetries < MAX_LLM_ERROR_RETRIES) {
615
+ state.iterRecoverableLlmError = true;
616
+ state.llmErrorRetries++;
617
+ logger.warn(`Recoverable LLM error, feeding back (${state.llmErrorRetries}/${MAX_LLM_ERROR_RETRIES}): ${err.message}`);
450
618
  slog.logError(err.message);
451
- pluginManager.runOnError({ iteration, logger, contextManager }, err);
619
+ pluginManager.runOnError({ iteration: state.iteration, logger, contextManager }, err);
452
620
  contextManager.addMessage({
453
621
  role: "user",
454
622
  content: `<system-summary>${err.message}</system-summary>`,
@@ -458,58 +626,29 @@ export class Agent {
458
626
  logger.logLLMResponse(config.model, textContent.length, Date.now() - llmStart, err.message, "agent");
459
627
  logger.error(`LLM call failed: ${err.message}`);
460
628
  slog.logError(err.message);
461
- pluginManager.runOnError({ iteration, logger, contextManager }, err);
629
+ pluginManager.runOnError({ iteration: state.iteration, logger, contextManager }, err);
462
630
  return {
463
631
  success: false,
464
- text: lastText,
632
+ text: state.lastText,
465
633
  error: t("error.llm", { message: err.message }),
466
- iterationCount: iteration,
634
+ iterationCount: state.iteration,
467
635
  };
468
636
  }
469
637
  finally {
470
- this.emitPhase(iteration, "done", onPhase);
638
+ this.emitPhase(state.iteration, "done", onPhase);
471
639
  }
472
- // Track response length so token metrics stay meaningful even when
473
- // the backend omits `usage` from the response.
474
- apiCompletionChars += (textContent || reasoningContent).length;
640
+ // Совокупная длина ответа метрики токенов остаются валидными, даже
641
+ // когда бэкенд опускает `usage`.
642
+ tokenTracker.addCompletionChars(state, (textContent || reasoningContent).length);
475
643
  logger.logLLMResponse(config.model, (textContent || reasoningContent).length, Date.now() - llmStart, undefined, "agent");
476
- // Log per-call token usage. llama.cpp streaming often omits `usage`,
477
- // so fall back to local estimates (context tokens + chars/4) and
478
- // mark the source the log must distinguish real API numbers from
479
- // heuristics.
480
- {
481
- const usagePrompt = apiPromptTokens - promptBefore;
482
- const usageCompletion = apiCompletionTokens - completionBefore;
483
- const source = usagePrompt > 0 || usageCompletion > 0 ? "api" : "estimate";
484
- const prompt = source === "api" ? usagePrompt : contextManager.getEstimatedTokens();
485
- const completion = source === "api"
486
- ? usageCompletion
487
- : Math.ceil((textContent || reasoningContent).length / 4);
488
- slog.logLlmUsage(iteration, {
489
- promptTokens: prompt,
490
- completionTokens: completion,
491
- totalTokens: prompt + completion,
492
- source,
493
- durationMs: Date.now() - llmStart,
494
- });
495
- this.costTracker.record(prompt, completion, this.callerProvenance().provider);
496
- }
644
+ state.totalLlmDuration += Date.now() - llmStart;
645
+ // Per-call лог usage + стоимость. llama.cpp в стриминге часто опускает
646
+ // `usage` фолбэк на локальные оценки (контекст + chars/4) с пометкой
647
+ // источника: лог должен отличать реальные API-цифры от эвристики.
648
+ tokenTracker.logUsage(state, baseline, textContent || reasoningContent, contextManager.getEstimatedTokens(), state.iteration, Date.now() - llmStart, slog);
497
649
  if (this.shutdownRequested) {
498
650
  break;
499
651
  }
500
- // Show the model's commentary text. When a tool call accompanies the
501
- // response, keep the text too (opencode-like narration), unless it is
502
- // a raw JSON payload that small models sometimes emit instead of
503
- // describing the call. `toolComments: false` restores the old behavior
504
- // of suppressing text next to a tool call.
505
- const toolComments = this.deps.config.ui?.toolComments ?? true;
506
- const showText = textChunks.length > 0 && (!sawToolCall || (toolComments && !isToolCallJson(textContent)));
507
- if (showText) {
508
- for (const chunk of textChunks) {
509
- const textOut = pluginManager.runOnText({ iteration, logger, contextManager }, chunk);
510
- onChunk?.(textOut);
511
- }
512
- }
513
652
  let llmResponse = null;
514
653
  if (sawToolCall) {
515
654
  llmResponse = { type: "tool_call", calls: toolCalls };
@@ -520,305 +659,117 @@ export class Agent {
520
659
  else if (reasoningContent) {
521
660
  llmResponse = { type: "reasoning", content: reasoningContent };
522
661
  }
523
- pluginManager.runOnAfterThink({ iteration, logger, contextManager }, llmResponse);
524
- if (this.deps.exitOnComplete && sawToolCall) {
662
+ pluginManager.runOnAfterThink({ iteration: state.iteration, logger, contextManager }, llmResponse);
663
+ // Детекция повторных одинаковых тулов (структурная, не по ключевым
664
+ // словам). Сигнал питает reasoning policy для понижения effort на рутине.
665
+ if (sawToolCall) {
525
666
  const signature = toolCalls
526
667
  .map((tc) => `${tc.name}:${JSON.stringify(tc.arguments)}`)
527
668
  .join("|");
528
- if (signature && signature === lastToolSignature) {
529
- // A repeated identical tool call is often the model re-running
530
- // a command after a confusing result. Give it one more chance
531
- // to produce a final text answer instead of stopping with
532
- // text: "" (observed on 08-r4: bash re-run empty result).
533
- repeatedToolCount++;
534
- if (repeatedToolCount >= MAX_REPEATED_TOOL_CALLS) {
535
- logger.debug("Exit-on-complete: repeated identical tool call, stopping");
536
- break;
537
- }
669
+ if (signature && signature === state.lastToolSignature) {
670
+ state.iterRepetitive = true;
671
+ // Nudge fires in ALL modes: an identical (tool,args) repeat is a
672
+ // wasted iteration observed with qwen3.5-9b re-running the same
673
+ // verification command back-to-back after the answer was already
674
+ // in the result. Mutating tools stay nudge-only in interactive
675
+ // sessions (the user keeps control); status tools, which can never
676
+ // make progress by repeating, hard-stop after a few identical calls.
677
+ logger.info(`Repeated identical tool call detected (iteration ${state.iteration}) — nudging model to finish or change approach`);
538
678
  contextManager.addMessage({
539
679
  role: "user",
540
680
  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>`,
541
681
  });
542
- }
543
- lastToolSignature = signature;
544
- }
545
- if (sawToolCall) {
546
- slog.logAssistant(textContent || "", reasoningContent, toolCalls, iteration);
547
- }
548
- if (sawToolCall) {
549
- contextManager.addMessage({
550
- role: "assistant",
551
- content: textContent || "",
552
- tool_calls: toolCalls.map((tc) => ({
553
- id: tc.id,
554
- type: "function",
555
- function: {
556
- name: tc.name,
557
- arguments: JSON.stringify(tc.arguments),
558
- },
559
- })),
560
- });
561
- const summaries = [];
562
- let anyToolFailed = false;
563
- // Informational read-only tools whose "failure" is expected behavior
564
- // (e.g. file_info "Not found" after delete_file) — excluded from
565
- // per-tool failure counting and consecutive failure tracking.
566
- const infoTools = new Set(["file_info"]);
567
- for (const call of toolCalls) {
568
- this.setScope();
569
- const startTime = Date.now();
570
- pluginManager.runOnToolCall({
571
- toolName: call.name,
572
- args: call.arguments,
573
- });
574
- pluginManager.runOnToolStart({ iteration, logger, contextManager }, { id: call.id, name: call.name, arguments: call.arguments });
575
- const toolIcon = this.deps.toolExecutor.getRegistry().get(call.name)?.icon;
576
- onTool?.({ type: "start", tool: call.name, args: call.arguments, icon: toolIcon });
577
- slog.logToolCall(call, iteration);
578
- const tokensBeforeTool = contextManager.getEstimatedTokens();
579
- const result = await toolExecutor.execute(call, this.abortController?.signal);
580
- // Interrupt during the tool call: the session is already shutting
581
- // down (Esc/Ctrl+C), so stop immediately — no rendering, no session
582
- // log writes, no context update for a result nobody will see. This
583
- // fixes the "Replaced in ..." lines appearing AFTER "Session ended".
584
- if (this.shutdownRequested)
585
- break;
586
- const duration = Date.now() - startTime;
587
- if (!result.success && !infoTools.has(call.name))
588
- anyToolFailed = true;
589
- if (result.success && call.arguments.path) {
590
- const filePath = String(call.arguments.path);
591
- if (call.name === "write_file" || call.name === "edit_file") {
592
- hallucinationDetector.getConsistencyCheck().trackCreatedFile(filePath);
593
- }
594
- else if (call.name === "delete_file") {
595
- hallucinationDetector.getConsistencyCheck().trackDeletedFile(filePath);
596
- }
597
- }
598
- pluginManager.runOnToolEnd({ iteration, logger, contextManager }, { id: call.id, name: call.name, arguments: call.arguments }, result, duration);
599
- // Presence check, not truthiness: read_file returns display ""
600
- // on a full read to suppress output entirely (the tool header
601
- // already shows the path) — falling back on empty string would
602
- // dump the whole file content into the REPL.
603
- if (result.display !== undefined) {
604
- if (result.display.length > 0) {
605
- onMeta?.("\n" + result.display + "\n");
682
+ state.repeatedToolCount++;
683
+ if (this.deps.exitOnComplete) {
684
+ if (state.repeatedToolCount >= MAX_REPEATED_TOOL_CALLS) {
685
+ logger.debug("Exit-on-complete: repeated identical tool call, stopping");
686
+ break;
606
687
  }
607
688
  }
608
- else {
609
- const metaOut = pluginManager.runOnMeta({ iteration, logger, contextManager }, result.output);
610
- onMeta?.("\n" + pc.dim(metaOut) + "\n");
611
- }
612
- if (result.diff) {
613
- onMeta?.("\n" + result.diff + "\n");
614
- }
615
- const currentTokens = contextManager.getEstimatedTokens();
616
- const budget = contextManager.getBudget();
617
- const truncatedOutput = this.truncateToolOutput(result.output, budget, currentTokens, boundedToolNames.has(call.name));
618
- contextManager.addMessage({
619
- role: "tool",
620
- content: truncatedOutput,
621
- name: call.name,
622
- tool_call_id: call.id,
623
- success: result.success,
624
- arguments: call.arguments,
625
- });
626
- const tokensAfterTool = contextManager.getEstimatedTokens();
627
- onTool?.({
628
- type: "end",
629
- tool: call.name,
630
- args: call.arguments,
631
- duration,
632
- error: !result.success,
633
- ctxDelta: tokensAfterTool - tokensBeforeTool,
634
- costUsd: this.costTracker.total,
635
- });
636
- summaries.push(`[Tool: ${call.name} (${JSON.stringify(call.arguments)}) → ${truncatedOutput.slice(0, 200)}]`);
637
- if (config.session.autoSave) {
638
- slog.logToolResult(call, result, duration, iteration);
639
- }
640
- if (contextManager.needsCompaction()) {
641
- const result = contextManager.compact();
642
- if (result) {
643
- logger.debug("Context compacted after tool result");
644
- slog.logCompaction({
645
- reason: "after_tool",
646
- iteration,
647
- ...result,
648
- });
649
- hallucinationDetector.addKnownFiles(contextManager.getKnownFiles());
650
- }
689
+ else if (state.repeatedToolCount >= MAX_REPEATED_TOOL_CALLS_INTERACTIVE &&
690
+ toolCalls.every((tc) => LOOP_PRONE_STATUS_TOOLS.has(tc.name))) {
691
+ logger.warn(`Repeated identical status tool ${state.repeatedToolCount}× (iteration ${state.iteration}) stopping to avoid an infinite loop`);
692
+ break;
651
693
  }
652
- // Track per-tool failure counts (NOT just consecutive) so a tool
653
- // that keeps failing while other tools succeed between attempts
654
- // is still learned from (observed: LSP spawn npx ENOENT failed
655
- // 8x in one session, never consecutively, so it never reached
656
- // memory via the 5-consecutive-failures path).
657
- if (!result.success && !infoTools.has(call.name)) {
658
- const key = call.name;
659
- const prev = toolFailureCounts.get(key) ?? { count: 0, error: "" };
660
- prev.count++;
661
- prev.error = String(result.output ?? "").slice(0, 200);
662
- toolFailureCounts.set(key, prev);
663
- if (prev.count >= MIN_REPEATED_TOOL_FAILURES && !memoryRuleRecorded.has(key)) {
664
- memoryRuleRecorded.add(key);
665
- const memStore = this.deps.memoryStore;
666
- if (memStore) {
667
- 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");
668
- logger.warn(`Recorded repeated ${key} failures to memory (${prev.count}x)`);
669
- }
670
- }
694
+ }
695
+ else {
696
+ state.repeatedToolCount = 0;
697
+ }
698
+ state.lastToolSignature = signature;
699
+ }
700
+ // Детекция «перфекционизм-цикла»: многократные правки ОДНОГО файла или
701
+ // запуски одной команды через не-подряд итерации (edit build → read →
702
+ // edit…). Аргументы каждый раз новые, поэтому подряд-детекция дубликатов
703
+ // выше этот паттерн не видит. Nudge уходит ПОСЛЕ tool-результатов, чтобы
704
+ // не ломать парность assistant(tool_calls) → tool(results).
705
+ const cycleNudges = [];
706
+ if (sawToolCall) {
707
+ for (const tc of toolCalls) {
708
+ const target = mutationTargetKey(tc.name, tc.arguments);
709
+ if (!target)
710
+ continue;
711
+ const count = (state.toolTargetCounts.get(target) ?? 0) + 1;
712
+ state.toolTargetCounts.set(target, count);
713
+ if (count >= MUTATION_CYCLE_NUDGE_THRESHOLD &&
714
+ !state.cycleNudgedKeys.has(target)) {
715
+ state.cycleNudgedKeys.add(target);
716
+ cycleNudges.push(target);
671
717
  }
672
718
  }
673
- if (this.shutdownRequested)
719
+ }
720
+ if (sawToolCall) {
721
+ const batch = await toolBatch.execute(state, toolCalls, boundedToolNames, textContent, reasoningContent, input, onTool, onMeta, slog);
722
+ if (batch.interrupted) {
674
723
  break;
675
- if (anyToolFailed) {
676
- consecutiveToolFailures++;
677
- }
678
- else {
679
- consecutiveToolFailures = 0;
680
724
  }
681
- if (consecutiveToolFailures >= MAX_CONSECUTIVE_TOOL_FAILURES) {
682
- const recoveryMsg = t("exec.consecutive_failures_recovery", {
683
- count: consecutiveToolFailures,
684
- });
685
- logger.warn(`Consecutive tool failures: ${consecutiveToolFailures}`);
686
- const taskSnippet = input.length > 200 ? input.slice(0, 200) + "..." : input;
687
- const taskReminder = t("exec.task_reminder", { task: taskSnippet });
725
+ if (cycleNudges.length > 0) {
726
+ logger.info(`Perfectionism cycle detected (${cycleNudges.length} target(s) at ${MUTATION_CYCLE_NUDGE_THRESHOLD}+ mutations) — nudging model to finalize`);
688
727
  contextManager.addMessage({
689
728
  role: "user",
690
- content: `<system-summary>${recoveryMsg}\n${taskReminder}</system-summary>`,
729
+ content: `<system-summary>You have already modified or re-run the same target many times (e.g. ${cycleNudges[0]}). If the latest output is correct, STOP polishing and answer with a final text response. More identical edits will not change the result.</system-summary>`,
691
730
  });
692
- const memStore = this.deps.memoryStore;
693
- if (memStore) {
694
- 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");
695
- }
696
731
  }
697
- contextManager.addMessage({
698
- role: "user",
699
- content: `<system-summary>${summaries.join("\n")}</system-summary>`,
700
- });
701
- const ui = this.deps.config.ui;
702
- if (ui?.showContextStats) {
703
- const ctxTokens = contextManager.getEstimatedTokens();
704
- const ctxBudget = contextManager.getBudget();
705
- const ctxPct = Math.min(100, Math.round((ctxTokens / ctxBudget.history) * 100));
706
- const barLen = 10;
707
- const filled = Math.round((ctxPct / 100) * barLen);
708
- const ctxBar = pc.green("█".repeat(filled)) + pc.dim("░".repeat(barLen - filled));
709
- const pctColor = ctxPct >= 75 ? pc.yellow : pc.dim;
710
- const compCount = contextManager.getCompactionCount();
711
- const quality = contextManager.getQuality();
712
- const qualityColor = quality >= 70 ? pc.green : quality >= 40 ? pc.yellow : pc.red;
713
- onMeta?.(`\n ${ctxBar} ${pctColor(`${ctxPct}%`)} ${pc.dim(`ctx: ${ctxTokens}/${ctxBudget.history}`)} ${pc.dim(`compactions: ${compCount}`)} ${qualityColor(`quality: ${quality}%`)}\n`);
714
- }
715
- else if (ui?.showCompaction) {
716
- const compCount = contextManager.getCompactionCount();
717
- if (compCount > this.lastCompactionShown) {
718
- this.lastCompactionShown = compCount;
719
- onMeta?.(pc.dim(`\n ⟳ Context compacted (${compCount})\n`));
720
- }
721
- }
722
- // A tool call means the model kept working instead of just
723
- // re-answering — the audit-re-answer leniency no longer applies.
724
- suppressRepetitionRetry = false;
732
+ this.contextRenderer.render(this.deps.config.ui, contextManager, onMeta);
733
+ // Тулы означают, что модель продолжила работать, а не переотвечает —
734
+ // послабление на повтор после аудита больше не действует.
735
+ state.suppressRepetitionRetry = false;
725
736
  continue;
726
737
  }
727
- hallucinationDetector.getConfidenceCheck().setPreviousResponse(lastModelText);
728
- // Update AFTER setPreviousResponse so the comparison uses the
729
- // previous iteration's output, not this one (which would otherwise
730
- // always overlap with itself).
731
- if (textContent)
732
- lastModelText = textContent;
733
- const hallucinationResult = await hallucinationDetector.validate(textContent);
734
- if (hallucinationResult.status === "block") {
735
- logger.warn(`Response blocked: ${hallucinationResult.reason}`);
738
+ const verdict = await hallucinationGate.evaluate({
739
+ state,
740
+ textContent,
741
+ input,
742
+ exitOnComplete: this.deps.exitOnComplete ?? false,
743
+ hasFinalAudit: !!this.deps.finalAudit,
744
+ logger,
745
+ contextManager,
746
+ onMeta,
747
+ onChunk,
748
+ });
749
+ if (verdict.action === "block") {
736
750
  return {
737
751
  success: false,
738
- text: lastText,
739
- error: t("error.response_blocked", {
740
- reason: hallucinationResult.reason || "",
741
- }),
742
- iterationCount: iteration,
752
+ text: state.lastText,
753
+ error: t("error.response_blocked", { reason: verdict.reason }),
754
+ iterationCount: state.iteration,
743
755
  };
744
756
  }
745
- if (hallucinationResult.status === "warn") {
746
- logger.warn(`Hallucination warning: ${hallucinationResult.reason}`);
747
- const warnLine = `${t("hall.uncertainty_prefix").trim()} ${hallucinationResult.reason ?? ""}`;
748
- if (onMeta) {
749
- onMeta(`\n${pc.yellow(warnLine)}\n`);
750
- }
751
- else if (onChunk) {
752
- onChunk(`\n${warnLine}\n`);
753
- }
757
+ if (verdict.action === "retry") {
758
+ continue;
754
759
  }
755
- if (hallucinationResult.status === "retry") {
756
- if (this.deps.exitOnComplete && textContent?.trim()) {
757
- logger.debug("Exit-on-complete: stopping on first response");
758
- // Save the response BEFORE breaking — lastText is still the
759
- // previous (tool-only) iteration's text, so without this the
760
- // final answer is lost (reported text: "").
761
- lastText = textContent;
762
- break;
763
- }
764
- if (suppressRepetitionRetry && hallucinationResult.kind === "repetition") {
765
- // The audit gate rejected the previous final answer and
766
- // re-prompted the model. Its re-answer restating the completed
767
- // task is naturally "repetitive" — that is expected, not
768
- // degeneration. Downgrade to a warning and let the response
769
- // flow through to the final audit gate again (which is itself
770
- // bounded by MAX_AUDIT_RETRIES).
771
- suppressRepetitionRetry = false;
772
- logger.warn(`Audit-triggered re-answer repetition — not counted as hallucination retry`);
773
- const warnLine = `${t("hall.uncertainty_prefix").trim()} ${hallucinationResult.reason ?? ""}`;
774
- if (onMeta) {
775
- onMeta(`\n${pc.yellow(warnLine)}\n`);
776
- }
777
- else if (onChunk) {
778
- onChunk(`\n${warnLine}\n`);
779
- }
780
- // fall through to the acceptance path below (assistant message,
781
- // lastText, final audit gate).
782
- }
783
- else {
784
- // NOTE: with exitOnComplete and an EMPTY text we deliberately
785
- // do NOT break — the model produced no usable answer yet (same
786
- // case as the empty-response guard below). Falling through to
787
- // the retry path keeps us from finishing with text: "".
788
- if (hallucinationRetries >= MAX_HALLUCINATION_RETRIES) {
789
- logger.warn(`Hallucination retries exhausted (${MAX_HALLUCINATION_RETRIES}), returning error`);
790
- return {
791
- success: false,
792
- text: lastText,
793
- error: t("error.response_blocked", {
794
- reason: t("hall.max_retries_exhausted"),
795
- }),
796
- iterationCount: iteration,
797
- };
798
- }
799
- hallucinationRetries++;
800
- logger.warn(`Hallucination retry (${hallucinationRetries}/${MAX_HALLUCINATION_RETRIES}): ${hallucinationResult.reason}`);
801
- if (textContent) {
802
- contextManager.addMessage({
803
- role: "assistant",
804
- content: textContent,
805
- });
806
- }
807
- contextManager.addMessage({
808
- role: "user",
809
- 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>`,
810
- });
811
- continue;
812
- }
760
+ if (verdict.action === "exit") {
761
+ // lastText/finalAnswerAccepted уже выставлены вентилем.
762
+ break;
813
763
  }
764
+ // warn/accept → принятие ответа
814
765
  if (textContent) {
815
766
  contextManager.addMessage({ role: "assistant", content: textContent });
816
767
  if (config.session.autoSave) {
817
768
  slog.saveAssistantMessage(textContent);
818
769
  }
819
- slog.logAssistant(textContent, reasoningContent, undefined, iteration);
770
+ slog.logAssistant(textContent, reasoningContent, undefined, state.iteration);
820
771
  }
821
- lastText = textContent;
772
+ state.lastText = textContent;
822
773
  {
823
774
  const decisionPatterns = [
824
775
  ...textContent.matchAll(/(?:plan|decided|decision|решено|план|решение):\s*(.+?)(?:\n|$)/gi),
@@ -829,63 +780,51 @@ export class Agent {
829
780
  .trackDecision(match[1].trim(), "agent_response");
830
781
  }
831
782
  }
832
- if (!sawToolCall) {
833
- // Guard: the model returned an EMPTY final response (no text, no
834
- // tool calls often just reasoning content after context
835
- // compaction). Nudge it to produce a real answer instead of
836
- // silently finishing with text: "".
837
- if (!textContent?.trim()) {
838
- if (emptyResponseRetries < MAX_EMPTY_RESPONSE_RETRIES) {
839
- emptyResponseRetries++;
840
- logger.warn(`Empty response on iteration ${iteration} (retry ${emptyResponseRetries}/${MAX_EMPTY_RESPONSE_RETRIES})`);
841
- contextManager.addMessage({
842
- role: "user",
843
- 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>`,
844
- });
845
- continue;
846
- }
847
- emptyResponseExhausted = true;
848
- logger.warn(`Empty response retries exhausted after ${MAX_EMPTY_RESPONSE_RETRIES} attempts`);
849
- }
850
- if (this.deps.finalAudit) {
851
- const audit = await this.deps.finalAudit();
852
- if (audit && !audit.passed) {
853
- logger.warn(`Final audit incomplete: ${audit.summary}`);
854
- const steps = audit.pendingSteps.slice(0, 5).join("; ") || "—";
855
- contextManager.addMessage({
856
- role: "user",
857
- content: `<system-summary>${t("exec.audit_incomplete", {
858
- summary: audit.summary,
859
- steps,
860
- })}</system-summary>`,
861
- });
862
- slog.logAudit(audit.summary, iteration);
863
- auditRetries++;
864
- lastAuditSummary = audit.summary;
865
- if (auditRetries >= MAX_AUDIT_RETRIES || iteration >= config.maxToolIterations - 1) {
866
- logger.warn(`Final audit still incomplete after ${auditRetries} retries — reporting failure`);
867
- auditFailed = true;
868
- break;
869
- }
870
- // The next non-tool response is a forced re-answer of an
871
- // already-completed task — allow one repetition without
872
- // burning the hallucination budget (see retry branch).
873
- suppressRepetitionRetry = true;
874
- continue;
875
- }
783
+ // Guard: пустой финальный ответ (текста нет, тулов нет — часто только
784
+ // reasoning после компакции). Подталкиваем к реальному ответу вместо
785
+ // молчаливого завершения с text: "".
786
+ if (!textContent?.trim()) {
787
+ if (state.emptyResponseRetries < MAX_EMPTY_RESPONSE_RETRIES) {
788
+ state.emptyResponseRetries++;
789
+ logger.warn(`Empty response on iteration ${state.iteration} (retry ${state.emptyResponseRetries}/${MAX_EMPTY_RESPONSE_RETRIES})`);
790
+ contextManager.addMessage({
791
+ role: "user",
792
+ 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>`,
793
+ });
794
+ continue;
876
795
  }
796
+ state.emptyResponseExhausted = true;
797
+ logger.warn(`Empty response retries exhausted after ${MAX_EMPTY_RESPONSE_RETRIES} attempts`);
798
+ }
799
+ const auditVerdict = await auditGate.evaluate({
800
+ state,
801
+ iteration: state.iteration,
802
+ maxIterations: config.maxToolIterations,
803
+ logger,
804
+ contextManager,
805
+ slog,
806
+ });
807
+ if (auditVerdict === null || auditVerdict.action === "pass") {
808
+ state.finalAnswerAccepted = true;
809
+ break;
810
+ }
811
+ if (auditVerdict.action === "exhausted") {
812
+ // auditFailed уже выставлен вентилем.
877
813
  break;
878
814
  }
815
+ // reject → continue: аудит уже добавил контекст и взвёл suppressRepetitionRetry.
816
+ continue;
879
817
  }
880
818
  const tokensUsed = contextManager.getEstimatedTokens();
881
819
  const budget = contextManager.getBudget();
882
- const usageTokens = this.resolveUsageTokens(apiPromptTokens, apiCompletionTokens, tokensUsed, apiCompletionChars);
883
- if (iteration >= config.maxToolIterations) {
820
+ const usageTokens = tokenTracker.resolveFinal(state);
821
+ const cacheStats = this.buildCacheStats(state);
822
+ if (state.iteration >= config.maxToolIterations && !state.finalAnswerAccepted) {
884
823
  return {
885
824
  success: false,
886
- text: lastText,
825
+ text: state.lastText,
887
826
  error: t("error.max_iters", { max: config.maxToolIterations }),
888
- iterationCount: iteration,
827
+ iterationCount: state.iteration,
889
828
  contextUsed: tokensUsed,
890
829
  contextLimit: budget.history,
891
830
  promptTokens: usageTokens.prompt,
@@ -893,19 +832,20 @@ export class Agent {
893
832
  totalTokens: usageTokens.total,
894
833
  totalCost: this.costTracker.total,
895
834
  costBreakdown: this.costTracker.breakdown(),
835
+ cache: cacheStats,
896
836
  compactionCount: contextManager.getCompactionCount(),
897
837
  contextQuality: contextManager.getQuality(),
898
838
  };
899
839
  }
900
840
  return {
901
- success: emptyResponseExhausted || auditFailed ? false : true,
902
- text: lastText,
903
- error: emptyResponseExhausted
841
+ success: state.emptyResponseExhausted || state.auditFailed ? false : true,
842
+ text: state.lastText,
843
+ error: state.emptyResponseExhausted
904
844
  ? t("error.empty_response")
905
- : auditFailed
906
- ? t("error.audit_failed", { summary: lastAuditSummary })
845
+ : state.auditFailed
846
+ ? t("error.audit_failed", { summary: state.lastAuditSummary })
907
847
  : undefined,
908
- iterationCount: iteration,
848
+ iterationCount: state.iteration,
909
849
  contextUsed: tokensUsed,
910
850
  contextLimit: budget.history,
911
851
  promptTokens: usageTokens.prompt,
@@ -913,8 +853,34 @@ export class Agent {
913
853
  totalTokens: usageTokens.total,
914
854
  totalCost: this.costTracker.total,
915
855
  costBreakdown: this.costTracker.breakdown(),
856
+ cache: cacheStats,
916
857
  compactionCount: contextManager.getCompactionCount(),
917
858
  contextQuality: contextManager.getQuality(),
859
+ llmDurationMs: state.totalLlmDuration,
860
+ };
861
+ }
862
+ /**
863
+ * Собирает статистику кеша промпта за прогон: провайдерские токены (если
864
+ * были) плюс client-side стабильность префикса. Возвращает undefined, когда
865
+ * нет ни данных провайдера, ни префикс-монитора — чтобы не засорять вывод.
866
+ */
867
+ buildCacheStats(state) {
868
+ const hasTokens = state.cacheCachedTotal > 0 || state.cacheUncachedTotal > 0 || state.cacheWriteTotal > 0;
869
+ // Приоритет — последнему реальному разрыву, иначе состояние текущей
870
+ // итерации (нормальная дописка / первая итерация).
871
+ const prefix = state.lastPrefixBreak ?? state.prefixDelta;
872
+ if (!hasTokens && prefix?.stableRatio === undefined)
873
+ return undefined;
874
+ const denom = state.cacheCachedTotal + state.cacheUncachedTotal;
875
+ return {
876
+ cachedTokens: state.cacheCachedTotal,
877
+ uncachedTokens: state.cacheUncachedTotal,
878
+ cacheWriteTokens: state.cacheWriteTotal,
879
+ hitRate: denom > 0 ? state.cacheCachedTotal / denom : 0,
880
+ saved: this.costTracker.saved,
881
+ prefixStable: prefix?.stableRatio,
882
+ prefixCause: prefix?.cause,
883
+ source: state.cacheSource,
918
884
  };
919
885
  }
920
886
  clearContext() {
@@ -922,43 +888,35 @@ export class Agent {
922
888
  this.systemPromptAdded = false;
923
889
  }
924
890
  async reconfigure(config) {
925
- const { OpenAICompatProvider } = await import("../llm/openai-compat");
926
891
  const { TokenCounter } = await import("../llm/token-counter");
927
- const newProvider = new OpenAICompatProvider({
928
- model: config.model,
929
- baseUrl: config.provider.baseUrl,
930
- apiKey: config.provider.apiKey,
931
- contextWindow: config.contextWindow,
932
- retry: config.retry,
933
- rateLimits: config.security?.rateLimits,
934
- maxCompletionTokens: config.provider.entries?.find((e) => e.label === config.provider.active)?.maxCompletionTokens ??
935
- config.provider.maxCompletionTokens,
892
+ const { provider: newProvider } = buildActiveProvider(config, this.deps.logger, {
893
+ getSessionId: () => this.deps.sessionManager?.getActiveMeta()?.id,
936
894
  });
937
895
  this.deps.llmProvider = newProvider;
938
896
  this.deps.toolExecutor.updateProvider(newProvider);
939
897
  const newTokenCounter = new TokenCounter(config.model);
940
898
  this.deps.contextManager.resize(config.contextWindow, config.contextBudget, newTokenCounter);
941
899
  this.deps.config = config;
900
+ if (this.deps.configRef)
901
+ this.deps.configRef.current = config;
942
902
  this.costTracker.setModel(config.model);
943
903
  }
944
904
  setProvider(name, model) {
945
- const manager = new ProviderManager(this.deps.config.provider, {
946
- contextWindow: this.deps.config.contextWindow,
947
- retry: this.deps.config.retry,
948
- rateLimits: this.deps.config.security?.rateLimits,
905
+ const { manager, provider } = buildActiveProvider(this.deps.config, this.deps.logger, {
906
+ getSessionId: () => this.deps.sessionManager?.getActiveMeta()?.id,
949
907
  });
950
908
  manager.switch(name, model);
951
909
  const providerCfg = manager.toConfig();
952
910
  this.deps.config.provider = providerCfg;
953
- this.deps.llmProvider = manager.active;
954
- this.deps.toolExecutor.updateProvider(manager.active);
955
- this.deps.toolExecutor.ctx.llmProvider = manager.active;
911
+ this.deps.llmProvider = provider;
912
+ this.deps.toolExecutor.updateProvider(provider);
956
913
  }
957
914
  listProviders() {
958
915
  const manager = new ProviderManager(this.deps.config.provider, {
959
916
  contextWindow: this.deps.config.contextWindow,
960
917
  retry: this.deps.config.retry,
961
918
  rateLimits: this.deps.config.security?.rateLimits,
919
+ getSessionId: () => this.deps.sessionManager?.getActiveMeta()?.id,
962
920
  });
963
921
  const activeName = this.deps.config.provider.active;
964
922
  return manager.list().map((e) => ({
@@ -973,6 +931,8 @@ export class Agent {
973
931
  contextWindow: this.deps.config.contextWindow,
974
932
  retry: this.deps.config.retry,
975
933
  rateLimits: this.deps.config.security?.rateLimits,
934
+ getSessionId: () => this.deps.sessionManager?.getActiveMeta()?.id,
935
+ logger: this.deps.logger,
976
936
  });
977
937
  manager.setModel(this.deps.config.model);
978
938
  manager.switch(name);
@@ -1005,7 +965,7 @@ export class Agent {
1005
965
  contextManager.onCompact = null;
1006
966
  const killed = processRegistry.killAll();
1007
967
  if (killed > 0) {
1008
- logger.info(`Killed ${killed} background process(es) on shutdown`);
968
+ logger.info(t("env.killed_processes", { count: String(killed) }));
1009
969
  }
1010
970
  // Note: do NOT close session log here — the session is still active.
1011
971
  // Session log is closed only when the session actually ends (exit, session delete).