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.
- package/CHANGELOG.md +148 -1
- package/dist/cli/cache-line.js +30 -0
- package/dist/cli/command-suggest.js +38 -0
- package/dist/cli/commands.js +285 -60
- package/dist/cli/completer.js +16 -16
- package/dist/cli/json-payload.js +32 -0
- package/dist/cli/main.js +165 -77
- package/dist/cli/plugin-commands.js +5 -4
- package/dist/cli/relaunch.js +37 -0
- package/dist/cli/repl-commands.js +441 -307
- package/dist/cli/repl.js +360 -83
- package/dist/cli/run-result.js +12 -6
- package/dist/cli/security-commands.js +64 -60
- package/dist/cli/setup-order.js +57 -0
- package/dist/cli/setup-prompt.js +49 -0
- package/dist/cli/setup.js +52 -48
- package/dist/config/budget.js +48 -0
- package/dist/config/config.js +132 -70
- package/dist/config/defaults.js +37 -11
- package/dist/config/domains.js +9 -50
- package/dist/config/utils.js +56 -0
- package/dist/core/agent/audit-gate.js +49 -0
- package/dist/core/agent/compaction.js +89 -0
- package/dist/core/agent/constants.js +61 -0
- package/dist/core/agent/context-renderer.js +40 -0
- package/dist/core/agent/hallucination-gate.js +87 -0
- package/dist/core/agent/loop-state.js +53 -0
- package/dist/core/agent/prefix-monitor.js +101 -0
- package/dist/core/agent/reasoning-resolver.js +56 -0
- package/dist/core/agent/token-tracker.js +96 -0
- package/dist/core/agent/tool-batch.js +237 -0
- package/dist/core/agent/tool-output.js +62 -0
- package/dist/core/agent-moe.js +214 -69
- package/dist/core/agent.js +506 -546
- package/dist/core/bootstrap.js +297 -98
- package/dist/core/crash-handler.js +2 -1
- package/dist/core/prompt-builder.js +3 -0
- package/dist/core/prompt-overflow.js +307 -0
- package/dist/core/session-logger.js +34 -2
- package/dist/i18n/en.json +7 -4
- package/dist/i18n/ru.json +7 -4
- package/dist/index.js +5 -1
- package/dist/llm/cache-usage.js +76 -0
- package/dist/llm/image-utils.js +20 -16
- package/dist/llm/llm-errors.js +41 -0
- package/dist/llm/model-loader.js +30 -0
- package/dist/llm/openai-compat.js +287 -101
- package/dist/llm/orchestrator.js +140 -68
- package/dist/llm/provider-budget.js +68 -0
- package/dist/llm/provider.js +0 -1
- package/dist/llm/stream-state.js +26 -0
- package/dist/llm/token-counter.js +28 -0
- package/dist/logger/app-logger.js +12 -15
- package/dist/main.js +1606 -800
- package/dist/migration/detect.js +3 -1
- package/dist/modules/browser/actions.js +0 -3
- package/dist/modules/browser/bridge-client.js +2 -0
- package/dist/modules/browser/driver.js +46 -4
- package/dist/modules/certification/cli.js +85 -42
- package/dist/modules/certification/loader.js +15 -1
- package/dist/modules/certification/manifest.js +126 -15
- package/dist/modules/certification/runner.js +4 -26
- package/dist/modules/certification/scenarios.js +184 -5
- package/dist/modules/certification/syntax-scenarios.js +51 -0
- package/dist/modules/context/chunk-query.js +25 -5
- package/dist/modules/context/fact-extractor.js +6 -2
- package/dist/modules/context/manager.js +23 -7
- package/dist/modules/execution/audit-runners.js +7 -1
- package/dist/modules/execution/auditor.js +3 -3
- package/dist/modules/execution/execution-plugin.js +22 -15
- package/dist/modules/execution/input-from.js +46 -0
- package/dist/modules/execution/module.js +107 -18
- package/dist/modules/execution/moe-executor.js +166 -54
- package/dist/modules/execution/plan-actions.js +524 -0
- package/dist/modules/execution/plan-steps.js +23 -0
- package/dist/modules/execution/plan-store.js +15 -3
- package/dist/modules/execution/plan-tool.js +6 -488
- package/dist/modules/execution/plan-validator.js +24 -0
- package/dist/modules/execution/stuck-detector.js +3 -18
- package/dist/modules/execution/tracker.js +14 -5
- package/dist/modules/execution/transient-error.js +30 -0
- package/dist/modules/execution/verifier.js +94 -7
- package/dist/modules/execution/windows-commands.js +11 -0
- package/dist/modules/hallucination/confidence.js +36 -23
- package/dist/modules/hallucination/consistency.js +3 -0
- package/dist/modules/hallucination/detector.js +8 -3
- package/dist/modules/hallucination/factual.js +26 -7
- package/dist/modules/hallucination/llm-judge.js +12 -2
- package/dist/modules/indexer/map-command.js +35 -0
- package/dist/modules/indexer/map-select.js +87 -0
- package/dist/modules/indexer/module.js +34 -22
- package/dist/modules/indexer/symbols.js +189 -0
- package/dist/modules/indexer/walker.js +96 -42
- package/dist/modules/lsp/check-tool.js +2 -1
- package/dist/modules/lsp/client.js +49 -32
- package/dist/modules/lsp/config.js +55 -2
- package/dist/modules/lsp/module.js +38 -5
- package/dist/modules/lsp/probe.js +4 -3
- package/dist/modules/lsp/project-root.js +41 -1
- package/dist/modules/lsp/startup-check.js +12 -4
- package/dist/modules/mcp/client.js +153 -104
- package/dist/modules/mcp/module.js +165 -41
- package/dist/modules/memory/module.js +4 -3
- package/dist/modules/plugins/builtin/lint-on-write.js +36 -6
- package/dist/modules/plugins/manager.js +47 -84
- package/dist/modules/pricing/index.js +17 -7
- package/dist/modules/pricing/prices.js +30 -12
- package/dist/modules/processes/index.js +1 -0
- package/dist/modules/processes/kill-tree.js +56 -0
- package/dist/modules/processes/registry.js +2 -54
- package/dist/modules/providers/cache.js +23 -0
- package/dist/modules/providers/factory.js +28 -0
- package/dist/modules/providers/fallback.js +7 -5
- package/dist/modules/providers/health.js +2 -1
- package/dist/modules/providers/index.js +1 -0
- package/dist/modules/providers/manager.js +17 -2
- package/dist/modules/providers/presets.js +79 -6
- package/dist/modules/reasoning/policy.js +40 -0
- package/dist/modules/reasoning/probe.js +111 -0
- package/dist/modules/security/audit-notifier.js +42 -27
- package/dist/modules/security/command-validator.js +25 -20
- package/dist/modules/security/encryption.js +6 -12
- package/dist/modules/security/network-validator.js +76 -5
- package/dist/modules/security/path-validator.js +77 -34
- package/dist/modules/security/rate-limiter.js +11 -0
- package/dist/modules/security/security-policies.js +1 -1
- package/dist/modules/security/session-encryption.js +13 -2
- package/dist/modules/security/session-isolation.js +2 -9
- package/dist/modules/session/manager.js +11 -0
- package/dist/modules/session/module.js +11 -3
- package/dist/modules/session/store.js +41 -5
- package/dist/modules/skills/loader.js +7 -1
- package/dist/modules/skills/module.js +2 -1
- package/dist/modules/updater/changelog-reader.js +94 -0
- package/dist/modules/updater/dev-detect.js +17 -0
- package/dist/modules/updater/index.js +1 -0
- package/dist/modules/updater/module.js +14 -3
- package/dist/output/bus.js +32 -0
- package/dist/output/channel.js +233 -0
- package/dist/output/format.js +14 -0
- package/dist/output/index.js +7 -0
- package/dist/output/json-sink.js +22 -0
- package/dist/output/machine.js +8 -0
- package/dist/output/session-sink.js +27 -0
- package/dist/output/types.js +1 -0
- package/dist/tools/approve.js +6 -2
- package/dist/tools/attach-image.js +11 -11
- package/dist/tools/auto-fixer.js +198 -0
- package/dist/tools/bash.js +142 -89
- package/dist/tools/chunk-query.js +10 -6
- package/dist/tools/download-file.js +1 -1
- package/dist/tools/edit-file.js +20 -2
- package/dist/tools/executor.js +54 -9
- package/dist/tools/glob-tool.js +7 -0
- package/dist/tools/grep-tool.js +15 -1
- package/dist/tools/index.js +3 -1
- package/dist/tools/list-dir.js +3 -1
- package/dist/tools/load-skill.js +2 -1
- package/dist/tools/mcp-call.js +1 -1
- package/dist/tools/move-file.js +5 -4
- package/dist/tools/path-utils.js +7 -0
- package/dist/tools/pipeline-run.js +1 -1
- package/dist/tools/prompt-io.js +28 -0
- package/dist/tools/question.js +12 -12
- package/dist/tools/scope-request.js +91 -0
- package/dist/tools/session-info.js +44 -0
- package/dist/tools/set-thinking.js +71 -0
- package/dist/tools/subagent.js +50 -9
- package/dist/tools/syntax-validator.js +177 -0
- package/dist/tools/user-input.js +16 -9
- package/dist/tools/write-file.js +17 -1
- package/dist/ui/diff.js +10 -0
- package/dist/ui/line-editor.js +179 -26
- package/dist/ui/line-math.js +20 -3
- package/dist/ui/md-formatter.js +100 -10
- package/dist/ui/output.js +5 -4
- package/dist/ui/plan-view.js +2 -7
- package/dist/ui/renderer.js +89 -85
- package/dist/ui/spinner.js +14 -4
- package/dist/utils/error.js +4 -0
- package/dist/utils/index.js +4 -0
- package/dist/utils/retry.js +17 -0
- package/dist/utils/sleep.js +23 -0
- package/dist/utils/truncate.js +9 -0
- package/package.json +1 -1
package/dist/core/agent.js
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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(
|
|
24
|
-
return true;
|
|
50
|
+
a = JSON.parse(rawArgs);
|
|
25
51
|
}
|
|
26
52
|
catch {
|
|
27
|
-
return
|
|
53
|
+
return null;
|
|
28
54
|
}
|
|
29
55
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
255
|
-
|
|
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), {
|
|
262
|
-
|
|
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,
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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 +
|
|
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
|
-
|
|
337
|
-
|
|
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
|
-
|
|
378
|
-
|
|
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
|
-
|
|
391
|
-
this.emitPhase(iteration, "thinking", onPhase);
|
|
523
|
+
this.emitPhase(state.iteration, "thinking", onPhase);
|
|
392
524
|
const llmStart = Date.now();
|
|
393
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
434
|
-
|
|
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
|
-
//
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
if (err
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
//
|
|
473
|
-
//
|
|
474
|
-
|
|
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
|
-
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
610
|
-
|
|
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
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
|
|
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 (
|
|
682
|
-
|
|
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
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
|
|
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 (
|
|
746
|
-
|
|
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 (
|
|
756
|
-
|
|
757
|
-
|
|
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
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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 =
|
|
883
|
-
|
|
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 =
|
|
928
|
-
|
|
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 =
|
|
946
|
-
|
|
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 =
|
|
954
|
-
this.deps.toolExecutor.updateProvider(
|
|
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(
|
|
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).
|