mixdog 0.9.1 → 0.9.3
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/package.json +9 -1
- package/scripts/_bench-cwc.json +20 -0
- package/scripts/agent-loop-policy-test.mjs +37 -0
- package/scripts/agent-parallel-smoke.mjs +54 -10
- package/scripts/anthropic-maxtokens-test.mjs +119 -0
- package/scripts/background-task-meta-smoke.mjs +1 -1
- package/scripts/bench-run.mjs +262 -0
- package/scripts/build-tui.mjs +13 -1
- package/scripts/compact-smoke.mjs +12 -0
- package/scripts/compact-trigger-migration-smoke.mjs +67 -1
- package/scripts/explore-bench.mjs +124 -0
- package/scripts/hook-bus-test.mjs +191 -0
- package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
- package/scripts/internal-comms-bench.mjs +727 -0
- package/scripts/internal-comms-smoke.mjs +75 -0
- package/scripts/lead-workflow-smoke.mjs +4 -4
- package/scripts/live-worker-smoke.mjs +9 -9
- package/scripts/output-style-bench.mjs +285 -0
- package/scripts/output-style-smoke.mjs +13 -10
- package/scripts/patch-replay.mjs +90 -0
- package/scripts/path-suffix-test.mjs +57 -0
- package/scripts/provider-stream-stall-test.mjs +276 -0
- package/scripts/provider-toolcall-test.mjs +599 -1
- package/scripts/recall-bench.mjs +207 -0
- package/scripts/routing-corpus.mjs +281 -0
- package/scripts/session-bench.mjs +1526 -0
- package/scripts/session-diag.mjs +595 -0
- package/scripts/task-bench.mjs +207 -0
- package/scripts/tool-failures.mjs +6 -6
- package/scripts/tool-smoke.mjs +310 -67
- package/scripts/toolcall-args-test.mjs +81 -0
- package/src/agents/debugger/AGENT.md +4 -4
- package/src/agents/heavy-worker/AGENT.md +20 -9
- package/src/agents/reviewer/AGENT.md +4 -4
- package/src/agents/worker/AGENT.md +17 -9
- package/src/app.mjs +10 -6
- package/src/defaults/{hidden-roles.json → agents.json} +7 -7
- package/src/examples/schedules/SCHEDULE.example.md +32 -0
- package/src/examples/webhooks/WEBHOOK.example.md +40 -0
- package/src/headless-role.mjs +14 -14
- package/src/help.mjs +1 -0
- package/src/lib/rules-builder.cjs +32 -54
- package/src/mixdog-session-runtime.mjs +1040 -2036
- package/src/output-styles/default.md +12 -7
- package/src/output-styles/minimal.md +25 -0
- package/src/output-styles/oneline.md +21 -0
- package/src/output-styles/simple.md +10 -9
- package/src/repl.mjs +17 -7
- package/src/rules/agent/00-common.md +7 -5
- package/src/rules/agent/30-explorer.md +8 -12
- package/src/rules/lead/01-general.md +3 -1
- package/src/rules/lead/lead-tool.md +13 -2
- package/src/rules/shared/01-tool.md +23 -12
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
- package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
- package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
- package/src/runtime/agent/orchestrator/config.mjs +3 -0
- package/src/runtime/agent/orchestrator/context/collect.mjs +182 -67
- package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
- package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
- package/src/runtime/agent/orchestrator/mcp/client.mjs +100 -18
- package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +313 -84
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +104 -38
- package/src/runtime/agent/orchestrator/providers/api-usage.mjs +28 -33
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
- package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +227 -31
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +83 -6
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +229 -115
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +213 -33
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
- package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +49 -17
- package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +22 -17
- package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
- package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +262 -0
- package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
- package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
- package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +49 -0
- package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
- package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
- package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
- package/src/runtime/agent/orchestrator/session/loop.mjs +513 -1000
- package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +227 -0
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
- package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +137 -0
- package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +232 -1152
- package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
- package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
- package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +162 -0
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
- package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
- package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +14 -5
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +50 -39
- package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
- package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +70 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +497 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1192 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/code-graph.mjs +39 -4189
- package/src/runtime/agent/orchestrator/tools/patch.mjs +119 -5
- package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -2
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -2
- package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
- package/src/runtime/channels/backends/discord.mjs +99 -9
- package/src/runtime/channels/backends/telegram.mjs +501 -0
- package/src/runtime/channels/index.mjs +437 -1439
- package/src/runtime/channels/lib/boot-profile.mjs +23 -0
- package/src/runtime/channels/lib/config.mjs +54 -2
- package/src/runtime/channels/lib/crash-log.mjs +106 -0
- package/src/runtime/channels/lib/format.mjs +4 -2
- package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
- package/src/runtime/channels/lib/output-forwarder.mjs +88 -67
- package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
- package/src/runtime/channels/lib/scheduler.mjs +1 -1
- package/src/runtime/channels/lib/telegram-format.mjs +280 -0
- package/src/runtime/channels/lib/tool-format.mjs +1 -1
- package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
- package/src/runtime/channels/lib/webhook.mjs +59 -31
- package/src/runtime/channels/lib/whisper-language.mjs +42 -0
- package/src/runtime/channels/tool-defs.mjs +1 -1
- package/src/runtime/memory/index.mjs +465 -345
- package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
- package/src/runtime/memory/lib/core-memory-store.mjs +352 -2
- package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
- package/src/runtime/memory/lib/http-wire.mjs +57 -0
- package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle2.mjs +65 -9
- package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
- package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
- package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
- package/src/runtime/memory/lib/memory.mjs +121 -4
- package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
- package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
- package/src/runtime/memory/lib/recall-format.mjs +183 -0
- package/src/runtime/memory/lib/session-ingest.mjs +107 -0
- package/src/runtime/memory/lib/trace-store.mjs +69 -22
- package/src/runtime/memory/tool-defs.mjs +10 -7
- package/src/runtime/shared/abort-controller.mjs +1 -1
- package/src/runtime/shared/background-tasks.mjs +2 -3
- package/src/runtime/shared/buffered-appender.mjs +149 -0
- package/src/runtime/shared/channel-notification-routing.mjs +12 -0
- package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
- package/src/runtime/shared/config.mjs +9 -0
- package/src/runtime/shared/llm/http-agent.mjs +12 -5
- package/src/runtime/shared/schedules-store.mjs +21 -19
- package/src/runtime/shared/task-notification-envelope.mjs +98 -0
- package/src/runtime/shared/task-notification-envelope.test.mjs +107 -0
- package/src/runtime/shared/tool-execution-contract.mjs +2 -2
- package/src/runtime/shared/tool-surface.mjs +98 -13
- package/src/runtime/shared/transcript-writer.mjs +156 -0
- package/src/runtime/shared/update-checker.mjs +214 -0
- package/src/session-runtime/config-helpers.mjs +209 -0
- package/src/session-runtime/effort.mjs +128 -0
- package/src/session-runtime/fs-utils.mjs +10 -0
- package/src/session-runtime/model-capabilities.mjs +130 -0
- package/src/session-runtime/output-styles.mjs +124 -0
- package/src/session-runtime/plugin-mcp.mjs +114 -0
- package/src/session-runtime/session-text.mjs +100 -0
- package/src/session-runtime/statusline-route.mjs +35 -0
- package/src/session-runtime/tool-catalog.mjs +720 -0
- package/src/session-runtime/workflow.mjs +358 -0
- package/src/standalone/agent-tool.mjs +302 -117
- package/src/standalone/channel-admin.mjs +133 -40
- package/src/standalone/channel-worker.mjs +10 -292
- package/src/standalone/explore-tool.mjs +12 -5
- package/src/standalone/hook-bus.mjs +165 -8
- package/src/standalone/memory-runtime-proxy.mjs +3 -1
- package/src/standalone/opencode-go-login.mjs +121 -0
- package/src/standalone/provider-admin.mjs +25 -3
- package/src/standalone/usage-dashboard.mjs +1 -1
- package/src/tui/App.jsx +2883 -778
- package/src/tui/components/ConfirmBar.jsx +47 -0
- package/src/tui/components/ContextPanel.jsx +5 -3
- package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
- package/src/tui/components/Markdown.jsx +22 -98
- package/src/tui/components/Message.jsx +14 -35
- package/src/tui/components/Picker.jsx +87 -12
- package/src/tui/components/PromptInput.jsx +355 -22
- package/src/tui/components/QueuedCommands.jsx +1 -1
- package/src/tui/components/SlashCommandPalette.jsx +8 -5
- package/src/tui/components/Spinner.jsx +7 -7
- package/src/tui/components/StatusLine.jsx +40 -21
- package/src/tui/components/TextEntryPanel.jsx +51 -7
- package/src/tui/components/ToolExecution.jsx +183 -101
- package/src/tui/components/TurnDone.jsx +4 -4
- package/src/tui/components/UsagePanel.jsx +1 -1
- package/src/tui/components/tool-output-format.mjs +161 -23
- package/src/tui/components/tool-output-format.test.mjs +87 -0
- package/src/tui/display-width.mjs +69 -0
- package/src/tui/display-width.test.mjs +35 -0
- package/src/tui/dist/index.mjs +6731 -2333
- package/src/tui/engine/agent-envelope.mjs +296 -0
- package/src/tui/engine/boot-profile.mjs +21 -0
- package/src/tui/engine/labels.mjs +67 -0
- package/src/tui/engine/notice-text.mjs +112 -0
- package/src/tui/engine/queue-helpers.mjs +161 -0
- package/src/tui/engine/session-stats.mjs +46 -0
- package/src/tui/engine/tool-call-fields.mjs +23 -0
- package/src/tui/engine/tool-result-text.mjs +126 -0
- package/src/tui/engine.mjs +569 -948
- package/src/tui/index.jsx +117 -7
- package/src/tui/input-editing.mjs +58 -8
- package/src/tui/input-editing.selection.test.mjs +75 -0
- package/src/tui/keyboard-protocol.mjs +42 -0
- package/src/tui/lib/voice-recorder.mjs +469 -0
- package/src/tui/markdown/format-token.mjs +128 -76
- package/src/tui/markdown/format-token.test.mjs +61 -19
- package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
- package/src/tui/markdown/render-ansi.test.mjs +1 -1
- package/src/tui/markdown/streaming-markdown.mjs +167 -0
- package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
- package/src/tui/markdown/table-layout.mjs +9 -9
- package/src/tui/paste-attachments.mjs +36 -9
- package/src/tui/paste-fix.test.mjs +119 -0
- package/src/tui/prompt-history-store.mjs +129 -0
- package/src/tui/prompt-history-store.test.mjs +52 -0
- package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
- package/src/tui/theme.mjs +41 -657
- package/src/tui/themes/base.mjs +86 -0
- package/src/tui/themes/basic.mjs +85 -0
- package/src/tui/themes/catppuccin.mjs +72 -0
- package/src/tui/themes/dracula.mjs +70 -0
- package/src/tui/themes/everforest.mjs +71 -0
- package/src/tui/themes/gruvbox.mjs +71 -0
- package/src/tui/themes/index.mjs +71 -0
- package/src/tui/themes/indigo.mjs +78 -0
- package/src/tui/themes/kanagawa.mjs +80 -0
- package/src/tui/themes/light.mjs +81 -0
- package/src/tui/themes/nord.mjs +72 -0
- package/src/tui/themes/onedark.mjs +16 -0
- package/src/tui/themes/rosepine.mjs +70 -0
- package/src/tui/themes/teal.mjs +80 -0
- package/src/tui/themes/tokyonight.mjs +79 -0
- package/src/tui/themes/utils.mjs +106 -0
- package/src/tui/themes/warm.mjs +79 -0
- package/src/tui/transcript-tool-failures.mjs +13 -2
- package/src/ui/markdown.mjs +1 -1
- package/src/ui/model-display.mjs +2 -2
- package/src/ui/statusline.mjs +75 -27
- package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
- package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
- package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
- package/src/workflows/default/WORKFLOW.md +46 -12
- package/src/workflows/sequential/WORKFLOW.md +51 -0
- package/src/workflows/solo/WORKFLOW.md +12 -1
- package/vendor/ink/build/display-width.js +62 -0
- package/vendor/ink/build/ink.js +100 -12
- package/vendor/ink/build/measure-text.js +4 -1
- package/vendor/ink/build/output.js +115 -9
- package/vendor/ink/build/render-node-to-output.js +4 -1
- package/vendor/ink/build/render.js +4 -0
- package/src/output-styles/extreme-simple.md +0 -20
- package/src/rules/lead/04-workflow.md +0 -51
- package/src/workflows/default/workflow.json +0 -13
- package/src/workflows/solo/workflow.json +0 -7
|
@@ -1,28 +1,23 @@
|
|
|
1
1
|
import { classifyResultKind } from './result-classification.mjs';
|
|
2
2
|
import { executeMcpTool, isMcpTool, mcpToolHasField } from '../mcp/client.mjs';
|
|
3
|
-
import { canonicalizeBuiltinToolName, executeBuiltinTool, formatUnknownBuiltinToolMessage, isBuiltinTool } from '../tools/builtin.mjs';
|
|
3
|
+
import { canonicalizeBuiltinToolName, executeBuiltinTool, formatUnknownBuiltinToolMessage, isBuiltinTool, isExternalAdapterTool } from '../tools/builtin.mjs';
|
|
4
4
|
import { executeBashSessionTool } from '../tools/bash-session.mjs';
|
|
5
|
-
import { executePatchTool } from '../tools/patch.mjs';
|
|
5
|
+
import { executePatchTool, takeApplyPatchUiDiff } from '../tools/patch.mjs';
|
|
6
6
|
import { executeInternalTool, isInternalTool } from '../internal-tools.mjs';
|
|
7
|
-
import {
|
|
8
|
-
import { traceAgentLoop, traceAgentTool, traceAgentToolFailure, traceAgentCompact, estimateProviderPayloadBytes, messagePrefixHash } from '../agent-trace.mjs';
|
|
7
|
+
import { normalizeToolEnvelope, makeToolEnvelope } from './tool-envelope.mjs';
|
|
8
|
+
import { traceAgentLoop, traceAgentTool, traceAgentToolFailure, traceAgentCompact, estimateProviderPayloadBytes, messagePrefixHash, appendAgentTrace } from '../agent-trace.mjs';
|
|
9
|
+
import { resolveSessionMaxLoopIterations } from '../agent-runtime/agent-loop-policy.mjs';
|
|
9
10
|
import { isAgentOwner } from '../agent-owner.mjs';
|
|
10
11
|
import { markSessionToolCall, updateSessionStage, SessionClosedError, getSessionAbortSignal, enqueuePendingMessage, bumpUsageMetricsEpoch } from './manager.mjs';
|
|
11
|
-
import { estimateMessagesTokens, estimateRequestReserveTokens, sanitizeToolPairs } from './context-utils.mjs';
|
|
12
12
|
import {
|
|
13
13
|
recallFastTrackCompactMessages,
|
|
14
14
|
pruneToolOutputs,
|
|
15
|
+
pruneToolOutputsUnanchored,
|
|
15
16
|
semanticCompactMessages,
|
|
16
|
-
|
|
17
|
-
compactTypeIsSemantic,
|
|
18
|
-
normalizeCompactType,
|
|
17
|
+
effectiveBudget as compactEffectiveBudget,
|
|
19
18
|
DEFAULT_COMPACT_TYPE,
|
|
20
|
-
DEFAULT_COMPACTION_BUFFER_TOKENS,
|
|
21
|
-
DEFAULT_COMPACTION_BUFFER_RATIO,
|
|
22
|
-
DEFAULT_COMPACTION_KEEP_TOKENS,
|
|
23
|
-
compactionBufferTokensForBoundary,
|
|
24
|
-
normalizeCompactionBufferRatio,
|
|
25
19
|
drainSessionCycle1,
|
|
20
|
+
countRawPendingRows,
|
|
26
21
|
} from './compact.mjs';
|
|
27
22
|
import { isContextOverflowError } from '../providers/retry-classifier.mjs';
|
|
28
23
|
import { stripSoftWarns } from '../tool-loop-guard.mjs';
|
|
@@ -33,26 +28,15 @@ import { modelVisibleToolCompletionMessage } from '../../../shared/tool-executio
|
|
|
33
28
|
import { createHash } from 'crypto';
|
|
34
29
|
import { isInvalidToolArgsMarker, formatInvalidToolArgsResult } from '../providers/openai-compat-stream.mjs';
|
|
35
30
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
function _isMutationTool(name) {
|
|
47
|
-
const n = _stripMcpPrefix(name);
|
|
48
|
-
return n === 'apply_patch';
|
|
49
|
-
}
|
|
50
|
-
const SCOPED_CACHEABLE_TOOLS = new Set([
|
|
51
|
-
'code_graph',
|
|
52
|
-
'grep',
|
|
53
|
-
'list',
|
|
54
|
-
'glob',
|
|
55
|
-
]);
|
|
31
|
+
import {
|
|
32
|
+
_stripMcpPrefix,
|
|
33
|
+
_isReadTool,
|
|
34
|
+
_isMutationTool,
|
|
35
|
+
_isScopedCacheableTool,
|
|
36
|
+
_isShellTool,
|
|
37
|
+
_intraTurnSig,
|
|
38
|
+
} from './loop/tool-classify.mjs';
|
|
39
|
+
import { preDispatchDenyForSession } from './loop/pre-dispatch-deny.mjs';
|
|
56
40
|
let codeGraphRuntimePromise = null;
|
|
57
41
|
async function executeCodeGraphToolLazy(name, args, cwd, signal = null, options = {}) {
|
|
58
42
|
codeGraphRuntimePromise ??= import('../tools/code-graph.mjs');
|
|
@@ -60,389 +44,105 @@ async function executeCodeGraphToolLazy(name, args, cwd, signal = null, options
|
|
|
60
44
|
if (typeof mod.executeCodeGraphTool !== 'function') throw new Error('code_graph runtime is not available');
|
|
61
45
|
return mod.executeCodeGraphTool(name, args, cwd, signal, options);
|
|
62
46
|
}
|
|
63
|
-
function _isScopedCacheableTool(name) {
|
|
64
|
-
const n = _stripMcpPrefix(name);
|
|
65
|
-
return SCOPED_CACHEABLE_TOOLS.has(n);
|
|
66
|
-
}
|
|
67
|
-
function _isShellTool(name) {
|
|
68
|
-
const n = _stripMcpPrefix(name);
|
|
69
|
-
return n === 'shell' || n === 'bash_session';
|
|
70
|
-
}
|
|
71
47
|
|
|
72
48
|
// classifyResultKind is imported from result-classification.mjs at the top of
|
|
73
49
|
// this file; import it from there directly rather than via this module.
|
|
74
|
-
|
|
75
|
-
// Canonical signature for intra-turn duplicate detection. Sorting keys
|
|
76
|
-
// produces a stable hash regardless of arg-object key order. Anything
|
|
77
|
-
// non-serializable falls back to String(args) — still deterministic for
|
|
78
|
-
// the model's typical structured-arg shape.
|
|
79
|
-
function _canonicalArgs(args) {
|
|
80
|
-
if (args == null || typeof args !== 'object') {
|
|
81
|
-
try { return JSON.stringify(args); } catch { return String(args); }
|
|
82
|
-
}
|
|
83
|
-
try {
|
|
84
|
-
const keys = Object.keys(args).sort();
|
|
85
|
-
const sorted = {};
|
|
86
|
-
for (const k of keys) sorted[k] = args[k];
|
|
87
|
-
return JSON.stringify(sorted);
|
|
88
|
-
} catch { return String(args); }
|
|
89
|
-
}
|
|
90
|
-
function _intraTurnSig(name, args) {
|
|
91
|
-
return createHash('sha256').update(`${name}:${_canonicalArgs(args)}`).digest('hex').slice(0, 16);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Shared pre-dispatch deny — single source of truth for the remaining
|
|
95
|
-
// control-plane / role scoping rejects. Called by BOTH the eager dispatch
|
|
96
|
-
// path (startEagerTool) and the serial dispatch path (executeTool body).
|
|
97
|
-
// Returns null when the call is allowed to proceed; otherwise returns the
|
|
98
|
-
// Error string the serial path would emit. The eager caller ignores the
|
|
99
|
-
// message body and just treats non-null as "do not start eager".
|
|
100
|
-
//
|
|
101
|
-
// This is NOT a permission gate — runtime permission enforcement was removed
|
|
102
|
-
// (every tool call is trusted). What remains is architectural scoping:
|
|
103
|
-
// agent workers are sandboxed to code/research tools. They must never reach
|
|
104
|
-
// owner/host control surfaces: session management, the ENTIRE channels module
|
|
105
|
-
// (Discord messaging, schedules, webhook/config, channel-bridge toggle,
|
|
106
|
-
// command injection), or host input injection. Explicit name list (no imports)
|
|
107
|
-
// keeps this hot-path gate dependency-free; add new owner/channel tools here.
|
|
108
|
-
const WORKER_DENIED_TOOLS = new Set([
|
|
109
|
-
// session control-plane — unified into the single `agent` tool
|
|
110
|
-
// (type=spawn|send|close|list). Denying the one name blocks all worker
|
|
111
|
-
// session control.
|
|
112
|
-
'agent',
|
|
113
|
-
// channels module (owner/Discord-facing)
|
|
114
|
-
'reply', 'react', 'edit_message', 'download_attachment', 'fetch',
|
|
115
|
-
'schedule_status', 'trigger_schedule', 'schedule_control',
|
|
116
|
-
'activate_channel_bridge', 'reload_config', 'inject_command',
|
|
117
|
-
// host input injection
|
|
118
|
-
'inject_input',
|
|
119
|
-
]);
|
|
120
|
-
function _preDispatchDeny(call, toolKind, sessionRef) {
|
|
121
|
-
const name = call?.name;
|
|
122
|
-
if (typeof name !== 'string' || !name) return null;
|
|
123
|
-
const _agentOwned = sessionRef?.scope?.startsWith?.('agent:')
|
|
124
|
-
|| isAgentOwner(sessionRef);
|
|
125
|
-
const _controlPlaneTool = WORKER_DENIED_TOOLS.has(name);
|
|
126
|
-
if (_agentOwned && _controlPlaneTool) {
|
|
127
|
-
return `Error: control-plane tool "${name}" is Lead-only and not available to agent workers.`;
|
|
128
|
-
}
|
|
129
|
-
const noToolRole = sessionRef?.role === 'cycle1-agent' || sessionRef?.role === 'cycle2-agent';
|
|
130
|
-
if (noToolRole) {
|
|
131
|
-
return `Error: tool "${name}" is not available in role "${sessionRef.role}". Re-emit the answer as pipe-separated text per the role's output format (first character a digit, NO tool_use blocks, NO JSON, NO prose, NO apology).`;
|
|
132
|
-
}
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
135
|
-
/** Exported for smoke tests — same runtime deny as the agent loop. */
|
|
136
|
-
export function preDispatchDenyForSession(sessionRef, call, toolKind = 'builtin') {
|
|
137
|
-
return _preDispatchDeny(call, toolKind, sessionRef);
|
|
138
|
-
}
|
|
139
50
|
import { compressToolResult, recordToolBatch } from '../tools/result-compression.mjs';
|
|
140
51
|
|
|
141
52
|
|
|
142
|
-
import {
|
|
143
|
-
import {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const normalized = (Array.isArray(entries) ? entries : [])
|
|
186
|
-
.map(normalizeSteeringEntry)
|
|
187
|
-
.filter(Boolean);
|
|
188
|
-
if (normalized.length === 0) return null;
|
|
189
|
-
const displayText = normalized.map((entry) => entry.text || steeringContentText(entry.content))
|
|
190
|
-
.filter((text) => String(text || '').trim())
|
|
191
|
-
.join('\n');
|
|
192
|
-
if (normalized.every((entry) => typeof entry.content === 'string')) {
|
|
193
|
-
return {
|
|
194
|
-
content: normalized.map((entry) => entry.content).filter(Boolean).join('\n'),
|
|
195
|
-
text: displayText,
|
|
196
|
-
count: normalized.length,
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
|
-
const parts = [];
|
|
200
|
-
for (const entry of normalized) {
|
|
201
|
-
if (typeof entry.content === 'string') {
|
|
202
|
-
if (entry.content.trim()) parts.push({ type: 'text', text: entry.content });
|
|
203
|
-
} else if (Array.isArray(entry.content)) {
|
|
204
|
-
parts.push(...entry.content);
|
|
205
|
-
} else {
|
|
206
|
-
const text = steeringContentText(entry.content);
|
|
207
|
-
if (text.trim()) parts.push({ type: 'text', text });
|
|
208
|
-
}
|
|
209
|
-
parts.push({ type: 'text', text: '\n' });
|
|
210
|
-
}
|
|
211
|
-
while (parts.length && parts[parts.length - 1]?.type === 'text' && parts[parts.length - 1]?.text === '\n') parts.pop();
|
|
212
|
-
return { content: parts, text: displayText || steeringContentText(parts), count: normalized.length };
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
class AgentContextOverflowError extends Error {
|
|
216
|
-
constructor({ stage, sessionId, provider, model, contextWindow, budgetTokens, reserveTokens, messageTokensEst }, cause) {
|
|
217
|
-
const target = [provider, model].filter(Boolean).join('/') || 'target model';
|
|
218
|
-
const causeMsg = cause && cause.message ? `: ${cause.message}` : '';
|
|
219
|
-
super(
|
|
220
|
-
`agent context overflow (${target}, stage=${stage || 'compact'}): ` +
|
|
221
|
-
`latest turn cannot fit target context budget=${budgetTokens ?? 'unknown'} ` +
|
|
222
|
-
`reserve=${reserveTokens ?? 'unknown'} contextWindow=${contextWindow ?? 'unknown'} ` +
|
|
223
|
-
`messageTokensEst=${messageTokensEst ?? 'unknown'}${causeMsg}`,
|
|
224
|
-
);
|
|
225
|
-
this.name = 'AgentContextOverflowError';
|
|
226
|
-
this.code = 'AGENT_CONTEXT_OVERFLOW';
|
|
227
|
-
this.sessionId = sessionId || null;
|
|
228
|
-
this.provider = provider || null;
|
|
229
|
-
this.model = model || null;
|
|
230
|
-
this.contextWindow = contextWindow ?? null;
|
|
231
|
-
this.budgetTokens = budgetTokens ?? null;
|
|
232
|
-
this.reserveTokens = reserveTokens ?? null;
|
|
233
|
-
this.messageTokensEst = messageTokensEst ?? null;
|
|
234
|
-
if (cause) this.cause = cause;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
53
|
+
import { resolve as resolvePath, isAbsolute } from 'path';
|
|
54
|
+
import {
|
|
55
|
+
estimateMessagesTokensSafe,
|
|
56
|
+
compactDiagnosticError,
|
|
57
|
+
compactByteLength,
|
|
58
|
+
compactDebugLog,
|
|
59
|
+
} from './loop/compact-debug.mjs';
|
|
60
|
+
import { mergeSteeringEntries, steeringContentText } from './loop/steering.mjs';
|
|
61
|
+
import { agentContextOverflowError } from './loop/context-overflow.mjs';
|
|
62
|
+
import { positiveTokenInt } from './loop/env.mjs';
|
|
63
|
+
import { normalizeUsage, addUsage } from './loop/usage.mjs';
|
|
64
|
+
import { HIDDEN_AGENT_NAMES } from './loop/hidden-agents.mjs';
|
|
65
|
+
import {
|
|
66
|
+
resolveWorkerCompactPolicy,
|
|
67
|
+
compactionTelemetryPressureTokens,
|
|
68
|
+
compactTargetBudget,
|
|
69
|
+
shouldCompactForSession,
|
|
70
|
+
countPrunedToolOutputs,
|
|
71
|
+
rememberCompactTelemetry,
|
|
72
|
+
emitCompactEvent,
|
|
73
|
+
compactEventType,
|
|
74
|
+
} from './loop/compact-policy.mjs';
|
|
75
|
+
import {
|
|
76
|
+
isEagerDispatchable,
|
|
77
|
+
messagesArrayChanged,
|
|
78
|
+
getToolKind,
|
|
79
|
+
buildSkillsListResponse,
|
|
80
|
+
viewSkill,
|
|
81
|
+
normalizeHookUpdatedToolOutput,
|
|
82
|
+
resolveToolResultAfterHook,
|
|
83
|
+
parseNativeToolSearchPayload,
|
|
84
|
+
extractBashSessionId,
|
|
85
|
+
buildAgentBashSessionArgs,
|
|
86
|
+
formatMissingToolApprovalUiDenial,
|
|
87
|
+
resolvePreToolAskApproval,
|
|
88
|
+
approvalGranted,
|
|
89
|
+
approvalReason,
|
|
90
|
+
} from './loop/tool-helpers.mjs';
|
|
91
|
+
import {
|
|
92
|
+
compactToolCallsForHistory,
|
|
93
|
+
restoreToolCallBodyForId,
|
|
94
|
+
} from './loop/stored-tool-args.mjs';
|
|
95
|
+
import { repairTranscriptBeforeProviderSend } from './loop/transcript-repair.mjs';
|
|
237
96
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
97
|
+
// Facade re-exports: these symbols moved to split modules under ./loop/ but
|
|
98
|
+
// remain part of loop.mjs's public surface (imported by scripts/tests and other
|
|
99
|
+
// runtime modules). Re-export the already-imported local bindings so every
|
|
100
|
+
// existing import path keeps working (no duplicate module binding).
|
|
101
|
+
export {
|
|
102
|
+
preDispatchDenyForSession,
|
|
103
|
+
repairTranscriptBeforeProviderSend,
|
|
104
|
+
normalizeHookUpdatedToolOutput,
|
|
105
|
+
resolveToolResultAfterHook,
|
|
106
|
+
buildAgentBashSessionArgs,
|
|
107
|
+
formatMissingToolApprovalUiDenial,
|
|
108
|
+
resolvePreToolAskApproval,
|
|
109
|
+
approvalGranted,
|
|
110
|
+
approvalReason,
|
|
111
|
+
};
|
|
250
112
|
|
|
251
|
-
// Cache-hit results always inline the cached body. The earlier size-gated
|
|
252
|
-
// `[cache-hit-ref]` branch confused agents whose context did not
|
|
253
|
-
// contain the referenced prior tool_result, triggering shell-cat detours.
|
|
254
113
|
// Hard iteration ceiling for every agent loop. Reset to 0 whenever the
|
|
255
114
|
// transcript is compacted (see the trim block below): a long task that keeps
|
|
256
115
|
// compacting can proceed past this count, while a tight NON-compacting loop
|
|
257
116
|
// still stops here and returns the accumulated transcript.
|
|
258
|
-
const MAX_LOOP_ITERATIONS = 200;
|
|
259
117
|
// Consecutive identical-AND-failing tool calls (same name+args, error result)
|
|
260
118
|
// tolerated across iterations before the loop refuses to re-execute and steers
|
|
261
119
|
// the model to change approach. Distinct from the hard iteration cap above:
|
|
262
120
|
// this catches tight deterministic-failure loops (e.g. a command that errors
|
|
263
121
|
// the same way every time) far earlier than 100 iterations.
|
|
264
122
|
const REPEAT_FAIL_LIMIT = 3;
|
|
265
|
-
const _HIDDEN_ROLES_JSON = resolvePath(dirname(fileURLToPath(import.meta.url)), '../../../../defaults/hidden-roles.json');
|
|
266
|
-
let _hiddenRolesCache = null;
|
|
267
|
-
function _getHiddenRoles() {
|
|
268
|
-
if (_hiddenRolesCache) return _hiddenRolesCache;
|
|
269
|
-
try {
|
|
270
|
-
_hiddenRolesCache = JSON.parse(_readFileSync(_HIDDEN_ROLES_JSON, 'utf8'));
|
|
271
|
-
} catch { _hiddenRolesCache = { roles: [] }; }
|
|
272
|
-
return _hiddenRolesCache;
|
|
273
|
-
}
|
|
274
|
-
// Transcript pairing guard. Anthropic 400-rejects when an assistant message
|
|
275
|
-
// ends with tool_use blocks and the next message isn't tool results for
|
|
276
|
-
// those exact ids. abort/timeout/error race in the loop body can leave a
|
|
277
|
-
// dangling assistant tool_use at the tail (e.g. the structure_probe loop
|
|
278
|
-
// running 12 deep then aborting between push-assistant and push-tool).
|
|
279
|
-
// Strip any trailing assistant tool_use that has no matching tool result
|
|
280
|
-
// so provider.send sees a valid transcript instead of leaking the 400 to
|
|
281
|
-
// the user. Repair runs every iteration but is a no-op on healthy paths.
|
|
282
|
-
function _ensureTranscriptPairing(msgs, sessionId) {
|
|
283
|
-
// Walk backwards to find the last assistant message that emitted
|
|
284
|
-
// tool_use, then validate that every id has a matching tool result
|
|
285
|
-
// inside the CONTIGUOUS tool-message block immediately following it.
|
|
286
|
-
// Earlier guard splice'd the entire tail — which silently deleted any
|
|
287
|
-
// user prompt appended after the dangling assistant by manager.mjs:
|
|
288
|
-
// when the guard fired with shape
|
|
289
|
-
// [..., assistant{a,b}, tool{a}, user{new prompt}]
|
|
290
|
-
// the splice removed user{new prompt} along with the orphan suffix.
|
|
291
|
-
// Fix: remove only assistant + the contiguous tool block; preserve
|
|
292
|
-
// anything past it (user / system / next assistant) untouched.
|
|
293
|
-
let popped = 0;
|
|
294
|
-
while (msgs.length > 0) {
|
|
295
|
-
let lastAssistantIdx = -1;
|
|
296
|
-
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
297
|
-
const m = msgs[i];
|
|
298
|
-
if (m?.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
|
|
299
|
-
lastAssistantIdx = i;
|
|
300
|
-
break;
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
if (lastAssistantIdx === -1) break;
|
|
304
|
-
// Collect the contiguous tool messages directly after this assistant.
|
|
305
|
-
// Anything past that block is unrelated (next user prompt, system
|
|
306
|
-
// marker, etc.) and must survive the repair.
|
|
307
|
-
let toolBlockEnd = lastAssistantIdx + 1;
|
|
308
|
-
while (toolBlockEnd < msgs.length && msgs[toolBlockEnd]?.role === 'tool') {
|
|
309
|
-
toolBlockEnd += 1;
|
|
310
|
-
}
|
|
311
|
-
const toolBlock = msgs.slice(lastAssistantIdx + 1, toolBlockEnd);
|
|
312
|
-
const ids = msgs[lastAssistantIdx].toolCalls.map(c => c.id);
|
|
313
|
-
const matched = ids.every(id => toolBlock.some(m => m.toolCallId === id));
|
|
314
|
-
if (matched) break;
|
|
315
|
-
const removed = toolBlockEnd - lastAssistantIdx;
|
|
316
|
-
msgs.splice(lastAssistantIdx, removed);
|
|
317
|
-
popped += removed;
|
|
318
|
-
}
|
|
319
|
-
// Second sweep — catch dangling tool results that survived the
|
|
320
|
-
// contiguous-block splice. Anthropic strict spec requires every
|
|
321
|
-
// tool result to sit in a contiguous block right after the
|
|
322
|
-
// assistant whose toolCalls produced it; a `[..., assistant{a,b},
|
|
323
|
-
// tool{a}, user, tool{b}]` shape leaves tool{b} orphaned even
|
|
324
|
-
// after assistant + tool{a} are repaired by the loop above.
|
|
325
|
-
// Walk back from each tool message to the nearest non-tool
|
|
326
|
-
// ancestor; if it is not an assistant whose toolCalls include
|
|
327
|
-
// this id, drop the orphan.
|
|
328
|
-
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
329
|
-
const m = msgs[i];
|
|
330
|
-
if (m?.role !== 'tool') continue;
|
|
331
|
-
if (!m.toolCallId) {
|
|
332
|
-
msgs.splice(i, 1);
|
|
333
|
-
popped += 1;
|
|
334
|
-
continue;
|
|
335
|
-
}
|
|
336
|
-
let prevIdx = i - 1;
|
|
337
|
-
while (prevIdx >= 0 && msgs[prevIdx]?.role === 'tool') prevIdx--;
|
|
338
|
-
const anchor = prevIdx >= 0 ? msgs[prevIdx] : null;
|
|
339
|
-
const anchorOk = anchor?.role === 'assistant'
|
|
340
|
-
&& Array.isArray(anchor.toolCalls)
|
|
341
|
-
&& anchor.toolCalls.some(c => c.id === m.toolCallId);
|
|
342
|
-
if (!anchorOk) {
|
|
343
|
-
msgs.splice(i, 1);
|
|
344
|
-
popped += 1;
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
if (popped > 0 && sessionId) {
|
|
348
|
-
try { process.stderr.write(`[transcript-repair] sess=${sessionId} popped=${popped} dangling assistant tool_use\n`); } catch {}
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/**
|
|
353
|
-
* Pre-provider transcript repair for the agent loop. Reattach valid tool
|
|
354
|
-
* results (non-destructive) before any destructive orphan pairing cleanup.
|
|
355
|
-
* Mutates `messages` in place to preserve the session array reference.
|
|
356
|
-
*/
|
|
357
|
-
export function repairTranscriptBeforeProviderSend(messages, sessionId = null) {
|
|
358
|
-
if (!Array.isArray(messages)) return messages;
|
|
359
|
-
const sanitized = sanitizeToolPairs(messages);
|
|
360
|
-
if (sanitized !== messages) {
|
|
361
|
-
messages.length = 0;
|
|
362
|
-
messages.push(...sanitized);
|
|
363
|
-
}
|
|
364
|
-
_ensureTranscriptPairing(messages, sessionId);
|
|
365
|
-
return messages;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// Eager-dispatch: tools with readOnlyHint:true in their declaration are safe
|
|
369
|
-
// to execute during SSE parsing so tool work overlaps with the rest of the
|
|
370
|
-
// stream. Writes, bash, MCP and skills stay serial after send() returns.
|
|
371
|
-
function isEagerDispatchable(name, tools) {
|
|
372
|
-
if (!Array.isArray(tools)) return false;
|
|
373
|
-
const def = tools.find(t => t?.name === name);
|
|
374
|
-
return def?.annotations?.readOnlyHint === true;
|
|
375
|
-
}
|
|
376
|
-
function messagesArrayChanged(before, after) {
|
|
377
|
-
if (!Array.isArray(before) || !Array.isArray(after)) return before !== after;
|
|
378
|
-
if (before.length !== after.length) return true;
|
|
379
|
-
for (let i = 0; i < before.length; i += 1) {
|
|
380
|
-
if (before[i] !== after[i]) return true;
|
|
381
|
-
}
|
|
382
|
-
return false;
|
|
383
|
-
}
|
|
384
|
-
function normalizeUsage(usage) {
|
|
385
|
-
if (!usage) return null;
|
|
386
|
-
const costUsd = Number(usage.costUsd);
|
|
387
|
-
return {
|
|
388
|
-
inputTokens: usage.inputTokens || 0,
|
|
389
|
-
outputTokens: usage.outputTokens || 0,
|
|
390
|
-
cachedTokens: usage.cachedTokens || 0,
|
|
391
|
-
cacheWriteTokens: usage.cacheWriteTokens || 0,
|
|
392
|
-
promptTokens: usage.promptTokens || 0,
|
|
393
|
-
...(Number.isFinite(costUsd) ? { costUsd } : {}),
|
|
394
|
-
raw: usage.raw,
|
|
395
|
-
};
|
|
396
|
-
}
|
|
397
|
-
function addUsage(total, usage) {
|
|
398
|
-
const delta = normalizeUsage(usage);
|
|
399
|
-
if (!delta) return total;
|
|
400
|
-
if (!total) return { ...delta };
|
|
401
|
-
const next = {
|
|
402
|
-
...total,
|
|
403
|
-
inputTokens: (total.inputTokens || 0) + delta.inputTokens,
|
|
404
|
-
outputTokens: (total.outputTokens || 0) + delta.outputTokens,
|
|
405
|
-
cachedTokens: (total.cachedTokens || 0) + delta.cachedTokens,
|
|
406
|
-
cacheWriteTokens: (total.cacheWriteTokens || 0) + delta.cacheWriteTokens,
|
|
407
|
-
promptTokens: (total.promptTokens || 0) + delta.promptTokens,
|
|
408
|
-
};
|
|
409
|
-
if (delta.costUsd != null || total.costUsd != null) {
|
|
410
|
-
next.costUsd = (total.costUsd || 0) + (delta.costUsd || 0);
|
|
411
|
-
}
|
|
412
|
-
return next;
|
|
413
|
-
}
|
|
414
|
-
function positiveTokenInt(value) {
|
|
415
|
-
const n = Number(value);
|
|
416
|
-
return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
|
|
417
|
-
}
|
|
418
|
-
function envFlag(name, fallback = false) {
|
|
419
|
-
const v = process.env[name];
|
|
420
|
-
if (v === undefined) return fallback;
|
|
421
|
-
return !['0', 'false', 'off', 'no'].includes(String(v).trim().toLowerCase());
|
|
422
|
-
}
|
|
423
|
-
function envTokenInt(name) {
|
|
424
|
-
return positiveTokenInt(process.env[name]);
|
|
425
|
-
}
|
|
426
|
-
function resolveSemanticCompactSetting(sessionRef, cfg = {}) {
|
|
427
|
-
if (process.env.MIXDOG_AGENT_COMPACT_SEMANTIC !== undefined) return envFlag('MIXDOG_AGENT_COMPACT_SEMANTIC', true);
|
|
428
|
-
if (cfg.semantic === false || cfg.semantic === 'false' || cfg.semantic === 'off') return false;
|
|
429
|
-
if (cfg.semantic === true || cfg.semantic === 'true' || cfg.semantic === 'on') return true;
|
|
430
|
-
// The compact type is already explicit (`semantic` by default). Honor it
|
|
431
|
-
// directly instead of substituting another compaction path.
|
|
432
|
-
return true;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
function resolveCompactTypeSetting(_sessionRef, cfg = {}) {
|
|
436
|
-
const configured = process.env.MIXDOG_AGENT_COMPACT_TYPE
|
|
437
|
-
?? process.env.MIXDOG_COMPACT_TYPE
|
|
438
|
-
?? cfg.type
|
|
439
|
-
?? cfg.compactType
|
|
440
|
-
?? cfg.compact_type;
|
|
441
|
-
return normalizeCompactType(configured, DEFAULT_COMPACT_TYPE);
|
|
442
|
-
}
|
|
443
|
-
|
|
444
123
|
async function runRecallFastTrackCompact({ sessionRef, messages, compactBudgetTokens, compactPolicy, sessionId, signal }) {
|
|
445
124
|
if (!sessionId) throw new Error('recall-fasttrack requires a session id');
|
|
125
|
+
const startedAt = Date.now();
|
|
126
|
+
const diagnostics = {
|
|
127
|
+
hydrateLimit: null,
|
|
128
|
+
ingestMs: null,
|
|
129
|
+
ingestSkipped: false,
|
|
130
|
+
ingestError: null,
|
|
131
|
+
initialDumpMs: null,
|
|
132
|
+
initialDumpBytes: null,
|
|
133
|
+
initialDumpChars: null,
|
|
134
|
+
initialRawPending: null,
|
|
135
|
+
cycle1Ms: null,
|
|
136
|
+
cycle1Skipped: false,
|
|
137
|
+
cycle1SkipReason: null,
|
|
138
|
+
cycle1Passes: null,
|
|
139
|
+
cycle1RawRemaining: null,
|
|
140
|
+
cycle1TextBytes: null,
|
|
141
|
+
cycle1Error: null,
|
|
142
|
+
finalRecallBytes: null,
|
|
143
|
+
finalRecallChars: null,
|
|
144
|
+
totalMs: null,
|
|
145
|
+
};
|
|
446
146
|
const query = `session:${sessionId}:all-chunks`;
|
|
447
147
|
const querySha = createHash('sha256').update(query).digest('hex').slice(0, 16);
|
|
448
148
|
const callerCtx = {
|
|
@@ -454,6 +154,8 @@ async function runRecallFastTrackCompact({ sessionRef, messages, compactBudgetTo
|
|
|
454
154
|
};
|
|
455
155
|
const hydrateLimit = positiveTokenInt(sessionRef?.compaction?.recallIngestLimit)
|
|
456
156
|
|| Math.max(500, Math.min(5000, messages.length || 0));
|
|
157
|
+
diagnostics.hydrateLimit = hydrateLimit;
|
|
158
|
+
let t0 = Date.now();
|
|
457
159
|
try {
|
|
458
160
|
await executeInternalTool('memory', {
|
|
459
161
|
action: 'ingest_session',
|
|
@@ -463,7 +165,11 @@ async function runRecallFastTrackCompact({ sessionRef, messages, compactBudgetTo
|
|
|
463
165
|
limit: hydrateLimit,
|
|
464
166
|
}, callerCtx);
|
|
465
167
|
} catch (err) {
|
|
168
|
+
diagnostics.ingestSkipped = true;
|
|
169
|
+
diagnostics.ingestError = compactDiagnosticError(err);
|
|
466
170
|
try { process.stderr.write(`[loop] recall-fasttrack ingest skipped (sess=${sessionId || 'unknown'}): ${err?.message || err}\n`); } catch {}
|
|
171
|
+
} finally {
|
|
172
|
+
diagnostics.ingestMs = Date.now() - t0;
|
|
467
173
|
}
|
|
468
174
|
const dumpArgs = {
|
|
469
175
|
action: 'dump_session_roots',
|
|
@@ -472,10 +178,16 @@ async function runRecallFastTrackCompact({ sessionRef, messages, compactBudgetTo
|
|
|
472
178
|
limit: positiveTokenInt(sessionRef?.compaction?.recallChunkLimit ?? sessionRef?.compaction?.recallLimit) || hydrateLimit,
|
|
473
179
|
};
|
|
474
180
|
const runTool = (name, args) => executeInternalTool(name, args, callerCtx);
|
|
181
|
+
t0 = Date.now();
|
|
475
182
|
let recallText = await executeInternalTool('memory', dumpArgs, callerCtx);
|
|
183
|
+
diagnostics.initialDumpMs = Date.now() - t0;
|
|
184
|
+
diagnostics.initialDumpChars = String(recallText || '').length;
|
|
185
|
+
diagnostics.initialDumpBytes = compactByteLength(recallText);
|
|
186
|
+
diagnostics.initialRawPending = countRawPendingRows(recallText);
|
|
476
187
|
let cycle1Text = '';
|
|
477
188
|
const hasRawRows = /(?:^|\n)# raw_pending\s+\d+\s+id=/i.test(String(recallText || ''));
|
|
478
189
|
if (hasRawRows) {
|
|
190
|
+
t0 = Date.now();
|
|
479
191
|
try {
|
|
480
192
|
// Drain this session's cycle1 in window×concurrency units until no
|
|
481
193
|
// raw rows remain, so the injected root is fully chunked rather than
|
|
@@ -496,19 +208,32 @@ async function runRecallFastTrackCompact({ sessionRef, messages, compactBudgetTo
|
|
|
496
208
|
});
|
|
497
209
|
recallText = drained.recallText;
|
|
498
210
|
cycle1Text = drained.cycle1Text;
|
|
211
|
+
diagnostics.cycle1Passes = drained.passes;
|
|
212
|
+
diagnostics.cycle1RawRemaining = drained.rawRemaining;
|
|
213
|
+
diagnostics.cycle1TextBytes = compactByteLength(cycle1Text);
|
|
499
214
|
if (drained.rawRemaining > 0) {
|
|
500
215
|
try { process.stderr.write(`[loop] recall-fasttrack drained passes=${drained.passes} rawRemaining=${drained.rawRemaining} (sess=${sessionId || 'unknown'})\n`); } catch {}
|
|
501
216
|
}
|
|
502
217
|
} catch (err) {
|
|
218
|
+
diagnostics.cycle1Error = compactDiagnosticError(err);
|
|
503
219
|
try { process.stderr.write(`[loop] recall-fasttrack cycle1 skipped (sess=${sessionId || 'unknown'}): ${err?.message || err}\n`); } catch {}
|
|
220
|
+
} finally {
|
|
221
|
+
diagnostics.cycle1Ms = Date.now() - t0;
|
|
504
222
|
}
|
|
505
223
|
} else {
|
|
224
|
+
diagnostics.cycle1Skipped = true;
|
|
225
|
+
diagnostics.cycle1SkipReason = 'session chunks already hydrated';
|
|
226
|
+
diagnostics.cycle1Passes = 0;
|
|
227
|
+
diagnostics.cycle1RawRemaining = 0;
|
|
506
228
|
cycle1Text = 'cycle1: skipped (session chunks already hydrated)';
|
|
507
229
|
}
|
|
508
|
-
|
|
230
|
+
const combinedRecallText = [`session_id=${sessionId}`, cycle1Text, recallText].map(v => String(v || '').trim()).filter(Boolean).join('\n\n');
|
|
231
|
+
diagnostics.finalRecallChars = combinedRecallText.length;
|
|
232
|
+
diagnostics.finalRecallBytes = compactByteLength(combinedRecallText);
|
|
233
|
+
const result = recallFastTrackCompactMessages(messages, compactBudgetTokens, {
|
|
509
234
|
reserveTokens: compactPolicy.reserveTokens,
|
|
510
235
|
force: true,
|
|
511
|
-
recallText:
|
|
236
|
+
recallText: combinedRecallText,
|
|
512
237
|
query,
|
|
513
238
|
querySha,
|
|
514
239
|
allowEmptyRecall: true,
|
|
@@ -516,562 +241,16 @@ async function runRecallFastTrackCompact({ sessionRef, messages, compactBudgetTo
|
|
|
516
241
|
keepTokens: compactPolicy.keepTokens,
|
|
517
242
|
preserveRecentTokens: compactPolicy.preserveRecentTokens,
|
|
518
243
|
});
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
// convention, so resolve percent-named values to a fraction first.
|
|
525
|
-
function _resolveBufferRatioCandidate(percentInputs, ratioInputs) {
|
|
526
|
-
for (const raw of percentInputs) {
|
|
527
|
-
const n = Number(raw);
|
|
528
|
-
if (Number.isFinite(n) && n > 0) return Math.min(1, n / 100);
|
|
529
|
-
}
|
|
530
|
-
for (const raw of ratioInputs) {
|
|
531
|
-
const n = Number(raw);
|
|
532
|
-
if (Number.isFinite(n) && n > 0) return n > 1 ? n / 100 : n;
|
|
533
|
-
}
|
|
534
|
-
return null;
|
|
535
|
-
}
|
|
536
|
-
function resolveCompactBufferRatio(cfg = {}) {
|
|
537
|
-
const resolved = _resolveBufferRatioCandidate(
|
|
538
|
-
[cfg.bufferPercent, cfg.bufferPct, process.env.MIXDOG_AGENT_COMPACT_BUFFER_PERCENT],
|
|
539
|
-
[cfg.bufferRatio, cfg.bufferFraction, process.env.MIXDOG_AGENT_COMPACT_BUFFER_RATIO],
|
|
540
|
-
);
|
|
541
|
-
return normalizeCompactionBufferRatio(resolved, DEFAULT_COMPACTION_BUFFER_RATIO);
|
|
542
|
-
}
|
|
543
|
-
const LEGACY_DEFAULT_COMPACTION_BUFFER_RATIO = 0.1;
|
|
544
|
-
function isPersistedZeroBufferTelemetry(cfg = {}, boundaryTokens = 0) {
|
|
545
|
-
const boundary = positiveTokenInt(boundaryTokens);
|
|
546
|
-
if (!boundary) return false;
|
|
547
|
-
if (envTokenInt('MIXDOG_AGENT_COMPACT_BUFFER_TOKENS')) return false;
|
|
548
|
-
for (const envName of ['MIXDOG_AGENT_COMPACT_BUFFER_PERCENT', 'MIXDOG_AGENT_COMPACT_BUFFER_RATIO']) {
|
|
549
|
-
const n = Number(process.env[envName]);
|
|
550
|
-
if (Number.isFinite(n) && n > 0) return false;
|
|
551
|
-
}
|
|
552
|
-
for (const key of ['bufferPercent', 'bufferPct', 'bufferFraction']) {
|
|
553
|
-
const n = Number(cfg?.[key]);
|
|
554
|
-
if (Number.isFinite(n) && n > 0) return false;
|
|
555
|
-
}
|
|
556
|
-
const ratio = Number(cfg?.bufferRatio);
|
|
557
|
-
if (Number.isFinite(ratio) && ratio > 0) return false;
|
|
558
|
-
const explicitTokens = Number(cfg?.bufferTokens ?? cfg?.buffer);
|
|
559
|
-
if (!Number.isFinite(explicitTokens) || explicitTokens !== 0) return false;
|
|
560
|
-
return true;
|
|
561
|
-
}
|
|
562
|
-
function isLegacyDefaultBufferTelemetry(cfg = {}, boundaryTokens = 0) {
|
|
563
|
-
const boundary = positiveTokenInt(boundaryTokens);
|
|
564
|
-
if (!boundary) return false;
|
|
565
|
-
if (envTokenInt('MIXDOG_AGENT_COMPACT_BUFFER_TOKENS')) return false;
|
|
566
|
-
for (const envName of ['MIXDOG_AGENT_COMPACT_BUFFER_PERCENT', 'MIXDOG_AGENT_COMPACT_BUFFER_RATIO']) {
|
|
567
|
-
const n = Number(process.env[envName]);
|
|
568
|
-
if (Number.isFinite(n) && n > 0) return false;
|
|
569
|
-
}
|
|
570
|
-
// Percent/fraction-named fields are operator config. The legacy default
|
|
571
|
-
// telemetry persisted bufferTokens + bufferRatio after a check/compact pass.
|
|
572
|
-
for (const key of ['bufferPercent', 'bufferPct', 'bufferFraction']) {
|
|
573
|
-
const n = Number(cfg?.[key]);
|
|
574
|
-
if (Number.isFinite(n) && n > 0) return false;
|
|
575
|
-
}
|
|
576
|
-
const explicitTokens = positiveTokenInt(cfg?.bufferTokens ?? cfg?.buffer);
|
|
577
|
-
const ratio = Number(cfg?.bufferRatio);
|
|
578
|
-
if (!explicitTokens || !Number.isFinite(ratio) || Math.abs(ratio - LEGACY_DEFAULT_COMPACTION_BUFFER_RATIO) > 1e-9) return false;
|
|
579
|
-
const expectedTokens = Math.floor(boundary * LEGACY_DEFAULT_COMPACTION_BUFFER_RATIO);
|
|
580
|
-
const cfgBoundary = positiveTokenInt(cfg?.boundaryTokens);
|
|
581
|
-
const cfgTrigger = positiveTokenInt(cfg?.triggerTokens);
|
|
582
|
-
return explicitTokens === expectedTokens
|
|
583
|
-
|| (cfgBoundary === boundary && cfgTrigger > 0 && explicitTokens === Math.max(0, boundary - cfgTrigger));
|
|
584
|
-
}
|
|
585
|
-
function compactBufferConfigForBoundary(cfg = {}, boundaryTokens = 0) {
|
|
586
|
-
const base = cfg || {};
|
|
587
|
-
if (!isLegacyDefaultBufferTelemetry(base, boundaryTokens)
|
|
588
|
-
&& !isPersistedZeroBufferTelemetry(base, boundaryTokens)) {
|
|
589
|
-
return base;
|
|
590
|
-
}
|
|
591
|
-
return {
|
|
592
|
-
...base,
|
|
593
|
-
bufferTokens: null,
|
|
594
|
-
buffer: null,
|
|
595
|
-
bufferRatio: null,
|
|
596
|
-
};
|
|
597
|
-
}
|
|
598
|
-
function resolveCompactBufferTokens(boundaryTokens, cfg = {}) {
|
|
599
|
-
const boundary = positiveTokenInt(boundaryTokens);
|
|
600
|
-
const effectiveCfg = compactBufferConfigForBoundary(cfg, boundary);
|
|
601
|
-
const configured = positiveTokenInt(effectiveCfg.bufferTokens ?? effectiveCfg.buffer)
|
|
602
|
-
|| envTokenInt('MIXDOG_AGENT_COMPACT_BUFFER_TOKENS')
|
|
603
|
-
|| 0;
|
|
604
|
-
if (!boundary) return configured || DEFAULT_COMPACTION_BUFFER_TOKENS;
|
|
605
|
-
return compactionBufferTokensForBoundary(boundary, {
|
|
606
|
-
explicitTokens: configured,
|
|
607
|
-
ratio: resolveCompactBufferRatio(effectiveCfg),
|
|
608
|
-
maxRatio: COMPACT_BUFFER_MAX_WINDOW_FRACTION,
|
|
609
|
-
});
|
|
610
|
-
}
|
|
611
|
-
const COMPACT_TARGET_RATIO = 0.02;
|
|
612
|
-
const COMPACT_TARGET_MIN_TOKENS = 4_000;
|
|
613
|
-
const COMPACT_TARGET_MAX_TOKENS = 16_000;
|
|
614
|
-
function resolveCompactTargetRatio(cfg = {}) {
|
|
615
|
-
const raw = cfg.targetPercent
|
|
616
|
-
?? cfg.targetPct
|
|
617
|
-
?? cfg.targetRatio
|
|
618
|
-
?? cfg.targetFraction
|
|
619
|
-
?? process.env.MIXDOG_AGENT_COMPACT_TARGET_PERCENT
|
|
620
|
-
?? process.env.MIXDOG_COMPACT_TARGET_PERCENT
|
|
621
|
-
?? COMPACT_TARGET_RATIO;
|
|
622
|
-
const n = Number(raw);
|
|
623
|
-
if (!Number.isFinite(n) || n <= 0) return COMPACT_TARGET_RATIO;
|
|
624
|
-
return n > 1 ? n / 100 : n;
|
|
625
|
-
}
|
|
626
|
-
function resolveCompactTargetTokens(boundaryTokens, cfg = {}) {
|
|
627
|
-
const boundary = positiveTokenInt(boundaryTokens);
|
|
628
|
-
if (!boundary) return null;
|
|
629
|
-
const explicit = positiveTokenInt(cfg.targetTokens ?? cfg.target)
|
|
630
|
-
|| envTokenInt('MIXDOG_AGENT_COMPACT_TARGET_TOKENS')
|
|
631
|
-
|| envTokenInt('MIXDOG_COMPACT_TARGET_TOKENS');
|
|
632
|
-
if (explicit) return Math.max(1, Math.min(boundary, explicit));
|
|
633
|
-
const minTarget = Math.min(boundary, positiveTokenInt(cfg.targetMinTokens ?? cfg.minTargetTokens)
|
|
634
|
-
|| envTokenInt('MIXDOG_AGENT_COMPACT_TARGET_MIN_TOKENS')
|
|
635
|
-
|| envTokenInt('MIXDOG_COMPACT_TARGET_MIN_TOKENS')
|
|
636
|
-
|| COMPACT_TARGET_MIN_TOKENS);
|
|
637
|
-
const maxTarget = Math.min(boundary, positiveTokenInt(cfg.targetMaxTokens ?? cfg.maxTargetTokens)
|
|
638
|
-
|| envTokenInt('MIXDOG_AGENT_COMPACT_TARGET_MAX_TOKENS')
|
|
639
|
-
|| envTokenInt('MIXDOG_COMPACT_TARGET_MAX_TOKENS')
|
|
640
|
-
|| COMPACT_TARGET_MAX_TOKENS);
|
|
641
|
-
const byRatio = Math.max(1, Math.floor(boundary * resolveCompactTargetRatio(cfg)));
|
|
642
|
-
return Math.max(1, Math.min(boundary, maxTarget, Math.max(minTarget, byRatio)));
|
|
643
|
-
}
|
|
644
|
-
function resolveCompactKeepTokens(cfg = {}) {
|
|
645
|
-
return positiveTokenInt(cfg.keepTokens ?? cfg.keep?.tokens ?? cfg.preserveRecentTokens)
|
|
646
|
-
|| envTokenInt('MIXDOG_AGENT_COMPACT_KEEP_TOKENS')
|
|
647
|
-
|| DEFAULT_COMPACTION_KEEP_TOKENS;
|
|
648
|
-
}
|
|
649
|
-
function resolveWorkerCompactPolicy(sessionRef, tools) {
|
|
650
|
-
if (!sessionRef) return null;
|
|
651
|
-
const cfg = sessionRef.compaction || {};
|
|
652
|
-
const auto = cfg.auto !== false && envFlag('MIXDOG_AGENT_COMPACT_AUTO', true);
|
|
653
|
-
if (!auto) return { auto: false };
|
|
654
|
-
const contextWindow = positiveTokenInt(sessionRef.contextWindow ?? cfg.contextWindow);
|
|
655
|
-
const explicitBoundary = positiveTokenInt(sessionRef.compactBoundaryTokens ?? cfg.boundaryTokens);
|
|
656
|
-
const autoLimit = positiveTokenInt(sessionRef.autoCompactTokenLimit ?? cfg.autoCompactTokenLimit);
|
|
657
|
-
const boundaryTokens = explicitBoundary && contextWindow
|
|
658
|
-
? Math.min(explicitBoundary, contextWindow)
|
|
659
|
-
: (explicitBoundary || contextWindow || autoLimit);
|
|
660
|
-
if (!boundaryTokens) return null;
|
|
661
|
-
const compactBoundaryTokens = Math.max(1, Math.floor(boundaryTokens * COMPACT_SAFETY_PERCENT));
|
|
662
|
-
// Only an explicit auto-compact limit STRICTLY BELOW the boundary acts as
|
|
663
|
-
// the trigger. A persisted value == boundary (legacy derived full-window
|
|
664
|
-
// autoCompactTokenLimit) would set autoTriggerTokens == boundary and
|
|
665
|
-
// collapse/override the default trigger, so it is ignored in favor of the
|
|
666
|
-
// default boundary trigger.
|
|
667
|
-
const autoTriggerTokens = autoLimit && autoLimit < compactBoundaryTokens ? Math.max(1, autoLimit) : null;
|
|
668
|
-
// Sanitized explicit limit: only a sub-boundary value is a real auto-compact
|
|
669
|
-
// limit. Anything >= boundary is a legacy derived full-window artifact and
|
|
670
|
-
// is reported as null so rememberCompactTelemetry does not re-persist it
|
|
671
|
-
// back onto the session and re-collapse the buffer on the next turn.
|
|
672
|
-
const explicitAutoCompactTokenLimit = autoTriggerTokens;
|
|
673
|
-
const bufferTokens = autoTriggerTokens
|
|
674
|
-
? Math.max(0, compactBoundaryTokens - autoTriggerTokens)
|
|
675
|
-
: resolveCompactBufferTokens(compactBoundaryTokens, cfg);
|
|
676
|
-
const bufferRatio = compactBoundaryTokens ? (bufferTokens / compactBoundaryTokens) : resolveCompactBufferRatio(cfg);
|
|
677
|
-
const triggerTokens = autoTriggerTokens || Math.max(1, compactBoundaryTokens - bufferTokens);
|
|
678
|
-
const configuredReserve = positiveTokenInt(cfg.reservedTokens)
|
|
679
|
-
|| envTokenInt('MIXDOG_AGENT_COMPACT_RESERVED_TOKENS')
|
|
680
|
-
|| 0;
|
|
681
|
-
const requestReserve = estimateRequestReserveTokens(tools);
|
|
682
|
-
const keepTokens = resolveCompactKeepTokens(cfg);
|
|
683
|
-
const compactType = resolveCompactTypeSetting(sessionRef, cfg);
|
|
684
|
-
return {
|
|
685
|
-
auto: true,
|
|
686
|
-
type: compactType,
|
|
687
|
-
compactType,
|
|
688
|
-
prune: cfg.prune === true || envFlag('MIXDOG_AGENT_COMPACT_PRUNE', false),
|
|
689
|
-
boundaryTokens: compactBoundaryTokens,
|
|
690
|
-
triggerTokens,
|
|
691
|
-
bufferTokens,
|
|
692
|
-
bufferRatio,
|
|
693
|
-
contextWindow,
|
|
694
|
-
rawContextWindow: positiveTokenInt(sessionRef.rawContextWindow ?? cfg.rawContextWindow) || contextWindow,
|
|
695
|
-
effectiveContextWindowPercent: Number.isFinite(Number(sessionRef.effectiveContextWindowPercent ?? cfg.effectiveContextWindowPercent))
|
|
696
|
-
? Number(sessionRef.effectiveContextWindowPercent ?? cfg.effectiveContextWindowPercent)
|
|
697
|
-
: null,
|
|
698
|
-
autoCompactTokenLimit: explicitAutoCompactTokenLimit,
|
|
699
|
-
semantic: compactTypeIsSemantic(compactType) && resolveSemanticCompactSetting(sessionRef, cfg),
|
|
700
|
-
recallFastTrack: compactTypeIsRecallFastTrack(compactType),
|
|
701
|
-
semanticTimeoutMs: positiveTokenInt(cfg.timeoutMs) || envTokenInt('MIXDOG_AGENT_COMPACT_TIMEOUT_MS') || 30_000,
|
|
702
|
-
tailTurns: positiveTokenInt(cfg.tailTurns) || envTokenInt('MIXDOG_AGENT_COMPACT_TAIL_TURNS') || 2,
|
|
703
|
-
keepTokens,
|
|
704
|
-
preserveRecentTokens: positiveTokenInt(cfg.preserveRecentTokens) || envTokenInt('MIXDOG_AGENT_COMPACT_PRESERVE_RECENT_TOKENS') || keepTokens,
|
|
705
|
-
reserveTokens: requestReserve + configuredReserve,
|
|
706
|
-
requestReserveTokens: requestReserve,
|
|
707
|
-
configuredReserveTokens: configuredReserve,
|
|
708
|
-
};
|
|
709
|
-
}
|
|
710
|
-
/** Transcript + request reserve only (never provider lastContextTokens). */
|
|
711
|
-
function compactPressureTokens(messageTokensEst, policy) {
|
|
712
|
-
if (messageTokensEst === null) return 0;
|
|
713
|
-
return Math.max(0, messageTokensEst + (policy?.reserveTokens || 0));
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
/** Telemetry pressure when a reactive overflow retry forces the next compact. */
|
|
717
|
-
function compactionTelemetryPressureTokens(messageTokensEst, policy, { reactivePending = false } = {}) {
|
|
718
|
-
const base = compactPressureTokens(messageTokensEst, policy);
|
|
719
|
-
if (!reactivePending) return base;
|
|
720
|
-
const floor = positiveTokenInt(policy?.triggerTokens) || positiveTokenInt(policy?.boundaryTokens) || 0;
|
|
721
|
-
return floor ? Math.max(base, floor) : base;
|
|
722
|
-
}
|
|
723
|
-
function compactTargetBudget(policy) {
|
|
724
|
-
const boundary = positiveTokenInt(policy?.boundaryTokens);
|
|
725
|
-
if (!boundary) return null;
|
|
726
|
-
const reserve = Math.max(0, Number(policy?.reserveTokens) || 0);
|
|
727
|
-
const targetEffective = resolveCompactTargetTokens(boundary, policy) || boundary;
|
|
728
|
-
return Math.max(1, Math.min(boundary, targetEffective + reserve));
|
|
729
|
-
}
|
|
730
|
-
function shouldCompactForSession(messageTokensEst, policy, { forceReactive = false } = {}) {
|
|
731
|
-
if (!policy?.auto || !policy.boundaryTokens) return false;
|
|
732
|
-
if (forceReactive) return true;
|
|
733
|
-
if (messageTokensEst === null) return true;
|
|
734
|
-
return compactPressureTokens(messageTokensEst, policy) >= (policy.triggerTokens || policy.boundaryTokens);
|
|
735
|
-
}
|
|
736
|
-
function countPrunedToolOutputs(before, after) {
|
|
737
|
-
if (!Array.isArray(before) || !Array.isArray(after)) return 0;
|
|
738
|
-
let count = 0;
|
|
739
|
-
const n = Math.min(before.length, after.length);
|
|
740
|
-
for (let i = 0; i < n; i += 1) {
|
|
741
|
-
if (before[i]?.role !== 'tool' || after[i]?.role !== 'tool') continue;
|
|
742
|
-
if (before[i]?.content !== after[i]?.content && after[i]?.compactedKind === 'tool_output_prune') count += 1;
|
|
743
|
-
}
|
|
744
|
-
return count;
|
|
745
|
-
}
|
|
746
|
-
function rememberCompactTelemetry(sessionRef, policy, meta = {}) {
|
|
747
|
-
if (!sessionRef || !policy) return;
|
|
748
|
-
const prev = sessionRef.compaction && typeof sessionRef.compaction === 'object'
|
|
749
|
-
? sessionRef.compaction
|
|
750
|
-
: {};
|
|
751
|
-
const changed = meta.compactChanged === true || meta.pruneCount > 0;
|
|
752
|
-
sessionRef.compaction = {
|
|
753
|
-
...prev,
|
|
754
|
-
auto: policy.auto !== false,
|
|
755
|
-
prune: policy.prune === true,
|
|
756
|
-
reservedTokens: policy.configuredReserveTokens || prev.reservedTokens || null,
|
|
757
|
-
requestReserveTokens: policy.requestReserveTokens || 0,
|
|
758
|
-
reserveTokens: policy.reserveTokens || 0,
|
|
759
|
-
boundaryTokens: policy.boundaryTokens || null,
|
|
760
|
-
triggerTokens: policy.triggerTokens || null,
|
|
761
|
-
bufferTokens: policy.bufferTokens || 0,
|
|
762
|
-
bufferRatio: policy.bufferRatio ?? prev.bufferRatio ?? null,
|
|
763
|
-
contextWindow: policy.contextWindow || null,
|
|
764
|
-
rawContextWindow: policy.rawContextWindow || null,
|
|
765
|
-
effectiveContextWindowPercent: policy.effectiveContextWindowPercent ?? null,
|
|
766
|
-
autoCompactTokenLimit: policy.autoCompactTokenLimit || null,
|
|
767
|
-
type: policy.compactType || policy.type || DEFAULT_COMPACT_TYPE,
|
|
768
|
-
compactType: policy.compactType || policy.type || DEFAULT_COMPACT_TYPE,
|
|
769
|
-
semantic: policy.semantic === true ? 'auto' : false,
|
|
770
|
-
recallFastTrack: policy.recallFastTrack === true,
|
|
771
|
-
semanticModel: policy.semanticModel || null,
|
|
772
|
-
semanticTimeoutMs: policy.semanticTimeoutMs || null,
|
|
773
|
-
tailTurns: policy.tailTurns || null,
|
|
774
|
-
keepTokens: policy.keepTokens || null,
|
|
775
|
-
preserveRecentTokens: policy.preserveRecentTokens || null,
|
|
776
|
-
lastCheckedAt: Date.now(),
|
|
777
|
-
lastBeforeTokens: meta.beforeTokens ?? null,
|
|
778
|
-
lastAfterTokens: meta.afterTokens ?? null,
|
|
779
|
-
lastPressureTokens: meta.pressureTokens ?? null,
|
|
780
|
-
currentEstimatedTokens: meta.pressureTokens ?? prev.currentEstimatedTokens ?? null,
|
|
781
|
-
lastApiRequestTokens: positiveTokenInt(sessionRef?.lastContextTokens) || prev.lastApiRequestTokens || null,
|
|
782
|
-
lastStage: meta.stage || prev.lastStage || null,
|
|
783
|
-
lastChanged: changed,
|
|
784
|
-
lastTrigger: meta.trigger || prev.lastTrigger || null,
|
|
785
|
-
lastSemantic: meta.semanticCompact === true,
|
|
786
|
-
lastSemanticError: Object.hasOwn(meta, 'semanticError')
|
|
787
|
-
? (meta.semanticError ?? null)
|
|
788
|
-
: (prev.lastSemanticError ?? null),
|
|
789
|
-
lastRecallFastTrack: meta.recallFastTrack === true,
|
|
790
|
-
lastRecallFastTrackError: Object.hasOwn(meta, 'recallFastTrackError')
|
|
791
|
-
? (meta.recallFastTrackError ?? null)
|
|
792
|
-
: (prev.lastRecallFastTrackError ?? null),
|
|
793
|
-
lastError: Object.hasOwn(meta, 'compactError') || Object.hasOwn(meta, 'lastError')
|
|
794
|
-
? (meta.compactError ?? meta.lastError ?? null)
|
|
795
|
-
: (prev.lastError ?? null),
|
|
796
|
-
lastPruneCount: meta.pruneCount || 0,
|
|
797
|
-
lastDurationMs: meta.durationMs != null && Number.isFinite(Number(meta.durationMs))
|
|
798
|
-
? Math.max(0, Math.round(Number(meta.durationMs)))
|
|
799
|
-
: null,
|
|
800
|
-
compactCount: (prev.compactCount || 0) + (changed ? 1 : 0),
|
|
801
|
-
};
|
|
802
|
-
if (changed) {
|
|
803
|
-
const changedAt = Date.now();
|
|
804
|
-
sessionRef.compaction.lastChangedAt = changedAt;
|
|
805
|
-
sessionRef.compaction.lastCompactAt = changedAt;
|
|
806
|
-
sessionRef.lastContextTokensStaleAfterCompact = true;
|
|
807
|
-
}
|
|
808
|
-
sessionRef.contextWindow = policy.contextWindow || sessionRef.contextWindow;
|
|
809
|
-
sessionRef.rawContextWindow = policy.rawContextWindow || sessionRef.rawContextWindow;
|
|
810
|
-
sessionRef.compactBoundaryTokens = policy.boundaryTokens || sessionRef.compactBoundaryTokens || null;
|
|
811
|
-
// Persist only the sanitized (sub-boundary) explicit limit. policy.autoCompactTokenLimit
|
|
812
|
-
// is already null for legacy derived full-window values, so a stale
|
|
813
|
-
// boundary-sized autoCompactTokenLimit on the session is cleared here rather
|
|
814
|
-
// than carried forward to re-collapse the buffer next turn.
|
|
815
|
-
{
|
|
816
|
-
const _boundary = positiveTokenInt(sessionRef.compactBoundaryTokens);
|
|
817
|
-
const _prevLimit = positiveTokenInt(sessionRef.autoCompactTokenLimit);
|
|
818
|
-
const _keepPrev = _prevLimit && (!_boundary || _prevLimit < _boundary) ? _prevLimit : null;
|
|
819
|
-
sessionRef.autoCompactTokenLimit = policy.autoCompactTokenLimit || _keepPrev || null;
|
|
820
|
-
}
|
|
821
|
-
if (policy.effectiveContextWindowPercent !== null) {
|
|
822
|
-
sessionRef.effectiveContextWindowPercent = policy.effectiveContextWindowPercent;
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
function emitCompactEvent(opts, event = {}) {
|
|
827
|
-
if (!opts || typeof opts.onCompactEvent !== 'function') return;
|
|
828
|
-
try { opts.onCompactEvent({ ts: Date.now(), ...event }); }
|
|
829
|
-
catch { /* best-effort UI/log hook */ }
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
function compactEventType(policy, fallback = DEFAULT_COMPACT_TYPE) {
|
|
833
|
-
return policy?.compactType || policy?.type || fallback;
|
|
834
|
-
}
|
|
835
|
-
const SKILL_TOOL_NAMES = new Set(['Skill', 'skills_list', 'skill_view']);
|
|
836
|
-
const SPECIAL_TOOL_NAMES = new Set(['bash_session', 'apply_patch', 'code_graph']);
|
|
837
|
-
const BASH_SESSION_HEADER_RE = /\[session: ([^\]\r\n]+)\]/;
|
|
838
|
-
const STORED_TOOL_ARG_BODY_KEY_RE = /^(?:content|old_string|new_string|patch|rewrite)$/i;
|
|
839
|
-
const STORED_TOOL_ARG_LONG_KEY_RE = /^(?:command|script)$/i;
|
|
840
|
-
const STORED_TOOL_ARG_BODY_LIMIT = 2_000;
|
|
841
|
-
const STORED_TOOL_ARG_LONG_LIMIT = 8_000;
|
|
842
|
-
const STORED_TOOL_ARG_PREVIEW_HEAD = 360;
|
|
843
|
-
const STORED_TOOL_ARG_PREVIEW_TAIL = 160;
|
|
844
|
-
|
|
845
|
-
function compactStoredToolArgString(value, key = '') {
|
|
846
|
-
if (typeof value !== 'string') return value;
|
|
847
|
-
const isBody = STORED_TOOL_ARG_BODY_KEY_RE.test(key);
|
|
848
|
-
const isLong = isBody || STORED_TOOL_ARG_LONG_KEY_RE.test(key);
|
|
849
|
-
const limit = isBody ? STORED_TOOL_ARG_BODY_LIMIT : (isLong ? STORED_TOOL_ARG_LONG_LIMIT : Infinity);
|
|
850
|
-
if (value.length <= limit) return value;
|
|
851
|
-
const hash = createHash('sha256').update(value).digest('hex').slice(0, 16);
|
|
852
|
-
const head = value.slice(0, STORED_TOOL_ARG_PREVIEW_HEAD).replace(/\r\n/g, '\n');
|
|
853
|
-
const tail = value.slice(-STORED_TOOL_ARG_PREVIEW_TAIL).replace(/\r\n/g, '\n');
|
|
854
|
-
return `[mixdog compacted ${key || 'string'}: ${value.length} chars, sha256:${hash}]\n${head}\n... [middle omitted from stored tool-call args] ...\n${tail}`;
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
function compactStoredToolArgValue(value, key = '', depth = 0) {
|
|
858
|
-
if (value === null || value === undefined) return value;
|
|
859
|
-
if (typeof value === 'string') return compactStoredToolArgString(value, key);
|
|
860
|
-
if (typeof value !== 'object') return value;
|
|
861
|
-
if (depth >= 6) return Array.isArray(value) ? `[${value.length} items]` : '{...}';
|
|
862
|
-
if (Array.isArray(value)) {
|
|
863
|
-
return value.map((item) => compactStoredToolArgValue(item, key, depth + 1));
|
|
864
|
-
}
|
|
865
|
-
const out = {};
|
|
866
|
-
for (const [k, v] of Object.entries(value)) {
|
|
867
|
-
out[k] = compactStoredToolArgValue(v, k, depth + 1);
|
|
868
|
-
}
|
|
869
|
-
return out;
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
function compactToolCallsForHistory(calls) {
|
|
873
|
-
if (!Array.isArray(calls)) return calls;
|
|
874
|
-
return calls.map((call) => {
|
|
875
|
-
if (!call || typeof call !== 'object') return call;
|
|
876
|
-
return {
|
|
877
|
-
...call,
|
|
878
|
-
arguments: compactStoredToolArgValue(call.arguments),
|
|
879
|
-
};
|
|
880
|
-
});
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
// Restore the FULL body of ONE tool call inside a history assistant message
|
|
884
|
-
// whose toolCalls were compacted at push time. Used for a failed edit call so
|
|
885
|
-
// the model sees the original patch/old_string on retry instead of a
|
|
886
|
-
// `[mixdog compacted …]` placeholder it cannot act on. Must run BEFORE the
|
|
887
|
-
// message is first transmitted so it never mutates an already-cached prefix
|
|
888
|
-
// (the prompt cache is content-prefix matched).
|
|
889
|
-
//
|
|
890
|
-
// Only the compactable body/long keys (patch, old_string, new_string, content,
|
|
891
|
-
// rewrite, command, script) are restored, and at ANY depth — compaction is
|
|
892
|
-
// recursive (compactStoredToolArgValue), so batch shapes like edits[].old_string
|
|
893
|
-
// or writes[].content carry nested compacted bodies too. Every other field
|
|
894
|
-
// (e.g. `path`, which a tool may mutate in place during execution) is taken from
|
|
895
|
-
// the compacted snapshot captured at push time, before any mutation. The
|
|
896
|
-
// compacted args tree is built fresh by compactToolCallsForHistory and is not
|
|
897
|
-
// shared with originalCalls, so rebuilding it here is safe.
|
|
898
|
-
function restoreToolCallBodyForId(assistantMsg, originalCalls, callId) {
|
|
899
|
-
if (!assistantMsg || !Array.isArray(assistantMsg.toolCalls) || !callId) return;
|
|
900
|
-
if (!Array.isArray(originalCalls)) return;
|
|
901
|
-
const tc = assistantMsg.toolCalls.find((t) => t && t.id === callId);
|
|
902
|
-
const orig = originalCalls.find((c) => c && c.id === callId);
|
|
903
|
-
if (!tc || !orig) return;
|
|
904
|
-
if (!tc.arguments || typeof tc.arguments !== 'object'
|
|
905
|
-
|| !orig.arguments || typeof orig.arguments !== 'object') return;
|
|
906
|
-
tc.arguments = _restoreCompactedBodies(tc.arguments, orig.arguments, '');
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
// Recursively rebuild a compacted args tree: replace ONLY compactable body/long
|
|
910
|
-
// string fields (matched by key at any depth) with their full originals, and
|
|
911
|
-
// keep every other field from the compacted snapshot. tcVal and origVal share
|
|
912
|
-
// the same structure (compaction only shortens body strings), so the walk
|
|
913
|
-
// descends them in parallel; a missing or non-object origVal falls back to the
|
|
914
|
-
// compacted value rather than throwing.
|
|
915
|
-
function _restoreCompactedBodies(tcVal, origVal, key) {
|
|
916
|
-
if ((STORED_TOOL_ARG_BODY_KEY_RE.test(key) || STORED_TOOL_ARG_LONG_KEY_RE.test(key))
|
|
917
|
-
&& typeof origVal === 'string') {
|
|
918
|
-
return origVal;
|
|
919
|
-
}
|
|
920
|
-
if (Array.isArray(tcVal) && Array.isArray(origVal)) {
|
|
921
|
-
return tcVal.map((item, i) => _restoreCompactedBodies(item, origVal[i], key));
|
|
922
|
-
}
|
|
923
|
-
if (tcVal && typeof tcVal === 'object' && origVal && typeof origVal === 'object') {
|
|
924
|
-
const out = {};
|
|
925
|
-
for (const k of Object.keys(tcVal)) {
|
|
926
|
-
out[k] = (k in origVal) ? _restoreCompactedBodies(tcVal[k], origVal[k], k) : tcVal[k];
|
|
927
|
-
}
|
|
928
|
-
return out;
|
|
929
|
-
}
|
|
930
|
-
return tcVal;
|
|
931
|
-
}
|
|
932
|
-
/**
|
|
933
|
-
* Execute a single tool call — routes to MCP or builtin.
|
|
934
|
-
*/
|
|
935
|
-
function getToolKind(name) {
|
|
936
|
-
if (SKILL_TOOL_NAMES.has(name)) return 'skill';
|
|
937
|
-
if (SPECIAL_TOOL_NAMES.has(name)) return 'builtin';
|
|
938
|
-
if (isMcpTool(name)) return 'mcp';
|
|
939
|
-
if (isInternalTool(name)) return 'internal';
|
|
940
|
-
if (isBuiltinTool(name)) return 'builtin';
|
|
941
|
-
return 'builtin';
|
|
942
|
-
}
|
|
943
|
-
function buildSkillsListResponse(cwd) {
|
|
944
|
-
const skills = collectSkillsCached(cwd);
|
|
945
|
-
const entries = skills.map(s => ({ name: s.name, description: s.description || '' }));
|
|
946
|
-
return JSON.stringify({ skills: entries });
|
|
947
|
-
}
|
|
948
|
-
function viewSkill(cwd, name) {
|
|
949
|
-
if (!name) return 'Error: skill name is required';
|
|
950
|
-
const content = loadSkillContent(name, cwd);
|
|
951
|
-
if (!content) return `Error: skill "${name}" not found`;
|
|
952
|
-
return `<skill>\n<name>${String(name).replace(/[<>&]/g, (ch) => ({ '<': '<', '>': '>', '&': '&' }[ch]))}</name>\n${content}\n</skill>`;
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
/** Normalize PostToolUse hook override values (legacy MCP text envelopes only). */
|
|
956
|
-
export function normalizeHookUpdatedToolOutput(value) {
|
|
957
|
-
if (typeof value === 'string') return value;
|
|
958
|
-
if (value == null) return '';
|
|
959
|
-
if (typeof value === 'object' && Array.isArray(value.content)) {
|
|
960
|
-
const hasNonText = value.content.some((c) => c && typeof c === 'object' && c.type && c.type !== 'text');
|
|
961
|
-
if (hasNonText) return value;
|
|
962
|
-
return value.content
|
|
963
|
-
.map((c) => (c?.type === 'text' ? c.text || '' : JSON.stringify(c)))
|
|
964
|
-
.join('\n');
|
|
965
|
-
}
|
|
966
|
-
return value;
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
export function resolveToolResultAfterHook(originalResult, hookResult) {
|
|
970
|
-
if (!hookResult || typeof hookResult !== 'object' || hookResult.updatedToolOutput === undefined) {
|
|
971
|
-
return originalResult;
|
|
972
|
-
}
|
|
973
|
-
const updated = normalizeHookUpdatedToolOutput(hookResult.updatedToolOutput);
|
|
974
|
-
return updated === undefined ? originalResult : updated;
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
function parseNativeToolSearchPayload(toolName, result) {
|
|
978
|
-
if (toolName !== 'tool_search' || typeof result !== 'string') return null;
|
|
979
|
-
try {
|
|
980
|
-
const parsed = JSON.parse(result);
|
|
981
|
-
const native = parsed?.nativeToolSearch;
|
|
982
|
-
if (!native || typeof native !== 'object') return null;
|
|
983
|
-
const toolReferences = Array.isArray(native.toolReferences)
|
|
984
|
-
? native.toolReferences.map((name) => String(name || '').trim()).filter(Boolean)
|
|
985
|
-
: [];
|
|
986
|
-
const openaiTools = Array.isArray(native.openaiTools)
|
|
987
|
-
? native.openaiTools.filter((tool) => tool && typeof tool === 'object')
|
|
988
|
-
: [];
|
|
989
|
-
if (!toolReferences.length && !openaiTools.length) return null;
|
|
990
|
-
return {
|
|
991
|
-
toolReferences,
|
|
992
|
-
openaiTools,
|
|
993
|
-
summary: typeof native.summary === 'string' && native.summary
|
|
994
|
-
? native.summary
|
|
995
|
-
: `Loaded deferred tools: ${toolReferences.join(', ') || openaiTools.map((tool) => tool.name).filter(Boolean).join(', ')}`,
|
|
244
|
+
diagnostics.totalMs = Date.now() - startedAt;
|
|
245
|
+
if (result && typeof result === 'object') {
|
|
246
|
+
result.diagnostics = {
|
|
247
|
+
...(result.diagnostics || {}),
|
|
248
|
+
pipeline: diagnostics,
|
|
996
249
|
};
|
|
997
|
-
} catch {
|
|
998
|
-
return null;
|
|
999
|
-
}
|
|
1000
|
-
}
|
|
1001
|
-
function extractBashSessionId(result) {
|
|
1002
|
-
if (typeof result !== 'string') return null;
|
|
1003
|
-
const match = BASH_SESSION_HEADER_RE.exec(result);
|
|
1004
|
-
return match ? match[1] : null;
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
export function buildAgentBashSessionArgs(args, sessionRef) {
|
|
1008
|
-
if (!isAgentOwner(sessionRef)) return null;
|
|
1009
|
-
// run_in_background is a detached one-shot job, incompatible with the
|
|
1010
|
-
// persistent bash session. Fall through to the background-job path
|
|
1011
|
-
// (executeBuiltinTool -> startBackgroundShellJob) so the worker gets a
|
|
1012
|
-
// task_id that task control can resolve — otherwise the persistent
|
|
1013
|
-
// session returns a [session: ...] header and task control reports "task not found".
|
|
1014
|
-
if (args?.run_in_background === true) return null;
|
|
1015
|
-
const routedArgs = { ...(args || {}) };
|
|
1016
|
-
const explicitSessionId = typeof routedArgs.session_id === 'string' && routedArgs.session_id.trim()
|
|
1017
|
-
? routedArgs.session_id.trim()
|
|
1018
|
-
: null;
|
|
1019
|
-
const wantsPersistent = routedArgs.persistent === true || !!explicitSessionId;
|
|
1020
|
-
if (!wantsPersistent) return null;
|
|
1021
|
-
if (!explicitSessionId && sessionRef?.implicitBashSessionId) {
|
|
1022
|
-
routedArgs.session_id = sessionRef.implicitBashSessionId;
|
|
1023
|
-
} else if (explicitSessionId) {
|
|
1024
|
-
routedArgs.session_id = explicitSessionId;
|
|
1025
250
|
}
|
|
1026
|
-
|
|
1027
|
-
return
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
export function formatMissingToolApprovalUiDenial(toolName, askReason) {
|
|
1031
|
-
const reason = String(askReason || 'approval requested by hook').trim();
|
|
1032
|
-
const name = String(toolName || 'tool');
|
|
1033
|
-
return `Error: tool "${name}" denied by hook: approval required but no approval UI is available${reason ? ` (${reason})` : ''}`;
|
|
251
|
+
compactDebugLog('recall-fasttrack pipeline', diagnostics);
|
|
252
|
+
return result;
|
|
1034
253
|
}
|
|
1035
|
-
|
|
1036
|
-
/**
|
|
1037
|
-
* Resolve PreToolUse `{ action: 'ask' }` against an optional approval callback.
|
|
1038
|
-
* Returns `{ denial }` when the tool must not run; otherwise `{ approval }`.
|
|
1039
|
-
*/
|
|
1040
|
-
export async function resolvePreToolAskApproval({
|
|
1041
|
-
toolName,
|
|
1042
|
-
args,
|
|
1043
|
-
cwd,
|
|
1044
|
-
sessionId,
|
|
1045
|
-
toolCallId,
|
|
1046
|
-
askReason,
|
|
1047
|
-
toolApprovalHook,
|
|
1048
|
-
}) {
|
|
1049
|
-
const name = String(toolName || 'tool');
|
|
1050
|
-
const reason = String(askReason || 'approval requested by hook').trim();
|
|
1051
|
-
if (typeof toolApprovalHook !== 'function') {
|
|
1052
|
-
return { denial: formatMissingToolApprovalUiDenial(name, reason) };
|
|
1053
|
-
}
|
|
1054
|
-
let approval;
|
|
1055
|
-
try {
|
|
1056
|
-
approval = await toolApprovalHook({
|
|
1057
|
-
name,
|
|
1058
|
-
args,
|
|
1059
|
-
cwd,
|
|
1060
|
-
sessionId,
|
|
1061
|
-
toolCallId: toolCallId || null,
|
|
1062
|
-
reason,
|
|
1063
|
-
});
|
|
1064
|
-
} catch (error) {
|
|
1065
|
-
const detail = error?.message || String(error || 'approval failed');
|
|
1066
|
-
return { denial: `Error: tool "${name}" denied by hook: ${detail}` };
|
|
1067
|
-
}
|
|
1068
|
-
if (!approvalGranted(approval)) {
|
|
1069
|
-
const detail = approvalReason(approval, reason || 'not approved');
|
|
1070
|
-
return { denial: `Error: tool "${name}" denied by hook: ${detail}` };
|
|
1071
|
-
}
|
|
1072
|
-
return { approval };
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
254
|
function _scopedCacheOutcomeForCall(sessionRef, toolCallId, toolName, callerSessionId, executeOpts = {}) {
|
|
1076
255
|
if (executeOpts.scopedCacheOutcome) {
|
|
1077
256
|
if (sessionRef && toolCallId) {
|
|
@@ -1238,13 +417,19 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
|
|
|
1238
417
|
}
|
|
1239
418
|
if (name === 'apply_patch') {
|
|
1240
419
|
const patchArgs = typeof args === 'string' ? { patch: args } : args;
|
|
1241
|
-
return executePatchTool(name, patchArgs, cwd, { sessionId: callerSessionId });
|
|
420
|
+
return executePatchTool(name, patchArgs, cwd, { sessionId: callerSessionId, toolCallId: executeOpts.toolCallId || null });
|
|
1242
421
|
}
|
|
1243
422
|
if (isBuiltinTool(name)) {
|
|
1244
423
|
// clientHostPid threaded for the same per-terminal job-scope reason as
|
|
1245
424
|
// the bash branch above (see resolveJobOwnerHostPid).
|
|
1246
425
|
return executeBuiltinTool(name, args, cwd, completionToolOpts);
|
|
1247
426
|
}
|
|
427
|
+
if (isExternalAdapterTool(name)) {
|
|
428
|
+
// Foreign-CLI tool names (StrReplace/Write/bash variants) adapt to a
|
|
429
|
+
// native execution inside executeBuiltinTool's default: case; on a
|
|
430
|
+
// shape mismatch it falls back to the redirect guidance message.
|
|
431
|
+
return executeBuiltinTool(name, args, cwd, completionToolOpts);
|
|
432
|
+
}
|
|
1248
433
|
return formatUnknownBuiltinToolMessage(name, args, 'tool');
|
|
1249
434
|
})();
|
|
1250
435
|
if (typeof afterToolHook === 'function') {
|
|
@@ -1257,7 +442,14 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
|
|
|
1257
442
|
toolCallId: executeOpts.toolCallId || null,
|
|
1258
443
|
result: __result,
|
|
1259
444
|
});
|
|
1260
|
-
|
|
445
|
+
// Envelope-aware hook override: a PostToolUse hook may override the
|
|
446
|
+
// model-VISIBLE tool output (the envelope's `result` / stub), but it
|
|
447
|
+
// must NEVER drop the `newMessages` channel. Split first, apply the
|
|
448
|
+
// override to `result` only, then re-wrap so newMessages survive.
|
|
449
|
+
const { result: __res, newMessages: __nm } = normalizeToolEnvelope(__result);
|
|
450
|
+
const __overridden = resolveToolResultAfterHook(__res, hookResult);
|
|
451
|
+
if (__nm.length) return makeToolEnvelope(__overridden, __nm);
|
|
452
|
+
return __overridden;
|
|
1261
453
|
} catch {
|
|
1262
454
|
// PostToolUse hooks are best-effort; never let one break the tool result.
|
|
1263
455
|
}
|
|
@@ -1275,13 +467,6 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
|
|
|
1275
467
|
* wrapper can propagate a clean cancellation.
|
|
1276
468
|
* - `onStageChange(stage)` / `onStreamDelta()` — forwarded to provider.send for heartbeats
|
|
1277
469
|
*/
|
|
1278
|
-
// Source of truth: defaults/hidden-roles.json (loaded via _getHiddenRoles
|
|
1279
|
-
// above). Build the name Set eagerly at module load so HIDDEN_ROLE_NAMES
|
|
1280
|
-
// stays in sync with the declarative registry — no hardcoded duplicate.
|
|
1281
|
-
const HIDDEN_ROLE_NAMES = new Set(
|
|
1282
|
-
(_getHiddenRoles().roles || []).map((r) => r && r.name).filter((n) => typeof n === 'string' && n.length > 0)
|
|
1283
|
-
);
|
|
1284
|
-
|
|
1285
470
|
// Stop reasons that signal the turn was cut short mid-synthesis (token cap,
|
|
1286
471
|
// provider pause). Empty content + one of these reasons means the worker
|
|
1287
472
|
// was not done — re-prompt instead of accepting empty as final.
|
|
@@ -1291,22 +476,6 @@ const INCOMPLETE_STOP_REASONS = new Set([
|
|
|
1291
476
|
'pause_turn', 'max_tokens', 'length', 'MAX_TOKENS', 'OTHER',
|
|
1292
477
|
]);
|
|
1293
478
|
|
|
1294
|
-
export function approvalGranted(value) {
|
|
1295
|
-
if (value === true) return true;
|
|
1296
|
-
if (!value || typeof value !== 'object') return false;
|
|
1297
|
-
if (value.approved === true || value.allow === true || value.allowed === true) return true;
|
|
1298
|
-
const decision = String(value.decision || value.action || value.result || '').trim().toLowerCase();
|
|
1299
|
-
return decision === 'approve' || decision === 'approved' || decision === 'allow' || decision === 'yes';
|
|
1300
|
-
}
|
|
1301
|
-
|
|
1302
|
-
export function approvalReason(value, fallback = '') {
|
|
1303
|
-
if (value && typeof value === 'object') {
|
|
1304
|
-
const reason = String(value.reason || value.message || '').trim();
|
|
1305
|
-
if (reason) return reason;
|
|
1306
|
-
}
|
|
1307
|
-
return fallback;
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
479
|
export async function agentLoop(provider, messages, model, tools, onToolCall, cwd, sendOpts) {
|
|
1311
480
|
let iterations = 0;
|
|
1312
481
|
let toolCallsTotal = 0;
|
|
@@ -1315,6 +484,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1315
484
|
let response;
|
|
1316
485
|
let contractNudges = 0;
|
|
1317
486
|
let contextOverflowRetryUsed = false;
|
|
487
|
+
// Set when the hard iteration-cap break below fires. Consumed at the final
|
|
488
|
+
// return to tag terminationReason='iteration_cap' so a worker that exhausts
|
|
489
|
+
// the loop without a final answer surfaces to Lead as an explicit error
|
|
490
|
+
// instead of a silent empty "completed".
|
|
491
|
+
let terminatedByCap = false;
|
|
1318
492
|
// Set when a provider context-overflow refusal triggers the in-turn
|
|
1319
493
|
// reactive compact retry below; consumed by the next pre-send compact pass
|
|
1320
494
|
// so its telemetry/events carry trigger:'reactive' (distinct from the
|
|
@@ -1323,7 +497,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1323
497
|
const opts = sendOpts || {};
|
|
1324
498
|
const sessionId = opts.sessionId || null;
|
|
1325
499
|
const signal = opts.signal || null;
|
|
1326
|
-
const
|
|
500
|
+
const sessionAgent = opts.session?.agent;
|
|
1327
501
|
const forcedFirstTool = opts.forcedFirstTool ?? null;
|
|
1328
502
|
const forcedFirstToolDef = forcedFirstTool
|
|
1329
503
|
? tools.find(tool => tool?.name === forcedFirstTool)
|
|
@@ -1396,9 +570,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1396
570
|
});
|
|
1397
571
|
return true;
|
|
1398
572
|
};
|
|
1399
|
-
const maxLoopIterations =
|
|
1400
|
-
? sessionRef.maxLoopIterations
|
|
1401
|
-
: MAX_LOOP_ITERATIONS;
|
|
573
|
+
const maxLoopIterations = resolveSessionMaxLoopIterations(sessionRef);
|
|
1402
574
|
// Tool execution must use the session cwd even when the caller omitted the
|
|
1403
575
|
// legacy positional cwd argument. Agent workers always carry their cwd on
|
|
1404
576
|
// sessionRef; falling through to pwd()/process.cwd() resolves relatives
|
|
@@ -1408,6 +580,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1408
580
|
throwIfAborted();
|
|
1409
581
|
if (iterations >= maxLoopIterations) {
|
|
1410
582
|
process.stderr.write(`[loop] hard iteration cap ${maxLoopIterations} reached (sess=${sessionId || 'unknown'}); stopping loop.\n`);
|
|
583
|
+
terminatedByCap = true;
|
|
1411
584
|
break;
|
|
1412
585
|
}
|
|
1413
586
|
// Drain queued steering/prompts BEFORE the
|
|
@@ -1460,6 +633,18 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1460
633
|
// them apart, then clear the one-shot flag.
|
|
1461
634
|
const compactTrigger = reactiveOverflowRetryPending ? 'reactive' : 'auto';
|
|
1462
635
|
reactiveOverflowRetryPending = false;
|
|
636
|
+
// PreCompact: bridge to the standard hook bus before compaction
|
|
637
|
+
// runs. session-property hook (manager/loop have no bus access).
|
|
638
|
+
// { trigger } normalized to 'auto'|'manual'. Best-effort.
|
|
639
|
+
{
|
|
640
|
+
const _preCompactHook = typeof opts.preCompactHook === 'function'
|
|
641
|
+
? opts.preCompactHook
|
|
642
|
+
: sessionRef?.preCompactHook;
|
|
643
|
+
if (typeof _preCompactHook === 'function') {
|
|
644
|
+
try { await _preCompactHook({ sessionId, cwd, trigger: compactTrigger === 'manual' ? 'manual' : 'auto' }); }
|
|
645
|
+
catch { /* best-effort: PreCompact hook must never break compaction */ }
|
|
646
|
+
}
|
|
647
|
+
}
|
|
1463
648
|
rememberCompactTelemetry(sessionRef, compactPolicy, {
|
|
1464
649
|
stage: 'compacting',
|
|
1465
650
|
beforeTokens: messageTokensEst,
|
|
@@ -1570,6 +755,51 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1570
755
|
}
|
|
1571
756
|
summaryChanged = messagesArrayChanged(compactInputMessages, compacted);
|
|
1572
757
|
} catch (compactErr) {
|
|
758
|
+
// Anchor-independent prune safety net. When SEMANTIC compact
|
|
759
|
+
// throws (e.g. a degenerate single-turn transcript, or a
|
|
760
|
+
// summary that cannot fit), attempt one non-LLM prune that
|
|
761
|
+
// needs no user anchor: middle-truncate the oldest oversized
|
|
762
|
+
// tool_result bodies until the transcript fits the budget.
|
|
763
|
+
// If it shrinks the transcript we continue with that result
|
|
764
|
+
// instead of escalating to overflow. Structure/pairing is
|
|
765
|
+
// preserved (only string content shrinks) and the result is
|
|
766
|
+
// re-reconciled inside the helper.
|
|
767
|
+
//
|
|
768
|
+
// GATED to the non-recall path: a recall-fasttrack failure
|
|
769
|
+
// must NOT be silently recovered by this prune (that would
|
|
770
|
+
// change the type-2 path's contract by shipping a pruned
|
|
771
|
+
// transcript with no recall output). When recallFastTrackError
|
|
772
|
+
// is set the fallback is skipped and the original overflow
|
|
773
|
+
// escalation runs unchanged.
|
|
774
|
+
if (!recallFastTrackError) {
|
|
775
|
+
try {
|
|
776
|
+
// Accept only if the pruned transcript fits the SAME
|
|
777
|
+
// effective budget the prune targets (compactBudgetTokens
|
|
778
|
+
// minus the request reserve) — comparing against the raw
|
|
779
|
+
// compactBudgetTokens would accept a result with no
|
|
780
|
+
// reserve headroom and overflow on the very next send.
|
|
781
|
+
const acceptThreshold = compactEffectiveBudget(compactBudgetTokens, {
|
|
782
|
+
reserveTokens: compactPolicy.reserveTokens,
|
|
783
|
+
});
|
|
784
|
+
const salvaged = pruneToolOutputsUnanchored(messages, compactBudgetTokens, {
|
|
785
|
+
reserveTokens: compactPolicy.reserveTokens,
|
|
786
|
+
});
|
|
787
|
+
if (messagesArrayChanged(messages, salvaged)
|
|
788
|
+
&& estimateMessagesTokensSafe(salvaged) <= acceptThreshold) {
|
|
789
|
+
compacted = salvaged;
|
|
790
|
+
pruneCount = countPrunedToolOutputs(messages, salvaged);
|
|
791
|
+
summaryChanged = true;
|
|
792
|
+
}
|
|
793
|
+
} catch { /* fall through to overflow escalation */ }
|
|
794
|
+
}
|
|
795
|
+
if (compacted !== undefined) {
|
|
796
|
+
try {
|
|
797
|
+
process.stderr.write(
|
|
798
|
+
`[loop] compact fallback prune recovered (sess=${sessionId || 'unknown'}): ` +
|
|
799
|
+
`${compactErr?.message || compactErr}\n`,
|
|
800
|
+
);
|
|
801
|
+
} catch { /* best-effort */ }
|
|
802
|
+
} else {
|
|
1573
803
|
const compactFailMsg = compactErr && compactErr.message ? compactErr.message : String(compactErr);
|
|
1574
804
|
const semanticFailMsg = semanticCompactError?.message || null;
|
|
1575
805
|
const recallFailMsg = recallFastTrackError?.message || null;
|
|
@@ -1593,6 +823,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1593
823
|
iteration: iterations + 1,
|
|
1594
824
|
stage: 'pre_send',
|
|
1595
825
|
trigger: compactTrigger,
|
|
826
|
+
compact_type: compactPolicy.compactType || compactPolicy.type || DEFAULT_COMPACT_TYPE,
|
|
1596
827
|
prune_count: pruneCount,
|
|
1597
828
|
compact_changed: false,
|
|
1598
829
|
input_prefix_hash: messagePrefixHash(messages),
|
|
@@ -1602,13 +833,23 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1602
833
|
after_bytes: getBeforeBytes(),
|
|
1603
834
|
context_window: compactPolicy.contextWindow,
|
|
1604
835
|
budget_tokens: compactPolicy.boundaryTokens,
|
|
836
|
+
boundary_tokens: compactPolicy.boundaryTokens,
|
|
1605
837
|
target_budget_tokens: compactBudgetTokens,
|
|
1606
838
|
reserve_tokens: compactPolicy.reserveTokens,
|
|
839
|
+
pressure_tokens: pressureTokens,
|
|
840
|
+
trigger_tokens: compactPolicy.triggerTokens,
|
|
1607
841
|
message_tokens_est: messageTokensEst,
|
|
842
|
+
duration_ms: Date.now() - compactStartedAt,
|
|
1608
843
|
provider: sessionRef.provider,
|
|
1609
844
|
model: sessionRef.model || model,
|
|
1610
845
|
error: compactFailMsg,
|
|
1611
846
|
error_code: compactFailCode,
|
|
847
|
+
details: {
|
|
848
|
+
semantic: semanticCompactResult?.diagnostics || null,
|
|
849
|
+
recallFastTrack: recallFastTrackResult?.diagnostics || null,
|
|
850
|
+
semanticError: semanticFailMsg,
|
|
851
|
+
recallFastTrackError: recallFailMsg,
|
|
852
|
+
},
|
|
1612
853
|
});
|
|
1613
854
|
emitCompactEvent(opts, {
|
|
1614
855
|
sessionId,
|
|
@@ -1640,6 +881,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1640
881
|
reserveTokens: compactPolicy.reserveTokens,
|
|
1641
882
|
messageTokensEst,
|
|
1642
883
|
}, compactErr);
|
|
884
|
+
}
|
|
1643
885
|
}
|
|
1644
886
|
try { opts.onStageChange?.('requesting'); } catch { /* best-effort */ }
|
|
1645
887
|
const compactChanged = messagesArrayChanged(messages, compacted);
|
|
@@ -1684,6 +926,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1684
926
|
iteration: iterations + 1,
|
|
1685
927
|
stage: 'pre_send',
|
|
1686
928
|
trigger: compactTrigger,
|
|
929
|
+
compact_type: compactPolicy.compactType || compactPolicy.type || DEFAULT_COMPACT_TYPE,
|
|
1687
930
|
prune_count: pruneCount,
|
|
1688
931
|
compact_changed: compactChanged || summaryChanged,
|
|
1689
932
|
input_prefix_hash: messagePrefixHash(messages),
|
|
@@ -1693,11 +936,19 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1693
936
|
after_bytes: afterBytes,
|
|
1694
937
|
context_window: compactPolicy.contextWindow,
|
|
1695
938
|
budget_tokens: compactPolicy.boundaryTokens,
|
|
939
|
+
boundary_tokens: compactPolicy.boundaryTokens,
|
|
1696
940
|
target_budget_tokens: compactBudgetTokens,
|
|
1697
941
|
reserve_tokens: compactPolicy.reserveTokens,
|
|
942
|
+
pressure_tokens: pressureTokens,
|
|
943
|
+
trigger_tokens: compactPolicy.triggerTokens,
|
|
1698
944
|
message_tokens_est: messageTokensEst,
|
|
945
|
+
duration_ms: compactDurationMs,
|
|
1699
946
|
provider: sessionRef.provider,
|
|
1700
947
|
model: sessionRef.model || model,
|
|
948
|
+
details: {
|
|
949
|
+
semantic: semanticCompactResult?.diagnostics || null,
|
|
950
|
+
recallFastTrack: recallFastTrackResult?.diagnostics || null,
|
|
951
|
+
},
|
|
1701
952
|
});
|
|
1702
953
|
emitCompactEvent(opts, {
|
|
1703
954
|
sessionId,
|
|
@@ -1721,6 +972,17 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1721
972
|
durationMs: compactDurationMs,
|
|
1722
973
|
});
|
|
1723
974
|
}
|
|
975
|
+
// PostCompact: bridge to the standard hook bus after compaction
|
|
976
|
+
// completes. session-property hook; { trigger } 'auto'|'manual'.
|
|
977
|
+
{
|
|
978
|
+
const _postCompactHook = typeof opts.postCompactHook === 'function'
|
|
979
|
+
? opts.postCompactHook
|
|
980
|
+
: sessionRef?.postCompactHook;
|
|
981
|
+
if (typeof _postCompactHook === 'function') {
|
|
982
|
+
try { await _postCompactHook({ sessionId, cwd, trigger: compactTrigger === 'manual' ? 'manual' : 'auto' }); }
|
|
983
|
+
catch { /* best-effort: PostCompact hook must never break the loop */ }
|
|
984
|
+
}
|
|
985
|
+
}
|
|
1724
986
|
}
|
|
1725
987
|
const nextIteration = iterations + 1;
|
|
1726
988
|
opts.iteration = nextIteration;
|
|
@@ -1775,12 +1037,12 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1775
1037
|
// Shared pre-dispatch deny: identical predicate runs in the
|
|
1776
1038
|
// serial path below. If any role/permission guard would reject
|
|
1777
1039
|
// this call there, never start it eagerly here.
|
|
1778
|
-
if (
|
|
1040
|
+
if (preDispatchDenyForSession(sessionRef, call, toolKind) !== null) return null;
|
|
1779
1041
|
const entry = { startedAt: Date.now(), endedAt: null, mutationEpoch: _mutationEpoch };
|
|
1780
1042
|
_eagerInFlightSigs.set(_sig, call.id);
|
|
1781
1043
|
entry.promise = (async () => {
|
|
1782
1044
|
try {
|
|
1783
|
-
return { ok: true, value: await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval }) };
|
|
1045
|
+
return { ok: true, value: await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval, iteration: nextIteration }) };
|
|
1784
1046
|
} catch (error) {
|
|
1785
1047
|
return { ok: false, error };
|
|
1786
1048
|
}
|
|
@@ -1810,10 +1072,17 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1810
1072
|
// calls (they return null above), so those `continue`-before-
|
|
1811
1073
|
// execution stub paths can never early-notify (contract #5).
|
|
1812
1074
|
try {
|
|
1075
|
+
// UI-only: surface the model-VISIBLE result (envelope
|
|
1076
|
+
// stub for envelope returns), never the envelope object
|
|
1077
|
+
// or its injected newMessages body — no [object Object],
|
|
1078
|
+
// no full skill body in the tool card.
|
|
1079
|
+
const _earlyVisible = settled && settled.ok
|
|
1080
|
+
? normalizeToolEnvelope(settled.value).result
|
|
1081
|
+
: null;
|
|
1813
1082
|
const _earlyContent = settled && settled.ok
|
|
1814
|
-
? (typeof
|
|
1815
|
-
?
|
|
1816
|
-
: (
|
|
1083
|
+
? (typeof _earlyVisible === 'string'
|
|
1084
|
+
? _earlyVisible
|
|
1085
|
+
: (_earlyVisible == null ? '' : String(_earlyVisible)))
|
|
1817
1086
|
: `Error: ${settled && settled.error instanceof Error ? settled.error.message : String(settled && settled.error)}`;
|
|
1818
1087
|
opts.onToolResult?.({
|
|
1819
1088
|
role: 'tool',
|
|
@@ -1870,6 +1139,88 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1870
1139
|
try {
|
|
1871
1140
|
response = await provider.send(messages, model, sendTools.length ? sendTools : undefined, opts);
|
|
1872
1141
|
} catch (sendErr) {
|
|
1142
|
+
// Partial-final recovery (owner-notify fix): the recurring "worker
|
|
1143
|
+
// finished but the task hung / no result delivered" case is a FINAL,
|
|
1144
|
+
// no-tool summary stream that wedges (ping-only) AFTER all real tool
|
|
1145
|
+
// work completed in earlier iterations. The provider attaches its
|
|
1146
|
+
// partial stream state to the StreamStalledError. When the stall
|
|
1147
|
+
// carries streamed assistant text, has NO pending tool_use, and did
|
|
1148
|
+
// NOT emit a tool call this iteration, accept the partial as a
|
|
1149
|
+
// successful terminal response (deliver the summary we have) instead
|
|
1150
|
+
// of throwing — which would strand/notify-as-failure a turn whose
|
|
1151
|
+
// work actually succeeded. A stall WITH a pending/emitted tool call
|
|
1152
|
+
// is NOT recoverable (a tool whose input never completed must never
|
|
1153
|
+
// look done) and falls through to the normal error path.
|
|
1154
|
+
if (
|
|
1155
|
+
sendErr?.streamStalled === true
|
|
1156
|
+
&& sendErr.pendingToolUse !== true
|
|
1157
|
+
&& sendErr.unsafeToRetry !== true
|
|
1158
|
+
&& typeof sendErr.partialContent === 'string'
|
|
1159
|
+
&& sendErr.partialContent.trim().length > 0
|
|
1160
|
+
&& !(Array.isArray(sendErr.partialToolCalls) && sendErr.partialToolCalls.length > 0)
|
|
1161
|
+
) {
|
|
1162
|
+
try {
|
|
1163
|
+
process.stderr.write(
|
|
1164
|
+
`[loop] final stream stalled with partial text (sess=${sessionId || 'unknown'} `
|
|
1165
|
+
+ `iter=${nextIteration} len=${sendErr.partialContent.length}); `
|
|
1166
|
+
+ `accepting as partial-final success\n`,
|
|
1167
|
+
);
|
|
1168
|
+
} catch { /* best-effort */ }
|
|
1169
|
+
response = {
|
|
1170
|
+
content: sendErr.partialContent,
|
|
1171
|
+
model: sendErr.partialModel || model,
|
|
1172
|
+
toolCalls: undefined,
|
|
1173
|
+
usage: sendErr.partialUsage || undefined,
|
|
1174
|
+
stopReason: sendErr.partialStopReason || 'end_turn',
|
|
1175
|
+
hasThinkingContent: sendErr.partialHasThinking === true,
|
|
1176
|
+
partialFinal: true,
|
|
1177
|
+
};
|
|
1178
|
+
} else
|
|
1179
|
+
// Partial tool-call recovery (agent-hang fix): a stream that stalls
|
|
1180
|
+
// AFTER fully-parsed tool calls were emitted used to lose the whole
|
|
1181
|
+
// turn — unsafeToRetry blocks the mid-stream replay (correct: a
|
|
1182
|
+
// replay would re-run side-effecting tools) and the old code threw,
|
|
1183
|
+
// discarding tool work that had ALREADY completed via eager dispatch.
|
|
1184
|
+
// But the parsed calls are complete (pendingToolUse false ⇒ no
|
|
1185
|
+
// half-streamed tool input), so instead of replaying the request we
|
|
1186
|
+
// accept the partial as a normal tool-call turn and fall through to
|
|
1187
|
+
// the standard execution path: eager-dispatched (read-only) calls
|
|
1188
|
+
// resolve from the pending map without re-running, side-effecting
|
|
1189
|
+
// calls were never started during streaming and execute exactly
|
|
1190
|
+
// once. providerState stays undefined so the next iteration resends
|
|
1191
|
+
// a full frame on a fresh stream.
|
|
1192
|
+
if (
|
|
1193
|
+
sendErr?.streamStalled === true
|
|
1194
|
+
&& sendErr.pendingToolUse !== true
|
|
1195
|
+
&& Array.isArray(sendErr.partialToolCalls)
|
|
1196
|
+
&& sendErr.partialToolCalls.length > 0
|
|
1197
|
+
) {
|
|
1198
|
+
try {
|
|
1199
|
+
process.stderr.write(
|
|
1200
|
+
`[loop] stream stalled after ${sendErr.partialToolCalls.length} complete tool call(s) `
|
|
1201
|
+
+ `(sess=${sessionId || 'unknown'} iter=${nextIteration}); `
|
|
1202
|
+
+ `recovering as tool-call turn instead of failing\n`,
|
|
1203
|
+
);
|
|
1204
|
+
} catch { /* best-effort */ }
|
|
1205
|
+
try {
|
|
1206
|
+
appendAgentTrace({
|
|
1207
|
+
kind: 'stall_tool_recovery',
|
|
1208
|
+
sessionId: sessionId || null,
|
|
1209
|
+
iteration: nextIteration,
|
|
1210
|
+
toolCalls: sendErr.partialToolCalls.length,
|
|
1211
|
+
partialContentLen: typeof sendErr.partialContent === 'string' ? sendErr.partialContent.length : 0,
|
|
1212
|
+
});
|
|
1213
|
+
} catch { /* best-effort */ }
|
|
1214
|
+
response = {
|
|
1215
|
+
content: typeof sendErr.partialContent === 'string' ? sendErr.partialContent : '',
|
|
1216
|
+
model: sendErr.partialModel || model,
|
|
1217
|
+
toolCalls: sendErr.partialToolCalls.slice(),
|
|
1218
|
+
usage: sendErr.partialUsage || undefined,
|
|
1219
|
+
stopReason: 'tool_use',
|
|
1220
|
+
hasThinkingContent: sendErr.partialHasThinking === true,
|
|
1221
|
+
partialToolRecovery: true,
|
|
1222
|
+
};
|
|
1223
|
+
} else
|
|
1873
1224
|
// Context-window-exceeded is a deterministic refusal from the API.
|
|
1874
1225
|
// Recover context overflow reactively by compacting and retrying
|
|
1875
1226
|
// in the same active turn. MixDog's proactive estimator can miss a
|
|
@@ -1922,13 +1273,18 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1922
1273
|
// the stateless contract for providers that don't use continuation).
|
|
1923
1274
|
providerState = response?.providerState ?? undefined;
|
|
1924
1275
|
iterations = nextIteration;
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1276
|
+
// Payload byte estimate serializes the FULL messages+tools array —
|
|
1277
|
+
// only pay that cost when verbose loop tracing is actually enabled
|
|
1278
|
+
// (traceAgentLoop is a no-op otherwise).
|
|
1279
|
+
if (process.env.MIXDOG_AGENT_TRACE_VERBOSE === '1') {
|
|
1280
|
+
traceAgentLoop({
|
|
1281
|
+
sessionId,
|
|
1282
|
+
iteration: iterations,
|
|
1283
|
+
sendMs: Date.now() - sendStartedAt,
|
|
1284
|
+
messageCount: Array.isArray(messages) ? messages.length : 0,
|
|
1285
|
+
bodyBytesEst: estimateProviderPayloadBytes(messages, model, sendTools),
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1932
1288
|
// Accumulate usage across iterations — every billable slot, not just
|
|
1933
1289
|
// input/output. Anthropic cache_read/cache_write typically stay 0 on
|
|
1934
1290
|
// the first iteration and surge on later ones (warm prefix reuse),
|
|
@@ -1948,6 +1304,37 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1948
1304
|
// Provider may have returned despite an abort (SDKs that don't honour
|
|
1949
1305
|
// signal) — bail before processing any of its output.
|
|
1950
1306
|
throwIfAborted();
|
|
1307
|
+
// P1 audit fix (Step4): a text-only turn truncated by the provider's
|
|
1308
|
+
// max-output limit (response.truncated, set by the provider layer
|
|
1309
|
+
// when stopReason==='length' AND content is non-empty) used to look
|
|
1310
|
+
// identical to a clean completion — the model's answer could be
|
|
1311
|
+
// silently cut mid-sentence with zero signal to the operator. Surface
|
|
1312
|
+
// it as a one-line stderr warning + trace event WITHOUT failing the
|
|
1313
|
+
// turn (the partial content is still usable and the loop's own
|
|
1314
|
+
// isIncompleteStop nudge below already re-prompts when content is
|
|
1315
|
+
// empty).
|
|
1316
|
+
if (response?.truncated === true) {
|
|
1317
|
+
try {
|
|
1318
|
+
process.stderr.write(
|
|
1319
|
+
`[loop] provider output truncated at max-output limit (sess=${sessionId || 'unknown'} `
|
|
1320
|
+
+ `iter=${iterations} stopReason=${response.stopReason ?? response.stop_reason ?? 'length'} `
|
|
1321
|
+
+ `contentLen=${typeof response.content === 'string' ? response.content.length : 0}); `
|
|
1322
|
+
+ `answer may be cut off mid-sentence.\n`,
|
|
1323
|
+
);
|
|
1324
|
+
} catch { /* best-effort */ }
|
|
1325
|
+
try {
|
|
1326
|
+
appendAgentTrace({
|
|
1327
|
+
sessionId,
|
|
1328
|
+
iteration: iterations,
|
|
1329
|
+
kind: 'output_truncated',
|
|
1330
|
+
payload: {
|
|
1331
|
+
stop_reason: response.stopReason ?? response.stop_reason ?? 'length',
|
|
1332
|
+
content_len: typeof response.content === 'string' ? response.content.length : 0,
|
|
1333
|
+
agent: sessionAgent || null,
|
|
1334
|
+
},
|
|
1335
|
+
});
|
|
1336
|
+
} catch { /* best-effort */ }
|
|
1337
|
+
}
|
|
1951
1338
|
// Incremental metric persistence (fix A): push per-iteration token delta
|
|
1952
1339
|
// immediately so watchdog / agent type=list sees live totals mid-turn.
|
|
1953
1340
|
if (sessionId && opts.onUsageDelta && response.usage) {
|
|
@@ -1994,7 +1381,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1994
1381
|
// reminders waste turns and fragment the working context, so
|
|
1995
1382
|
// the second empty turn is accepted as terminal.
|
|
1996
1383
|
const hasContent = typeof response.content === 'string' && response.content.trim().length > 0;
|
|
1997
|
-
const isHidden =
|
|
1384
|
+
const isHidden = HIDDEN_AGENT_NAMES.has(sessionAgent);
|
|
1998
1385
|
const stopReason = response.stopReason ?? response.stop_reason ?? null;
|
|
1999
1386
|
const isIncompleteStop = stopReason && INCOMPLETE_STOP_REASONS.has(stopReason);
|
|
2000
1387
|
// A user/schedule notification can arrive while provider.send() is
|
|
@@ -2091,6 +1478,14 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
2091
1478
|
}
|
|
2092
1479
|
// R15: per-turn scalar read-count Map. Lifetime = this turn's tool-call batch.
|
|
2093
1480
|
// Declared between the duplicate-detection block and the for-loop so it resets
|
|
1481
|
+
// Per-batch buffer for the general `newMessages` tool-result channel.
|
|
1482
|
+
// A tool MAY return a `{ __toolEnvelope, result, newMessages }` envelope;
|
|
1483
|
+
// its newMessages (e.g. the Skill SKILL.md body as a role:'user' message)
|
|
1484
|
+
// are collected here across EVERY call in this assistant turn and flushed
|
|
1485
|
+
// ONCE, AFTER the batch's last tool_result is pushed — never interleaved
|
|
1486
|
+
// between two tool results of the same multi-tool turn (which would put a
|
|
1487
|
+
// user message between tool(A) and tool(B) and break provider pairing).
|
|
1488
|
+
const _batchNewMessages = [];
|
|
2094
1489
|
for (let callIndex = 0; callIndex < calls.length; callIndex += 1) {
|
|
2095
1490
|
const call = calls[callIndex];
|
|
2096
1491
|
if (isBuiltinTool(call.name)) {
|
|
@@ -2131,8 +1526,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
2131
1526
|
let toolStartedAt;
|
|
2132
1527
|
let toolEndedAt;
|
|
2133
1528
|
const toolKind = getToolKind(call.name);
|
|
2134
|
-
// Cross-turn read dedup
|
|
2135
|
-
// fileReadCache.ts: if the path's stat tuple (mtime/size/ino/dev)
|
|
1529
|
+
// Cross-turn read dedup: if the path's stat tuple (mtime/size/ino/dev)
|
|
2136
1530
|
// is unchanged since a prior read in THIS session, return the cached
|
|
2137
1531
|
// body instead of executing. Both scalar and array/object-array path
|
|
2138
1532
|
// forms are cached — keyed by (abs, offset, limit, mode, n) per entry.
|
|
@@ -2216,18 +1610,18 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
2216
1610
|
// Runtime pre-dispatch deny. Schema profiles may hide
|
|
2217
1611
|
// tools for routing efficiency, but this remains the
|
|
2218
1612
|
// control-plane boundary for any tool_use that still
|
|
2219
|
-
// reaches the loop.
|
|
1613
|
+
// reaches the loop. preDispatchDenyForSession is the SHARED helper
|
|
2220
1614
|
// used by both the eager dispatch path (startEagerTool)
|
|
2221
1615
|
// and this serial path — keeps the agent-owned control-
|
|
2222
1616
|
// plane reject and no-tool role guards consistent across
|
|
2223
1617
|
// both paths.
|
|
2224
|
-
const _denyMsg =
|
|
1618
|
+
const _denyMsg = preDispatchDenyForSession(sessionRef, call, toolKind);
|
|
2225
1619
|
if (_denyMsg !== null) {
|
|
2226
1620
|
result = _denyMsg;
|
|
2227
1621
|
toolEndedAt = Date.now();
|
|
2228
1622
|
_resultKind = 'error';
|
|
2229
1623
|
} else {
|
|
2230
|
-
result = await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval });
|
|
1624
|
+
result = await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval, iteration: iterations });
|
|
2231
1625
|
toolEndedAt = Date.now();
|
|
2232
1626
|
// Boundary: tool-return string convention → structural kind.
|
|
2233
1627
|
// The only prefix check in this codebase; downstream layers
|
|
@@ -2249,6 +1643,48 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
2249
1643
|
result = `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
2250
1644
|
_resultKind = 'error';
|
|
2251
1645
|
}
|
|
1646
|
+
// CENTRAL ENVELOPE NORMALIZE (general newMessages channel).
|
|
1647
|
+
// executeTool (serial + eager) and cache/error paths above all
|
|
1648
|
+
// funnel into `result`. Split ONCE here: downstream post-processing
|
|
1649
|
+
// (classifyResultKind / maybeOffloadToolResult / compressToolResult /
|
|
1650
|
+
// traceAgentTool / cache writes / messages.push) sees ONLY the
|
|
1651
|
+
// model-visible `result`; the `newMessages` ride a per-batch buffer
|
|
1652
|
+
// flushed after the batch's last tool_result (never interleaved).
|
|
1653
|
+
{
|
|
1654
|
+
const _env = normalizeToolEnvelope(result);
|
|
1655
|
+
result = _env.result;
|
|
1656
|
+
if (_env.newMessages.length) _batchNewMessages.push(..._env.newMessages);
|
|
1657
|
+
}
|
|
1658
|
+
// Bounded-map cleanup: a scoped-cache outcome recorded for this call.id
|
|
1659
|
+
// (via _scopedCacheOutcomeForCall) is only ever consumed/deleted on the
|
|
1660
|
+
// success path below (_executeOk && _resultKind==='normal'). A failed or
|
|
1661
|
+
// errored call would otherwise leak its entry in
|
|
1662
|
+
// sessionRef._scopedCacheOutcomeByCallId forever — reclaim it here.
|
|
1663
|
+
if (sessionRef?._scopedCacheOutcomeByCallId && call?.id && (!_executeOk || _resultKind === 'error')) {
|
|
1664
|
+
sessionRef._scopedCacheOutcomeByCallId.delete(call.id);
|
|
1665
|
+
}
|
|
1666
|
+
// PostToolUseFailure: a tool that resolved to a failure (thrown-error
|
|
1667
|
+
// path -> `Error:` string, or an is_error result classified as
|
|
1668
|
+
// 'error') fires the optional session failure hook. Same shape as
|
|
1669
|
+
// afterToolHook; `result` carries the error text. Best-effort — a
|
|
1670
|
+
// hook error must never wedge the tool loop.
|
|
1671
|
+
if (!_executeOk || _resultKind === 'error') {
|
|
1672
|
+
const _afterToolFailureHook = typeof opts.afterToolFailureHook === 'function'
|
|
1673
|
+
? opts.afterToolFailureHook
|
|
1674
|
+
: sessionRef?.afterToolFailureHook;
|
|
1675
|
+
if (typeof _afterToolFailureHook === 'function') {
|
|
1676
|
+
try {
|
|
1677
|
+
await _afterToolFailureHook({
|
|
1678
|
+
name: call.name,
|
|
1679
|
+
args: call.arguments,
|
|
1680
|
+
cwd,
|
|
1681
|
+
sessionId,
|
|
1682
|
+
toolCallId: call.id,
|
|
1683
|
+
result: typeof result === 'string' ? result : String(result ?? ''),
|
|
1684
|
+
});
|
|
1685
|
+
} catch { /* best-effort: PostToolUseFailure hook must never break the loop */ }
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
2252
1688
|
// Update the cross-iteration repeat-failure guard with this call's
|
|
2253
1689
|
// outcome: bump the consecutive-failure count for an identical
|
|
2254
1690
|
// signature, or clear it the moment the same call succeeds.
|
|
@@ -2400,7 +1836,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
2400
1836
|
toolKind,
|
|
2401
1837
|
toolMs: toolEndedAt - toolStartedAt,
|
|
2402
1838
|
toolArgs: call.arguments,
|
|
2403
|
-
|
|
1839
|
+
agent: sessionRef?.agent || null,
|
|
2404
1840
|
model: sessionRef?.model || null,
|
|
2405
1841
|
resultKind: _resultKind,
|
|
2406
1842
|
resultText: result,
|
|
@@ -2432,12 +1868,22 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
2432
1868
|
setReadCached({ sessionId, args: call.arguments, cwd, content: result, toolUseId: call.id });
|
|
2433
1869
|
}
|
|
2434
1870
|
}
|
|
1871
|
+
// UI-only: apply_patch stashes the standard unified diff keyed
|
|
1872
|
+
// by tool_use id (never in the model-visible result). Attach it
|
|
1873
|
+
// here as a side-channel field so the TUI's expanded (ctrl+o)
|
|
1874
|
+
// raw view renders a colored +/- diff. The provider lowering
|
|
1875
|
+
// (anthropic/openai/etc.) never reads `uiDiff`, so the model
|
|
1876
|
+
// sees only `content` (the compact summary) — no token bloat.
|
|
1877
|
+
const _applyPatchUiDiff = _stripMcpPrefix(call.name) === 'apply_patch'
|
|
1878
|
+
? takeApplyPatchUiDiff(call.id)
|
|
1879
|
+
: null;
|
|
2435
1880
|
pushToolResultMessage({
|
|
2436
1881
|
role: 'tool',
|
|
2437
1882
|
content: result,
|
|
2438
1883
|
toolCallId: call.id,
|
|
2439
1884
|
toolKind: _resultKind,
|
|
2440
1885
|
...(_nativeToolSearch ? { nativeToolSearch: _nativeToolSearch } : {}),
|
|
1886
|
+
...(_applyPatchUiDiff ? { uiDiff: _applyPatchUiDiff } : {}),
|
|
2441
1887
|
});
|
|
2442
1888
|
} catch (postErr) {
|
|
2443
1889
|
_postProcessOk = false;
|
|
@@ -2453,7 +1899,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
2453
1899
|
toolKind,
|
|
2454
1900
|
toolMs: toolEndedAt && toolStartedAt ? toolEndedAt - toolStartedAt : null,
|
|
2455
1901
|
toolArgs: call.arguments,
|
|
2456
|
-
|
|
1902
|
+
agent: sessionRef?.agent || null,
|
|
2457
1903
|
model: sessionRef?.model || null,
|
|
2458
1904
|
cwd,
|
|
2459
1905
|
resultText: _postMsg,
|
|
@@ -2477,6 +1923,44 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
2477
1923
|
// discard the rest of the batch and skip the next provider.send.
|
|
2478
1924
|
throwIfAborted();
|
|
2479
1925
|
}
|
|
1926
|
+
// Flush the per-batch newMessages channel. All tool_results for this
|
|
1927
|
+
// assistant turn are now pushed; appending the injected role:'user'
|
|
1928
|
+
// messages here (AFTER the last tool_result, BEFORE the next provider
|
|
1929
|
+
// send) keeps provider pairing valid — no user message is interleaved
|
|
1930
|
+
// between tool(A) and tool(B). pre-send repairTranscriptBeforeProviderSend
|
|
1931
|
+
// normalizes any residual ordering. The injected messages carry their
|
|
1932
|
+
// own meta flag (e.g. meta:'skill') so compaction's latest-human-prompt
|
|
1933
|
+
// selection does not mistake them for the user's request.
|
|
1934
|
+
for (const _nm of _batchNewMessages) {
|
|
1935
|
+
if (!_nm || _nm.role !== 'user' || typeof _nm.content !== 'string' || !_nm.content) continue;
|
|
1936
|
+
messages.push({ role: 'user', content: _nm.content, ...(_nm.meta ? { meta: _nm.meta } : {}) });
|
|
1937
|
+
}
|
|
1938
|
+
// PostToolBatch: the full parallel batch of tool calls for this
|
|
1939
|
+
// assistant turn has resolved and all tool_results are pushed. Fire the
|
|
1940
|
+
// optional session hook before the next model call. No matcher event.
|
|
1941
|
+
// Block support: if the hook returns blocked===true, inject its reason
|
|
1942
|
+
// as a system-note user message for the next send (natural mechanism —
|
|
1943
|
+
// same channel the newMessages flush just used). Best-effort otherwise.
|
|
1944
|
+
{
|
|
1945
|
+
const _afterToolBatchHook = typeof opts.afterToolBatchHook === 'function'
|
|
1946
|
+
? opts.afterToolBatchHook
|
|
1947
|
+
: sessionRef?.afterToolBatchHook;
|
|
1948
|
+
if (typeof _afterToolBatchHook === 'function' && calls.length > 0) {
|
|
1949
|
+
try {
|
|
1950
|
+
const _batchDecision = await _afterToolBatchHook({
|
|
1951
|
+
sessionId,
|
|
1952
|
+
cwd,
|
|
1953
|
+
toolCount: calls.length,
|
|
1954
|
+
});
|
|
1955
|
+
if (_batchDecision?.blocked === true) {
|
|
1956
|
+
const _reason = String(_batchDecision.reason || 'PostToolBatch hook blocked continuation').trim();
|
|
1957
|
+
if (_reason) {
|
|
1958
|
+
messages.push({ role: 'user', content: `<system-reminder>\n${_reason}\n</system-reminder>`, meta: 'hook' });
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
} catch { /* best-effort: PostToolBatch hook must never break the loop */ }
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
2480
1964
|
// Mid-turn steering is drained at the next loop's pre-send point,
|
|
2481
1965
|
// AFTER any auto-compact pass. Draining here would put the steering
|
|
2482
1966
|
// user turn after the fresh tool results before compaction runs; then
|
|
@@ -2485,6 +1969,33 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
2485
1969
|
// About to re-send with tool results — transition back to connecting for the next turn.
|
|
2486
1970
|
if (sessionId) updateSessionStage(sessionId, 'connecting');
|
|
2487
1971
|
}
|
|
1972
|
+
// Classify WHY the loop ended so agent-tool can promote an empty/abnormal
|
|
1973
|
+
// finish to an explicit Lead-facing error instead of a silent empty
|
|
1974
|
+
// "completed". Determine "has content" exactly the way the no-tool-call
|
|
1975
|
+
// branch above does (trimmed string content, or any reasoning content).
|
|
1976
|
+
const _finalHasContent = (typeof response?.content === 'string' && response.content.trim().length > 0)
|
|
1977
|
+
|| (typeof response?.reasoningContent === 'string' && response.reasoningContent.trim().length > 0);
|
|
1978
|
+
const _finalStopReason = response?.stopReason ?? response?.stop_reason ?? null;
|
|
1979
|
+
const _finalIncompleteStop = _finalStopReason && INCOMPLETE_STOP_REASONS.has(_finalStopReason);
|
|
1980
|
+
const _finalIsHidden = HIDDEN_AGENT_NAMES.has(sessionAgent);
|
|
1981
|
+
let terminationReason;
|
|
1982
|
+
if (terminatedByCap) {
|
|
1983
|
+
// Real problem regardless of hidden/public: the loop never terminated
|
|
1984
|
+
// on its own contract.
|
|
1985
|
+
terminationReason = 'iteration_cap';
|
|
1986
|
+
} else if (!_finalHasContent && _finalIncompleteStop) {
|
|
1987
|
+
// Cut short mid-synthesis (token cap / provider pause). Real problem
|
|
1988
|
+
// for hidden agents too.
|
|
1989
|
+
terminationReason = 'truncated';
|
|
1990
|
+
} else if (!_finalHasContent && !_finalIsHidden) {
|
|
1991
|
+
// Empty terminal turn. Only public agents violate their contract by
|
|
1992
|
+
// finishing empty — hidden agents (explorer/cycle/…) legitimately emit
|
|
1993
|
+
// text-only/empty terminal turns per their own role contract, so leave
|
|
1994
|
+
// terminationReason undefined for them.
|
|
1995
|
+
terminationReason = 'empty';
|
|
1996
|
+
} else {
|
|
1997
|
+
terminationReason = undefined;
|
|
1998
|
+
}
|
|
2488
1999
|
return {
|
|
2489
2000
|
...response,
|
|
2490
2001
|
usage: lastUsage || response.usage,
|
|
@@ -2493,5 +2004,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
2493
2004
|
iterations,
|
|
2494
2005
|
toolCallsTotal,
|
|
2495
2006
|
providerState,
|
|
2007
|
+
terminationReason,
|
|
2008
|
+
maxLoopIterations,
|
|
2496
2009
|
};
|
|
2497
2010
|
}
|