mixdog 0.8.1 → 0.9.1
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/README.md +47 -23
- package/package.json +34 -28
- package/scripts/_test-folder-dialog.mjs +30 -0
- package/scripts/agent-parallel-smoke.mjs +388 -0
- package/scripts/agent-tag-reuse-smoke.mjs +183 -0
- package/scripts/background-task-meta-smoke.mjs +38 -0
- package/scripts/boot-smoke.mjs +52 -9
- package/scripts/build-runtime-linux.sh +348 -0
- package/scripts/build-runtime-macos.sh +217 -0
- package/scripts/build-runtime-windows.ps1 +242 -0
- package/scripts/compact-active-turn-test.mjs +68 -0
- package/scripts/compact-smoke.mjs +859 -129
- package/scripts/compact-trigger-migration-smoke.mjs +187 -0
- package/scripts/fix-brief-fn.mjs +35 -0
- package/scripts/fix-format-tool-surface.mjs +24 -0
- package/scripts/fix-tool-exec-visible.mjs +42 -0
- package/scripts/generate-runtime-manifest.mjs +166 -0
- package/scripts/hook-bus-test.mjs +330 -0
- package/scripts/lead-workflow-smoke.mjs +33 -39
- package/scripts/live-worker-smoke.mjs +43 -37
- package/scripts/llm-trace-summary.mjs +315 -0
- package/scripts/memory-meta-concurrency-test.mjs +20 -0
- package/scripts/output-style-smoke.mjs +56 -15
- package/scripts/parent-abort-link-test.mjs +44 -0
- package/scripts/patch-agent-brief.mjs +48 -0
- package/scripts/patch-app.mjs +21 -0
- package/scripts/patch-app2.mjs +18 -0
- package/scripts/patch-dist-brief.mjs +96 -0
- package/scripts/patch-tool-exec.mjs +70 -0
- package/scripts/pretool-ask-runtime-test.mjs +54 -0
- package/scripts/provider-toolcall-test.mjs +376 -0
- package/scripts/reactive-compact-persist-smoke.mjs +124 -0
- package/scripts/sanitize-tool-pairs-test.mjs +260 -0
- package/scripts/session-context-bench.mjs +205 -33
- package/scripts/session-ingest-smoke.mjs +177 -0
- package/scripts/set-effort-config-test.mjs +41 -0
- package/scripts/smoke-runtime-negative.ps1 +106 -0
- package/scripts/smoke-runtime-negative.sh +97 -0
- package/scripts/smoke.mjs +25 -0
- package/scripts/tool-result-hook-test.mjs +48 -0
- package/scripts/tool-smoke.mjs +1222 -90
- package/scripts/toolcall-args-test.mjs +150 -0
- package/scripts/tui-background-failure-smoke.mjs +73 -0
- package/scripts/usage-metrics-epoch-smoke.mjs +114 -0
- package/src/agents/debugger/AGENT.md +8 -0
- package/src/agents/explore/AGENT.md +4 -0
- package/src/agents/heavy-worker/AGENT.md +9 -3
- package/src/agents/maintainer/AGENT.md +4 -0
- package/src/agents/reviewer/AGENT.md +8 -0
- package/src/agents/scheduler-task/AGENT.md +12 -0
- package/src/agents/scheduler-task/agent.json +6 -0
- package/src/agents/webhook-handler/AGENT.md +12 -0
- package/src/agents/webhook-handler/agent.json +6 -0
- package/src/agents/worker/AGENT.md +9 -3
- package/src/app.mjs +77 -3
- package/src/defaults/hidden-roles.json +17 -12
- package/src/headless-role.mjs +117 -0
- package/src/help.mjs +30 -0
- package/src/hooks/lib/permission-evaluator.cjs +11 -475
- package/src/lib/keychain-cjs.cjs +9 -1
- package/src/lib/mixdog-debug.cjs +0 -29
- package/src/lib/plugin-paths.cjs +1 -7
- package/src/lib/rules-builder.cjs +242 -98
- package/src/lib/text-utils.cjs +1 -1
- package/src/mixdog-session-runtime.mjs +2211 -446
- package/src/output-styles/default.md +12 -28
- package/src/output-styles/extreme-simple.md +9 -6
- package/src/output-styles/simple.md +22 -9
- package/src/repl.mjs +108 -59
- package/src/rules/agent/00-common.md +15 -0
- package/src/rules/{bridge → agent}/20-skip-protocol.md +1 -2
- package/src/rules/agent/30-explorer.md +22 -0
- package/src/rules/{bridge → agent}/40-cycle1-agent.md +7 -0
- package/src/rules/{bridge → agent}/41-cycle2-agent.md +7 -0
- package/src/rules/{bridge → agent}/42-cycle3-agent.md +7 -0
- package/src/rules/lead/01-general.md +9 -5
- package/src/rules/lead/04-workflow.md +51 -12
- package/src/rules/lead/lead-tool.md +6 -0
- package/src/rules/shared/01-tool.md +12 -1
- package/src/runtime/agent/orchestrator/activity-bus.mjs +7 -18
- package/src/runtime/agent/orchestrator/agent-owner.mjs +11 -0
- package/src/runtime/agent/orchestrator/{smart-bridge/bridge-llm.mjs → agent-runtime/agent-dispatch.mjs} +138 -111
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +94 -0
- package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/cache-strategy.mjs +32 -23
- package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/session-builder.mjs +33 -27
- package/src/runtime/agent/orchestrator/{bridge-trace.mjs → agent-trace.mjs} +132 -81
- package/src/runtime/agent/orchestrator/cache-mtime.mjs +0 -21
- package/src/runtime/agent/orchestrator/config.mjs +174 -55
- package/src/runtime/agent/orchestrator/context/collect.mjs +195 -487
- package/src/runtime/agent/orchestrator/dispatch-persist.mjs +1 -1
- package/src/runtime/agent/orchestrator/internal-roles.mjs +77 -29
- package/src/runtime/agent/orchestrator/internal-tools.mjs +5 -6
- package/src/runtime/agent/orchestrator/mcp/client.mjs +15 -9
- package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +380 -268
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +131 -93
- package/src/runtime/agent/orchestrator/providers/api-usage.mjs +236 -4
- package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +49 -0
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +58 -13
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -149
- package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +132 -2
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +4 -1
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +45 -0
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +61 -116
- package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +25 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +79 -255
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +203 -71
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +512 -312
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +343 -518
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/registry.mjs +88 -51
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +289 -11
- package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +41 -8
- package/src/runtime/agent/orchestrator/session/compact.mjs +1173 -267
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +199 -36
- package/src/runtime/agent/orchestrator/session/loop.mjs +840 -612
- package/src/runtime/agent/orchestrator/session/manager.mjs +1574 -459
- package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +107 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +67 -29
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
- package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -16
- package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +3 -219
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +35 -8
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +19 -8
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -53
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +60 -37
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +21 -2
- package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -7
- package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -3
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +36 -12
- package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +5 -12
- package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -36
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -40
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +148 -27
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +43 -75
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +90 -20
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -5
- package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +86 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +11 -11
- package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4106 -4019
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -4
- package/src/runtime/agent/orchestrator/tools/patch.mjs +90 -6
- package/src/runtime/agent/orchestrator/tools/progress-message.mjs +6 -4
- package/src/runtime/agent/orchestrator/tools/result-compression.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +8 -1
- package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -4
- package/src/runtime/channels/index.mjs +149 -51
- package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
- package/src/runtime/channels/lib/config.mjs +0 -1
- package/src/runtime/channels/lib/drop-trace.mjs +1 -1
- package/src/runtime/channels/lib/executor.mjs +0 -3
- package/src/runtime/channels/lib/memory-client.mjs +0 -38
- package/src/runtime/channels/lib/output-forwarder.mjs +1 -8
- package/src/runtime/channels/lib/runtime-paths.mjs +0 -6
- package/src/runtime/channels/lib/scheduler.mjs +18 -14
- package/src/runtime/channels/lib/session-discovery.mjs +3 -6
- package/src/runtime/channels/lib/tool-format.mjs +0 -1
- package/src/runtime/channels/lib/transcript-discovery.mjs +4 -12
- package/src/runtime/channels/lib/webhook.mjs +1 -1
- package/src/runtime/channels/tool-defs.mjs +29 -29
- package/src/runtime/lib/keychain-cjs.cjs +0 -1
- package/src/runtime/memory/data/runtime-manifest.json +6 -7
- package/src/runtime/memory/index.mjs +519 -118
- package/src/runtime/memory/lib/agent-ipc.mjs +29 -12
- package/src/runtime/memory/lib/core-memory-store.mjs +2 -2
- package/src/runtime/memory/lib/embedding-model-config.mjs +55 -0
- package/src/runtime/memory/lib/embedding-provider.mjs +31 -4
- package/src/runtime/memory/lib/embedding-worker.mjs +19 -10
- package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
- package/src/runtime/memory/lib/memory-cycle1.mjs +28 -7
- package/src/runtime/memory/lib/memory-cycle2.mjs +4 -5
- package/src/runtime/memory/lib/memory-cycle3.mjs +2 -2
- package/src/runtime/memory/lib/memory-ops-policy.mjs +2 -2
- package/src/runtime/memory/lib/memory-session-merge.mjs +38 -0
- package/src/runtime/memory/lib/memory.mjs +88 -9
- package/src/runtime/memory/lib/model-profile.mjs +1 -1
- package/src/runtime/memory/lib/pg/adapter.mjs +1 -1
- package/src/runtime/memory/lib/pg/supervisor.mjs +12 -0
- package/src/runtime/memory/lib/runtime-fetcher.mjs +80 -21
- package/src/runtime/memory/lib/session-ingest.mjs +196 -0
- package/src/runtime/memory/lib/trace-store.mjs +96 -51
- package/src/runtime/memory/tool-defs.mjs +46 -37
- package/src/runtime/search/index.mjs +104 -473
- package/src/runtime/search/lib/config.mjs +0 -4
- package/src/runtime/search/lib/state.mjs +1 -15
- package/src/runtime/search/lib/web-tools.mjs +44 -25
- package/src/runtime/search/tool-defs.mjs +16 -23
- package/src/runtime/shared/abort-controller.mjs +1 -1
- package/src/runtime/shared/atomic-file.mjs +4 -3
- package/src/runtime/shared/background-tasks.mjs +122 -11
- package/src/runtime/shared/child-spawn-gate.mjs +139 -0
- package/src/runtime/shared/config.mjs +7 -4
- package/src/runtime/shared/err-text.mjs +131 -4
- package/src/runtime/shared/llm/cost.mjs +2 -2
- package/src/runtime/shared/llm/http-agent.mjs +23 -7
- package/src/runtime/shared/llm/index.mjs +34 -11
- package/src/runtime/shared/llm/usage-log.mjs +4 -4
- package/src/runtime/shared/markdown-frontmatter.mjs +56 -0
- package/src/runtime/shared/singleton-owner.mjs +104 -0
- package/src/runtime/shared/tool-execution-contract.mjs +199 -20
- package/src/runtime/shared/tool-execution-contract.test.mjs +183 -0
- package/src/runtime/shared/tool-surface.mjs +624 -95
- package/src/runtime/shared/user-data-guard.mjs +0 -2
- package/src/standalone/agent-task-status.mjs +203 -0
- package/src/standalone/agent-task-status.test.mjs +76 -0
- package/src/standalone/agent-tool.mjs +1913 -0
- package/src/standalone/channel-worker.mjs +370 -14
- package/src/standalone/explore-tool.mjs +165 -70
- package/src/standalone/folder-dialog.mjs +314 -0
- package/src/standalone/hook-bus.mjs +898 -22
- package/src/standalone/memory-runtime-proxy.mjs +320 -0
- package/src/standalone/projects.mjs +226 -0
- package/src/standalone/provider-admin.mjs +41 -24
- package/src/standalone/seeds.mjs +1 -78
- package/src/standalone/usage-dashboard.mjs +96 -8
- package/src/tui/App.jsx +4800 -2140
- package/src/tui/components/AnsiText.jsx +39 -28
- package/src/tui/components/ContextPanel.jsx +14 -4
- package/src/tui/components/Markdown.jsx +43 -77
- package/src/tui/components/MarkdownTable.jsx +9 -184
- package/src/tui/components/Message.jsx +28 -11
- package/src/tui/components/Picker.jsx +95 -56
- package/src/tui/components/PromptInput.jsx +428 -239
- package/src/tui/components/QueuedCommands.jsx +1 -1
- package/src/tui/components/SlashCommandPalette.jsx +27 -21
- package/src/tui/components/Spinner.jsx +67 -38
- package/src/tui/components/StatusLine.jsx +606 -38
- package/src/tui/components/TextEntryPanel.jsx +128 -9
- package/src/tui/components/ToolExecution.jsx +597 -362
- package/src/tui/components/TurnDone.jsx +3 -3
- package/src/tui/components/UsagePanel.jsx +3 -5
- package/src/tui/components/tool-output-format.mjs +499 -0
- package/src/tui/components/tool-output-format.test.mjs +312 -0
- package/src/tui/dist/index.mjs +9192 -2399
- package/src/tui/engine-runtime-notification.test.mjs +115 -0
- package/src/tui/engine-tool-result-text.test.mjs +75 -0
- package/src/tui/engine.mjs +1455 -279
- package/src/tui/figures.mjs +21 -40
- package/src/tui/index.jsx +75 -31
- package/src/tui/input-editing.mjs +25 -0
- package/src/tui/markdown/format-token.mjs +671 -69
- package/src/tui/markdown/format-token.test.mjs +312 -0
- package/src/tui/markdown/render-ansi.mjs +94 -0
- package/src/tui/markdown/render-ansi.test.mjs +108 -0
- package/src/tui/markdown/stream-fence.mjs +34 -0
- package/src/tui/markdown/stream-fence.test.mjs +26 -0
- package/src/tui/markdown/table-layout.mjs +250 -0
- package/src/tui/paste-attachments.mjs +0 -7
- package/src/tui/spinner-verbs.mjs +1 -2
- package/src/tui/statusline-ansi-bridge.mjs +172 -0
- package/src/tui/statusline-ansi-bridge.test.mjs +159 -0
- package/src/tui/theme.mjs +756 -24
- package/src/tui/time-format.mjs +1 -1
- package/src/tui/transcript-tool-failures.mjs +67 -0
- package/src/tui/transcript-tool-failures.test.mjs +111 -0
- package/src/ui/ansi.mjs +1 -2
- package/src/ui/markdown.mjs +85 -26
- package/src/ui/markdown.test.mjs +70 -0
- package/src/ui/model-display.mjs +121 -0
- package/src/ui/session-stats.mjs +44 -0
- package/src/ui/statusline-context-label.test.mjs +15 -0
- package/src/ui/statusline.mjs +386 -178
- package/src/ui/tool-card.mjs +2 -13
- package/src/vendor/statusline/bin/statusline-lib.mjs +1 -620
- package/src/vendor/statusline/bin/statusline-route.mjs +169 -37
- package/src/vendor/statusline/bin/statusline-route.test.mjs +80 -0
- package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +3 -3
- package/src/vendor/statusline/src/gateway/claude-current.mjs +1 -1
- package/src/vendor/statusline/src/gateway/route-meta.mjs +44 -6
- package/src/vendor/statusline/src/gateway/session-routes.mjs +1 -1
- package/src/workflows/default/WORKFLOW.md +12 -5
- package/src/workflows/default/workflow.json +0 -1
- package/src/workflows/solo/WORKFLOW.md +15 -0
- package/src/workflows/solo/workflow.json +7 -0
- package/vendor/ink/build/ink.js +54 -8
- package/vendor/ink/build/output.js +6 -1
- package/src/agents/scheduler-task.md +0 -3
- package/src/agents/web-researcher/AGENT.md +0 -3
- package/src/agents/web-researcher/agent.json +0 -6
- package/src/agents/webhook-handler.md +0 -3
- package/src/hooks/lib/permission-rules.cjs +0 -170
- package/src/hooks/lib/settings-loader.cjs +0 -112
- package/src/lib/hook-pipe-path.cjs +0 -10
- package/src/rules/bridge/00-common.md +0 -5
- package/src/rules/bridge/30-explorer.md +0 -4
- package/src/rules/lead/00-tool-lead.md +0 -5
- package/src/rules/shared/00-language.md +0 -3
- package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
- package/src/runtime/agent/orchestrator/tools/mutation-content-cache.mjs +0 -67
- package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
- package/src/runtime/memory/lib/bridge-trace-queries.mjs +0 -120
- package/src/runtime/shared/llm/pid-cleanup.mjs +0 -27
- package/src/standalone/bridge-tool.mjs +0 -1414
- package/src/tui/runtime/shared/process-shutdown.mjs +0 -1
|
@@ -5,35 +5,44 @@ import { join } from 'path';
|
|
|
5
5
|
import { getProvider, providerInputExcludesCache } from '../providers/registry.mjs';
|
|
6
6
|
import { getModelMetadataSync } from '../providers/model-catalog.mjs';
|
|
7
7
|
import { fetchOAuthUsageSnapshot } from '../providers/oauth-usage.mjs';
|
|
8
|
-
|
|
8
|
+
// Image content is kept in-memory and in the model-visible history so multi-turn
|
|
9
|
+
// recognition matches reference agent behavior (live transcript always retains images). The
|
|
10
|
+
// stored-history placeholder swap now happens only at disk-serialization time
|
|
11
|
+
// inside the session store, so it is no longer imported here.
|
|
9
12
|
import {
|
|
10
|
-
|
|
11
|
-
compactMessages,
|
|
13
|
+
recallFastTrackCompactMessages,
|
|
12
14
|
semanticCompactMessages,
|
|
15
|
+
compactTypeIsRecallFastTrack,
|
|
16
|
+
compactTypeIsSemantic,
|
|
17
|
+
normalizeCompactType,
|
|
18
|
+
DEFAULT_COMPACT_TYPE,
|
|
19
|
+
SUMMARY_PREFIX,
|
|
13
20
|
DEFAULT_COMPACTION_BUFFER_RATIO,
|
|
14
21
|
compactionBufferTokensForBoundary,
|
|
15
22
|
normalizeCompactionBufferRatio,
|
|
23
|
+
drainSessionCycle1,
|
|
16
24
|
} from './compact.mjs';
|
|
17
|
-
import { estimateMessagesTokens, estimateRequestReserveTokens } from './context-utils.mjs';
|
|
25
|
+
import { estimateMessagesTokens, estimateRequestReserveTokens, estimateTranscriptContextUsage } from './context-utils.mjs';
|
|
18
26
|
import { getMcpTools } from '../mcp/client.mjs';
|
|
19
27
|
import { getInternalTools, executeInternalTool } from '../internal-tools.mjs';
|
|
20
|
-
import { BUILTIN_TOOLS } from '../tools/builtin.mjs';
|
|
28
|
+
import { BUILTIN_TOOLS } from '../tools/builtin/builtin-tools.mjs';
|
|
21
29
|
import { PATCH_TOOL_DEFS } from '../tools/patch-tool-defs.mjs';
|
|
22
30
|
import { CODE_GRAPH_TOOL_DEFS } from '../tools/code-graph-tool-defs.mjs';
|
|
23
|
-
import {
|
|
24
|
-
import { closeBashSession } from '../tools/bash-session.mjs';
|
|
25
|
-
import { collectSkillsCached, buildSkillToolDefs, loadAgentTemplate, loadRoleTemplate, composeSystemPrompt, collectMixdogMd } from '../context/collect.mjs';
|
|
31
|
+
import { collectSkillsCached, buildSkillManifest, buildSkillToolDefs, composeSystemPrompt } from '../context/collect.mjs';
|
|
26
32
|
import { saveSession, saveSessionAsync, loadSession, listStoredSessionSummaries, sweepStaleSessions, markSessionClosed, publishHeartbeat, deleteHeartbeat, setLiveSession } from './store.mjs';
|
|
27
33
|
import { clearReadDedupSession, tryPrefetchCached, setPrefetchCached } from './read-dedup.mjs';
|
|
28
34
|
import { clearOffloadSession } from './tool-result-offload.mjs';
|
|
29
35
|
import { classifyResultKind } from './result-classification.mjs';
|
|
30
36
|
import { createAbortController } from '../../../shared/abort-controller.mjs';
|
|
37
|
+
import { isInternalRuntimeNotificationText as contractIsInternalRuntimeNotificationText } from '../../../shared/tool-execution-contract.mjs';
|
|
31
38
|
import { logLlmCall } from '../../../shared/llm/usage-log.mjs';
|
|
32
39
|
import { resolvePluginData, mixdogRoot } from '../../../shared/plugin-paths.mjs';
|
|
33
40
|
import { updateJsonAtomicSync } from '../../../shared/atomic-file.mjs';
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
41
|
+
import { appendAgentTrace } from '../agent-trace.mjs';
|
|
42
|
+
import { isAgentOwner } from '../agent-owner.mjs';
|
|
43
|
+
import { maxMtimeRecursive } from '../cache-mtime.mjs';
|
|
44
|
+
import { getHiddenRole, getRoleInstructionDir, listHiddenRoleNames } from '../internal-roles.mjs';
|
|
45
|
+
import { DEFAULT_ACTIVITY_HEARTBEAT_MS } from '../stall-policy.mjs';
|
|
37
46
|
import {
|
|
38
47
|
buildGatewayLimits,
|
|
39
48
|
recordGatewayUsageEvent,
|
|
@@ -53,80 +62,134 @@ const _rulesBuilder = (() => {
|
|
|
53
62
|
try { return _require('../../../../lib/rules-builder.cjs'); } catch { return null; }
|
|
54
63
|
})();
|
|
55
64
|
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
let
|
|
66
|
-
let
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
// BP1/BP2/BP3 prompt-layer caches — invalidated by source file mtime, not a
|
|
66
|
+
// timer. Cheap: O(sentinel-count) stat calls on each session creation, no file
|
|
67
|
+
// I/O when warm.
|
|
68
|
+
let _sharedRulesCache = null;
|
|
69
|
+
let _sharedRulesMtime = 0;
|
|
70
|
+
const _agentRulesCacheByProfile = new Map();
|
|
71
|
+
let _leadRulesCache = null;
|
|
72
|
+
let _leadRulesMtime = 0;
|
|
73
|
+
let _leadMetaCache = null;
|
|
74
|
+
let _leadMetaMtime = 0;
|
|
75
|
+
let _codeGraphRuntimePromise = null;
|
|
76
|
+
let _agentLoopPromise = null;
|
|
77
|
+
let _bashSessionRuntimePromise = null;
|
|
78
|
+
async function _executeCodeGraphToolLazy(name, args, cwd, signal = null, options = {}) {
|
|
79
|
+
_codeGraphRuntimePromise ??= import('../tools/code-graph.mjs');
|
|
80
|
+
const mod = await _codeGraphRuntimePromise;
|
|
81
|
+
if (typeof mod.executeCodeGraphTool !== 'function') throw new Error('code_graph runtime is not available');
|
|
82
|
+
return mod.executeCodeGraphTool(name, args, cwd, signal, options);
|
|
83
|
+
}
|
|
84
|
+
async function _getAgentLoop() {
|
|
85
|
+
_agentLoopPromise ??= import('./loop.mjs');
|
|
86
|
+
const mod = await _agentLoopPromise;
|
|
87
|
+
if (typeof mod.agentLoop !== 'function') throw new Error('agent loop runtime is not available');
|
|
88
|
+
return mod.agentLoop;
|
|
89
|
+
}
|
|
90
|
+
function _closeBashSessionLazy(sessionId, reason) {
|
|
91
|
+
if (!sessionId) return;
|
|
92
|
+
_bashSessionRuntimePromise ??= import('../tools/bash-session.mjs');
|
|
93
|
+
_bashSessionRuntimePromise
|
|
94
|
+
.then((mod) => { if (typeof mod.closeBashSession === 'function') mod.closeBashSession(sessionId, reason); })
|
|
95
|
+
.catch(() => {});
|
|
96
|
+
}
|
|
97
|
+
function _buildSharedRules() {
|
|
98
|
+
if (!_rulesBuilder || typeof _rulesBuilder.buildSharedToolContent !== 'function') return '';
|
|
69
99
|
const PLUGIN_ROOT = mixdogRoot();
|
|
70
|
-
const DATA_DIR = resolvePluginData();
|
|
71
100
|
const RULES_DIR = join(PLUGIN_ROOT, 'rules');
|
|
72
101
|
const mtime = maxMtimeRecursive([
|
|
73
102
|
join(RULES_DIR, 'shared'),
|
|
74
|
-
|
|
75
|
-
|
|
103
|
+
]);
|
|
104
|
+
if (_sharedRulesCache !== null && mtime <= _sharedRulesMtime) {
|
|
105
|
+
return _sharedRulesCache;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const built = _rulesBuilder.buildSharedToolContent({ PLUGIN_ROOT, DATA_DIR: resolvePluginData() });
|
|
109
|
+
_sharedRulesCache = built;
|
|
110
|
+
_sharedRulesMtime = mtime;
|
|
111
|
+
return built;
|
|
112
|
+
} catch (e) {
|
|
113
|
+
throw new Error(`[session] shared tool rules build failed: ${e.message}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function _buildAgentRules(profile = 'full') {
|
|
118
|
+
if (!_rulesBuilder || typeof _rulesBuilder.buildAgentRoleContent !== 'function') return '';
|
|
119
|
+
const key = String(profile || 'full');
|
|
120
|
+
const PLUGIN_ROOT = mixdogRoot();
|
|
121
|
+
const DATA_DIR = resolvePluginData();
|
|
122
|
+
const RULES_DIR = join(PLUGIN_ROOT, 'rules');
|
|
123
|
+
const mtime = maxMtimeRecursive([
|
|
124
|
+
join(RULES_DIR, 'agent'),
|
|
76
125
|
join(DATA_DIR, 'mixdog-config.json'),
|
|
77
126
|
]);
|
|
78
|
-
|
|
79
|
-
|
|
127
|
+
const cached = _agentRulesCacheByProfile.get(key);
|
|
128
|
+
if (cached && mtime <= cached.mtime) {
|
|
129
|
+
return cached.value;
|
|
80
130
|
}
|
|
81
131
|
try {
|
|
82
|
-
const built = _rulesBuilder.
|
|
83
|
-
|
|
84
|
-
_bridgeRulesMtime = mtime;
|
|
132
|
+
const built = _rulesBuilder.buildAgentRoleContent({ PLUGIN_ROOT, DATA_DIR, profile: key });
|
|
133
|
+
_agentRulesCacheByProfile.set(key, { mtime, value: built });
|
|
85
134
|
return built;
|
|
86
135
|
} catch (e) {
|
|
87
|
-
throw new Error(`[session]
|
|
136
|
+
throw new Error(`[session] agent role rules build failed: ${e.message}`);
|
|
88
137
|
}
|
|
89
138
|
}
|
|
90
139
|
|
|
91
|
-
let _leadRulesCache = null;
|
|
92
|
-
let _leadRulesMtime = 0;
|
|
93
140
|
function _buildLeadRules() {
|
|
94
|
-
if (!_rulesBuilder || typeof _rulesBuilder.
|
|
141
|
+
if (!_rulesBuilder || typeof _rulesBuilder.buildLeadRoleContent !== 'function') return '';
|
|
95
142
|
const PLUGIN_ROOT = mixdogRoot();
|
|
96
143
|
const DATA_DIR = resolvePluginData();
|
|
97
144
|
const RULES_DIR = join(PLUGIN_ROOT, 'rules');
|
|
98
|
-
const mtime =
|
|
99
|
-
PLUGIN_ROOT,
|
|
100
|
-
DATA_DIR,
|
|
101
|
-
]), maxMtimeRecursive([
|
|
102
|
-
join(RULES_DIR, 'shared'),
|
|
145
|
+
const mtime = maxMtimeRecursive([
|
|
103
146
|
join(RULES_DIR, 'lead'),
|
|
104
|
-
join(DATA_DIR, 'history'),
|
|
105
147
|
join(DATA_DIR, 'mixdog-config.json'),
|
|
106
|
-
|
|
107
|
-
join(DATA_DIR, 'user-workflow.json'),
|
|
108
|
-
join(PLUGIN_ROOT, 'output-styles'),
|
|
109
|
-
join(DATA_DIR, 'output-styles'),
|
|
110
|
-
]));
|
|
148
|
+
]);
|
|
111
149
|
if (_leadRulesCache !== null && mtime <= _leadRulesMtime) {
|
|
112
150
|
return _leadRulesCache;
|
|
113
151
|
}
|
|
114
152
|
try {
|
|
115
|
-
const built = _rulesBuilder.
|
|
153
|
+
const built = _rulesBuilder.buildLeadRoleContent({ PLUGIN_ROOT, DATA_DIR });
|
|
116
154
|
_leadRulesCache = built;
|
|
117
155
|
_leadRulesMtime = mtime;
|
|
118
156
|
return built;
|
|
119
157
|
} catch (e) {
|
|
120
|
-
throw new Error(`[session] lead rules build failed: ${e.message}`);
|
|
158
|
+
throw new Error(`[session] lead role rules build failed: ${e.message}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function _buildLeadMetaContext() {
|
|
163
|
+
if (!_rulesBuilder || typeof _rulesBuilder.buildLeadMetaContent !== 'function') return '';
|
|
164
|
+
const PLUGIN_ROOT = mixdogRoot();
|
|
165
|
+
const DATA_DIR = resolvePluginData();
|
|
166
|
+
const RULES_DIR = join(PLUGIN_ROOT, 'rules');
|
|
167
|
+
const mtime = maxMtimeRecursive([
|
|
168
|
+
join(RULES_DIR, 'lead'),
|
|
169
|
+
join(DATA_DIR, 'history'),
|
|
170
|
+
join(DATA_DIR, 'mixdog-config.json'),
|
|
171
|
+
join(DATA_DIR, 'user-workflow.md'),
|
|
172
|
+
join(PLUGIN_ROOT, 'output-styles'),
|
|
173
|
+
join(DATA_DIR, 'output-styles'),
|
|
174
|
+
]);
|
|
175
|
+
if (_leadMetaCache !== null && mtime <= _leadMetaMtime) {
|
|
176
|
+
return _leadMetaCache;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const built = _rulesBuilder.buildLeadMetaContent({ PLUGIN_ROOT, DATA_DIR });
|
|
180
|
+
_leadMetaCache = built;
|
|
181
|
+
_leadMetaMtime = mtime;
|
|
182
|
+
return built;
|
|
183
|
+
} catch (e) {
|
|
184
|
+
throw new Error(`[session] lead meta context build failed: ${e.message}`);
|
|
121
185
|
}
|
|
122
186
|
}
|
|
123
187
|
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
// return ''.
|
|
188
|
+
// BP4-adjacent role-specific data cache — keyed by role. webhook / schedule
|
|
189
|
+
// roles each have their own scoped instruction set; other roles return ''.
|
|
127
190
|
const _roleSpecificCache = new Map(); // role → { value, mtime }
|
|
128
191
|
function _buildRoleSpecific(currentRole) {
|
|
129
|
-
if (!_rulesBuilder || typeof _rulesBuilder.
|
|
192
|
+
if (!_rulesBuilder || typeof _rulesBuilder.buildAgentRoleSpecificContent !== 'function') return '';
|
|
130
193
|
if (!currentRole) return '';
|
|
131
194
|
const PLUGIN_ROOT = mixdogRoot();
|
|
132
195
|
const DATA_DIR = resolvePluginData();
|
|
@@ -145,7 +208,7 @@ function _buildRoleSpecific(currentRole) {
|
|
|
145
208
|
return entry.value;
|
|
146
209
|
}
|
|
147
210
|
try {
|
|
148
|
-
const built = _rulesBuilder.
|
|
211
|
+
const built = _rulesBuilder.buildAgentRoleSpecificContent({ PLUGIN_ROOT, DATA_DIR, currentRole });
|
|
149
212
|
_roleSpecificCache.set(currentRole, { mtime, value: built });
|
|
150
213
|
return built;
|
|
151
214
|
} catch (e) {
|
|
@@ -153,27 +216,27 @@ function _buildRoleSpecific(currentRole) {
|
|
|
153
216
|
}
|
|
154
217
|
}
|
|
155
218
|
|
|
156
|
-
//
|
|
219
|
+
// Agent Runtime is optional — injected via setAgentRuntime() during plugin init
|
|
157
220
|
// so session creation never depends on a circular import. If never injected,
|
|
158
221
|
// createSession simply falls back to classic preset-only behavior.
|
|
159
|
-
let
|
|
160
|
-
let
|
|
222
|
+
let _agentRuntimeApi = null;
|
|
223
|
+
let _agentRuntimeWarned = false;
|
|
161
224
|
|
|
162
225
|
/**
|
|
163
|
-
* Inject the
|
|
164
|
-
* after
|
|
226
|
+
* Inject the Agent Runtime singleton. Called once by agent/index.mjs init()
|
|
227
|
+
* after initAgentRuntime(). Safe to call multiple times — later calls
|
|
165
228
|
* replace the previous reference.
|
|
166
229
|
*/
|
|
167
|
-
export function
|
|
168
|
-
|
|
230
|
+
export function setAgentRuntime(api) {
|
|
231
|
+
_agentRuntimeApi = api || null;
|
|
169
232
|
}
|
|
170
233
|
|
|
171
|
-
function
|
|
172
|
-
return
|
|
234
|
+
function getAgentRuntimeSync() {
|
|
235
|
+
return _agentRuntimeApi;
|
|
173
236
|
}
|
|
174
237
|
|
|
175
238
|
/**
|
|
176
|
-
* Thrown when a session is closed while a call is in-flight. Callers (
|
|
239
|
+
* Thrown when a session is closed while a call is in-flight. Callers (agent
|
|
177
240
|
* handler, CLI) should render this as "cancelled" rather than a hard error.
|
|
178
241
|
*/
|
|
179
242
|
export class SessionClosedError extends Error {
|
|
@@ -189,9 +252,15 @@ export class SessionClosedError extends Error {
|
|
|
189
252
|
}
|
|
190
253
|
}
|
|
191
254
|
const HEARTBEAT_THROTTLE_MS = 60_000; // 60s
|
|
255
|
+
// Cap how long the terminal unwind blocks on the post-result session save.
|
|
256
|
+
// The result is already produced (and relayed for agent surfaces) before this
|
|
257
|
+
// save, so a stalled disk write must not hold askSession() open — otherwise the
|
|
258
|
+
// owning background task is stranded in `running` and its completion
|
|
259
|
+
// notification never fires. A slow write finishes in the background.
|
|
260
|
+
const TERMINAL_SAVE_TIMEOUT_MS = nonNegativeIntEnv('MIXDOG_TERMINAL_SAVE_TIMEOUT_MS', 5_000);
|
|
192
261
|
|
|
193
262
|
// Merge externally-connected MCP tools with the plugin's in-process tools
|
|
194
|
-
// (registered by agent's toolExecutor
|
|
263
|
+
// (registered by agent's toolExecutor adapter). Internal tools are exposed
|
|
195
264
|
// under their bare names — no mcp__ prefix, since the dispatcher in
|
|
196
265
|
// server.mjs handles them directly without a transport.
|
|
197
266
|
// Sorted deterministically by name — protects BP_1 hash stability from
|
|
@@ -208,7 +277,7 @@ function _getMcpTools() {
|
|
|
208
277
|
inputSchema: t.inputSchema || { type: 'object', properties: {} },
|
|
209
278
|
// Keep annotations so the permission filter / role invariants can
|
|
210
279
|
// tell read-only from write-capable internal tools, and so
|
|
211
|
-
//
|
|
280
|
+
// agentHidden can be read during deny filtering.
|
|
212
281
|
annotations: t.annotations || {},
|
|
213
282
|
}));
|
|
214
283
|
return [...mcp, ...internal].sort((a, b) => {
|
|
@@ -233,7 +302,7 @@ function _getMcpTools() {
|
|
|
233
302
|
// adding a new toolset id here is a localised change.
|
|
234
303
|
//
|
|
235
304
|
// Unified-shard policy — the session's tool array normally never narrows
|
|
236
|
-
// with permission or role.
|
|
305
|
+
// with permission or role. Agent sessions share the same schema so BP_1
|
|
237
306
|
// stays bit-identical and the provider-side cache shard is shared
|
|
238
307
|
// workspace-wide. Rare specialist roles may pass schemaAllowedTools from a
|
|
239
308
|
// declarative hidden-role toolSchemaProfile to keep their first-turn routing
|
|
@@ -242,6 +311,7 @@ function _getMcpTools() {
|
|
|
242
311
|
|
|
243
312
|
const SESSION_ROUTE_TOOL_ORDER = [
|
|
244
313
|
'code_graph',
|
|
314
|
+
'find',
|
|
245
315
|
'glob',
|
|
246
316
|
'list',
|
|
247
317
|
'grep',
|
|
@@ -253,6 +323,7 @@ const SESSION_ROUTE_TOOL_ORDER = [
|
|
|
253
323
|
const SESSION_ROUTE_TOOL_RANK = new Map(SESSION_ROUTE_TOOL_ORDER.map((name, index) => [name, index]));
|
|
254
324
|
const FILESYSTEM_TOOL_NAMES = new Set([
|
|
255
325
|
'code_graph',
|
|
326
|
+
'find',
|
|
256
327
|
'glob',
|
|
257
328
|
'list',
|
|
258
329
|
'grep',
|
|
@@ -261,12 +332,115 @@ const FILESYSTEM_TOOL_NAMES = new Set([
|
|
|
261
332
|
]);
|
|
262
333
|
const READONLY_TOOL_NAMES = new Set([
|
|
263
334
|
'code_graph',
|
|
335
|
+
'find',
|
|
336
|
+
'glob',
|
|
337
|
+
'list',
|
|
338
|
+
'grep',
|
|
339
|
+
'read',
|
|
340
|
+
]);
|
|
341
|
+
|
|
342
|
+
const AGENT_STRING_PERMISSION_READ_ALLOW = Object.freeze([
|
|
343
|
+
'code_graph',
|
|
344
|
+
'find',
|
|
264
345
|
'glob',
|
|
265
346
|
'list',
|
|
266
347
|
'grep',
|
|
267
348
|
'read',
|
|
349
|
+
'explore',
|
|
350
|
+
'search',
|
|
351
|
+
'web_fetch',
|
|
268
352
|
]);
|
|
269
353
|
|
|
354
|
+
function stringToolPermissionAllowList(toolPermission) {
|
|
355
|
+
if (toolPermission === 'read') return AGENT_STRING_PERMISSION_READ_ALLOW;
|
|
356
|
+
if (toolPermission === 'read-write') return AGENT_STRING_PERMISSION_READ_WRITE_ALLOW;
|
|
357
|
+
if (toolPermission === 'none') return [];
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const AGENT_STRING_PERMISSION_READ_WRITE_ALLOW = Object.freeze([
|
|
362
|
+
'code_graph',
|
|
363
|
+
'find',
|
|
364
|
+
'glob',
|
|
365
|
+
'list',
|
|
366
|
+
'grep',
|
|
367
|
+
'read',
|
|
368
|
+
'apply_patch',
|
|
369
|
+
'explore',
|
|
370
|
+
'search',
|
|
371
|
+
'web_fetch',
|
|
372
|
+
]);
|
|
373
|
+
|
|
374
|
+
function applyToolPermissionNarrowing(tools, toolPermission, warnRole = null) {
|
|
375
|
+
if (toolPermission === 'none') return [];
|
|
376
|
+
const allowList = stringToolPermissionAllowList(toolPermission);
|
|
377
|
+
if (allowList) {
|
|
378
|
+
const allowSet = new Set(allowList.map((n) => String(n).toLowerCase()));
|
|
379
|
+
return tools.filter((t) => allowSet.has(String(t?.name || '').toLowerCase()));
|
|
380
|
+
}
|
|
381
|
+
if (toolPermission && typeof toolPermission === 'object') {
|
|
382
|
+
const allowSet = Array.isArray(toolPermission.allow) && toolPermission.allow.length > 0
|
|
383
|
+
? new Set(toolPermission.allow.map(n => String(n).toLowerCase()))
|
|
384
|
+
: null;
|
|
385
|
+
const denySet = Array.isArray(toolPermission.deny) && toolPermission.deny.length > 0
|
|
386
|
+
? new Set(toolPermission.deny.map(n => String(n).toLowerCase()))
|
|
387
|
+
: null;
|
|
388
|
+
if (allowSet || denySet) {
|
|
389
|
+
const filtered = tools.filter(t => {
|
|
390
|
+
const name = String(t?.name || '').toLowerCase();
|
|
391
|
+
if (denySet && denySet.has(name)) return false;
|
|
392
|
+
if (allowSet && !allowSet.has(name)) return false;
|
|
393
|
+
return true;
|
|
394
|
+
});
|
|
395
|
+
if (filtered.length === 0) {
|
|
396
|
+
process.stderr.write(`[session] WARN: role permission intersection produced 0 tools — failing closed (role=${warnRole || 'unknown'})\n`);
|
|
397
|
+
}
|
|
398
|
+
return filtered;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return tools;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function recursiveWrapperToolNameForPublicAgentRole(role) {
|
|
405
|
+
if (!role) return null;
|
|
406
|
+
const key = String(role).trim();
|
|
407
|
+
for (const hiddenName of listHiddenRoleNames()) {
|
|
408
|
+
const def = getHiddenRole(hiddenName);
|
|
409
|
+
const invokedBy = typeof def?.invokedBy === 'string' ? def.invokedBy.trim() : '';
|
|
410
|
+
if (invokedBy && invokedBy === key) return invokedBy;
|
|
411
|
+
}
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function finalizeSessionToolList(tools, {
|
|
416
|
+
schemaAllowedTools = null,
|
|
417
|
+
disallowedTools = null,
|
|
418
|
+
ownerIsAgent = false,
|
|
419
|
+
resolvedRole = null,
|
|
420
|
+
} = {}) {
|
|
421
|
+
let out = Array.isArray(tools) ? tools : [];
|
|
422
|
+
const hasCallerAllow = Array.isArray(schemaAllowedTools);
|
|
423
|
+
if (hasCallerAllow) {
|
|
424
|
+
const allowSet = new Set(schemaAllowedTools.map(n => String(n).toLowerCase()));
|
|
425
|
+
out = out.filter(t => allowSet.has(String(t?.name || '').toLowerCase()));
|
|
426
|
+
}
|
|
427
|
+
const callerDeny = Array.isArray(disallowedTools) ? disallowedTools.map(n => String(n)) : [];
|
|
428
|
+
if (callerDeny.length) {
|
|
429
|
+
const denySet = new Set(callerDeny.map(n => n.toLowerCase()));
|
|
430
|
+
out = out.filter(t => !denySet.has(String(t?.name || '').toLowerCase()));
|
|
431
|
+
}
|
|
432
|
+
const recursiveDeny = ownerIsAgent ? recursiveWrapperToolNameForPublicAgentRole(resolvedRole) : null;
|
|
433
|
+
if (recursiveDeny) {
|
|
434
|
+
const deny = recursiveDeny.toLowerCase();
|
|
435
|
+
out = out.filter(t => String(t?.name || '').toLowerCase() !== deny);
|
|
436
|
+
}
|
|
437
|
+
if (ownerIsAgent) {
|
|
438
|
+
out = out.filter(t => !t?.annotations?.agentHidden);
|
|
439
|
+
out = orderSessionTools(out);
|
|
440
|
+
}
|
|
441
|
+
return out;
|
|
442
|
+
}
|
|
443
|
+
|
|
270
444
|
function orderSessionTools(tools) {
|
|
271
445
|
return tools.map((tool, index) => ({ tool, index }))
|
|
272
446
|
.sort((a, b) => {
|
|
@@ -284,13 +458,13 @@ const ALL_BUILTIN_SESSION_TOOLS = orderSessionTools(_dedupByName([
|
|
|
284
458
|
...CODE_GRAPH_TOOL_DEFS,
|
|
285
459
|
]));
|
|
286
460
|
|
|
287
|
-
function resolveSessionTools(toolSpec, skills, {
|
|
461
|
+
function resolveSessionTools(toolSpec, skills, { ownerIsAgentSession = false } = {}) {
|
|
288
462
|
const mcp = _getMcpTools();
|
|
289
|
-
//
|
|
463
|
+
// Agent sessions freeze the skill meta-tool into the schema
|
|
290
464
|
// unconditionally — concrete skill resolution is cwd-scoped at tool-call
|
|
291
465
|
// time (loop.mjs), so the schema bytes stay bit-identical across roles /
|
|
292
466
|
// cwds and the provider cache shard does not fragment.
|
|
293
|
-
const skillTools = buildSkillToolDefs(skills, {
|
|
467
|
+
const skillTools = buildSkillToolDefs(skills, { ownerIsAgentSession });
|
|
294
468
|
return _computeBaseTools(toolSpec, mcp, skillTools);
|
|
295
469
|
}
|
|
296
470
|
|
|
@@ -314,8 +488,8 @@ function _dedupByName(tools) {
|
|
|
314
488
|
return [...seen.values()];
|
|
315
489
|
}
|
|
316
490
|
|
|
317
|
-
//
|
|
318
|
-
// Tools with
|
|
491
|
+
// Agent visibility is declared per-tool via annotations.agentHidden.
|
|
492
|
+
// Tools with agentHidden:true are stripped from agent sessions at schema
|
|
319
493
|
// build time (see deny filtering below). No code-level name list needed.
|
|
320
494
|
|
|
321
495
|
function _computeBaseTools(toolSpec, mcp, skillTools) {
|
|
@@ -356,7 +530,7 @@ function _computeBaseTools(toolSpec, mcp, skillTools) {
|
|
|
356
530
|
// Name-pattern match: picks up `search` and any future tool
|
|
357
531
|
// whose name contains `search`. `recall` and `explore` deliberately do NOT match
|
|
358
532
|
// — they need `tools:mcp` (full mcp surface) or their own
|
|
359
|
-
// toolset id if a role wants targeted retrieval. Public
|
|
533
|
+
// toolset id if a role wants targeted retrieval. Public agent
|
|
360
534
|
// roles never reach the wrapper bodies regardless: see the
|
|
361
535
|
// isBlockedPublicWrapperCall guard in session/loop.mjs.
|
|
362
536
|
addMany(mcp.filter(t => /search/i.test(t?.name || '')));
|
|
@@ -403,7 +577,7 @@ let nextId = Date.now();
|
|
|
403
577
|
// this map trimmed to live models; older generations slow down reads
|
|
404
578
|
// without buying anything.
|
|
405
579
|
const CONTEXT_WINDOWS = {
|
|
406
|
-
// OpenAI GPT-5.x family
|
|
580
|
+
// OpenAI GPT-5.x family (openai / openai-oauth)
|
|
407
581
|
'gpt-5.5': 272000,
|
|
408
582
|
'gpt-5.4': 272000,
|
|
409
583
|
'gpt-5.4-mini': 272000,
|
|
@@ -418,12 +592,33 @@ const CONTEXT_WINDOWS = {
|
|
|
418
592
|
'gemini-3-pro': 1000000,
|
|
419
593
|
'gemini-3.5-flash': 1000000,
|
|
420
594
|
'gemini-3-flash': 1000000,
|
|
595
|
+
// xAI Grok (catalog polyfill mirror — model-catalog PRICING_OVERRIDES)
|
|
596
|
+
'grok-build-0.1': 256000,
|
|
597
|
+
'grok-4.20': 1000000,
|
|
421
598
|
};
|
|
599
|
+
// Family-pattern fallback used only when both the provider catalog and the
|
|
600
|
+
// exact-id table miss (cold metadata, before the LiteLLM/models.dev catalog
|
|
601
|
+
// warms). Keep these aligned with the catalog so /context, gateway, and the
|
|
602
|
+
// runtime agree on the boundary the first time a model is routed. Local models
|
|
603
|
+
// (llama/mistral/phi/qwen/gemma) stay small so an unknown local id never claims
|
|
604
|
+
// a giant window.
|
|
422
605
|
function guessContextWindow(model) {
|
|
423
606
|
if (CONTEXT_WINDOWS[model])
|
|
424
607
|
return CONTEXT_WINDOWS[model];
|
|
425
|
-
|
|
608
|
+
const m = String(model || '').toLowerCase();
|
|
609
|
+
// Local/self-hosted families — never inflate an unknown local id.
|
|
610
|
+
if (m.includes('llama') || m.includes('mistral') || m.includes('mixtral')
|
|
611
|
+
|| m.includes('phi') || m.includes('qwen') || m.includes('gemma')
|
|
612
|
+
|| m.includes('deepseek-r1') || m.includes('codellama'))
|
|
426
613
|
return 8192;
|
|
614
|
+
// Current hosted families by name pattern.
|
|
615
|
+
if (m.startsWith('claude-opus') || m.startsWith('claude-sonnet')) return 1000000;
|
|
616
|
+
if (m.startsWith('claude-haiku') || m.startsWith('claude-')) return 200000;
|
|
617
|
+
if (m.startsWith('gemini-3') || m.startsWith('gemini-2')) return 1000000;
|
|
618
|
+
if (m.startsWith('gpt-5')) return 272000;
|
|
619
|
+
if (m.startsWith('grok-build')) return 256000;
|
|
620
|
+
if (m.startsWith('grok-')) return 1000000;
|
|
621
|
+
if (m.startsWith('deepseek-v')) return 1000000;
|
|
427
622
|
return 128000;
|
|
428
623
|
}
|
|
429
624
|
function positiveContextWindow(value) {
|
|
@@ -444,26 +639,108 @@ function providerNameOf(provider) {
|
|
|
444
639
|
if (typeof provider === 'string') return provider.toLowerCase();
|
|
445
640
|
return String(provider?.name || provider?.id || '').toLowerCase();
|
|
446
641
|
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
642
|
+
// Buffer-percent parsing trap: normalizeCompactionBufferRatio treats any value
|
|
643
|
+
// > 1 as a percent (n/100) and any value <= 1 as a literal ratio. That is wrong
|
|
644
|
+
// for percent-NAMED inputs: bufferPercent / bufferPct / *_BUFFER_PERCENT = 1
|
|
645
|
+
// means 1% (0.01), but the shared normalizer would read it as the literal ratio
|
|
646
|
+
// 1.0 (100%). Resolve percent-named and ratio-named inputs with the correct
|
|
647
|
+
// semantics here, before handing a finished ratio to the shared helper:
|
|
648
|
+
// percent inputs: n -> n/100 (1 -> 0.01, 10 -> 0.10)
|
|
649
|
+
// ratio inputs: n -> n>1 ? n/100 : n (0.01 -> 0.01, 10 -> 0.10 legacy)
|
|
650
|
+
function resolveBufferRatioCandidate(percentInputs, ratioInputs) {
|
|
651
|
+
for (const raw of percentInputs) {
|
|
652
|
+
const n = Number(raw);
|
|
653
|
+
if (Number.isFinite(n) && n > 0) return Math.min(1, n / 100);
|
|
654
|
+
}
|
|
655
|
+
for (const raw of ratioInputs) {
|
|
656
|
+
const n = Number(raw);
|
|
657
|
+
if (Number.isFinite(n) && n > 0) return n > 1 ? n / 100 : n;
|
|
658
|
+
}
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
function compactBufferRatioForConfig(cfg = {}) {
|
|
662
|
+
const resolved = resolveBufferRatioCandidate(
|
|
663
|
+
[cfg.bufferPercent, cfg.bufferPct, process.env.MIXDOG_AGENT_COMPACT_BUFFER_PERCENT],
|
|
664
|
+
[cfg.bufferRatio, cfg.bufferFraction, process.env.MIXDOG_AGENT_COMPACT_BUFFER_RATIO],
|
|
457
665
|
);
|
|
666
|
+
return normalizeCompactionBufferRatio(resolved, DEFAULT_COMPACTION_BUFFER_RATIO);
|
|
667
|
+
}
|
|
668
|
+
function compactBufferRatioForSession(session) {
|
|
669
|
+
return compactBufferRatioForConfig(session?.compaction || {});
|
|
670
|
+
}
|
|
671
|
+
// Carry the percent/ratio-named buffer config from a compaction config object
|
|
672
|
+
// onto session.compaction so downstream parsers (compactBufferRatioForSession,
|
|
673
|
+
// loop.resolveCompactBufferRatio, contextStatus) honor a configured buffer
|
|
674
|
+
// percent/ratio. Only finite positive values are copied; absent fields stay
|
|
675
|
+
// undefined so the default-ratio fallback still applies.
|
|
676
|
+
function preserveBufferConfigFields(cfg = {}) {
|
|
677
|
+
const out = {};
|
|
678
|
+
for (const key of ['bufferPercent', 'bufferPct', 'bufferRatio', 'bufferFraction']) {
|
|
679
|
+
const n = Number(cfg?.[key]);
|
|
680
|
+
if (Number.isFinite(n) && n > 0) out[key] = n;
|
|
681
|
+
}
|
|
682
|
+
return out;
|
|
683
|
+
}
|
|
684
|
+
const LEGACY_DEFAULT_COMPACTION_BUFFER_RATIO = 0.1;
|
|
685
|
+
function isPersistedZeroBufferTelemetry(cfg = {}, boundaryTokens = 0) {
|
|
686
|
+
const boundary = positiveContextWindow(boundaryTokens);
|
|
687
|
+
if (!boundary) return false;
|
|
688
|
+
if (positiveContextWindow(process.env.MIXDOG_AGENT_COMPACT_BUFFER_TOKENS)) return false;
|
|
689
|
+
if (Number.isFinite(Number(process.env.MIXDOG_AGENT_COMPACT_BUFFER_PERCENT)) && Number(process.env.MIXDOG_AGENT_COMPACT_BUFFER_PERCENT) > 0) return false;
|
|
690
|
+
if (Number.isFinite(Number(process.env.MIXDOG_AGENT_COMPACT_BUFFER_RATIO)) && Number(process.env.MIXDOG_AGENT_COMPACT_BUFFER_RATIO) > 0) return false;
|
|
691
|
+
for (const key of ['bufferPercent', 'bufferPct', 'bufferFraction']) {
|
|
692
|
+
const n = Number(cfg?.[key]);
|
|
693
|
+
if (Number.isFinite(n) && n > 0) return false;
|
|
694
|
+
}
|
|
695
|
+
const ratio = Number(cfg?.bufferRatio);
|
|
696
|
+
if (Number.isFinite(ratio) && ratio > 0) return false;
|
|
697
|
+
const explicitTokens = Number(cfg?.bufferTokens ?? cfg?.buffer);
|
|
698
|
+
if (!Number.isFinite(explicitTokens) || explicitTokens !== 0) return false;
|
|
699
|
+
return true;
|
|
700
|
+
}
|
|
701
|
+
function isLegacyDefaultBufferTelemetry(cfg = {}, boundaryTokens = 0) {
|
|
702
|
+
const boundary = positiveContextWindow(boundaryTokens);
|
|
703
|
+
if (!boundary) return false;
|
|
704
|
+
if (positiveContextWindow(process.env.MIXDOG_AGENT_COMPACT_BUFFER_TOKENS)) return false;
|
|
705
|
+
if (Number.isFinite(Number(process.env.MIXDOG_AGENT_COMPACT_BUFFER_PERCENT)) && Number(process.env.MIXDOG_AGENT_COMPACT_BUFFER_PERCENT) > 0) return false;
|
|
706
|
+
if (Number.isFinite(Number(process.env.MIXDOG_AGENT_COMPACT_BUFFER_RATIO)) && Number(process.env.MIXDOG_AGENT_COMPACT_BUFFER_RATIO) > 0) return false;
|
|
707
|
+
// Percent/fraction-named fields are treated as operator config. The legacy
|
|
708
|
+
// default telemetry always persisted bufferTokens + bufferRatio after a
|
|
709
|
+
// check/compact pass, so only that shape is migrated away.
|
|
710
|
+
for (const key of ['bufferPercent', 'bufferPct', 'bufferFraction']) {
|
|
711
|
+
const n = Number(cfg?.[key]);
|
|
712
|
+
if (Number.isFinite(n) && n > 0) return false;
|
|
713
|
+
}
|
|
714
|
+
const explicitTokens = positiveContextWindow(cfg?.bufferTokens ?? cfg?.buffer);
|
|
715
|
+
const ratio = Number(cfg?.bufferRatio);
|
|
716
|
+
if (!explicitTokens || !Number.isFinite(ratio) || Math.abs(ratio - LEGACY_DEFAULT_COMPACTION_BUFFER_RATIO) > 1e-9) return false;
|
|
717
|
+
const expectedTokens = Math.floor(boundary * LEGACY_DEFAULT_COMPACTION_BUFFER_RATIO);
|
|
718
|
+
const cfgBoundary = positiveContextWindow(cfg?.boundaryTokens);
|
|
719
|
+
const cfgTrigger = positiveContextWindow(cfg?.triggerTokens);
|
|
720
|
+
return explicitTokens === expectedTokens
|
|
721
|
+
|| (cfgBoundary === boundary && cfgTrigger > 0 && explicitTokens === Math.max(0, boundary - cfgTrigger));
|
|
722
|
+
}
|
|
723
|
+
function compactBufferConfigForBoundary(cfg = {}, boundaryTokens = 0) {
|
|
724
|
+
const base = cfg || {};
|
|
725
|
+
if (!isLegacyDefaultBufferTelemetry(base, boundaryTokens)
|
|
726
|
+
&& !isPersistedZeroBufferTelemetry(base, boundaryTokens)) {
|
|
727
|
+
return base;
|
|
728
|
+
}
|
|
729
|
+
return {
|
|
730
|
+
...base,
|
|
731
|
+
bufferTokens: null,
|
|
732
|
+
buffer: null,
|
|
733
|
+
bufferRatio: null,
|
|
734
|
+
};
|
|
458
735
|
}
|
|
459
736
|
function compactBufferTokensForSession(session, boundaryTokens) {
|
|
460
|
-
const cfg = session?.compaction || {};
|
|
737
|
+
const cfg = compactBufferConfigForBoundary(session?.compaction || {}, boundaryTokens);
|
|
461
738
|
const explicit = positiveContextWindow(cfg.bufferTokens ?? cfg.buffer)
|
|
462
|
-
|| positiveContextWindow(process.env.
|
|
739
|
+
|| positiveContextWindow(process.env.MIXDOG_AGENT_COMPACT_BUFFER_TOKENS)
|
|
463
740
|
|| 0;
|
|
464
741
|
return compactionBufferTokensForBoundary(boundaryTokens, {
|
|
465
742
|
explicitTokens: explicit,
|
|
466
|
-
ratio:
|
|
743
|
+
ratio: compactBufferRatioForConfig(cfg),
|
|
467
744
|
maxRatio: 0.25,
|
|
468
745
|
});
|
|
469
746
|
}
|
|
@@ -471,8 +748,8 @@ const COMPACT_TARGET_RATIO = 0.02;
|
|
|
471
748
|
const COMPACT_TARGET_MIN_TOKENS = 4_000;
|
|
472
749
|
const COMPACT_TARGET_MAX_TOKENS = 16_000;
|
|
473
750
|
function compactTargetRatio() {
|
|
474
|
-
const raw = process.env.
|
|
475
|
-
?? process.env.
|
|
751
|
+
const raw = process.env.MIXDOG_AGENT_COMPACT_TARGET_PERCENT
|
|
752
|
+
?? process.env.MIXDOG_COMPACT_TARGET_PERCENT
|
|
476
753
|
?? COMPACT_TARGET_RATIO;
|
|
477
754
|
const n = Number(raw);
|
|
478
755
|
if (!Number.isFinite(n) || n <= 0) return COMPACT_TARGET_RATIO;
|
|
@@ -482,8 +759,8 @@ function compactTargetTokensForBoundary(boundaryTokens) {
|
|
|
482
759
|
const boundary = positiveContextWindow(boundaryTokens);
|
|
483
760
|
if (!boundary) return null;
|
|
484
761
|
const explicit = positiveContextWindow(
|
|
485
|
-
process.env.
|
|
486
|
-
?? process.env.
|
|
762
|
+
process.env.MIXDOG_AGENT_COMPACT_TARGET_TOKENS
|
|
763
|
+
?? process.env.MIXDOG_COMPACT_TARGET_TOKENS,
|
|
487
764
|
);
|
|
488
765
|
if (explicit) return Math.max(1, Math.min(boundary, explicit));
|
|
489
766
|
const minTarget = Math.min(boundary, positiveContextWindow(process.env.MIXDOG_COMPACT_TARGET_MIN_TOKENS) || COMPACT_TARGET_MIN_TOKENS);
|
|
@@ -492,10 +769,10 @@ function compactTargetTokensForBoundary(boundaryTokens) {
|
|
|
492
769
|
return Math.max(1, Math.min(boundary, maxTarget, Math.max(minTarget, byRatio)));
|
|
493
770
|
}
|
|
494
771
|
function defaultEffectiveContextWindowPercent(provider) {
|
|
495
|
-
// Gateway/statusline route metadata reserves a universal
|
|
772
|
+
// Gateway/statusline route metadata reserves a universal 10% headroom from
|
|
496
773
|
// the raw catalog window. Keep session compaction on the same effective
|
|
497
774
|
// capacity so /context, the TUI statusline, and gateway telemetry agree.
|
|
498
|
-
return
|
|
775
|
+
return 90;
|
|
499
776
|
}
|
|
500
777
|
function resolveSessionContextMeta(provider, model, seed = {}) {
|
|
501
778
|
const info = typeof provider?.getCachedModelInfo === 'function'
|
|
@@ -525,7 +802,8 @@ function resolveSessionContextMeta(provider, model, seed = {}) {
|
|
|
525
802
|
);
|
|
526
803
|
const pct = boundedPercent(effectiveContextWindowPercent, 100);
|
|
527
804
|
const contextWindow = Math.max(1, Math.floor(rawContextWindow * pct / 100));
|
|
528
|
-
const
|
|
805
|
+
const compactBoundaryTokens = contextWindow;
|
|
806
|
+
const rawCompactLimit = positiveContextWindow(
|
|
529
807
|
seed.autoCompactTokenLimit
|
|
530
808
|
?? seed.auto_compact_token_limit
|
|
531
809
|
?? info?.autoCompactTokenLimit
|
|
@@ -533,11 +811,27 @@ function resolveSessionContextMeta(provider, model, seed = {}) {
|
|
|
533
811
|
?? catalogInfo?.autoCompactTokenLimit
|
|
534
812
|
?? catalogInfo?.auto_compact_token_limit,
|
|
535
813
|
);
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
814
|
+
// Legacy-data migration: old implementations derived autoCompactTokenLimit
|
|
815
|
+
// from the full effective/raw window and persisted it onto the session.
|
|
816
|
+
// A resumed session therefore re-seeds autoCompactTokenLimit == boundary
|
|
817
|
+
// (or the raw window), which compactTriggerForSession / loop policy used to
|
|
818
|
+
// honor as an explicit trigger, collapsing the compaction buffer to 0. Only
|
|
819
|
+
// accept an explicit limit that is STRICTLY BELOW the boundary; a value at
|
|
820
|
+
// or above the boundary is a derived full-window artifact and is dropped to
|
|
821
|
+
// null so the trigger falls back to the default boundary trigger.
|
|
822
|
+
const explicitCompactLimit = rawCompactLimit && rawCompactLimit < compactBoundaryTokens
|
|
823
|
+
? rawCompactLimit
|
|
824
|
+
: null;
|
|
825
|
+
// Do NOT derive the auto-compact limit from the full effective window.
|
|
826
|
+
// Setting it to contextWindow makes autoTriggerTokens == boundary and the
|
|
827
|
+
// compaction buffer collapse to 0 (loop.mjs:708-713 / compactTriggerForSession),
|
|
828
|
+
// so auto-compact only fires when the context is already at the limit —
|
|
829
|
+
// at which point semantic compact fails ("result exceeds budget" /
|
|
830
|
+
// "summary cannot fit") and the turn can no longer be resumed.
|
|
831
|
+
// Leave it null unless the provider/catalog/seed supplies an explicit
|
|
832
|
+
// limit; the downstream buffer logic (default 10%, capped 25%) then
|
|
833
|
+
// triggers compaction with headroom, matching the reference auto-compact threshold.
|
|
834
|
+
const autoCompactTokenLimit = explicitCompactLimit || null;
|
|
541
835
|
return {
|
|
542
836
|
contextWindow,
|
|
543
837
|
rawContextWindow,
|
|
@@ -550,10 +844,36 @@ function compactTriggerForSession(session, boundaryTokens) {
|
|
|
550
844
|
const boundary = positiveContextWindow(boundaryTokens);
|
|
551
845
|
if (!boundary) return null;
|
|
552
846
|
const autoLimit = positiveContextWindow(session?.autoCompactTokenLimit ?? session?.compaction?.autoCompactTokenLimit);
|
|
553
|
-
|
|
847
|
+
// Only honor an explicit auto-compact limit that sits STRICTLY BELOW the
|
|
848
|
+
// boundary. A persisted value == boundary (or >=) is a legacy derived
|
|
849
|
+
// full-window artifact; honoring it collapses the compaction buffer to 0,
|
|
850
|
+
// so fall through to the default boundary trigger instead.
|
|
851
|
+
if (autoLimit && autoLimit < boundary) return Math.max(1, autoLimit);
|
|
554
852
|
const buffer = compactBufferTokensForSession(session, boundary);
|
|
555
853
|
return Math.max(1, boundary - buffer);
|
|
556
854
|
}
|
|
855
|
+
// Test-only exports for the legacy auto-compact-limit migration + buffer-config
|
|
856
|
+
// preservation (see scripts/compact-trigger-migration-smoke.mjs).
|
|
857
|
+
export const _resolveSessionContextMeta = resolveSessionContextMeta;
|
|
858
|
+
export const _compactTriggerForSession = compactTriggerForSession;
|
|
859
|
+
export const _preserveBufferConfigFields = preserveBufferConfigFields;
|
|
860
|
+
// 'compacting' is a transient in-flight stage written just before semantic /
|
|
861
|
+
// recall-fasttrack compaction runs. If the process crashes or only partially
|
|
862
|
+
// saves while it is set, a later load/resume reads a session that is NOT
|
|
863
|
+
// actually compacting but whose UI marker (App.jsx / ContextPanel) shows
|
|
864
|
+
// "Compacting conversation" permanently. Normalize that stale transient stage
|
|
865
|
+
// to 'interrupted' so the surface recovers. Terminal stages (post_turn /
|
|
866
|
+
// manual / auto_clear / *_failed / overflow_failed) are intentionally left as
|
|
867
|
+
// the durable record of the last real outcome.
|
|
868
|
+
function normalizeStaleCompactingStage(session) {
|
|
869
|
+
const c = session?.compaction;
|
|
870
|
+
if (!c || typeof c !== 'object') return false;
|
|
871
|
+
if (c.lastStage !== 'compacting' && c.inProgress !== true) return false;
|
|
872
|
+
c.lastStage = 'interrupted';
|
|
873
|
+
c.inProgress = false;
|
|
874
|
+
c.lastCheckedAt = Date.now();
|
|
875
|
+
return true;
|
|
876
|
+
}
|
|
557
877
|
function compactTargetBudget(boundaryTokens, reserveTokens, _sourceTokens = null, _ratio = null) {
|
|
558
878
|
const boundary = positiveContextWindow(boundaryTokens);
|
|
559
879
|
if (!boundary) return null;
|
|
@@ -563,12 +883,21 @@ function compactTargetBudget(boundaryTokens, reserveTokens, _sourceTokens = null
|
|
|
563
883
|
}
|
|
564
884
|
function semanticCompactionEnabledForSession(session) {
|
|
565
885
|
const cfg = session?.compaction || {};
|
|
886
|
+
if (process.env.MIXDOG_AGENT_COMPACT_SEMANTIC !== undefined) return envFlag('MIXDOG_AGENT_COMPACT_SEMANTIC', true);
|
|
566
887
|
if (process.env.MIXDOG_COMPACT_SEMANTIC !== undefined) return envFlag('MIXDOG_COMPACT_SEMANTIC', true);
|
|
567
|
-
if (process.env.MIXDOG_BRIDGE_COMPACT_SEMANTIC !== undefined) return envFlag('MIXDOG_BRIDGE_COMPACT_SEMANTIC', true);
|
|
568
888
|
if (cfg.semantic === false || cfg.semantic === 'false' || cfg.semantic === 'off') return false;
|
|
569
889
|
if (cfg.semantic === true || cfg.semantic === 'true' || cfg.semantic === 'on' || cfg.semantic === 'auto') return true;
|
|
570
890
|
return true;
|
|
571
891
|
}
|
|
892
|
+
function compactTypeForSession(session) {
|
|
893
|
+
const cfg = session?.compaction || {};
|
|
894
|
+
const configured = process.env.MIXDOG_AGENT_COMPACT_TYPE
|
|
895
|
+
?? process.env.MIXDOG_COMPACT_TYPE
|
|
896
|
+
?? cfg.type
|
|
897
|
+
?? cfg.compactType
|
|
898
|
+
?? cfg.compact_type;
|
|
899
|
+
return normalizeCompactType(configured, DEFAULT_COMPACT_TYPE);
|
|
900
|
+
}
|
|
572
901
|
function addCompactUsageToSession(session, usage) {
|
|
573
902
|
if (!session || !usage) return;
|
|
574
903
|
const inputTokens = usage.inputTokens || 0;
|
|
@@ -581,6 +910,73 @@ function addCompactUsageToSession(session, usage) {
|
|
|
581
910
|
session.totalCacheWriteTokens = (session.totalCacheWriteTokens || 0) + cacheWriteTokens;
|
|
582
911
|
session.tokensCumulative = (session.tokensCumulative || 0) + inputTokens + outputTokens;
|
|
583
912
|
}
|
|
913
|
+
async function runRecallFastTrackForSession(session, messages, opts = {}) {
|
|
914
|
+
const sessionId = opts.sessionId || session?.id || null;
|
|
915
|
+
if (!sessionId) throw new Error('recall-fasttrack requires a session id');
|
|
916
|
+
const query = `session:${sessionId}:all-chunks`;
|
|
917
|
+
const querySha = createHash('sha256').update(query).digest('hex').slice(0, 16);
|
|
918
|
+
const callerCtx = {
|
|
919
|
+
callerSessionId: sessionId,
|
|
920
|
+
callerCwd: session?.cwd || undefined,
|
|
921
|
+
routingSessionId: sessionId,
|
|
922
|
+
clientHostPid: session?.clientHostPid,
|
|
923
|
+
signal: opts.signal || null,
|
|
924
|
+
};
|
|
925
|
+
const hydrateLimit = positiveContextWindow(session?.compaction?.recallIngestLimit)
|
|
926
|
+
|| Math.max(500, Math.min(5000, messages.length || 0));
|
|
927
|
+
try {
|
|
928
|
+
await executeInternalTool('memory', {
|
|
929
|
+
action: 'ingest_session',
|
|
930
|
+
sessionId,
|
|
931
|
+
messages,
|
|
932
|
+
cwd: session?.cwd,
|
|
933
|
+
limit: hydrateLimit,
|
|
934
|
+
}, callerCtx);
|
|
935
|
+
} catch (err) {
|
|
936
|
+
try { process.stderr.write(`[session] recall-fasttrack ingest skipped (sess=${sessionId}): ${err?.message || err}\n`); } catch {}
|
|
937
|
+
}
|
|
938
|
+
const dumpArgs = {
|
|
939
|
+
action: 'dump_session_roots',
|
|
940
|
+
sessionId,
|
|
941
|
+
includeRaw: true,
|
|
942
|
+
limit: positiveContextWindow(session?.compaction?.recallChunkLimit ?? session?.compaction?.recallLimit) || hydrateLimit,
|
|
943
|
+
};
|
|
944
|
+
const runTool = (name, args) => executeInternalTool(name, args, callerCtx);
|
|
945
|
+
let recallText = await executeInternalTool('memory', dumpArgs, callerCtx);
|
|
946
|
+
let cycle1Text = '';
|
|
947
|
+
const hasRawRows = /(?:^|\n)# raw_pending\s+\d+\s+id=/i.test(String(recallText || ''));
|
|
948
|
+
if (hasRawRows) {
|
|
949
|
+
try {
|
|
950
|
+
// Drain this session's cycle1 in window×concurrency units until no
|
|
951
|
+
// raw rows remain, so the injected root is fully chunked rather than
|
|
952
|
+
// carrying the unprocessed transcript tail (single-pass left raw in).
|
|
953
|
+
const drained = await drainSessionCycle1(runTool, {
|
|
954
|
+
sessionId,
|
|
955
|
+
dumpArgs,
|
|
956
|
+
deadlineMs: positiveContextWindow(session?.compaction?.recallCycle1DeadlineMs) || 120_000,
|
|
957
|
+
maxPasses: positiveContextWindow(session?.compaction?.recallCycle1MaxPasses) || 0,
|
|
958
|
+
cycleArgs: {
|
|
959
|
+
min_batch: 1,
|
|
960
|
+
session_cap: 1,
|
|
961
|
+
batch_size: positiveContextWindow(session?.compaction?.recallCycle1BatchSize) || 100,
|
|
962
|
+
rows_per_session: positiveContextWindow(session?.compaction?.recallRowsPerSession) || 100,
|
|
963
|
+
window_size: positiveContextWindow(session?.compaction?.recallWindowSize) || 20,
|
|
964
|
+
concurrency: positiveContextWindow(session?.compaction?.recallConcurrency) || 5,
|
|
965
|
+
},
|
|
966
|
+
});
|
|
967
|
+
recallText = drained.recallText;
|
|
968
|
+
cycle1Text = drained.cycle1Text;
|
|
969
|
+
if (drained.rawRemaining > 0) {
|
|
970
|
+
try { process.stderr.write(`[session] recall-fasttrack drained passes=${drained.passes} rawRemaining=${drained.rawRemaining} (sess=${sessionId})\n`); } catch {}
|
|
971
|
+
}
|
|
972
|
+
} catch (err) {
|
|
973
|
+
try { process.stderr.write(`[session] recall-fasttrack cycle1 skipped (sess=${sessionId}): ${err?.message || err}\n`); } catch {}
|
|
974
|
+
}
|
|
975
|
+
} else {
|
|
976
|
+
cycle1Text = 'cycle1: skipped (session chunks already hydrated)';
|
|
977
|
+
}
|
|
978
|
+
return { query, querySha, recallText: [`session_id=${sessionId}`, cycle1Text, recallText].map(v => String(v || '').trim()).filter(Boolean).join('\n\n') };
|
|
979
|
+
}
|
|
584
980
|
async function runSessionCompaction(session, opts = {}) {
|
|
585
981
|
if (!session || session.closed === true) return null;
|
|
586
982
|
const mode = opts.mode === 'auto' ? 'auto' : 'manual';
|
|
@@ -597,17 +993,18 @@ async function runSessionCompaction(session, opts = {}) {
|
|
|
597
993
|
}
|
|
598
994
|
const reserveTokens = estimateRequestReserveTokens(session.tools || []);
|
|
599
995
|
const beforeMessageTokens = estimateMessagesTokens(messages);
|
|
600
|
-
const lastContextTokens = positiveContextWindow(session.lastContextTokens) || 0;
|
|
601
996
|
const triggerTokens = compactTriggerForSession(session, boundary)
|
|
602
997
|
|| positiveContextWindow(session.compaction?.triggerTokens)
|
|
603
998
|
|| boundary;
|
|
604
999
|
const bufferTokens = Math.max(0, boundary - triggerTokens);
|
|
605
1000
|
const bufferRatio = boundary ? (bufferTokens / boundary) : compactBufferRatioForSession(session);
|
|
606
|
-
const pressureTokens =
|
|
1001
|
+
const pressureTokens = estimateTranscriptContextUsage(messages, session.tools || []);
|
|
607
1002
|
const beforeTokens = pressureTokens;
|
|
1003
|
+
const compactType = compactTypeForSession(session);
|
|
608
1004
|
if (!force && pressureTokens < triggerTokens) return {
|
|
609
1005
|
changed: false,
|
|
610
1006
|
reason: 'below threshold',
|
|
1007
|
+
compactType,
|
|
611
1008
|
beforeMessages: messages.length,
|
|
612
1009
|
afterMessages: messages.length,
|
|
613
1010
|
beforeTokens,
|
|
@@ -627,13 +1024,43 @@ async function runSessionCompaction(session, opts = {}) {
|
|
|
627
1024
|
const budgetSourceTokens = force ? Math.max(pressureTokens, triggerTokens) : pressureTokens;
|
|
628
1025
|
const compactBudget = compactTargetBudget(boundary, reserveTokens, budgetSourceTokens);
|
|
629
1026
|
const budget = compactBudget || boundary;
|
|
1027
|
+
try { opts.onStageChange?.('compacting'); } catch { /* best-effort */ }
|
|
630
1028
|
const provider = opts.provider || getProvider(session.provider) || null;
|
|
631
1029
|
let compacted;
|
|
632
1030
|
let compactError = null;
|
|
633
1031
|
let semanticCompactResult = null;
|
|
634
1032
|
let semanticCompactError = null;
|
|
635
|
-
|
|
1033
|
+
let recallFastTrackResult = null;
|
|
1034
|
+
let recallFastTrackError = null;
|
|
1035
|
+
if (compactTypeIsRecallFastTrack(compactType)) {
|
|
1036
|
+
try {
|
|
1037
|
+
const recallPayload = await runRecallFastTrackForSession(session, messages, opts);
|
|
1038
|
+
recallFastTrackResult = recallFastTrackCompactMessages(messages, budget, {
|
|
1039
|
+
reserveTokens,
|
|
1040
|
+
force: true,
|
|
1041
|
+
recallText: recallPayload.recallText,
|
|
1042
|
+
query: recallPayload.query,
|
|
1043
|
+
querySha: recallPayload.querySha,
|
|
1044
|
+
allowEmptyRecall: true,
|
|
1045
|
+
tailTurns: positiveContextWindow(session.compaction?.tailTurns) || 2,
|
|
1046
|
+
keepTokens: positiveContextWindow(session.compaction?.keepTokens ?? session.compaction?.keep?.tokens),
|
|
1047
|
+
preserveRecentTokens: positiveContextWindow(session.compaction?.preserveRecentTokens),
|
|
1048
|
+
});
|
|
1049
|
+
if (Array.isArray(recallFastTrackResult?.messages)) {
|
|
1050
|
+
compacted = recallFastTrackResult.messages;
|
|
1051
|
+
}
|
|
1052
|
+
} catch (err) {
|
|
1053
|
+
recallFastTrackError = err;
|
|
1054
|
+
compactError = err;
|
|
1055
|
+
try {
|
|
1056
|
+
process.stderr.write(`[session] recall-fasttrack ${mode} compact failed (sess=${session.id || 'unknown'}): ${err?.message || err}\n`);
|
|
1057
|
+
} catch { /* best-effort */ }
|
|
1058
|
+
}
|
|
1059
|
+
} else if (compactTypeIsSemantic(compactType)) {
|
|
636
1060
|
try {
|
|
1061
|
+
if (!semanticCompactionEnabledForSession(session)) {
|
|
1062
|
+
throw new Error('semantic compact is disabled for this session');
|
|
1063
|
+
}
|
|
637
1064
|
if (!provider || typeof provider.send !== 'function') {
|
|
638
1065
|
throw new Error(`semantic compact provider unavailable: ${session.provider || 'unknown'}`);
|
|
639
1066
|
}
|
|
@@ -662,22 +1089,14 @@ async function runSessionCompaction(session, opts = {}) {
|
|
|
662
1089
|
}
|
|
663
1090
|
} catch (err) {
|
|
664
1091
|
semanticCompactError = err;
|
|
1092
|
+
compactError = err;
|
|
665
1093
|
try {
|
|
666
|
-
process.stderr.write(`[session] semantic ${mode} compact failed (sess=${session.id || 'unknown'}): ${err?.message || err}
|
|
1094
|
+
process.stderr.write(`[session] semantic ${mode} compact failed (sess=${session.id || 'unknown'}): ${err?.message || err}\n`);
|
|
667
1095
|
} catch { /* best-effort */ }
|
|
668
1096
|
}
|
|
669
1097
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
} catch (err) {
|
|
673
|
-
try {
|
|
674
|
-
process.stderr.write(`[session] ${mode} compact fallback (sess=${session.id || 'unknown'}): ${err?.message || err}\n`);
|
|
675
|
-
} catch { /* best-effort */ }
|
|
676
|
-
try {
|
|
677
|
-
compacted = compactActiveTurn(messages, budget, { reserveTokens, force: true });
|
|
678
|
-
} catch (fallbackErr) {
|
|
679
|
-
compactError = fallbackErr;
|
|
680
|
-
}
|
|
1098
|
+
if (!compacted && !compactError) {
|
|
1099
|
+
compactError = new Error(`${compactType} compact produced no messages`);
|
|
681
1100
|
}
|
|
682
1101
|
if (!compacted) {
|
|
683
1102
|
const now = Date.now();
|
|
@@ -697,13 +1116,19 @@ async function runSessionCompaction(session, opts = {}) {
|
|
|
697
1116
|
lastPressureTokens: pressureTokens,
|
|
698
1117
|
lastCheckedAt: now,
|
|
699
1118
|
lastChanged: false,
|
|
1119
|
+
type: compactType,
|
|
1120
|
+
compactType,
|
|
1121
|
+
lastCompactType: compactType,
|
|
700
1122
|
lastSemantic: false,
|
|
701
1123
|
lastSemanticError: semanticCompactError?.message || null,
|
|
702
|
-
|
|
1124
|
+
lastRecallFastTrack: false,
|
|
1125
|
+
lastRecallFastTrackError: recallFastTrackError?.message || null,
|
|
1126
|
+
lastError: compactError?.message || semanticCompactError?.message || recallFastTrackError?.message || String(compactError || semanticCompactError || recallFastTrackError || 'compact failed'),
|
|
703
1127
|
};
|
|
704
1128
|
return {
|
|
705
1129
|
changed: false,
|
|
706
1130
|
error: session.compaction.lastError,
|
|
1131
|
+
compactType,
|
|
707
1132
|
beforeMessages: messages.length,
|
|
708
1133
|
afterMessages: messages.length,
|
|
709
1134
|
beforeTokens,
|
|
@@ -720,6 +1145,8 @@ async function runSessionCompaction(session, opts = {}) {
|
|
|
720
1145
|
reserveTokens,
|
|
721
1146
|
semanticCompact: false,
|
|
722
1147
|
semanticError: semanticCompactError?.message || null,
|
|
1148
|
+
recallFastTrack: false,
|
|
1149
|
+
recallFastTrackError: recallFastTrackError?.message || null,
|
|
723
1150
|
};
|
|
724
1151
|
}
|
|
725
1152
|
let beforeEncoded = '';
|
|
@@ -743,6 +1170,9 @@ async function runSessionCompaction(session, opts = {}) {
|
|
|
743
1170
|
bufferTokens,
|
|
744
1171
|
bufferRatio,
|
|
745
1172
|
reserveTokens,
|
|
1173
|
+
type: compactType,
|
|
1174
|
+
compactType,
|
|
1175
|
+
lastCompactType: compactType,
|
|
746
1176
|
lastStage: mode === 'auto' ? 'post_turn' : 'manual',
|
|
747
1177
|
lastBeforeTokens: beforeTokens,
|
|
748
1178
|
lastAfterTokens: afterTokens,
|
|
@@ -755,6 +1185,9 @@ async function runSessionCompaction(session, opts = {}) {
|
|
|
755
1185
|
lastCompactAt: changed ? now : session.compaction?.lastCompactAt || null,
|
|
756
1186
|
lastSemantic: semanticCompactResult?.semantic === true,
|
|
757
1187
|
lastSemanticError: semanticCompactError?.message || null,
|
|
1188
|
+
lastRecallFastTrack: recallFastTrackResult?.recallFastTrack === true,
|
|
1189
|
+
lastRecallFastTrackError: recallFastTrackError?.message || null,
|
|
1190
|
+
lastRecallFastTrackQuerySha: recallFastTrackResult?.query ? createHash('sha256').update(recallFastTrackResult.query).digest('hex').slice(0, 16) : null,
|
|
758
1191
|
lastSemanticUsage: semanticCompactResult?.usage ? {
|
|
759
1192
|
inputTokens: semanticCompactResult.usage.inputTokens || 0,
|
|
760
1193
|
outputTokens: semanticCompactResult.usage.outputTokens || 0,
|
|
@@ -767,6 +1200,7 @@ async function runSessionCompaction(session, opts = {}) {
|
|
|
767
1200
|
return {
|
|
768
1201
|
changed,
|
|
769
1202
|
reason: unchangedReason,
|
|
1203
|
+
compactType,
|
|
770
1204
|
beforeMessages: messages.length,
|
|
771
1205
|
afterMessages: compacted.length,
|
|
772
1206
|
beforeTokens,
|
|
@@ -783,20 +1217,20 @@ async function runSessionCompaction(session, opts = {}) {
|
|
|
783
1217
|
reserveTokens,
|
|
784
1218
|
semanticCompact: semanticCompactResult?.semantic === true,
|
|
785
1219
|
semanticError: semanticCompactError?.message || null,
|
|
1220
|
+
recallFastTrack: recallFastTrackResult?.recallFastTrack === true,
|
|
1221
|
+
recallFastTrackError: recallFastTrackError?.message || null,
|
|
786
1222
|
usage: semanticCompactResult?.usage || null,
|
|
787
1223
|
};
|
|
788
1224
|
}
|
|
789
|
-
async function autoCompactSessionAfterTurn(session, opts = {}) {
|
|
790
|
-
return runSessionCompaction(session, { ...opts, mode: 'auto', force: false });
|
|
791
|
-
}
|
|
792
1225
|
// Provider-scoped unified cache key. Goal: all orchestrator-internal
|
|
793
|
-
// dispatches (
|
|
1226
|
+
// dispatches (agent/maintenance/mcp/scheduler/webhook) targeting the
|
|
794
1227
|
// same provider land in a single server-side cache shard, so the
|
|
795
1228
|
// shared prefix (tools + system + pool system prompt) is reused
|
|
796
|
-
// regardless of role. Per-role / per-session differentiation lives
|
|
797
|
-
//
|
|
1229
|
+
// regardless of role. Per-role / per-session differentiation lives after the
|
|
1230
|
+
// system prefix (BP3 sessionMarker system block / later messages), which is
|
|
1231
|
+
// naturally separated by provider-side content hashing.
|
|
798
1232
|
const PROVIDER_ALIAS = {
|
|
799
|
-
'openai-oauth': 'codex', // ChatGPT subscription (
|
|
1233
|
+
'openai-oauth': 'codex', // ChatGPT subscription (OpenAI OAuth backend)
|
|
800
1234
|
'anthropic-oauth': 'claude', // Claude Max subscription
|
|
801
1235
|
'openai': 'openai',
|
|
802
1236
|
'anthropic': 'anthropic',
|
|
@@ -811,9 +1245,10 @@ function providerCacheKey(provider, override) {
|
|
|
811
1245
|
}
|
|
812
1246
|
|
|
813
1247
|
// ── Prefetch permission guard ─────────────────────────────────────────────────
|
|
814
|
-
//
|
|
815
|
-
//
|
|
816
|
-
//
|
|
1248
|
+
// Runs the shared permission evaluator for tool calls that originate in the
|
|
1249
|
+
// prefetch path (outside the agent loop). Permission enforcement is disabled
|
|
1250
|
+
// (the evaluator always returns allow), so this is effectively a pass-through
|
|
1251
|
+
// kept for API compatibility. Returns an error string if blocked, or null.
|
|
817
1252
|
const _permEvalForPrefetch = (() => {
|
|
818
1253
|
const _req = createRequire(import.meta.url);
|
|
819
1254
|
try {
|
|
@@ -824,9 +1259,8 @@ const _permEvalForPrefetch = (() => {
|
|
|
824
1259
|
})();
|
|
825
1260
|
function _guardedPrefetchTool(toolName, toolArgs, session) {
|
|
826
1261
|
if (!_permEvalForPrefetch) return null;
|
|
827
|
-
//
|
|
828
|
-
//
|
|
829
|
-
// bypass-proof hard-deny patterns still apply during prefetch dispatch.
|
|
1262
|
+
// When no explicit mode is attached to the session, run the evaluator
|
|
1263
|
+
// under 'default'. The evaluator now always allows, so this never blocks.
|
|
830
1264
|
const permissionMode = session?.permissionMode || 'default';
|
|
831
1265
|
const projectDir = session?.cwd || undefined;
|
|
832
1266
|
const userCwd = session?.cwd || undefined;
|
|
@@ -845,7 +1279,7 @@ function _guardedPrefetchTool(toolName, toolArgs, session) {
|
|
|
845
1279
|
|
|
846
1280
|
async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
|
|
847
1281
|
if (!explicitPrefetch || typeof explicitPrefetch !== 'object') return null;
|
|
848
|
-
if (session
|
|
1282
|
+
if (!isAgentOwner(session)) return null;
|
|
849
1283
|
const parts = [];
|
|
850
1284
|
const failed = [];
|
|
851
1285
|
const totalEntries = [];
|
|
@@ -887,7 +1321,7 @@ async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
|
|
|
887
1321
|
if (files.length > 0) {
|
|
888
1322
|
const _pfGuard = _guardedPrefetchTool('read', { path: files }, session);
|
|
889
1323
|
if (_pfGuard) {
|
|
890
|
-
process.stderr.write(`[
|
|
1324
|
+
process.stderr.write(`[agent-prefetch] files read blocked: ${_pfGuard}\n`);
|
|
891
1325
|
failed.push(...files);
|
|
892
1326
|
totalEntries.push(...files);
|
|
893
1327
|
} else {
|
|
@@ -931,7 +1365,7 @@ async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
|
|
|
931
1365
|
readArgs.n = Number.isFinite(opts.n) ? opts.n : 120;
|
|
932
1366
|
}
|
|
933
1367
|
const out = await executeInternalTool('read', readArgs).catch((e) => {
|
|
934
|
-
process.stderr.write(`[
|
|
1368
|
+
process.stderr.write(`[agent-prefetch] file read failed (${f}): ${e && e.message || e}\n`);
|
|
935
1369
|
return null;
|
|
936
1370
|
});
|
|
937
1371
|
if (out !== null) {
|
|
@@ -969,9 +1403,11 @@ async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
|
|
|
969
1403
|
parts.push(`### prefetch files\nread ${readParts.length}\n\n${readParts.join('\n\n')}`);
|
|
970
1404
|
}
|
|
971
1405
|
// Log hit/miss counters so dispatch telemetry shows prefetch effectiveness.
|
|
972
|
-
process.
|
|
973
|
-
|
|
974
|
-
|
|
1406
|
+
if (process.env.MIXDOG_DEBUG_SESSION_LOG) {
|
|
1407
|
+
process.stderr.write(
|
|
1408
|
+
`[prefetch] files=${files.length} cached=${fileHits.length} miss=${fileMisses.length} failed=${failed.length}\n`
|
|
1409
|
+
);
|
|
1410
|
+
}
|
|
975
1411
|
// Attach stats to session so post-hoc analyzers (inspect-session.mjs)
|
|
976
1412
|
// can see prefetch effectiveness without parsing stderr logs.
|
|
977
1413
|
if (session && typeof session === 'object') {
|
|
@@ -993,13 +1429,13 @@ async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
|
|
|
993
1429
|
totalEntries.push(symbol);
|
|
994
1430
|
const blocked = _guardedPrefetchTool('code_graph', cgArgs, session);
|
|
995
1431
|
if (blocked) {
|
|
996
|
-
process.stderr.write(`[
|
|
1432
|
+
process.stderr.write(`[agent-prefetch] callers(${symbol}) blocked: ${blocked}\n`);
|
|
997
1433
|
return Promise.resolve({ symbol, out: null, blocked: true });
|
|
998
1434
|
}
|
|
999
|
-
return
|
|
1435
|
+
return _executeCodeGraphToolLazy('code_graph', cgArgs, session?.cwd)
|
|
1000
1436
|
.then(out => ({ symbol, out }))
|
|
1001
1437
|
.catch(e => {
|
|
1002
|
-
process.stderr.write(`[
|
|
1438
|
+
process.stderr.write(`[agent-prefetch] callers(${symbol}) failed: ${e && e.message || e}\n`);
|
|
1003
1439
|
return { symbol, out: null };
|
|
1004
1440
|
});
|
|
1005
1441
|
});
|
|
@@ -1024,13 +1460,13 @@ async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
|
|
|
1024
1460
|
totalEntries.push(symbol);
|
|
1025
1461
|
const blocked = _guardedPrefetchTool('code_graph', cgArgs, session);
|
|
1026
1462
|
if (blocked) {
|
|
1027
|
-
process.stderr.write(`[
|
|
1463
|
+
process.stderr.write(`[agent-prefetch] references(${symbol}) blocked: ${blocked}\n`);
|
|
1028
1464
|
return Promise.resolve({ symbol, out: null, blocked: true });
|
|
1029
1465
|
}
|
|
1030
|
-
return
|
|
1466
|
+
return _executeCodeGraphToolLazy('code_graph', cgArgs, session?.cwd)
|
|
1031
1467
|
.then(out => ({ symbol, out }))
|
|
1032
1468
|
.catch(e => {
|
|
1033
|
-
process.stderr.write(`[
|
|
1469
|
+
process.stderr.write(`[agent-prefetch] references(${symbol}) failed: ${e && e.message || e}\n`);
|
|
1034
1470
|
return { symbol, out: null };
|
|
1035
1471
|
});
|
|
1036
1472
|
});
|
|
@@ -1065,29 +1501,29 @@ async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
|
|
|
1065
1501
|
return `${warnLine}<prefetch>\n${parts.join('\n\n')}\n</prefetch>`;
|
|
1066
1502
|
}
|
|
1067
1503
|
|
|
1068
|
-
// ---
|
|
1504
|
+
// --- agent spawn (createSession) ---
|
|
1069
1505
|
// opts can pass either a `preset` object (from config.presets) or raw provider/model.
|
|
1070
1506
|
// Preset shape: { name, provider, model, effort?, fast?, tools? }
|
|
1071
1507
|
//
|
|
1072
|
-
//
|
|
1508
|
+
// Agent Runtime integration:
|
|
1073
1509
|
// opts.taskType / opts.role / opts.profileId — enables profile-aware routing.
|
|
1074
1510
|
// Rule-based SmartRouter resolves these synchronously; the resolved
|
|
1075
1511
|
// profile controls context filtering (skip.skills/memory/etc) and cache
|
|
1076
1512
|
// strategy. If no rule matches, falls back to classic preset behavior.
|
|
1077
1513
|
// opts.profile — pre-resolved profile (bypasses router; used by async
|
|
1078
|
-
// callers who already ran
|
|
1514
|
+
// callers who already ran AgentRuntime.resolve()).
|
|
1079
1515
|
// opts.providerCacheOpts — pre-resolved cache options merged into ask() sendOpts.
|
|
1080
1516
|
export function createSession(opts) {
|
|
1081
1517
|
const presetObj = opts.preset && typeof opts.preset === 'object' ? opts.preset : null;
|
|
1082
1518
|
|
|
1083
|
-
// ---
|
|
1519
|
+
// --- Agent Runtime profile resolution (best-effort, sync) ---
|
|
1084
1520
|
let profile = opts.profile || null;
|
|
1085
1521
|
let providerCacheOpts = opts.providerCacheOpts || null;
|
|
1086
1522
|
if (!profile && (opts.taskType || opts.role || opts.profileId)) {
|
|
1087
|
-
const
|
|
1088
|
-
if (
|
|
1523
|
+
const agentRuntime = getAgentRuntimeSync();
|
|
1524
|
+
if (agentRuntime) {
|
|
1089
1525
|
try {
|
|
1090
|
-
const resolved =
|
|
1526
|
+
const resolved = agentRuntime.resolveSync({
|
|
1091
1527
|
taskType: opts.taskType,
|
|
1092
1528
|
role: opts.role,
|
|
1093
1529
|
profileId: opts.profileId,
|
|
@@ -1099,10 +1535,10 @@ export function createSession(opts) {
|
|
|
1099
1535
|
providerCacheOpts = resolved.providerCacheOpts;
|
|
1100
1536
|
}
|
|
1101
1537
|
} catch (e) {
|
|
1102
|
-
//
|
|
1103
|
-
if (!
|
|
1104
|
-
|
|
1105
|
-
process.stderr.write(`[session]
|
|
1538
|
+
// Agent Runtime error — log once, fall back to classic behavior.
|
|
1539
|
+
if (!_agentRuntimeWarned) {
|
|
1540
|
+
_agentRuntimeWarned = true;
|
|
1541
|
+
process.stderr.write(`[session] agent runtime resolve failed: ${e.message}\n`);
|
|
1106
1542
|
}
|
|
1107
1543
|
}
|
|
1108
1544
|
}
|
|
@@ -1130,132 +1566,108 @@ export function createSession(opts) {
|
|
|
1130
1566
|
throw new Error(`Provider "${providerName}" not found or not enabled`);
|
|
1131
1567
|
const id = `sess_${process.pid}_${nextId++}_${Date.now()}_${randomBytes(16).toString('hex')}`;
|
|
1132
1568
|
const messages = [];
|
|
1133
|
-
const
|
|
1134
|
-
const skills = opts.skipSkills ? [] : collectSkillsCached(opts.cwd);
|
|
1135
|
-
|
|
1136
|
-
// Bridge shared prefix (bit-identical across roles). Hidden roles reuse the
|
|
1137
|
-
// same shared bridge rules so the cache shard stays stable across bridge
|
|
1138
|
-
// callers. User-defined data (DATA_DIR roles/schedules/webhooks) is baked
|
|
1139
|
-
// into BP1 as a single fixed-value monolithic block so every role shares
|
|
1140
|
-
// one cache shard. A user edit invalidates BP1 once and the new prefix
|
|
1141
|
-
// re-warms across all roles together.
|
|
1142
|
-
const bridgeRulesRole = opts.role || profile?.taskType || null;
|
|
1143
|
-
const injectedRules = opts.skipBridgeRules ? '' : (opts.owner === 'bridge' ? _buildBridgeRules() : _buildLeadRules());
|
|
1144
|
-
const roleSpecific = opts.owner === 'bridge' && !opts.skipBridgeRules ? _buildRoleSpecific(bridgeRulesRole) : '';
|
|
1145
|
-
// mixdog.md user/project context (global + cwd ancestors, broad-to-specific).
|
|
1146
|
-
const projectContext = collectMixdogMd(opts.cwd);
|
|
1147
|
-
|
|
1148
|
-
// Role template (Phase B §4 — UI-managed). Reads <DATA_DIR>/roles/<role>.md
|
|
1149
|
-
// and parses frontmatter (description, permission). The template is
|
|
1150
|
-
// injected into the Tier 3 system-reminder so role differences never
|
|
1151
|
-
// touch the BP_2 cache prefix.
|
|
1569
|
+
const ownerIsAgent = isAgentOwner(opts.owner);
|
|
1152
1570
|
const resolvedRole = opts.role || profile?.taskType || null;
|
|
1153
|
-
const
|
|
1154
|
-
const
|
|
1155
|
-
|
|
1156
|
-
|
|
1571
|
+
const hiddenRole = getHiddenRole(resolvedRole);
|
|
1572
|
+
const isRetrievalRole = hiddenRole?.kind === 'retrieval';
|
|
1573
|
+
// Skill schema is fixed; the compact manifest is discovered once through
|
|
1574
|
+
// the mtime-cached frontmatter index so BP1 tells every role which Skill()
|
|
1575
|
+
// names are available without loading full SKILL.md bodies.
|
|
1576
|
+
const skills = opts.skipSkills ? [] : collectSkillsCached(opts.cwd);
|
|
1157
1577
|
|
|
1158
|
-
//
|
|
1159
|
-
//
|
|
1160
|
-
//
|
|
1161
|
-
|
|
1578
|
+
// BP1 is shared tool policy (+ compact skill manifest in compose). BP2 is
|
|
1579
|
+
// role/system rules. User-defined schedules/webhooks ride as normal user
|
|
1580
|
+
// context below so event data does not rewrite BP3 memory/meta.
|
|
1581
|
+
const agentRulesRole = opts.role || profile?.taskType || null;
|
|
1582
|
+
const agentRulesProfile = isRetrievalRole ? 'retrieval' : 'full';
|
|
1583
|
+
const skipAgentRules = opts.skipAgentRules === true;
|
|
1584
|
+
// Retrieval roles already inject a compact # Tool Use via BP2; skip the full BP1 shared tool policy to avoid duplicating it.
|
|
1585
|
+
const injectedRules = (skipAgentRules || isRetrievalRole) ? '' : _buildSharedRules();
|
|
1586
|
+
const roleRules = skipAgentRules ? '' : (ownerIsAgent ? _buildAgentRules(agentRulesProfile) : _buildLeadRules());
|
|
1587
|
+
const metaContext = skipAgentRules ? '' : (ownerIsAgent ? '' : _buildLeadMetaContext());
|
|
1588
|
+
const roleSpecific = ownerIsAgent && !skipAgentRules ? _buildRoleSpecific(agentRulesRole) : '';
|
|
1589
|
+
// Agent sessions must not inherit role/profile/preset tool narrowing: Pool
|
|
1590
|
+
// B and Pool C share one bit-identical tool schema to maximize provider
|
|
1591
|
+
// prefix reuse, and permission differences are enforced only at call time. Raw
|
|
1592
|
+
// non-agent callers keep the historical profile.tools / preset.tools
|
|
1162
1593
|
// behaviour.
|
|
1163
|
-
const toolSpec =
|
|
1594
|
+
const toolSpec = ownerIsAgent
|
|
1164
1595
|
? 'full'
|
|
1165
1596
|
: (Array.isArray(profile?.tools) ? profile.tools : toolPreset);
|
|
1166
1597
|
|
|
1167
1598
|
// Prompt permission is metadata only. Preset tool restrictions must NOT
|
|
1168
|
-
// enter the prompt, or they split the shared
|
|
1599
|
+
// enter the prompt, or they split the shared agent cache tail; they map
|
|
1169
1600
|
// to toolPermission below and are enforced only at call time.
|
|
1170
1601
|
const permission = opts.permission
|
|
1171
|
-
|| roleTemplate?.permission
|
|
1172
1602
|
|| null;
|
|
1173
1603
|
const toolPermission = opts.permission
|
|
1174
1604
|
|| profile?.permission
|
|
1175
|
-
|| roleTemplate?.permission
|
|
1176
1605
|
|| permissionFromToolSpec(toolPreset)
|
|
1177
1606
|
|| null;
|
|
1178
|
-
let toolsForRouting = resolveSessionTools(toolSpec, skills, {
|
|
1179
|
-
// Fail-closed permission intersection: when a
|
|
1180
|
-
//
|
|
1607
|
+
let toolsForRouting = resolveSessionTools(toolSpec, skills, { ownerIsAgentSession: ownerIsAgent });
|
|
1608
|
+
// Fail-closed permission intersection: when a session declares an explicit
|
|
1609
|
+
// object-form permission, intersect the
|
|
1181
1610
|
// resolved tool list with the permission's allow/deny lists. If the
|
|
1182
1611
|
// intersection produces an empty set the permission config is broken —
|
|
1183
1612
|
// fail closed (zero tools) rather than silently falling back to the full
|
|
1184
1613
|
// preset, which would grant the role more surface than declared.
|
|
1185
|
-
if (
|
|
1186
|
-
|
|
1187
|
-
? new Set(toolPermission.allow.map(n => String(n).toLowerCase()))
|
|
1188
|
-
: null;
|
|
1189
|
-
const denySet = Array.isArray(toolPermission.deny) && toolPermission.deny.length > 0
|
|
1190
|
-
? new Set(toolPermission.deny.map(n => String(n).toLowerCase()))
|
|
1191
|
-
: null;
|
|
1192
|
-
if (allowSet || denySet) {
|
|
1193
|
-
const filtered = toolsForRouting.filter(t => {
|
|
1194
|
-
const name = String(t?.name || '').toLowerCase();
|
|
1195
|
-
if (denySet && denySet.has(name)) return false;
|
|
1196
|
-
if (allowSet && !allowSet.has(name)) return false;
|
|
1197
|
-
return true;
|
|
1198
|
-
});
|
|
1199
|
-
// Fail-closed: an empty intersection means the permission config is
|
|
1200
|
-
// misconfigured — do not silently fall back to the full preset.
|
|
1201
|
-
toolsForRouting = filtered;
|
|
1202
|
-
if (filtered.length === 0) {
|
|
1203
|
-
process.stderr.write(`[session] WARN: role permission intersection produced 0 tools — failing closed (role=${opts.role || 'unknown'})
|
|
1204
|
-
`);
|
|
1205
|
-
}
|
|
1206
|
-
}
|
|
1614
|
+
if (ownerIsAgent) {
|
|
1615
|
+
toolsForRouting = applyToolPermissionNarrowing(toolsForRouting, toolPermission, opts.role || null);
|
|
1207
1616
|
}
|
|
1208
1617
|
|
|
1209
|
-
const { baseRules,
|
|
1618
|
+
const { baseRules, stableSystemContext, sessionMarker, volatileTail } = composeSystemPrompt({
|
|
1210
1619
|
userPrompt: opts.systemPrompt,
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
roleTemplate: roleTemplate || undefined,
|
|
1216
|
-
hasSkills: skills.length > 0,
|
|
1620
|
+
agentRules: injectedRules || undefined,
|
|
1621
|
+
roleRules: roleRules || undefined,
|
|
1622
|
+
metaContext: metaContext || undefined,
|
|
1623
|
+
skipRoleCatalog: !ownerIsAgent,
|
|
1217
1624
|
profile: profile || undefined,
|
|
1218
1625
|
role: resolvedRole,
|
|
1219
|
-
skipRoleReminder: opts.skipRoleReminder || false,
|
|
1220
|
-
permission,
|
|
1221
|
-
taskBrief: opts.taskBrief || null,
|
|
1222
1626
|
workflowContext: opts.workflowContext || null,
|
|
1223
1627
|
workspaceContext: opts.workspaceContext || null,
|
|
1224
1628
|
coreMemoryContext: opts.coreMemoryContext || null,
|
|
1225
|
-
|
|
1629
|
+
skillManifest: buildSkillManifest(skills),
|
|
1226
1630
|
tools: toolsForRouting,
|
|
1227
|
-
bashIsPersistent:
|
|
1631
|
+
bashIsPersistent: ownerIsAgent && toolsForRouting.some(t => t?.name === 'shell'),
|
|
1228
1632
|
// Effective cwd rides in tier3Reminder so explore-like tools know
|
|
1229
1633
|
// their search root without needing to shove "Override cwd:" into
|
|
1230
1634
|
// the user message body (that used to fragment the shard prefix).
|
|
1231
1635
|
cwd: opts.cwd || null,
|
|
1232
|
-
// BP2 catalog policy — explicit-cache providers see the unified
|
|
1233
|
-
// all-roles catalog; implicit-prefix-hash providers keep self-only.
|
|
1234
1636
|
provider: providerName || null,
|
|
1235
1637
|
});
|
|
1236
1638
|
// 4-BP layout (see composeSystemPrompt docs):
|
|
1237
|
-
// system block #1 = baseRules
|
|
1238
|
-
// system block #2 =
|
|
1239
|
-
//
|
|
1240
|
-
//
|
|
1241
|
-
//
|
|
1242
|
-
//
|
|
1243
|
-
//
|
|
1244
|
-
//
|
|
1639
|
+
// system block #1 = baseRules — BP1 (1h) shared tool policy + skills
|
|
1640
|
+
// system block #2 = stableSystemContext — BP2 (1h) role/system rules
|
|
1641
|
+
// system block #3 = sessionMarker — BP3 (1h) memory/meta + Profile
|
|
1642
|
+
// Preferences (language/name). It rides as a real `system` block so
|
|
1643
|
+
// locale/name directives are pinned firmly and do not drift to English
|
|
1644
|
+
// after a few turns the way a `user <system-reminder>` reminder did.
|
|
1645
|
+
// later normal messages = BP4/tail (task, role data, tool history)
|
|
1646
|
+
// Anthropic multi-block system pins each block with cache_control (BP3 is
|
|
1647
|
+
// the 3rd system block and carries the tier3 1h marker). OpenAI/xAI get
|
|
1648
|
+
// stable provider cache keys/session prefixes. Gemini manages explicit
|
|
1649
|
+
// cachedContents inside its provider.
|
|
1245
1650
|
if (baseRules) {
|
|
1246
1651
|
messages.push({ role: 'system', content: baseRules });
|
|
1247
1652
|
}
|
|
1248
|
-
if (
|
|
1249
|
-
messages.push({ role: 'system', content:
|
|
1653
|
+
if (stableSystemContext) {
|
|
1654
|
+
messages.push({ role: 'system', content: stableSystemContext });
|
|
1250
1655
|
}
|
|
1251
1656
|
if (sessionMarker) {
|
|
1252
|
-
|
|
1253
|
-
|
|
1657
|
+
// cacheTier:'tier3' tells the Anthropic providers to pin THIS system
|
|
1658
|
+
// block with the tier3 1h cache_control (BP3) — distinct from the
|
|
1659
|
+
// BP1/BP2 system TTL. Harmless on non-Anthropic providers (they ignore
|
|
1660
|
+
// the field and serialize content as a normal system instruction).
|
|
1661
|
+
messages.push({ role: 'system', content: sessionMarker, cacheTier: 'tier3' });
|
|
1254
1662
|
}
|
|
1255
1663
|
if (volatileTail) {
|
|
1256
1664
|
messages.push({ role: 'user', content: `<system-reminder>\n${volatileTail}\n</system-reminder>` });
|
|
1257
1665
|
messages.push({ role: 'assistant', content: '.' });
|
|
1258
1666
|
}
|
|
1667
|
+
if (roleSpecific) {
|
|
1668
|
+
messages.push({ role: 'user', content: `<system-reminder>\n${roleSpecific}\n</system-reminder>` });
|
|
1669
|
+
messages.push({ role: 'assistant', content: '.' });
|
|
1670
|
+
}
|
|
1259
1671
|
if (opts.files?.length) {
|
|
1260
1672
|
const fileContext = opts.files
|
|
1261
1673
|
.map(f => `### ${f.path}\n\`\`\`\n${f.content}\n\`\`\``)
|
|
@@ -1263,58 +1675,30 @@ export function createSession(opts) {
|
|
|
1263
1675
|
messages.push({ role: 'user', content: `Reference files:\n\n${fileContext}` });
|
|
1264
1676
|
messages.push({ role: 'assistant', content: '.' });
|
|
1265
1677
|
}
|
|
1266
|
-
let tools = toolsForRouting;
|
|
1267
|
-
|
|
1268
|
-
// Schema filtering applied after schema build:
|
|
1269
|
-
// - opts.schemaAllowedTools : declarative hidden-role schema profile
|
|
1270
|
-
// allowlist for tiny specialist roles where one-shot tool routing
|
|
1271
|
-
// beats the shared-schema cache win.
|
|
1272
|
-
// - opts.disallowedTools : per-call caller override (Anthropic
|
|
1273
|
-
// BuiltInAgentDefinition pattern)
|
|
1274
|
-
// - annotations.bridgeHidden : declarative per-tool flag (tools.json
|
|
1275
|
-
// and internal tool defs). Pool A (Lead) still sees all tools.
|
|
1276
|
-
//
|
|
1277
1678
|
const hasCallerAllow = Array.isArray(opts.schemaAllowedTools);
|
|
1278
|
-
const
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
process.stderr.write(`[session] schemaAllowedTools=${callerAllow.join(',')} kept ${tools.length}/${before} tools\n`);
|
|
1285
|
-
}
|
|
1286
|
-
}
|
|
1287
|
-
const callerDeny = Array.isArray(opts.disallowedTools) ? opts.disallowedTools.map(n => String(n)) : [];
|
|
1288
|
-
if (callerDeny.length) {
|
|
1289
|
-
const denySet = new Set(callerDeny);
|
|
1290
|
-
const before = tools.length;
|
|
1291
|
-
tools = tools.filter(t => !denySet.has(String(t?.name || '').toLowerCase()));
|
|
1292
|
-
if (tools.length !== before && !process.env.MIXDOG_QUIET_SESSION_LOG) {
|
|
1293
|
-
process.stderr.write(`[session] disallowedTools=${callerDeny.join(',')} stripped ${before - tools.length} tools\n`);
|
|
1294
|
-
}
|
|
1295
|
-
}
|
|
1296
|
-
if (opts.owner === 'bridge') {
|
|
1297
|
-
const before = tools.length;
|
|
1298
|
-
tools = tools.filter(t => !t?.annotations?.bridgeHidden);
|
|
1299
|
-
if (tools.length !== before && !process.env.MIXDOG_QUIET_SESSION_LOG) {
|
|
1300
|
-
process.stderr.write(`[session] bridgeHidden stripped ${before - tools.length} tools\n`);
|
|
1301
|
-
}
|
|
1302
|
-
}
|
|
1303
|
-
|
|
1304
|
-
// Bridge tool canonicalization: keep route-sensitive tools in policy order
|
|
1305
|
-
// while preserving deterministic MCP/skill order for BP1 shard stability.
|
|
1306
|
-
if (opts.owner === 'bridge') {
|
|
1307
|
-
tools = orderSessionTools(tools);
|
|
1308
|
-
}
|
|
1679
|
+
const tools = finalizeSessionToolList(toolsForRouting, {
|
|
1680
|
+
schemaAllowedTools: hasCallerAllow ? opts.schemaAllowedTools : null,
|
|
1681
|
+
disallowedTools: opts.disallowedTools,
|
|
1682
|
+
ownerIsAgent,
|
|
1683
|
+
resolvedRole,
|
|
1684
|
+
});
|
|
1309
1685
|
|
|
1310
1686
|
// Unified-shard policy — no broad role-specific schema filter. Keep
|
|
1311
|
-
//
|
|
1687
|
+
// agent schemas shared unless a hidden-role schema profile explicitly
|
|
1312
1688
|
// passes schemaAllowedTools for a small specialist; broad role
|
|
1313
1689
|
// whitelists would fragment the cache shard.
|
|
1314
|
-
if (resolvedRole &&
|
|
1690
|
+
if (resolvedRole && process.env.MIXDOG_DEBUG_SESSION_LOG) {
|
|
1315
1691
|
process.stderr.write(`[session] role=${resolvedRole} permission=${permission || 'full'} toolPermission=${toolPermission || 'full'} tools=${tools.length}\n`);
|
|
1316
1692
|
}
|
|
1317
1693
|
const contextMeta = resolveSessionContextMeta(provider, modelName);
|
|
1694
|
+
const workflowMeta = opts.workflow && typeof opts.workflow === 'object' && String(opts.workflow.id || '').trim()
|
|
1695
|
+
? {
|
|
1696
|
+
id: String(opts.workflow.id || '').trim(),
|
|
1697
|
+
name: String(opts.workflow.name || opts.workflow.id || '').trim(),
|
|
1698
|
+
description: String(opts.workflow.description || '').trim(),
|
|
1699
|
+
source: String(opts.workflow.source || '').trim(),
|
|
1700
|
+
}
|
|
1701
|
+
: null;
|
|
1318
1702
|
const session = {
|
|
1319
1703
|
id,
|
|
1320
1704
|
provider: providerName,
|
|
@@ -1329,13 +1713,27 @@ export function createSession(opts) {
|
|
|
1329
1713
|
auto: opts.compaction?.auto !== false,
|
|
1330
1714
|
prune: opts.compaction?.prune === true,
|
|
1331
1715
|
semantic: opts.compaction?.semantic ?? 'auto',
|
|
1716
|
+
type: normalizeCompactType(opts.compaction?.type ?? opts.compaction?.compactType ?? opts.compaction?.compact_type, DEFAULT_COMPACT_TYPE),
|
|
1717
|
+
compactType: normalizeCompactType(opts.compaction?.type ?? opts.compaction?.compactType ?? opts.compaction?.compact_type, DEFAULT_COMPACT_TYPE),
|
|
1332
1718
|
model: opts.compaction?.model || null,
|
|
1333
1719
|
timeoutMs: positiveContextWindow(opts.compaction?.timeoutMs),
|
|
1334
1720
|
tailTurns: positiveContextWindow(opts.compaction?.tailTurns),
|
|
1335
1721
|
bufferTokens: positiveContextWindow(opts.compaction?.bufferTokens ?? opts.compaction?.buffer),
|
|
1722
|
+
// Preserve percent/ratio-named buffer config so the manager/loop/
|
|
1723
|
+
// contextStatus parsers (which read bufferPercent/bufferPct/
|
|
1724
|
+
// bufferRatio/bufferFraction) can honor it. createSession previously
|
|
1725
|
+
// only stored bufferTokens, silently dropping a configured percent.
|
|
1726
|
+
...preserveBufferConfigFields(opts.compaction),
|
|
1336
1727
|
keepTokens: positiveContextWindow(opts.compaction?.keepTokens ?? opts.compaction?.keep?.tokens),
|
|
1337
1728
|
preserveRecentTokens: positiveContextWindow(opts.compaction?.preserveRecentTokens),
|
|
1338
1729
|
reservedTokens: positiveContextWindow(opts.compaction?.reservedTokens),
|
|
1730
|
+
recallIngestLimit: positiveContextWindow(opts.compaction?.recallIngestLimit),
|
|
1731
|
+
recallChunkLimit: positiveContextWindow(opts.compaction?.recallChunkLimit ?? opts.compaction?.recallLimit),
|
|
1732
|
+
recallCycle1BatchSize: positiveContextWindow(opts.compaction?.recallCycle1BatchSize),
|
|
1733
|
+
recallRowsPerSession: positiveContextWindow(opts.compaction?.recallRowsPerSession),
|
|
1734
|
+
recallWindowSize: positiveContextWindow(opts.compaction?.recallWindowSize),
|
|
1735
|
+
recallConcurrency: positiveContextWindow(opts.compaction?.recallConcurrency),
|
|
1736
|
+
recallCycle1DeadlineMs: positiveContextWindow(opts.compaction?.recallCycle1DeadlineMs),
|
|
1339
1737
|
boundaryTokens: contextMeta.compactBoundaryTokens,
|
|
1340
1738
|
},
|
|
1341
1739
|
tools,
|
|
@@ -1347,40 +1745,42 @@ export function createSession(opts) {
|
|
|
1347
1745
|
owner: opts.owner || 'user',
|
|
1348
1746
|
mcpPid: process.pid,
|
|
1349
1747
|
scopeKey: opts.scopeKey || null,
|
|
1350
|
-
lane: opts.lane || '
|
|
1748
|
+
lane: opts.lane || 'agent',
|
|
1351
1749
|
cwd: opts.cwd,
|
|
1750
|
+
workflow: workflowMeta,
|
|
1352
1751
|
createdAt: Date.now(),
|
|
1353
1752
|
updatedAt: Date.now(),
|
|
1354
1753
|
lastHeartbeatAt: null,
|
|
1355
1754
|
totalInputTokens: 0,
|
|
1356
1755
|
totalOutputTokens: 0,
|
|
1357
|
-
// Refreshed on each completed ask() — surfaced by
|
|
1756
|
+
// Refreshed on each completed ask() — surfaced by agent type=list for
|
|
1358
1757
|
// debugging + consumed by store.mjs's idle-sweep to reclaim stalled
|
|
1359
|
-
//
|
|
1758
|
+
// agent sessions past RUNNING_STALL_MS.
|
|
1360
1759
|
lastUsedAt: Date.now(),
|
|
1361
1760
|
tokensCumulative: 0,
|
|
1362
1761
|
role: opts.role || null,
|
|
1363
1762
|
taskType: opts.taskType || null,
|
|
1364
1763
|
maxLoopIterations: Number.isFinite(opts.maxLoopIterations) ? opts.maxLoopIterations : null,
|
|
1365
|
-
//
|
|
1764
|
+
// Agent tag (auto worker{n} on spawn) persisted so the forked status
|
|
1366
1765
|
// process (statusline) + aggregator can read it from the session JSON.
|
|
1367
1766
|
// In-process send/close still resolve via _tagSessionRegistry.
|
|
1368
|
-
|
|
1767
|
+
agentTag: opts.agentTag || null,
|
|
1369
1768
|
// Prompt permission is separate from runtime toolPermission so preset
|
|
1370
|
-
// restrictions do not fragment the
|
|
1769
|
+
// restrictions do not fragment the agent cache prefix.
|
|
1371
1770
|
permission: permission || null,
|
|
1372
1771
|
toolPermission: toolPermission || null,
|
|
1373
|
-
|
|
1772
|
+
schemaAllowedTools: hasCallerAllow ? opts.schemaAllowedTools.map((n) => String(n)) : null,
|
|
1773
|
+
// Origin tag written into every agent-trace usage row so analytics
|
|
1374
1774
|
// can slice by (sourceType, sourceName) — e.g. maintenance/cycle1,
|
|
1375
1775
|
// scheduler/daily-standup, webhook/github-push, lead/worker.
|
|
1376
1776
|
sourceType: opts.sourceType || null,
|
|
1377
1777
|
sourceName: opts.sourceName || null,
|
|
1378
1778
|
// Provider-scoped unified cache key — one shard per provider,
|
|
1379
|
-
// shared across all roles / sources (
|
|
1779
|
+
// shared across all roles / sources (agent/maintenance/mcp/
|
|
1380
1780
|
// scheduler/webhook). Role or source-specific context must be
|
|
1381
1781
|
// injected into the message tail, not the shared prefix.
|
|
1382
1782
|
promptCacheKey: providerCacheKey(presetObj?.provider || opts.provider, opts.cacheKeyOverride),
|
|
1383
|
-
//
|
|
1783
|
+
// Agent shell continuity: when an agent session explicitly opts into
|
|
1384
1784
|
// persistent shell state (`bash` with `persistent:true`, or direct
|
|
1385
1785
|
// `bash_session`), the minted bash_session id is stored here so later
|
|
1386
1786
|
// opted-in `bash` calls can reuse the same shell state.
|
|
@@ -1389,7 +1789,7 @@ export function createSession(opts) {
|
|
|
1389
1789
|
// orchestrator session so closeSession can kill them all, not just
|
|
1390
1790
|
// the most recently recorded one.
|
|
1391
1791
|
allBashSessionIds: [],
|
|
1392
|
-
//
|
|
1792
|
+
// Agent Runtime metadata — optional. Applied on every ask() to merge
|
|
1393
1793
|
// profile-driven cache settings into provider sendOpts.
|
|
1394
1794
|
profileId: profile?.id || null,
|
|
1395
1795
|
permissionMode: opts.permissionMode ?? null,
|
|
@@ -1405,7 +1805,7 @@ export function createSession(opts) {
|
|
|
1405
1805
|
}
|
|
1406
1806
|
|
|
1407
1807
|
// ── Runtime liveness map ──────────────────────────────────────────────
|
|
1408
|
-
// In-memory only. Tracks per-session stage + stream heartbeat so
|
|
1808
|
+
// In-memory only. Tracks per-session stage + stream heartbeat so agent type=list
|
|
1409
1809
|
// can surface whether a session is actually alive vs stuck. Never persisted —
|
|
1410
1810
|
// heartbeats would otherwise churn the session JSON on every SSE delta.
|
|
1411
1811
|
// Entry shape: {
|
|
@@ -1415,6 +1815,7 @@ export function createSession(opts) {
|
|
|
1415
1815
|
// closed?: boolean, // flipped by closeSession()
|
|
1416
1816
|
// }
|
|
1417
1817
|
const _runtimeState = new Map();
|
|
1818
|
+
const _toolActivityHeartbeats = new Map();
|
|
1418
1819
|
const VALID_STAGES = new Set([
|
|
1419
1820
|
'connecting', 'requesting', 'streaming', 'tool_running', 'idle', 'error', 'done', 'cancelling',
|
|
1420
1821
|
]);
|
|
@@ -1426,6 +1827,33 @@ function _touchRuntime(id) {
|
|
|
1426
1827
|
}
|
|
1427
1828
|
return entry;
|
|
1428
1829
|
}
|
|
1830
|
+
|
|
1831
|
+
function _stopToolActivityHeartbeat(id) {
|
|
1832
|
+
if (!id) return;
|
|
1833
|
+
const timer = _toolActivityHeartbeats.get(id);
|
|
1834
|
+
if (!timer) return;
|
|
1835
|
+
try { clearInterval(timer); } catch { /* ignore */ }
|
|
1836
|
+
_toolActivityHeartbeats.delete(id);
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
function _touchSessionActivityProgress(id) {
|
|
1840
|
+
const entry = _runtimeState.get(id);
|
|
1841
|
+
if (!entry || entry.closed || entry.controller?.signal?.aborted) return;
|
|
1842
|
+
if (entry.stage !== 'tool_running') return;
|
|
1843
|
+
const now = Date.now();
|
|
1844
|
+
entry.lastProgressAt = now;
|
|
1845
|
+
entry.updatedAt = now;
|
|
1846
|
+
publishHeartbeat(id, now);
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
function _startToolActivityHeartbeat(id) {
|
|
1850
|
+
_stopToolActivityHeartbeat(id);
|
|
1851
|
+
if (!(DEFAULT_ACTIVITY_HEARTBEAT_MS > 0)) return;
|
|
1852
|
+
const timer = setInterval(() => _touchSessionActivityProgress(id), DEFAULT_ACTIVITY_HEARTBEAT_MS);
|
|
1853
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
1854
|
+
_toolActivityHeartbeats.set(id, timer);
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1429
1857
|
export function updateSessionStage(id, stage) {
|
|
1430
1858
|
if (!id || !VALID_STAGES.has(stage)) return;
|
|
1431
1859
|
const entry = _touchRuntime(id);
|
|
@@ -1436,6 +1864,7 @@ export function updateSessionStage(id, stage) {
|
|
|
1436
1864
|
}
|
|
1437
1865
|
entry.lastProgressAt = now;
|
|
1438
1866
|
entry.updatedAt = now;
|
|
1867
|
+
if (stage !== 'tool_running') _stopToolActivityHeartbeat(id);
|
|
1439
1868
|
}
|
|
1440
1869
|
|
|
1441
1870
|
export function updateSessionRoute(id, route = {}) {
|
|
@@ -1501,7 +1930,11 @@ export function updateSessionRoute(id, route = {}) {
|
|
|
1501
1930
|
*/
|
|
1502
1931
|
export function markSessionAskStart(id) {
|
|
1503
1932
|
if (!id) return;
|
|
1933
|
+
_stopToolActivityHeartbeat(id);
|
|
1504
1934
|
const entry = _touchRuntime(id);
|
|
1935
|
+
entry.usageMetricsTurnIncremental = false;
|
|
1936
|
+
const sessionForTurn = entry.session ?? loadSession(id);
|
|
1937
|
+
if (sessionForTurn) bumpUsageMetricsTurnId(sessionForTurn);
|
|
1505
1938
|
entry.stage = 'connecting';
|
|
1506
1939
|
entry.lastStreamDeltaAt = null;
|
|
1507
1940
|
entry.lastToolCall = null;
|
|
@@ -1525,7 +1958,7 @@ export function markSessionAskStart(id) {
|
|
|
1525
1958
|
// Publish heartbeat immediately so the status aggregator picks the
|
|
1526
1959
|
// session up in the connecting / requesting window. Without this the
|
|
1527
1960
|
// .hb file only landed on the first stream chunk — producing a 3–10s
|
|
1528
|
-
// (xhigh: 30s+) invisible gap where
|
|
1961
|
+
// (xhigh: 30s+) invisible gap where agent sessions ran but the CC
|
|
1529
1962
|
// statusline showed no maintenance/agent badge. STREAM_FRESH_MS (5 min)
|
|
1530
1963
|
// still drops a session whose provider truly never returns a chunk;
|
|
1531
1964
|
// markSessionStreamDelta keeps refreshing once chunks arrive.
|
|
@@ -1543,6 +1976,7 @@ export async function markSessionStreamDelta(id) {
|
|
|
1543
1976
|
// path). Skip a missing, tombstoned, or aborted entry — never refresh liveness.
|
|
1544
1977
|
const entry = _runtimeState.get(id);
|
|
1545
1978
|
if (!entry || entry.closed || entry.controller?.signal?.aborted) return;
|
|
1979
|
+
_stopToolActivityHeartbeat(id);
|
|
1546
1980
|
const now = Date.now();
|
|
1547
1981
|
entry.lastStreamDeltaAt = now;
|
|
1548
1982
|
entry.lastProgressAt = now;
|
|
@@ -1572,9 +2006,14 @@ export function markSessionToolCall(id, toolName) {
|
|
|
1572
2006
|
entry.lastProgressAt = entry.toolStartedAt;
|
|
1573
2007
|
entry.updatedAt = entry.toolStartedAt;
|
|
1574
2008
|
publishHeartbeat(id, entry.toolStartedAt);
|
|
2009
|
+
_startToolActivityHeartbeat(id);
|
|
1575
2010
|
}
|
|
2011
|
+
// Parent AbortSignal listeners are dropped on askSession unwind (finally /
|
|
2012
|
+
// terminal return) and on error/cancel/close — not in markSessionDone, which
|
|
2013
|
+
// also runs between queued follow-up turns within one ask.
|
|
1576
2014
|
export function markSessionDone(id, { empty = false } = {}) {
|
|
1577
2015
|
if (!id) return;
|
|
2016
|
+
_stopToolActivityHeartbeat(id);
|
|
1578
2017
|
const entry = _touchRuntime(id);
|
|
1579
2018
|
entry.stage = 'done';
|
|
1580
2019
|
entry.lastError = null;
|
|
@@ -1609,6 +2048,7 @@ export function markSessionEmptyFinal(id) {
|
|
|
1609
2048
|
}
|
|
1610
2049
|
export function markSessionError(id, msg) {
|
|
1611
2050
|
if (!id) return;
|
|
2051
|
+
_stopToolActivityHeartbeat(id);
|
|
1612
2052
|
const entry = _touchRuntime(id);
|
|
1613
2053
|
entry.stage = 'error';
|
|
1614
2054
|
entry.lastError = msg ? String(msg).slice(0, 200) : null;
|
|
@@ -1623,9 +2063,11 @@ export function markSessionError(id, msg) {
|
|
|
1623
2063
|
entry.lastProgressAt = errTs;
|
|
1624
2064
|
entry.updatedAt = errTs;
|
|
1625
2065
|
deleteHeartbeat(id);
|
|
2066
|
+
_unlinkParentAbortListener(entry);
|
|
1626
2067
|
}
|
|
1627
2068
|
export function markSessionCancelled(id) {
|
|
1628
2069
|
if (!id) return;
|
|
2070
|
+
_stopToolActivityHeartbeat(id);
|
|
1629
2071
|
const entry = _touchRuntime(id);
|
|
1630
2072
|
entry.stage = 'done';
|
|
1631
2073
|
entry.lastError = null;
|
|
@@ -1638,11 +2080,24 @@ export function markSessionCancelled(id) {
|
|
|
1638
2080
|
entry.lastProgressAt = doneTs;
|
|
1639
2081
|
entry.updatedAt = doneTs;
|
|
1640
2082
|
deleteHeartbeat(id);
|
|
2083
|
+
_unlinkParentAbortListener(entry);
|
|
1641
2084
|
}
|
|
1642
2085
|
export function getSessionRuntime(id) {
|
|
1643
2086
|
return id ? (_runtimeState.get(id) || null) : null;
|
|
1644
2087
|
}
|
|
1645
2088
|
|
|
2089
|
+
const _COMPACTION_BLOCKED_STAGES = new Set([
|
|
2090
|
+
'connecting', 'requesting', 'streaming', 'tool_running', 'cancelling',
|
|
2091
|
+
]);
|
|
2092
|
+
|
|
2093
|
+
export function isSessionCompactionBlocked(sessionId) {
|
|
2094
|
+
if (!sessionId) return false;
|
|
2095
|
+
const entry = _runtimeState.get(sessionId);
|
|
2096
|
+
if (!entry || entry.closed === true) return false;
|
|
2097
|
+
if (entry.controller && !entry.controller.signal?.aborted) return true;
|
|
2098
|
+
return _COMPACTION_BLOCKED_STAGES.has(entry.stage);
|
|
2099
|
+
}
|
|
2100
|
+
|
|
1646
2101
|
export function getSessionProgressSnapshot(sessionId) {
|
|
1647
2102
|
const entry = _runtimeState.get(sessionId);
|
|
1648
2103
|
if (!entry) return null;
|
|
@@ -1682,29 +2137,126 @@ export function forEachSessionRuntime() {
|
|
|
1682
2137
|
}
|
|
1683
2138
|
|
|
1684
2139
|
// --- Incremental metric persistence (fix A) ---
|
|
1685
|
-
// Per-session idempotency tracking: sessionId → Set of seen
|
|
2140
|
+
// Per-session idempotency tracking: sessionId → Set of seen turn:epoch:iteration:source keys.
|
|
1686
2141
|
const _metricSeenIter = new Map();
|
|
1687
2142
|
|
|
2143
|
+
/** Monotonic per-session ask/turn id for incremental usage idempotency. */
|
|
2144
|
+
export function bumpUsageMetricsTurnId(session) {
|
|
2145
|
+
if (!session || typeof session !== 'object') return 0;
|
|
2146
|
+
const next = (Number(session.usageMetricsTurnId) || 0) + 1;
|
|
2147
|
+
session.usageMetricsTurnId = next;
|
|
2148
|
+
return next;
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
export function resolveUsageMetricsTurnId(session, delta = {}) {
|
|
2152
|
+
if (delta.usageMetricsTurnId != null && Number.isFinite(Number(delta.usageMetricsTurnId))) {
|
|
2153
|
+
return Number(delta.usageMetricsTurnId);
|
|
2154
|
+
}
|
|
2155
|
+
return Number(session?.usageMetricsTurnId) || 0;
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
/** Advance loop metrics epoch when agentLoop resets its iteration counter (post-compact). */
|
|
2159
|
+
export function bumpUsageMetricsEpoch(session) {
|
|
2160
|
+
if (!session || typeof session !== 'object') return 0;
|
|
2161
|
+
const next = (Number(session.usageMetricsEpoch) || 0) + 1;
|
|
2162
|
+
session.usageMetricsEpoch = next;
|
|
2163
|
+
return next;
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
/**
|
|
2167
|
+
* Resolve usage-metrics epoch for idempotency (exported for regression smoke).
|
|
2168
|
+
* Prefers session.usageMetricsEpoch (bumped in loop on compact reset) and optional
|
|
2169
|
+
* delta.usageMetricsEpoch; falls back to iteration regression when loop did not bump.
|
|
2170
|
+
*/
|
|
2171
|
+
export function resolveUsageMetricsEpoch(session, delta = {}) {
|
|
2172
|
+
if (!session) return 0;
|
|
2173
|
+
let epoch = Number(session.usageMetricsEpoch) || 0;
|
|
2174
|
+
if (delta.usageMetricsEpoch != null && Number.isFinite(Number(delta.usageMetricsEpoch))) {
|
|
2175
|
+
epoch = Math.max(epoch, Number(delta.usageMetricsEpoch));
|
|
2176
|
+
}
|
|
2177
|
+
const idx = Number(delta.iterationIndex);
|
|
2178
|
+
const prevLastIdx = typeof session.lastIterationIndex === 'number'
|
|
2179
|
+
? session.lastIterationIndex
|
|
2180
|
+
: null;
|
|
2181
|
+
if (
|
|
2182
|
+
(delta.usageMetricsEpoch == null || !Number.isFinite(Number(delta.usageMetricsEpoch)))
|
|
2183
|
+
&& prevLastIdx !== null
|
|
2184
|
+
&& Number.isFinite(idx)
|
|
2185
|
+
&& idx < prevLastIdx
|
|
2186
|
+
) {
|
|
2187
|
+
epoch += 1;
|
|
2188
|
+
}
|
|
2189
|
+
return epoch;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
export function usageMetricsSourceKey(delta = {}) {
|
|
2193
|
+
const raw = delta.source ?? delta.usageSource;
|
|
2194
|
+
if (raw == null || raw === '') return 'provider_send';
|
|
2195
|
+
return String(raw);
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
/** Idempotency key for incremental usage persistence (exported for regression smoke). */
|
|
2199
|
+
export function usageMetricsIdempotencyKey(sessionId, session, delta = {}) {
|
|
2200
|
+
const turnId = resolveUsageMetricsTurnId(session, delta);
|
|
2201
|
+
const epoch = resolveUsageMetricsEpoch(session, delta);
|
|
2202
|
+
const source = usageMetricsSourceKey(delta);
|
|
2203
|
+
return `${sessionId}:${turnId}:${epoch}:${delta.iterationIndex}:${source}`;
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
/**
|
|
2207
|
+
* Apply terminal ask usage to session totals. Skips lifetime totals when incremental
|
|
2208
|
+
* per-iteration persistence already counted this turn (askSession path).
|
|
2209
|
+
*/
|
|
2210
|
+
export function applyAskTerminalUsageTotals(session, result, options = {}) {
|
|
2211
|
+
if (!session || !result?.usage) return;
|
|
2212
|
+
const skipTotals = options.skipTotalsIfIncremental === true;
|
|
2213
|
+
if (!skipTotals) {
|
|
2214
|
+
session.totalInputTokens = (session.totalInputTokens || 0) + (result.usage.inputTokens || 0);
|
|
2215
|
+
session.totalOutputTokens = (session.totalOutputTokens || 0) + (result.usage.outputTokens || 0);
|
|
2216
|
+
session.tokensCumulative = (session.tokensCumulative || 0)
|
|
2217
|
+
+ (result.usage.inputTokens || 0)
|
|
2218
|
+
+ (result.usage.outputTokens || 0);
|
|
2219
|
+
session.totalCachedReadTokens = (session.totalCachedReadTokens || 0) + (result.usage.cachedTokens || 0);
|
|
2220
|
+
session.totalCacheWriteTokens = (session.totalCacheWriteTokens || 0) + (result.usage.cacheWriteTokens || 0);
|
|
2221
|
+
}
|
|
2222
|
+
const _lastTurn = result.lastTurnUsage || result.usage || {};
|
|
2223
|
+
session.lastInputTokens = _lastTurn.inputTokens || 0;
|
|
2224
|
+
session.lastOutputTokens = _lastTurn.outputTokens || 0;
|
|
2225
|
+
session.lastCachedReadTokens = _lastTurn.cachedTokens || 0;
|
|
2226
|
+
session.lastCacheWriteTokens = _lastTurn.cacheWriteTokens || 0;
|
|
2227
|
+
const _inputExcludesCache = providerInputExcludesCache(session.provider);
|
|
2228
|
+
session.lastContextTokens = _inputExcludesCache
|
|
2229
|
+
? (_lastTurn.inputTokens || 0) + (_lastTurn.cachedTokens || 0)
|
|
2230
|
+
: (_lastTurn.inputTokens || 0);
|
|
2231
|
+
session.lastContextTokensUpdatedAt = Date.now();
|
|
2232
|
+
session.lastContextTokensStaleAfterCompact = false;
|
|
2233
|
+
}
|
|
2234
|
+
|
|
1688
2235
|
/**
|
|
1689
2236
|
* Persist incremental usage delta immediately after each provider.send iteration.
|
|
1690
|
-
* Idempotency key `sessionId:iterationIndex`
|
|
1691
|
-
*
|
|
2237
|
+
* Idempotency key `sessionId:turnId:epoch:iterationIndex:source` scopes retries
|
|
2238
|
+
* per ask, compaction epoch, iteration, and usage source.
|
|
1692
2239
|
*/
|
|
1693
2240
|
export async function persistIterationMetrics(delta) {
|
|
1694
2241
|
if (!delta || !delta.sessionId) return;
|
|
1695
2242
|
const { sessionId, iterationIndex, deltaInput, deltaOutput, deltaCachedRead, deltaCacheWrite, ts } = delta;
|
|
2243
|
+
const runtimeEntry = _runtimeState.get(sessionId);
|
|
2244
|
+
const session = runtimeEntry?.session ?? loadSession(sessionId);
|
|
2245
|
+
if (!session || session.closed) return;
|
|
2246
|
+
const epoch = resolveUsageMetricsEpoch(session, delta);
|
|
2247
|
+
if (epoch !== (Number(session.usageMetricsEpoch) || 0)) {
|
|
2248
|
+
session.usageMetricsEpoch = epoch;
|
|
2249
|
+
}
|
|
1696
2250
|
let seen = _metricSeenIter.get(sessionId);
|
|
1697
2251
|
if (!seen) {
|
|
1698
2252
|
seen = new Set();
|
|
1699
2253
|
_metricSeenIter.set(sessionId, seen);
|
|
1700
2254
|
}
|
|
1701
|
-
const ikey =
|
|
2255
|
+
const ikey = usageMetricsIdempotencyKey(sessionId, session, delta);
|
|
1702
2256
|
const isReplay = seen.has(ikey);
|
|
1703
2257
|
seen.add(ikey);
|
|
1704
|
-
const runtimeEntry = _runtimeState.get(sessionId);
|
|
1705
|
-
const session = runtimeEntry?.session ?? loadSession(sessionId);
|
|
1706
|
-
if (!session || session.closed) return;
|
|
1707
2258
|
if (!isReplay) {
|
|
2259
|
+
if (runtimeEntry) runtimeEntry.usageMetricsTurnIncremental = true;
|
|
1708
2260
|
session.totalInputTokens = (session.totalInputTokens || 0) + (deltaInput || 0);
|
|
1709
2261
|
session.totalOutputTokens = (session.totalOutputTokens || 0) + (deltaOutput || 0);
|
|
1710
2262
|
session.tokensCumulative = (session.tokensCumulative || 0) + (deltaInput || 0) + (deltaOutput || 0);
|
|
@@ -1714,7 +2266,7 @@ export async function persistIterationMetrics(delta) {
|
|
|
1714
2266
|
// includes cached_read / cache_write in its terminal usage rollup).
|
|
1715
2267
|
session.totalCachedReadTokens = (session.totalCachedReadTokens || 0) + (deltaCachedRead || 0);
|
|
1716
2268
|
session.totalCacheWriteTokens = (session.totalCacheWriteTokens || 0) + (deltaCacheWrite || 0);
|
|
1717
|
-
// Window snapshot updated per iteration so
|
|
2269
|
+
// Window snapshot updated per iteration so agent type=list reflects the
|
|
1718
2270
|
// most-recent provider-reported input size even for short dispatches
|
|
1719
2271
|
// that finish before askSession's terminal save lands.
|
|
1720
2272
|
session.lastInputTokens = deltaInput || 0;
|
|
@@ -1740,6 +2292,14 @@ export async function persistIterationMetrics(delta) {
|
|
|
1740
2292
|
|
|
1741
2293
|
function standaloneStatusRouteInfo(session) {
|
|
1742
2294
|
if (!session) return null;
|
|
2295
|
+
// autoCompactTokenLimit is an EXPLICIT sub-boundary auto-compact limit only.
|
|
2296
|
+
// Do NOT fall back to compactBoundaryTokens/contextWindow here — that
|
|
2297
|
+
// labels a derived full-window value as an explicit limit, which the
|
|
2298
|
+
// runtime would treat as the compaction trigger and collapse the buffer.
|
|
2299
|
+
// The boundary/window stays available via contextWindow/rawContextWindow.
|
|
2300
|
+
const _boundary = Number(session.compactBoundaryTokens || session.contextWindow || 0);
|
|
2301
|
+
const _limit = Number(session.autoCompactTokenLimit || 0);
|
|
2302
|
+
const explicitAutoCompactTokenLimit = _limit > 0 && (!_boundary || _limit < _boundary) ? _limit : null;
|
|
1743
2303
|
return {
|
|
1744
2304
|
provider: session.provider,
|
|
1745
2305
|
model: session.model,
|
|
@@ -1749,7 +2309,7 @@ function standaloneStatusRouteInfo(session) {
|
|
|
1749
2309
|
contextWindow: session.contextWindow || null,
|
|
1750
2310
|
rawContextWindow: session.rawContextWindow || session.contextWindow || null,
|
|
1751
2311
|
effectiveContextWindowPercent: session.effectiveContextWindowPercent || null,
|
|
1752
|
-
autoCompactTokenLimit:
|
|
2312
|
+
autoCompactTokenLimit: explicitAutoCompactTokenLimit,
|
|
1753
2313
|
presetId: session.presetId || null,
|
|
1754
2314
|
presetName: session.presetName || null,
|
|
1755
2315
|
};
|
|
@@ -1764,9 +2324,17 @@ function recordStandaloneStatusTelemetry(session, result, durationMs) {
|
|
|
1764
2324
|
model: result.model,
|
|
1765
2325
|
serviceTier: result.serviceTier,
|
|
1766
2326
|
};
|
|
2327
|
+
// The transcript estimate is the SSOT for the displayed context footprint.
|
|
2328
|
+
// agentLoop()'s result has no `compact` field, so build a synthetic compact
|
|
2329
|
+
// arg carrying the live monotonic estimate (estimateMessagesTokens+reserve)
|
|
2330
|
+
// as afterTokens. This lights up summarizeGatewayUsage's estimate-based
|
|
2331
|
+
// contextUsedPct branch (provider input_tokens swing wildly / unbounded on
|
|
2332
|
+
// e.g. OpenAI gpt-5.5), and lets a genuine >100% pass through.
|
|
2333
|
+
const _estTokens = estimateTranscriptContextUsage(session.messages, session.tools || []);
|
|
2334
|
+
const _compactArg = { ...(result.compact && typeof result.compact === 'object' ? result.compact : {}), afterTokens: _estTokens };
|
|
1767
2335
|
try {
|
|
1768
2336
|
const summary = {
|
|
1769
|
-
...summarizeGatewayUsage(routeInfo, providerOut,
|
|
2337
|
+
...summarizeGatewayUsage(routeInfo, providerOut, _compactArg, durationMs),
|
|
1770
2338
|
requestKind: 'chat',
|
|
1771
2339
|
sessionId: session.id || null,
|
|
1772
2340
|
toolCount: result.toolCallsTotal ?? null,
|
|
@@ -1829,6 +2397,7 @@ export function getSessionLastProgressAt(sessionId) {
|
|
|
1829
2397
|
const entry = _runtimeState.get(sessionId);
|
|
1830
2398
|
if (!entry) return 0;
|
|
1831
2399
|
return Math.max(
|
|
2400
|
+
entry.lastProgressAt || 0,
|
|
1832
2401
|
entry.lastStreamDeltaAt || 0,
|
|
1833
2402
|
entry.toolStartedAt || 0,
|
|
1834
2403
|
entry.askStartedAt || 0,
|
|
@@ -1837,8 +2406,8 @@ export function getSessionLastProgressAt(sessionId) {
|
|
|
1837
2406
|
|
|
1838
2407
|
/**
|
|
1839
2408
|
* Link a parent AbortSignal to a sub-session's controller so that aborting
|
|
1840
|
-
* the parent (fan-out deadline or caller ESC) tears down the
|
|
1841
|
-
* provider call promptly. Safe to call after
|
|
2409
|
+
* the parent (fan-out deadline or caller ESC) tears down the agent role's
|
|
2410
|
+
* provider call promptly. Safe to call after prepareAgentSession but before
|
|
1842
2411
|
* askSession completes. No-op if the session runtime isn't found.
|
|
1843
2412
|
*
|
|
1844
2413
|
* @param {string} sessionId — the sub-session to abort
|
|
@@ -1855,15 +2424,27 @@ export function linkParentSignalToSession(sessionId, parentSignal) {
|
|
|
1855
2424
|
return new Error('parent signal aborted');
|
|
1856
2425
|
};
|
|
1857
2426
|
if (parentSignal.aborted) {
|
|
2427
|
+
_unlinkParentAbortListener(entry);
|
|
1858
2428
|
try { entry.controller.abort(abortReason()); } catch { /* ignore */ }
|
|
1859
2429
|
return;
|
|
1860
2430
|
}
|
|
1861
|
-
|
|
2431
|
+
_unlinkParentAbortListener(entry);
|
|
2432
|
+
const onParentAbort = () => {
|
|
1862
2433
|
try { entry.controller?.abort(abortReason()); } catch { /* ignore */ }
|
|
1863
|
-
}
|
|
2434
|
+
};
|
|
2435
|
+
parentSignal.addEventListener('abort', onParentAbort, { once: true });
|
|
2436
|
+
entry.parentAbortLink = { signal: parentSignal, listener: onParentAbort };
|
|
2437
|
+
}
|
|
2438
|
+
function _unlinkParentAbortListener(entry) {
|
|
2439
|
+
const link = entry?.parentAbortLink;
|
|
2440
|
+
if (!link) return;
|
|
2441
|
+
try { link.signal.removeEventListener('abort', link.listener); } catch { /* ignore */ }
|
|
2442
|
+
entry.parentAbortLink = null;
|
|
1864
2443
|
}
|
|
1865
2444
|
function _clearSessionRuntime(id) {
|
|
1866
2445
|
if (id) {
|
|
2446
|
+
_stopToolActivityHeartbeat(id);
|
|
2447
|
+
_unlinkParentAbortListener(_runtimeState.get(id));
|
|
1867
2448
|
_runtimeState.delete(id);
|
|
1868
2449
|
// R15: also drop the per-session metric-idempotency Set; otherwise it
|
|
1869
2450
|
// grows O(sessions x iterations) for the whole server lifetime since
|
|
@@ -1915,8 +2496,8 @@ export async function _api_call_with_interrupt(sessionId, fn) {
|
|
|
1915
2496
|
|
|
1916
2497
|
// Per-session mutex: queues concurrent askSession calls to prevent message loss
|
|
1917
2498
|
const _sessionLocks = new Map();
|
|
1918
|
-
// Per-session pending-message queue (
|
|
1919
|
-
// A `
|
|
2499
|
+
// Per-session pending-message queue (in-flight send enqueue pattern).
|
|
2500
|
+
// A `agent type=send` to a worker whose turn is still in flight ENQUEUES the
|
|
1920
2501
|
// message here instead of rejecting; askSession drains the queue after each
|
|
1921
2502
|
// turn and runs the messages as the next user turn(s), preserving order — the
|
|
1922
2503
|
// queued send runs AFTER the in-flight prompt, which also closes the spawn
|
|
@@ -1929,11 +2510,16 @@ const _sessionLocks = new Map();
|
|
|
1929
2510
|
// Keeping the queue outside the session JSON avoids racing session saves that
|
|
1930
2511
|
// loaded before the send arrived.
|
|
1931
2512
|
//
|
|
1932
|
-
// Map<sessionId, string
|
|
1933
|
-
// the enqueue/drain accessors below — one
|
|
2513
|
+
// Map<sessionId, Array<string|{text?:string,content:any}>>. Shared with
|
|
2514
|
+
// index.mjs's agent send handler via the enqueue/drain accessors below — one
|
|
2515
|
+
// queue contract, two call sites. Rich content is kept in memory for the live
|
|
2516
|
+
// relay path; the disk mirror stores only a text fallback so image bytes do not
|
|
2517
|
+
// leak into the pending-message JSON.
|
|
1934
2518
|
const _sessionPendingMessages = new Map();
|
|
1935
2519
|
const PENDING_MESSAGES_FILE = 'session-pending-messages.json';
|
|
1936
2520
|
const PENDING_MESSAGES_MODE = 0o600;
|
|
2521
|
+
const _pendingPersistBuffers = new Map();
|
|
2522
|
+
let _pendingPersistImmediate = null;
|
|
1937
2523
|
|
|
1938
2524
|
function pendingMessagesPath() {
|
|
1939
2525
|
return join(resolvePluginData(), PENDING_MESSAGES_FILE);
|
|
@@ -1962,14 +2548,53 @@ function normalizePendingStore(raw) {
|
|
|
1962
2548
|
return out;
|
|
1963
2549
|
}
|
|
1964
2550
|
|
|
1965
|
-
function
|
|
2551
|
+
function normalizePendingMessageEntry(entry) {
|
|
2552
|
+
if (typeof entry === 'string') {
|
|
2553
|
+
const text = entry.trim();
|
|
2554
|
+
return text ? { content: text, text } : null;
|
|
2555
|
+
}
|
|
2556
|
+
if (Array.isArray(entry)) {
|
|
2557
|
+
if (entry.length === 0) return null;
|
|
2558
|
+
const text = promptContentText(entry).trim();
|
|
2559
|
+
return { content: entry, text };
|
|
2560
|
+
}
|
|
2561
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
2562
|
+
const content = Object.prototype.hasOwnProperty.call(entry, 'content') ? entry.content : null;
|
|
2563
|
+
if (content == null) return null;
|
|
2564
|
+
const text = typeof entry.text === 'string' ? entry.text.trim() : promptContentText(content).trim();
|
|
2565
|
+
if (Array.isArray(content)) return content.length > 0 ? { content, text } : null;
|
|
2566
|
+
if (typeof content === 'string') {
|
|
2567
|
+
const value = content.trim();
|
|
2568
|
+
return value ? { content: value, text: text || value } : null;
|
|
2569
|
+
}
|
|
2570
|
+
const fallback = promptContentText(content).trim();
|
|
2571
|
+
return fallback ? { content: fallback, text: text || fallback } : null;
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
function pendingMessageText(entry) {
|
|
2575
|
+
const normalized = normalizePendingMessageEntry(entry);
|
|
2576
|
+
return normalized ? String(normalized.text || promptContentText(normalized.content) || '').trim() : '';
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
function pendingMessageQueueEntry(entry) {
|
|
2580
|
+
const normalized = normalizePendingMessageEntry(entry);
|
|
2581
|
+
if (!normalized) return null;
|
|
2582
|
+
if (typeof normalized.content === 'string' && normalized.content === normalized.text) return normalized.content;
|
|
2583
|
+
return { content: normalized.content, text: normalized.text || promptContentText(normalized.content).trim() };
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
function persistPendingMessages(sessionId, messages) {
|
|
1966
2587
|
if (!isValidPendingSessionId(sessionId)) return 0;
|
|
2588
|
+
const persistedMessages = (Array.isArray(messages) ? messages : [messages])
|
|
2589
|
+
.map(pendingMessageText)
|
|
2590
|
+
.filter(Boolean);
|
|
2591
|
+
if (persistedMessages.length === 0) return 0;
|
|
1967
2592
|
let depth = 0;
|
|
1968
2593
|
try {
|
|
1969
2594
|
updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
|
|
1970
2595
|
const next = normalizePendingStore(raw);
|
|
1971
2596
|
const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
|
|
1972
|
-
q.push(
|
|
2597
|
+
q.push(...persistedMessages);
|
|
1973
2598
|
next.sessions[sessionId] = q;
|
|
1974
2599
|
next.updatedAt = Date.now();
|
|
1975
2600
|
depth = q.length;
|
|
@@ -1981,6 +2606,43 @@ function persistPendingMessage(sessionId, message) {
|
|
|
1981
2606
|
return depth;
|
|
1982
2607
|
}
|
|
1983
2608
|
|
|
2609
|
+
function flushPendingMessagePersistsSync() {
|
|
2610
|
+
if (_pendingPersistImmediate) {
|
|
2611
|
+
try { clearImmediate(_pendingPersistImmediate); } catch {}
|
|
2612
|
+
_pendingPersistImmediate = null;
|
|
2613
|
+
}
|
|
2614
|
+
if (_pendingPersistBuffers.size === 0) return;
|
|
2615
|
+
const batches = [..._pendingPersistBuffers.entries()];
|
|
2616
|
+
_pendingPersistBuffers.clear();
|
|
2617
|
+
for (const [sid, messages] of batches) {
|
|
2618
|
+
persistPendingMessages(sid, messages);
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
function schedulePendingMessagePersist(sessionId, message) {
|
|
2623
|
+
if (!isValidPendingSessionId(sessionId)) return 0;
|
|
2624
|
+
const persistedMessage = pendingMessageText(message);
|
|
2625
|
+
if (!persistedMessage) return 0;
|
|
2626
|
+
const q = _pendingPersistBuffers.get(sessionId) || [];
|
|
2627
|
+
q.push(persistedMessage);
|
|
2628
|
+
_pendingPersistBuffers.set(sessionId, q);
|
|
2629
|
+
if (!_pendingPersistImmediate) {
|
|
2630
|
+
_pendingPersistImmediate = setImmediate(() => {
|
|
2631
|
+
_pendingPersistImmediate = null;
|
|
2632
|
+
flushPendingMessagePersistsSync();
|
|
2633
|
+
});
|
|
2634
|
+
}
|
|
2635
|
+
return q.length;
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
function takeBufferedPendingMessages(sessionId) {
|
|
2639
|
+
if (!isValidPendingSessionId(sessionId)) return [];
|
|
2640
|
+
const buffered = _pendingPersistBuffers.get(sessionId);
|
|
2641
|
+
if (!buffered || buffered.length === 0) return [];
|
|
2642
|
+
_pendingPersistBuffers.delete(sessionId);
|
|
2643
|
+
return buffered.slice();
|
|
2644
|
+
}
|
|
2645
|
+
|
|
1984
2646
|
function drainPersistedPendingMessages(sessionId) {
|
|
1985
2647
|
if (!isValidPendingSessionId(sessionId)) return [];
|
|
1986
2648
|
let drained = [];
|
|
@@ -2001,25 +2663,33 @@ function drainPersistedPendingMessages(sessionId) {
|
|
|
2001
2663
|
}
|
|
2002
2664
|
|
|
2003
2665
|
export function enqueuePendingMessage(sessionId, message) {
|
|
2004
|
-
|
|
2666
|
+
const entry = pendingMessageQueueEntry(message);
|
|
2667
|
+
if (!sessionId || !entry) return 0;
|
|
2005
2668
|
let q = _sessionPendingMessages.get(sessionId);
|
|
2006
2669
|
if (!q) { q = []; _sessionPendingMessages.set(sessionId, q); }
|
|
2007
|
-
q.push(
|
|
2008
|
-
const
|
|
2009
|
-
return Math.max(q.length,
|
|
2670
|
+
q.push(entry);
|
|
2671
|
+
const bufferedDepth = schedulePendingMessagePersist(sessionId, entry);
|
|
2672
|
+
return Math.max(q.length, bufferedDepth || 0);
|
|
2010
2673
|
}
|
|
2011
2674
|
export function drainPendingMessages(sessionId) {
|
|
2012
2675
|
const q = _sessionPendingMessages.get(sessionId);
|
|
2013
2676
|
const memory = q && q.length > 0 ? q.slice() : [];
|
|
2014
2677
|
_sessionPendingMessages.delete(sessionId);
|
|
2015
|
-
const persisted = drainPersistedPendingMessages(sessionId);
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
if (
|
|
2020
|
-
const
|
|
2021
|
-
|
|
2022
|
-
|
|
2678
|
+
const persisted = [...takeBufferedPendingMessages(sessionId), ...drainPersistedPendingMessages(sessionId)];
|
|
2679
|
+
const memoryVisible = modelVisiblePendingMessages(memory);
|
|
2680
|
+
const persistedVisible = modelVisiblePendingMessages(persisted);
|
|
2681
|
+
if (memoryVisible.length === 0) return persistedVisible;
|
|
2682
|
+
if (persistedVisible.length === 0) return memoryVisible;
|
|
2683
|
+
const persistedTexts = persistedVisible.map(pendingMessageText);
|
|
2684
|
+
const prefixMatches = memoryVisible.every((m, i) => persistedTexts[i] === pendingMessageText(m));
|
|
2685
|
+
if (prefixMatches) return [...memoryVisible, ...persistedVisible.slice(memoryVisible.length)];
|
|
2686
|
+
const out = persistedVisible.slice();
|
|
2687
|
+
const seen = new Set(persistedTexts);
|
|
2688
|
+
for (const m of memoryVisible) {
|
|
2689
|
+
const text = pendingMessageText(m);
|
|
2690
|
+
if (!text || seen.has(text)) continue;
|
|
2691
|
+
out.push(m);
|
|
2692
|
+
seen.add(text);
|
|
2023
2693
|
}
|
|
2024
2694
|
return out;
|
|
2025
2695
|
}
|
|
@@ -2037,6 +2707,10 @@ function promptContentText(content) {
|
|
|
2037
2707
|
return String(content ?? '');
|
|
2038
2708
|
}
|
|
2039
2709
|
|
|
2710
|
+
function hasModelVisiblePromptContent(prompt) {
|
|
2711
|
+
return !!promptContentText(prompt).trim();
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2040
2714
|
function promptContentBytes(content) {
|
|
2041
2715
|
try {
|
|
2042
2716
|
if (typeof content === 'string') return Buffer.byteLength(content, 'utf8');
|
|
@@ -2053,6 +2727,190 @@ function prefixUserTurnContent(content, contextBlock) {
|
|
|
2053
2727
|
}
|
|
2054
2728
|
return `${contextBlock}# Task\n${content}`;
|
|
2055
2729
|
}
|
|
2730
|
+
|
|
2731
|
+
function prefixSessionStartContent(content, sessionBlock) {
|
|
2732
|
+
if (!sessionBlock) return content;
|
|
2733
|
+
if (Array.isArray(content)) {
|
|
2734
|
+
return [{ type: 'text', text: `${sessionBlock}\n\n` }, ...content];
|
|
2735
|
+
}
|
|
2736
|
+
return `${sessionBlock}\n\n${content}`;
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
function localIsoDate(date = new Date()) {
|
|
2740
|
+
const year = date.getFullYear();
|
|
2741
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
2742
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
2743
|
+
return `${year}-${month}-${day}`;
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
function localDateTimeWithZone(date = new Date()) {
|
|
2747
|
+
const datePart = localIsoDate(date);
|
|
2748
|
+
const hh = String(date.getHours()).padStart(2, '0');
|
|
2749
|
+
const mm = String(date.getMinutes()).padStart(2, '0');
|
|
2750
|
+
const ss = String(date.getSeconds()).padStart(2, '0');
|
|
2751
|
+
let zone = '';
|
|
2752
|
+
try { zone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch {}
|
|
2753
|
+
return zone ? `${datePart} ${hh}:${mm}:${ss} ${zone}` : `${datePart} ${hh}:${mm}:${ss}`;
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
function temporalPromptText(content) {
|
|
2757
|
+
const text = promptContentText(content)
|
|
2758
|
+
.replace(/\s+/g, ' ')
|
|
2759
|
+
.trim()
|
|
2760
|
+
.toLowerCase();
|
|
2761
|
+
return text;
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
function promptNeedsDateReminder(content) {
|
|
2765
|
+
const text = temporalPromptText(content);
|
|
2766
|
+
if (!text) return false;
|
|
2767
|
+
return /(?:오늘|내일|어제|모레|그저께|요즘|최근|방금|아까|현재\s*(?:날짜|시간|시각)|지금\s*(?:몇\s*시|시간|날짜|요일)|몇\s*월\s*몇\s*일|몇\s*시|무슨\s*요일|요일|날짜|이번\s*(?:주|달|월|년)|지난\s*(?:주|달|월|년)|다음\s*(?:주|달|월|년)|올해|작년|내년|today|tomorrow|yesterday|recently|current\s+(?:date|time)|what\s+(?:date|time)|which\s+day|weekday|this\s+(?:week|month|year)|last\s+(?:week|month|year)|next\s+(?:week|month|year))/i.test(text);
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
function promptNeedsTimeReminder(content) {
|
|
2771
|
+
const text = temporalPromptText(content);
|
|
2772
|
+
if (!text) return false;
|
|
2773
|
+
return /(?:현재\s*(?:시간|시각)|지금\s*(?:몇\s*시|시간)|몇\s*시|시각|시간|current\s+time|what\s+time|time\s+is\s+it)/i.test(text);
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
function buildCurrentTimeBlock(content) {
|
|
2777
|
+
const needsTime = promptNeedsTimeReminder(content);
|
|
2778
|
+
if (!needsTime && !promptNeedsDateReminder(content)) return '';
|
|
2779
|
+
return localDateTimeWithZone(new Date());
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
function sessionModelDisplay(model) {
|
|
2783
|
+
const text = String(model || '').trim();
|
|
2784
|
+
if (!text) return '';
|
|
2785
|
+
return text
|
|
2786
|
+
.replace(/-\d{4}-\d{2}-\d{2}$/, '')
|
|
2787
|
+
.replace(/^gpt-/i, 'GPT-')
|
|
2788
|
+
.replace(/(?:^|-)([a-z])/g, (m) => m.toUpperCase());
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
function buildSessionStartBlock(session, cwd) {
|
|
2792
|
+
if (!session || session.owner === 'agent') return '';
|
|
2793
|
+
const lines = ['# Session'];
|
|
2794
|
+
const effectiveCwd = String(cwd || session.cwd || '').trim();
|
|
2795
|
+
if (effectiveCwd) lines.push(`Cwd: ${effectiveCwd}`);
|
|
2796
|
+
const modelBits = [
|
|
2797
|
+
sessionModelDisplay(session.model),
|
|
2798
|
+
session.effort ? String(session.effort).trim().toUpperCase() : '',
|
|
2799
|
+
session.fast === true ? 'FAST' : '',
|
|
2800
|
+
].filter(Boolean);
|
|
2801
|
+
if (modelBits.length) lines.push(`Model: ${modelBits.join(' · ')}`);
|
|
2802
|
+
const workflowName = String(session.workflow?.name || session.workflow?.id || '').trim();
|
|
2803
|
+
if (workflowName) lines.push(`Workflow: ${workflowName}`);
|
|
2804
|
+
return lines.length > 1 ? lines.join('\n') : '';
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
function isReferenceFilesMessage(message) {
|
|
2808
|
+
return message?.role === 'user'
|
|
2809
|
+
&& typeof message.content === 'string'
|
|
2810
|
+
&& /^Reference files:\s*/i.test(message.content.trimStart());
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
function isProtectedContextUserMessage(message) {
|
|
2814
|
+
return message?.role === 'user'
|
|
2815
|
+
&& typeof message.content === 'string'
|
|
2816
|
+
&& message.content.trimStart().startsWith('<system-reminder>');
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
function hasUserConversationMessage(messages) {
|
|
2820
|
+
return (Array.isArray(messages) ? messages : []).some((message) => (
|
|
2821
|
+
message?.role === 'user'
|
|
2822
|
+
&& !isProtectedContextUserMessage(message)
|
|
2823
|
+
&& !isReferenceFilesMessage(message)
|
|
2824
|
+
));
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
function modelVisiblePendingMessages(messages) {
|
|
2828
|
+
return (Array.isArray(messages) ? messages : [])
|
|
2829
|
+
.map(pendingMessageQueueEntry)
|
|
2830
|
+
.filter(Boolean)
|
|
2831
|
+
.filter((message) => !isInternalRuntimeNotificationText(
|
|
2832
|
+
message && typeof message === 'object' && Object.prototype.hasOwnProperty.call(message, 'content')
|
|
2833
|
+
? message.content
|
|
2834
|
+
: message,
|
|
2835
|
+
));
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
export function _mergePendingMessageEntries(entries) {
|
|
2839
|
+
const normalized = (Array.isArray(entries) ? entries : [])
|
|
2840
|
+
.map(normalizePendingMessageEntry)
|
|
2841
|
+
.filter(Boolean);
|
|
2842
|
+
if (normalized.length === 0) return null;
|
|
2843
|
+
const displayText = normalized.map((entry) => entry.text || promptContentText(entry.content))
|
|
2844
|
+
.filter((text) => String(text || '').trim())
|
|
2845
|
+
.join('\n');
|
|
2846
|
+
if (normalized.every((entry) => typeof entry.content === 'string')) {
|
|
2847
|
+
return {
|
|
2848
|
+
content: normalized.map((entry) => entry.content).filter(Boolean).join('\n'),
|
|
2849
|
+
text: displayText,
|
|
2850
|
+
count: normalized.length,
|
|
2851
|
+
};
|
|
2852
|
+
}
|
|
2853
|
+
const parts = [];
|
|
2854
|
+
for (const entry of normalized) {
|
|
2855
|
+
if (typeof entry.content === 'string') {
|
|
2856
|
+
if (entry.content.trim()) parts.push({ type: 'text', text: entry.content });
|
|
2857
|
+
} else if (Array.isArray(entry.content)) {
|
|
2858
|
+
parts.push(...entry.content);
|
|
2859
|
+
} else {
|
|
2860
|
+
const text = promptContentText(entry.content);
|
|
2861
|
+
if (text.trim()) parts.push({ type: 'text', text });
|
|
2862
|
+
}
|
|
2863
|
+
parts.push({ type: 'text', text: '\n' });
|
|
2864
|
+
}
|
|
2865
|
+
while (parts.length && parts[parts.length - 1]?.type === 'text' && parts[parts.length - 1]?.text === '\n') parts.pop();
|
|
2866
|
+
return { content: parts, text: displayText || promptContentText(parts), count: normalized.length };
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
function isInternalRuntimeNotificationText(content) {
|
|
2870
|
+
return contractIsInternalRuntimeNotificationText(promptContentText(content));
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
export const _isInternalRuntimeNotificationText = isInternalRuntimeNotificationText;
|
|
2874
|
+
|
|
2875
|
+
function isInternalCancelledAssistantMessage(message) {
|
|
2876
|
+
if (!message || message.role !== 'assistant') return false;
|
|
2877
|
+
if (message.cancelled === true) return true;
|
|
2878
|
+
const text = promptContentText(message.content).trim();
|
|
2879
|
+
return /^\[cancelled\]\s+This turn was interrupted before completion\./i.test(text)
|
|
2880
|
+
|| /Preserve the user request above as the active task context/i.test(text);
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
function sanitizeSessionMessagesForModel(messages) {
|
|
2884
|
+
// Drop internal runtime-notification turns and cancelled-assistant stubs so
|
|
2885
|
+
// they never reach the model, but KEEP image content intact. Reference-agent
|
|
2886
|
+
// parity: the live transcript and every model request retain attached
|
|
2887
|
+
// images across turns; only the compaction-summary call strips them. The
|
|
2888
|
+
// disk-stored session JSON replaces image bytes with a text placeholder at
|
|
2889
|
+
// serialization time (see store.mjs), so this no longer touches images.
|
|
2890
|
+
if (!Array.isArray(messages) || messages.length === 0) return [];
|
|
2891
|
+
const out = [];
|
|
2892
|
+
let droppingInternalTurn = false;
|
|
2893
|
+
for (const message of messages) {
|
|
2894
|
+
if (isInternalCancelledAssistantMessage(message)) {
|
|
2895
|
+
droppingInternalTurn = false;
|
|
2896
|
+
continue;
|
|
2897
|
+
}
|
|
2898
|
+
if (message?.role === 'user' && isInternalRuntimeNotificationText(message.content)) {
|
|
2899
|
+
droppingInternalTurn = true;
|
|
2900
|
+
continue;
|
|
2901
|
+
}
|
|
2902
|
+
if (droppingInternalTurn) {
|
|
2903
|
+
if (message?.role === 'user') {
|
|
2904
|
+
droppingInternalTurn = false;
|
|
2905
|
+
} else {
|
|
2906
|
+
continue;
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
out.push(message);
|
|
2910
|
+
}
|
|
2911
|
+
return out;
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2056
2914
|
function acquireSessionLock(sessionId) {
|
|
2057
2915
|
let entry = _sessionLocks.get(sessionId);
|
|
2058
2916
|
if (!entry) {
|
|
@@ -2072,19 +2930,114 @@ function acquireSessionLock(sessionId) {
|
|
|
2072
2930
|
});
|
|
2073
2931
|
}
|
|
2074
2932
|
|
|
2933
|
+
function sessionMessagesSnapshotChanged(before, after) {
|
|
2934
|
+
if (!Array.isArray(before) || !Array.isArray(after)) return before !== after;
|
|
2935
|
+
if (before.length !== after.length) return true;
|
|
2936
|
+
for (let i = 0; i < before.length; i += 1) {
|
|
2937
|
+
if (before[i] !== after[i]) return true;
|
|
2938
|
+
try {
|
|
2939
|
+
if (JSON.stringify(before[i]) !== JSON.stringify(after[i])) return true;
|
|
2940
|
+
} catch {
|
|
2941
|
+
return true;
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
return false;
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
function isCompactedOutgoingFinalAssistantMessage(message) {
|
|
2948
|
+
if (!message || message.role !== 'assistant') return false;
|
|
2949
|
+
if (message.emptyFinal === true) return true;
|
|
2950
|
+
return true;
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2953
|
+
function sessionMessagesAdvancedBeyondCompactedOutgoing(currentSanitized, compactedSanitized) {
|
|
2954
|
+
if (!Array.isArray(currentSanitized) || !Array.isArray(compactedSanitized)) return false;
|
|
2955
|
+
if (currentSanitized.length !== compactedSanitized.length + 1) return false;
|
|
2956
|
+
const prefix = currentSanitized.slice(0, compactedSanitized.length);
|
|
2957
|
+
if (sessionMessagesSnapshotChanged(compactedSanitized, prefix)) return false;
|
|
2958
|
+
return isCompactedOutgoingFinalAssistantMessage(currentSanitized[currentSanitized.length - 1]);
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
export const _sessionMessagesAdvancedBeyondCompactedOutgoing = sessionMessagesAdvancedBeyondCompactedOutgoing;
|
|
2962
|
+
|
|
2963
|
+
function applyCompactFailurePersistToSession(activeSession, {
|
|
2964
|
+
priorSanitized,
|
|
2965
|
+
sanitized,
|
|
2966
|
+
messagesAdvanced,
|
|
2967
|
+
error = null,
|
|
2968
|
+
}) {
|
|
2969
|
+
if (!messagesAdvanced && !sessionMessagesSnapshotChanged(priorSanitized, sanitized)) return false;
|
|
2970
|
+
if (!messagesAdvanced) {
|
|
2971
|
+
activeSession.messages = sanitized;
|
|
2972
|
+
activeSession.providerState = undefined;
|
|
2973
|
+
}
|
|
2974
|
+
activeSession.updatedAt = Date.now();
|
|
2975
|
+
activeSession.lastUsedAt = Date.now();
|
|
2976
|
+
if (activeSession.compaction && typeof activeSession.compaction === 'object'
|
|
2977
|
+
&& (activeSession.compaction.lastStage === 'compacting'
|
|
2978
|
+
|| activeSession.compaction.lastStage === 'overflow_failed')) {
|
|
2979
|
+
const prev = activeSession.compaction;
|
|
2980
|
+
const cause = error?.cause;
|
|
2981
|
+
const overflow = error?.code === 'AGENT_CONTEXT_OVERFLOW';
|
|
2982
|
+
activeSession.compaction = {
|
|
2983
|
+
...prev,
|
|
2984
|
+
lastStage: prev.lastStage === 'overflow_failed'
|
|
2985
|
+
? 'overflow_failed'
|
|
2986
|
+
: (overflow ? 'overflow_failed' : 'failed'),
|
|
2987
|
+
lastCheckedAt: Date.now(),
|
|
2988
|
+
lastError: prev.lastError || cause?.message || error?.message || null,
|
|
2989
|
+
lastSemanticError: prev.lastSemanticError || cause?.message || null,
|
|
2990
|
+
lastRecallFastTrackError: prev.lastRecallFastTrackError
|
|
2991
|
+
|| (cause?.message && String(cause?.name || '').includes('Recall') ? cause.message : null),
|
|
2992
|
+
};
|
|
2993
|
+
}
|
|
2994
|
+
return true;
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2997
|
+
export const _applyCompactFailurePersistToSession = applyCompactFailurePersistToSession;
|
|
2998
|
+
|
|
2999
|
+
async function persistCompactedOutgoingAfterAskFailure({
|
|
3000
|
+
sessionId,
|
|
3001
|
+
activeSession,
|
|
3002
|
+
askGeneration,
|
|
3003
|
+
turnOutgoing,
|
|
3004
|
+
error = null,
|
|
3005
|
+
}) {
|
|
3006
|
+
if (!activeSession || activeSession.closed === true) return;
|
|
3007
|
+
if (!Array.isArray(turnOutgoing) || turnOutgoing.length === 0) return;
|
|
3008
|
+
const currentRuntime = _runtimeState.get(sessionId);
|
|
3009
|
+
if (currentRuntime?.closed || currentRuntime?.generation !== askGeneration) return;
|
|
3010
|
+
const sanitized = sanitizeSessionMessagesForModel(turnOutgoing);
|
|
3011
|
+
const priorSanitized = sanitizeSessionMessagesForModel(
|
|
3012
|
+
Array.isArray(activeSession.messages) ? activeSession.messages : [],
|
|
3013
|
+
);
|
|
3014
|
+
const messagesAdvanced = sessionMessagesAdvancedBeyondCompactedOutgoing(priorSanitized, sanitized);
|
|
3015
|
+
const applied = applyCompactFailurePersistToSession(activeSession, {
|
|
3016
|
+
priorSanitized,
|
|
3017
|
+
sanitized,
|
|
3018
|
+
messagesAdvanced,
|
|
3019
|
+
error,
|
|
3020
|
+
});
|
|
3021
|
+
if (!applied) return;
|
|
3022
|
+
try {
|
|
3023
|
+
await saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
|
|
3024
|
+
} catch { /* best-effort: preserve in-memory compaction even if disk is slow */ }
|
|
3025
|
+
if (currentRuntime) currentRuntime.session = activeSession;
|
|
3026
|
+
}
|
|
3027
|
+
|
|
2075
3028
|
export async function askSession(sessionId, prompt, context, onToolCall, cwdOverride, explicitPrefetch, askOpts = {}) {
|
|
2076
3029
|
const _askStartedAt = Date.now();
|
|
2077
3030
|
const _promptSrc = 'prompt';
|
|
2078
3031
|
const _prefetchFiles = (explicitPrefetch?.files?.length) || 0;
|
|
2079
3032
|
const _prefetchCallers = (explicitPrefetch?.callers?.length) || 0;
|
|
2080
3033
|
const _prefetchRefs = (explicitPrefetch?.references?.length) || 0;
|
|
2081
|
-
if (process.env.
|
|
2082
|
-
process.stderr.write(`[
|
|
3034
|
+
if (process.env.MIXDOG_DEBUG_AGENT) {
|
|
3035
|
+
process.stderr.write(`[agent-trace] t0-ask-start sessionHash=${createHash('sha256').update(String(sessionId)).digest('hex').slice(0, 8)} role=? iteration=0 promptSrc=${_promptSrc} prefetchFiles=${_prefetchFiles} callers=${_prefetchCallers} references=${_prefetchRefs}\n`);
|
|
2083
3036
|
}
|
|
2084
3037
|
const unlock = await acquireSessionLock(sessionId);
|
|
2085
3038
|
const _lockWaitedMs = Date.now() - _askStartedAt;
|
|
2086
|
-
if (process.env.
|
|
2087
|
-
process.stderr.write(`[
|
|
3039
|
+
if (process.env.MIXDOG_DEBUG_AGENT) {
|
|
3040
|
+
process.stderr.write(`[agent-trace] lock-acquired waitedMs=${_lockWaitedMs}\n`);
|
|
2088
3041
|
}
|
|
2089
3042
|
// The mutex is held for the WHOLE askSession call, including any follow-up
|
|
2090
3043
|
// turns drained from the pending-message queue below — the single outer
|
|
@@ -2093,18 +3046,19 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2093
3046
|
// result, mirroring how a live chat returns the latest turn).
|
|
2094
3047
|
let _result;
|
|
2095
3048
|
// Local FIFO of follow-up prompts drained from the pending-message queue
|
|
2096
|
-
// after each turn — keeps queued `
|
|
3049
|
+
// after each turn — keeps queued `agent type=send` messages in order.
|
|
2097
3050
|
const _pendingTail = [];
|
|
2098
3051
|
// Hoisted so the outer finally (which runs once after the whole turn loop)
|
|
2099
3052
|
// can compare against the last turn's generation.
|
|
2100
3053
|
let askGeneration = 0;
|
|
2101
3054
|
try {
|
|
2102
3055
|
// Turn loop (pendingMessages pattern): run the current prompt, then drain
|
|
2103
|
-
// any `
|
|
3056
|
+
// any `agent type=send` messages that were queued while this turn was in
|
|
2104
3057
|
// flight and run them — in order — as the next user turn(s). Because the
|
|
2105
3058
|
// queued send always lands AFTER the in-flight prompt here, ordering is
|
|
2106
3059
|
// preserved and the spawn/connecting startup race disappears.
|
|
2107
3060
|
for (;;) {
|
|
3061
|
+
let _pwstTurnDrained = null;
|
|
2108
3062
|
// After the first turn, the next prompt comes from the drained queue.
|
|
2109
3063
|
// (On the first iteration _pendingTail is empty and `prompt` is the
|
|
2110
3064
|
// caller's original message.)
|
|
@@ -2114,6 +3068,23 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2114
3068
|
// prefetch is re-applied (those belonged to the original ask).
|
|
2115
3069
|
context = null;
|
|
2116
3070
|
explicitPrefetch = null;
|
|
3071
|
+
} else if (!hasModelVisiblePromptContent(prompt)) {
|
|
3072
|
+
// Idle resume: TUI kicks an empty ask() after execution completions
|
|
3073
|
+
// mirror model-visible bodies into session pending. Drain that queue
|
|
3074
|
+
// here so we never synthesize an empty user turn for the model.
|
|
3075
|
+
const _preDrained = drainPendingMessages(sessionId);
|
|
3076
|
+
if (_preDrained.length > 0) {
|
|
3077
|
+
const _mergedPre = _mergePendingMessageEntries(_preDrained);
|
|
3078
|
+
if (_mergedPre?.content) {
|
|
3079
|
+
prompt = _mergedPre.content;
|
|
3080
|
+
context = null;
|
|
3081
|
+
explicitPrefetch = null;
|
|
3082
|
+
}
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
3085
|
+
if (!hasModelVisiblePromptContent(prompt)) {
|
|
3086
|
+
_unlinkParentAbortListener(_runtimeState.get(sessionId));
|
|
3087
|
+
return _result;
|
|
2117
3088
|
}
|
|
2118
3089
|
// ── Synchronous pre-await setup (must happen before any await so
|
|
2119
3090
|
// closeSession() can't interleave between load and registration) ──
|
|
@@ -2124,6 +3095,11 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2124
3095
|
if (preSession.closed === true) {
|
|
2125
3096
|
throw new SessionClosedError(sessionId, 'session already closed');
|
|
2126
3097
|
}
|
|
3098
|
+
// A prior crash/partial-save during compaction may have pinned
|
|
3099
|
+
// compaction.lastStage='compacting'. This ask is starting fresh, so
|
|
3100
|
+
// recover the stale transient stage before the loop's pre-send compact
|
|
3101
|
+
// path runs (it will overwrite lastStage with real telemetry).
|
|
3102
|
+
normalizeStaleCompactingStage(preSession);
|
|
2127
3103
|
askGeneration = typeof preSession.generation === 'number' ? preSession.generation : 0;
|
|
2128
3104
|
const runtime = _touchRuntime(sessionId);
|
|
2129
3105
|
// Fresh controller per ask — the previous ask's controller may have aborted.
|
|
@@ -2137,6 +3113,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2137
3113
|
// leaving stage='connecting' forever.
|
|
2138
3114
|
let activeSession = preSession;
|
|
2139
3115
|
let cancelledUserTurnContent = '';
|
|
3116
|
+
let _turnOutgoing = null;
|
|
2140
3117
|
try {
|
|
2141
3118
|
const session = activeSession;
|
|
2142
3119
|
const provider = getProvider(session.provider);
|
|
@@ -2155,6 +3132,8 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2155
3132
|
...(session.compaction || {}),
|
|
2156
3133
|
auto: session.compaction?.auto !== false,
|
|
2157
3134
|
semantic: session.compaction?.semantic ?? 'auto',
|
|
3135
|
+
type: normalizeCompactType(session.compaction?.type ?? session.compaction?.compactType ?? session.compaction?.compact_type, DEFAULT_COMPACT_TYPE),
|
|
3136
|
+
compactType: normalizeCompactType(session.compaction?.type ?? session.compaction?.compactType ?? session.compaction?.compact_type, DEFAULT_COMPACT_TYPE),
|
|
2158
3137
|
boundaryTokens: contextMeta.compactBoundaryTokens,
|
|
2159
3138
|
bufferTokens: positiveContextWindow(session.compaction?.bufferTokens ?? session.compaction?.buffer) || session.compaction?.bufferTokens || null,
|
|
2160
3139
|
keepTokens: positiveContextWindow(session.compaction?.keepTokens ?? session.compaction?.keep?.tokens) || session.compaction?.keepTokens || null,
|
|
@@ -2191,7 +3170,8 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2191
3170
|
if (explicitPrefetchResult) {
|
|
2192
3171
|
_contextBlock += `# Prefetch\n${_capCtx(explicitPrefetchResult)}\n\n`;
|
|
2193
3172
|
}
|
|
2194
|
-
const
|
|
3173
|
+
const historyMessages = sanitizeSessionMessagesForModel(session.messages);
|
|
3174
|
+
const beforeCount = historyMessages.length + 1;
|
|
2195
3175
|
const promptTextForMetrics = promptContentText(prompt);
|
|
2196
3176
|
// Soft warning only; real size management (compaction primary,
|
|
2197
3177
|
// byte-budget trim as safety net) lives in agentLoop. Selecting a
|
|
@@ -2202,21 +3182,43 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2202
3182
|
process.stderr.write(`[session] Warning: prompt is very large (est. ${Math.round(promptTokenEstimate)} tokens vs ${softBudget} soft budget)\n`);
|
|
2203
3183
|
}
|
|
2204
3184
|
const effectiveCwd = cwdOverride || session.cwd;
|
|
2205
|
-
const
|
|
3185
|
+
const shouldInjectSessionStart = session.sessionStartMetaInjected !== true
|
|
3186
|
+
&& !hasUserConversationMessage(historyMessages);
|
|
3187
|
+
const _sessionStartBlock = shouldInjectSessionStart
|
|
3188
|
+
? buildSessionStartBlock(session, effectiveCwd)
|
|
3189
|
+
: '';
|
|
3190
|
+
const _currentTimeBlock = buildCurrentTimeBlock(prompt);
|
|
3191
|
+
const _turnReminderBlock = _currentTimeBlock
|
|
3192
|
+
? `<system-reminder>\n# Current Time\n${_currentTimeBlock}\n</system-reminder>`
|
|
3193
|
+
: '';
|
|
3194
|
+
const _turnPrefixBlock = [_sessionStartBlock, _turnReminderBlock].filter(Boolean).join('\n\n');
|
|
3195
|
+
const _baseUserTurnContent = prefixUserTurnContent(prompt, _contextBlock);
|
|
3196
|
+
const _userTurnContent = prefixSessionStartContent(_baseUserTurnContent, _turnPrefixBlock);
|
|
3197
|
+
if (shouldInjectSessionStart && _sessionStartBlock) {
|
|
3198
|
+
session.sessionStartMetaInjected = true;
|
|
3199
|
+
}
|
|
2206
3200
|
cancelledUserTurnContent = _userTurnContent;
|
|
2207
|
-
const outgoing = [...
|
|
3201
|
+
const outgoing = [...historyMessages, { role: 'user', content: _userTurnContent }];
|
|
3202
|
+
_turnOutgoing = outgoing;
|
|
3203
|
+
// Expose the in-flight working transcript so contextStatus() can
|
|
3204
|
+
// estimate the LIVE context footprint mid-turn. agentLoop mutates
|
|
3205
|
+
// `outgoing` in place (user turn + tool calls/results + compaction),
|
|
3206
|
+
// so the statusline context gauge climbs as the turn accumulates
|
|
3207
|
+
// tool output instead of freezing at the pre-turn snapshot. Cleared
|
|
3208
|
+
// on turn commit (below) and in the ask finally.
|
|
3209
|
+
session.liveTurnMessages = outgoing;
|
|
2208
3210
|
// Per-turn injected-context trace row (complements kind:"usage").
|
|
2209
3211
|
// Cheap byte-length accounting — no hashing, no payload bodies.
|
|
2210
|
-
// Honors the same
|
|
2211
|
-
//
|
|
3212
|
+
// Honors the same MIXDOG_AGENT_TRACE_DISABLE gate as usage rows;
|
|
3213
|
+
// appendAgentTrace is a no-op when that env is set.
|
|
2212
3214
|
try {
|
|
2213
3215
|
const _ctxBytes = Buffer.byteLength(context || '', 'utf8');
|
|
2214
3216
|
const _prefetchBytes = Buffer.byteLength(explicitPrefetchResult || '', 'utf8');
|
|
2215
3217
|
const _promptBytes = promptContentBytes(prompt);
|
|
2216
3218
|
const _userTurnBytes = promptContentBytes(_userTurnContent);
|
|
2217
|
-
const _messagesBytes = Buffer.byteLength(JSON.stringify(
|
|
3219
|
+
const _messagesBytes = Buffer.byteLength(JSON.stringify(historyMessages || []), 'utf8');
|
|
2218
3220
|
const _totalBytes = _userTurnBytes + _messagesBytes;
|
|
2219
|
-
|
|
3221
|
+
appendAgentTrace({
|
|
2220
3222
|
kind: 'context',
|
|
2221
3223
|
sessionId,
|
|
2222
3224
|
model: session.model,
|
|
@@ -2228,26 +3230,36 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2228
3230
|
promptBytes: _promptBytes,
|
|
2229
3231
|
userTurnBytes: _userTurnBytes,
|
|
2230
3232
|
messagesBytes: _messagesBytes,
|
|
2231
|
-
messagesCount:
|
|
3233
|
+
messagesCount: historyMessages.length,
|
|
2232
3234
|
},
|
|
2233
3235
|
});
|
|
2234
3236
|
} catch { /* trace must never break the ask path */ }
|
|
2235
|
-
const
|
|
3237
|
+
const agentLoop = await _getAgentLoop();
|
|
3238
|
+
const priorToolApprovalHook = session.toolApprovalHook;
|
|
3239
|
+
if (typeof askOpts?.onToolApproval === 'function') {
|
|
3240
|
+
session.toolApprovalHook = askOpts.onToolApproval;
|
|
3241
|
+
}
|
|
3242
|
+
let result;
|
|
3243
|
+
try {
|
|
3244
|
+
result = await _api_call_with_interrupt(sessionId, (signal) =>
|
|
2236
3245
|
agentLoop(provider, outgoing, session.model, session.tools, onToolCall, effectiveCwd, {
|
|
2237
3246
|
effort: session.effort || null,
|
|
2238
3247
|
fast: session.fast === true,
|
|
2239
3248
|
sessionId,
|
|
2240
3249
|
onTextDelta: typeof askOpts?.onTextDelta === 'function' ? askOpts.onTextDelta : undefined,
|
|
2241
3250
|
onReasoningDelta: typeof askOpts?.onReasoningDelta === 'function' ? askOpts.onReasoningDelta : undefined,
|
|
3251
|
+
onAssistantText: typeof askOpts?.onAssistantText === 'function' ? askOpts.onAssistantText : undefined,
|
|
2242
3252
|
onUsageDelta: (d) => {
|
|
2243
3253
|
persistIterationMetrics(d).catch(() => {});
|
|
2244
3254
|
try { askOpts?.onUsageDelta?.(d); } catch {}
|
|
2245
3255
|
},
|
|
2246
3256
|
onToolResult: typeof askOpts?.onToolResult === 'function' ? askOpts.onToolResult : undefined,
|
|
3257
|
+
onToolApproval: typeof askOpts?.onToolApproval === 'function' ? askOpts.onToolApproval : undefined,
|
|
3258
|
+
onCompactEvent: typeof askOpts?.onCompactEvent === 'function' ? askOpts.onCompactEvent : undefined,
|
|
2247
3259
|
// Mid-turn steering drain. agentLoop calls this at every
|
|
2248
3260
|
// tool-batch boundary (before the next provider.send) and
|
|
2249
3261
|
// injects any returned strings as user turns — so input
|
|
2250
|
-
// (user typing, `
|
|
3262
|
+
// (user typing, `agent type=send`) that arrives WHILE a
|
|
2251
3263
|
// long multi-tool turn is in flight is picked up on the
|
|
2252
3264
|
// model's very next iteration instead of waiting for the
|
|
2253
3265
|
// whole task to finish. The post-turn _pendingTail drain
|
|
@@ -2266,6 +3278,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2266
3278
|
return out;
|
|
2267
3279
|
},
|
|
2268
3280
|
onSteerMessage: typeof askOpts?.onSteerMessage === 'function' ? askOpts.onSteerMessage : undefined,
|
|
3281
|
+
notifyFn: typeof askOpts?.notifyFn === 'function' ? askOpts.notifyFn : undefined,
|
|
2269
3282
|
promptCacheKey: session.promptCacheKey || sessionId,
|
|
2270
3283
|
// Provider-scoped cache key (mixdog-codex, mixdog-claude…).
|
|
2271
3284
|
// Distinct from sessionId — providers that pool sockets
|
|
@@ -2277,7 +3290,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2277
3290
|
signal,
|
|
2278
3291
|
providerState: session.providerState ?? undefined,
|
|
2279
3292
|
session,
|
|
2280
|
-
//
|
|
3293
|
+
// Agent Runtime cache settings — merged last so session overrides
|
|
2281
3294
|
// don't get overridden by defaults. When session has no profile,
|
|
2282
3295
|
// providerCacheOpts is null and this spread is a no-op.
|
|
2283
3296
|
...(session.providerCacheOpts || {}),
|
|
@@ -2291,6 +3304,13 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2291
3304
|
},
|
|
2292
3305
|
}),
|
|
2293
3306
|
);
|
|
3307
|
+
} finally {
|
|
3308
|
+
if (priorToolApprovalHook === undefined) {
|
|
3309
|
+
delete session.toolApprovalHook;
|
|
3310
|
+
} else {
|
|
3311
|
+
session.toolApprovalHook = priorToolApprovalHook;
|
|
3312
|
+
}
|
|
3313
|
+
}
|
|
2294
3314
|
// Post-loop validation: if closeSession() landed while we were awaiting,
|
|
2295
3315
|
// drop the save so the tombstone on disk isn't overwritten.
|
|
2296
3316
|
const currentRuntime = _runtimeState.get(sessionId);
|
|
@@ -2301,10 +3321,16 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2301
3321
|
// Update and save. outgoing is mutated in place by agentLoop
|
|
2302
3322
|
// (compaction + safety trim), so its length reflects post-loop state.
|
|
2303
3323
|
const messagesDropped = Math.max(0, beforeCount - outgoing.length);
|
|
2304
|
-
session.messages = outgoing;
|
|
3324
|
+
session.messages = sanitizeSessionMessagesForModel(outgoing);
|
|
3325
|
+
// Turn committed into session.messages; drop the live-turn alias so
|
|
3326
|
+
// contextStatus() reverts to the authoritative committed transcript.
|
|
3327
|
+
session.liveTurnMessages = null;
|
|
2305
3328
|
if (result.content || result.reasoningContent) {
|
|
2306
3329
|
session.messages.push({
|
|
2307
3330
|
role: 'assistant',
|
|
3331
|
+
// Keep content as-is in memory (model-visible). Image bytes,
|
|
3332
|
+
// if any, are swapped for a placeholder only at disk write
|
|
3333
|
+
// time inside the session store (store.mjs _sessionForDisk).
|
|
2308
3334
|
content: result.content || '',
|
|
2309
3335
|
...(typeof result.reasoningContent === 'string' && result.reasoningContent
|
|
2310
3336
|
? { reasoningContent: result.reasoningContent }
|
|
@@ -2354,46 +3380,17 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2354
3380
|
}
|
|
2355
3381
|
session.updatedAt = Date.now();
|
|
2356
3382
|
session.lastUsedAt = Date.now();
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
// Cache totals — same `||0` undefined-safe accumulation pattern as
|
|
2364
|
-
// persistIterationMetrics so live + terminal paths stay in lock-step
|
|
2365
|
-
// and legacy sessions migrate lazily on first iteration.
|
|
2366
|
-
session.totalCachedReadTokens = (session.totalCachedReadTokens || 0) + (result.usage.cachedTokens || 0);
|
|
2367
|
-
session.totalCacheWriteTokens = (session.totalCacheWriteTokens || 0) + (result.usage.cacheWriteTokens || 0);
|
|
2368
|
-
// Window snapshot = the current context size, which is the LAST
|
|
2369
|
-
// single call — NOT result.usage (that is lastUsage, the per-turn
|
|
2370
|
-
// SUM accumulated with += across iterations in agentLoop). Use
|
|
2371
|
-
// lastTurnUsage (the final iteration's raw usage) so this reflects
|
|
2372
|
-
// "what's in the window now" rather than the lifetime sum.
|
|
2373
|
-
const _lastTurn = result.lastTurnUsage || result.usage || {};
|
|
2374
|
-
session.lastInputTokens = _lastTurn.inputTokens || 0;
|
|
2375
|
-
session.lastOutputTokens = _lastTurn.outputTokens || 0;
|
|
2376
|
-
session.lastCachedReadTokens = _lastTurn.cachedTokens || 0;
|
|
2377
|
-
session.lastCacheWriteTokens = _lastTurn.cacheWriteTokens || 0;
|
|
2378
|
-
// Provider-normalized footprint, identical formula to
|
|
2379
|
-
// persistIterationMetrics so both writers agree: Anthropic
|
|
2380
|
-
// input_tokens excludes cache (add it back), openai/grok/gemini
|
|
2381
|
-
// already include it.
|
|
2382
|
-
const _inputExcludesCache = providerInputExcludesCache(session.provider);
|
|
2383
|
-
session.lastContextTokens = _inputExcludesCache
|
|
2384
|
-
? (_lastTurn.inputTokens || 0) + (_lastTurn.cachedTokens || 0)
|
|
2385
|
-
: (_lastTurn.inputTokens || 0);
|
|
2386
|
-
session.lastContextTokensUpdatedAt = Date.now();
|
|
2387
|
-
session.lastContextTokensStaleAfterCompact = false;
|
|
2388
|
-
}
|
|
2389
|
-
// Smart Bridge cache stats — record hit/miss after every successful
|
|
2390
|
-
// ask so the registry reflects all bridge traffic, not just
|
|
2391
|
-
// maintenance cycles. Guarded against any smart-bridge error so
|
|
3383
|
+
applyAskTerminalUsageTotals(session, result, {
|
|
3384
|
+
skipTotalsIfIncremental: runtime?.usageMetricsTurnIncremental === true,
|
|
3385
|
+
});
|
|
3386
|
+
// Agent Runtime cache stats — record hit/miss after every successful
|
|
3387
|
+
// ask so the registry reflects all agent traffic, not just
|
|
3388
|
+
// maintenance cycles. Guarded against any agent-runtime error so
|
|
2392
3389
|
// metric recording never breaks the ask itself.
|
|
2393
3390
|
let prefixHashForLog = null;
|
|
2394
|
-
if (session.profileId && result.usage &&
|
|
3391
|
+
if (session.profileId && result.usage && _agentRuntimeApi) {
|
|
2395
3392
|
try {
|
|
2396
|
-
const profile =
|
|
3393
|
+
const profile = _agentRuntimeApi.getProfile(session.profileId);
|
|
2397
3394
|
if (profile) {
|
|
2398
3395
|
// Collect every leading system-role message (BP1, BP2, ...)
|
|
2399
3396
|
// until the first non-system message so the registry hash
|
|
@@ -2403,24 +3400,24 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2403
3400
|
if (m?.role !== 'system') break;
|
|
2404
3401
|
systemMsgs.push(typeof m.content === 'string' ? m.content : '');
|
|
2405
3402
|
}
|
|
2406
|
-
|
|
3403
|
+
_agentRuntimeApi.recordCall(profile, session.provider, {
|
|
2407
3404
|
systemPrompt: systemMsgs,
|
|
2408
3405
|
tools: session.tools || [],
|
|
2409
3406
|
usage: result.usage,
|
|
2410
3407
|
});
|
|
2411
|
-
const entry =
|
|
3408
|
+
const entry = _agentRuntimeApi.registry?.data?.profiles?.[session.profileId]?.[session.provider];
|
|
2412
3409
|
prefixHashForLog = entry?.prefixHash || null;
|
|
2413
3410
|
}
|
|
2414
3411
|
} catch {}
|
|
2415
3412
|
}
|
|
2416
|
-
// Append to
|
|
3413
|
+
// Append to the agent trace store with rich usage fields.
|
|
2417
3414
|
if (result.usage) {
|
|
2418
3415
|
const inputTokens = result.usage.inputTokens || 0;
|
|
2419
3416
|
const outputTokens = result.usage.outputTokens || 0;
|
|
2420
3417
|
const cacheReadTokens = result.usage.cachedTokens || 0;
|
|
2421
3418
|
const cacheWriteTokens = result.usage.cacheWriteTokens || 0;
|
|
2422
3419
|
// Unified total-prompt field. Anthropic = input+cache_read+cache_write
|
|
2423
|
-
// (additive); OpenAI/
|
|
3420
|
+
// (additive); OpenAI OAuth/API/Gemini = input_tokens already includes the
|
|
2424
3421
|
// cached portion (inclusive), so the fallback must not double-count.
|
|
2425
3422
|
const { isInclusiveProvider, computeCostUsd } = await import('../../../shared/llm/cost.mjs');
|
|
2426
3423
|
const inclusive = isInclusiveProvider(session.provider);
|
|
@@ -2460,29 +3457,61 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2460
3457
|
recordStandaloneStatusTelemetry(session, result, Date.now() - _askStartedAt);
|
|
2461
3458
|
}
|
|
2462
3459
|
// Persist opaque providerState for future stateful providers.
|
|
2463
|
-
// No provider currently emits it (
|
|
3460
|
+
// No provider currently emits it (openai-oauth is stateless per
|
|
2464
3461
|
// contract), so this branch is dormant — kept so a future
|
|
2465
3462
|
// Responses-API provider with stable continuation can plug in
|
|
2466
3463
|
// without reworking the session shape.
|
|
2467
3464
|
if (result.providerState !== undefined) {
|
|
2468
3465
|
session.providerState = result.providerState;
|
|
2469
3466
|
}
|
|
2470
|
-
const
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
if (
|
|
3467
|
+
const terminalResultPreview = {
|
|
3468
|
+
...result,
|
|
3469
|
+
trimmed: messagesDropped > 0,
|
|
3470
|
+
messagesDropped,
|
|
3471
|
+
};
|
|
3472
|
+
_pwstTurnDrained = drainPendingMessages(sessionId);
|
|
3473
|
+
if (_pwstTurnDrained.length === 0 && typeof askOpts?.onTerminalResult === 'function') {
|
|
3474
|
+
try {
|
|
3475
|
+
askOpts.onTerminalResult(terminalResultPreview, {
|
|
3476
|
+
sessionId,
|
|
3477
|
+
beforeSave: true,
|
|
3478
|
+
durationMs: Date.now() - _askStartedAt,
|
|
3479
|
+
});
|
|
3480
|
+
} catch { /* best-effort early completion relay */ }
|
|
3481
|
+
}
|
|
3482
|
+
// Auto-compact runs at the start of the next
|
|
3483
|
+
// query/provider send (agentLoop pre-send), not after the previous
|
|
3484
|
+
// answer. This lets queued follow-up prompts resume immediately;
|
|
3485
|
+
// if they need compaction, their own spinner shows compacting first.
|
|
3486
|
+
// Bounded, best-effort terminal save. The result is already produced
|
|
3487
|
+
// and (for agent surfaces) relayed via onTerminalResult above. If the
|
|
3488
|
+
// disk write stalls, blocking the terminal unwind here would strand the
|
|
3489
|
+
// owning background task in `running` and suppress its completion
|
|
3490
|
+
// notification. Cap the wait; let a slow write finish in the
|
|
3491
|
+
// background instead of holding askSession() open indefinitely.
|
|
3492
|
+
{
|
|
3493
|
+
const savePromise = saveSessionAsync(session, { expectedGeneration: askGeneration });
|
|
3494
|
+
let saveTimer = null;
|
|
3495
|
+
const saveTimeout = new Promise((resolveTimeout) => {
|
|
3496
|
+
saveTimer = setTimeout(() => resolveTimeout('__save_timeout__'), TERMINAL_SAVE_TIMEOUT_MS);
|
|
3497
|
+
saveTimer.unref?.();
|
|
3498
|
+
});
|
|
2477
3499
|
try {
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
)
|
|
2483
|
-
|
|
3500
|
+
const outcome = await Promise.race([
|
|
3501
|
+
savePromise.then(() => '__save_ok__', (err) => { throw err; }),
|
|
3502
|
+
saveTimeout,
|
|
3503
|
+
]);
|
|
3504
|
+
if (outcome === '__save_timeout__') {
|
|
3505
|
+
try { process.stderr.write(`[session] terminal save exceeded ${TERMINAL_SAVE_TIMEOUT_MS}ms; continuing best-effort (${sessionId})\n`); } catch {}
|
|
3506
|
+
// Don't drop the write — let it settle in the background.
|
|
3507
|
+
savePromise.catch((err) => {
|
|
3508
|
+
try { process.stderr.write(`[session] deferred terminal save failed: ${err?.message || err}\n`); } catch {}
|
|
3509
|
+
});
|
|
3510
|
+
}
|
|
3511
|
+
} finally {
|
|
3512
|
+
if (saveTimer) { try { clearTimeout(saveTimer); } catch {} }
|
|
3513
|
+
}
|
|
2484
3514
|
}
|
|
2485
|
-
await saveSessionAsync(session, { expectedGeneration: askGeneration });
|
|
2486
3515
|
activeSession = session;
|
|
2487
3516
|
runtime.session = session;
|
|
2488
3517
|
// Tag empty-synthesis BEFORE markSessionDone so the watchdog
|
|
@@ -2493,47 +3522,63 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2493
3522
|
markSessionEmptyFinal(sessionId);
|
|
2494
3523
|
}
|
|
2495
3524
|
markSessionDone(sessionId, { empty: isEmptyFinal });
|
|
2496
|
-
_result =
|
|
2497
|
-
...result,
|
|
2498
|
-
trimmed: messagesDropped > 0,
|
|
2499
|
-
messagesDropped,
|
|
2500
|
-
postTurnCompact: postTurnCompact?.changed ? postTurnCompact : null,
|
|
2501
|
-
};
|
|
3525
|
+
_result = terminalResultPreview;
|
|
2502
3526
|
} catch (err) {
|
|
3527
|
+
// Cancellation/error paths bypass the commit point above; drop the
|
|
3528
|
+
// live-turn alias so contextStatus() stops estimating from the
|
|
3529
|
+
// stale in-flight array once the turn unwinds.
|
|
3530
|
+
if (activeSession) activeSession.liveTurnMessages = null;
|
|
2503
3531
|
if (err instanceof SessionClosedError) {
|
|
2504
3532
|
const currentRuntime = _runtimeState.get(sessionId);
|
|
2505
|
-
if (!currentRuntime?.closed
|
|
2506
|
-
activeSession
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
3533
|
+
if (!currentRuntime?.closed) {
|
|
3534
|
+
if (activeSession) {
|
|
3535
|
+
const originalMessages = Array.isArray(activeSession.messages) ? activeSession.messages : [];
|
|
3536
|
+
const cleanedMessages = sanitizeSessionMessagesForModel(originalMessages);
|
|
3537
|
+
const nextMessages = cleanedMessages.slice();
|
|
3538
|
+
// In-memory cancelled turn keeps its original content
|
|
3539
|
+
// (images intact for the next model send); the store
|
|
3540
|
+
// layer placeholders image bytes on disk serialization.
|
|
3541
|
+
const cancelledStoredContent = cancelledUserTurnContent;
|
|
3542
|
+
const shouldPreserveUserTurn = cancelledStoredContent && !isInternalRuntimeNotificationText(cancelledStoredContent);
|
|
3543
|
+
const lastMessage = nextMessages[nextMessages.length - 1];
|
|
3544
|
+
if (shouldPreserveUserTurn && !(lastMessage?.role === 'user' && promptContentText(lastMessage.content) === promptContentText(cancelledStoredContent))) {
|
|
3545
|
+
nextMessages.push({ role: 'user', content: cancelledStoredContent });
|
|
3546
|
+
}
|
|
3547
|
+
const messagesChanged = nextMessages.length !== originalMessages.length
|
|
3548
|
+
|| nextMessages.some((message, index) => message !== originalMessages[index]);
|
|
3549
|
+
if (messagesChanged) {
|
|
3550
|
+
activeSession.messages = nextMessages;
|
|
3551
|
+
activeSession.updatedAt = Date.now();
|
|
3552
|
+
activeSession.lastUsedAt = Date.now();
|
|
3553
|
+
try {
|
|
3554
|
+
await saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
|
|
3555
|
+
} catch { /* cancellation cleanup is best-effort */ }
|
|
3556
|
+
}
|
|
3557
|
+
}
|
|
2521
3558
|
markSessionCancelled(sessionId);
|
|
2522
3559
|
}
|
|
2523
3560
|
// Cancellation is not an error; propagate silently so callers
|
|
2524
3561
|
// can render it as "cancelled" rather than a red failure.
|
|
2525
3562
|
throw err;
|
|
2526
3563
|
}
|
|
3564
|
+
await persistCompactedOutgoingAfterAskFailure({
|
|
3565
|
+
sessionId,
|
|
3566
|
+
activeSession,
|
|
3567
|
+
askGeneration,
|
|
3568
|
+
turnOutgoing: _turnOutgoing,
|
|
3569
|
+
error: err,
|
|
3570
|
+
});
|
|
2527
3571
|
markSessionError(sessionId, err && err.message ? err.message : String(err));
|
|
2528
3572
|
throw err;
|
|
2529
3573
|
}
|
|
2530
|
-
// ── Turn complete. Drain the pending-message queue
|
|
2531
|
-
// pendingMessages): any `bridge type=send` that arrived while this
|
|
3574
|
+
// ── Turn complete. Drain the pending-message queue: any `agent type=send` that arrived while this
|
|
2532
3575
|
// turn was in flight runs next, in order, as a follow-up user turn.
|
|
2533
3576
|
// The mutex is still held, so a send racing this drain either landed
|
|
2534
3577
|
// before (picked up here) or enqueues for the next loop. When the
|
|
2535
3578
|
// queue is empty we return the latest turn's result. ──
|
|
2536
|
-
const _drained =
|
|
3579
|
+
const _drained = (_pwstTurnDrained && _pwstTurnDrained.length > 0)
|
|
3580
|
+
? _pwstTurnDrained
|
|
3581
|
+
: drainPendingMessages(sessionId);
|
|
2537
3582
|
if (_drained.length > 0) {
|
|
2538
3583
|
// Same merge rule as the mid-turn steering drain (loop.mjs) and
|
|
2539
3584
|
// the TUI engine.mjs drain(): a single drain batch is joined with
|
|
@@ -2541,11 +3586,9 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2541
3586
|
// Keeps every steering/follow-up path on identical
|
|
2542
3587
|
// merge-then-deliver semantics. Anything that arrives AFTER this
|
|
2543
3588
|
// drain enqueues for the next loop pass and is merged there.
|
|
2544
|
-
const _mergedTail = _drained
|
|
2545
|
-
|
|
2546
|
-
.
|
|
2547
|
-
if (_mergedTail.length > 0) {
|
|
2548
|
-
_pendingTail.push(_mergedTail);
|
|
3589
|
+
const _mergedTail = _mergePendingMessageEntries(_drained);
|
|
3590
|
+
if (_mergedTail?.content) {
|
|
3591
|
+
_pendingTail.push(_mergedTail.content);
|
|
2549
3592
|
const refreshed = loadSession(sessionId);
|
|
2550
3593
|
if (refreshed && refreshed.closed !== true) {
|
|
2551
3594
|
activeSession = refreshed;
|
|
@@ -2554,14 +3597,16 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2554
3597
|
continue;
|
|
2555
3598
|
}
|
|
2556
3599
|
}
|
|
3600
|
+
_unlinkParentAbortListener(_runtimeState.get(sessionId));
|
|
2557
3601
|
return _result;
|
|
2558
3602
|
}
|
|
2559
3603
|
} finally {
|
|
2560
3604
|
// Clear the controller only if it's still ours (closeSession may have
|
|
2561
|
-
// swapped it). Leave the rest of the runtime entry intact so
|
|
3605
|
+
// swapped it). Leave the rest of the runtime entry intact so agent type=list
|
|
2562
3606
|
// can still surface the final stage (done/error/cancelling).
|
|
2563
3607
|
const entry = _runtimeState.get(sessionId);
|
|
2564
3608
|
if (entry && entry.generation === askGeneration) {
|
|
3609
|
+
_unlinkParentAbortListener(entry);
|
|
2565
3610
|
entry.controller = null;
|
|
2566
3611
|
// Detach the live session reference; ask is over.
|
|
2567
3612
|
entry.session = null;
|
|
@@ -2569,7 +3614,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2569
3614
|
unlock();
|
|
2570
3615
|
}
|
|
2571
3616
|
}
|
|
2572
|
-
// Session lookup by scopeKey — used by CLI
|
|
3617
|
+
// Session lookup by scopeKey — used by CLI agent to resume a pinned
|
|
2573
3618
|
// scope session when the caller passes --scope (agent/<name>).
|
|
2574
3619
|
export function findSessionByScopeKey(scopeKey) {
|
|
2575
3620
|
if (!scopeKey) return null;
|
|
@@ -2591,20 +3636,38 @@ export async function resumeSession(sessionId, preset) {
|
|
|
2591
3636
|
// than silently dropping the tool-refresh side effects.
|
|
2592
3637
|
if (session.closed === true) return null;
|
|
2593
3638
|
if (!session.owner) session.owner = 'user';
|
|
3639
|
+
// A crash / partial save during compaction can leave compaction.lastStage
|
|
3640
|
+
// pinned at 'compacting'. The TUI (App.jsx openContextPicker) reads that as
|
|
3641
|
+
// an in-progress compaction and would show "Compacting conversation"
|
|
3642
|
+
// forever on resume. No compaction is actually running for a freshly loaded
|
|
3643
|
+
// session, so normalize the stale transient stage back to an interrupted
|
|
3644
|
+
// marker. Successful auto/manual compact persistence (lastStage post_turn /
|
|
3645
|
+
// manual / auto_clear) is untouched.
|
|
3646
|
+
normalizeStaleCompactingStage(session);
|
|
2594
3647
|
// Refresh tools (MCP connections may have changed).
|
|
2595
3648
|
// Re-resolve from profile.tools when the session stored a profileId —
|
|
2596
3649
|
// otherwise fall back to preset.tools. Same resolution order as
|
|
2597
3650
|
// createSession so resume and spawn produce identical BP_1 shapes.
|
|
2598
3651
|
const oldTools = session.tools || [];
|
|
2599
|
-
const
|
|
2600
|
-
|
|
2601
|
-
|
|
3652
|
+
const ownerIsAgent = isAgentOwner(session);
|
|
3653
|
+
const skills = ownerIsAgent ? [] : collectSkillsCached(session.cwd);
|
|
3654
|
+
let toolSpec = ownerIsAgent ? 'full' : (preset || session.preset || 'full');
|
|
3655
|
+
if (session.profileId && _agentRuntimeApi?.getProfile) {
|
|
2602
3656
|
try {
|
|
2603
|
-
const profile =
|
|
2604
|
-
if (Array.isArray(profile?.tools)) toolSpec = profile.tools;
|
|
3657
|
+
const profile = _agentRuntimeApi.getProfile(session.profileId);
|
|
3658
|
+
if (!ownerIsAgent && Array.isArray(profile?.tools)) toolSpec = profile.tools;
|
|
2605
3659
|
} catch { /* ignore lookup failures, keep preset fallback */ }
|
|
2606
3660
|
}
|
|
2607
|
-
|
|
3661
|
+
let toolsForRouting = resolveSessionTools(toolSpec, skills, { ownerIsAgentSession: ownerIsAgent });
|
|
3662
|
+
if (ownerIsAgent) {
|
|
3663
|
+
toolsForRouting = applyToolPermissionNarrowing(toolsForRouting, session.toolPermission, session.role || null);
|
|
3664
|
+
}
|
|
3665
|
+
session.tools = finalizeSessionToolList(toolsForRouting, {
|
|
3666
|
+
schemaAllowedTools: Array.isArray(session.schemaAllowedTools) ? session.schemaAllowedTools : null,
|
|
3667
|
+
disallowedTools: null,
|
|
3668
|
+
ownerIsAgent,
|
|
3669
|
+
resolvedRole: session.role || null,
|
|
3670
|
+
});
|
|
2608
3671
|
const newTools = session.tools;
|
|
2609
3672
|
const missing = oldTools.filter(t => !newTools.find(n => n.name === t.name));
|
|
2610
3673
|
if (missing.length) {
|
|
@@ -2622,44 +3685,89 @@ export function listSessions(opts = {}) {
|
|
|
2622
3685
|
const sessions = listStoredSessionSummaries();
|
|
2623
3686
|
const hiddenIds = new Set([..._runtimeState.entries()].filter(([, e]) => e.listHidden).map(([id]) => id));
|
|
2624
3687
|
// Tombstoned sessions (closed===true) are excluded unless the caller opts in
|
|
2625
|
-
// (e.g.
|
|
3688
|
+
// (e.g. agent list includeClosed:true).
|
|
2626
3689
|
return sessions.filter(s => !hiddenIds.has(s.id) && (includeClosed || s.closed !== true));
|
|
2627
3690
|
}
|
|
2628
3691
|
// --- Clear messages (keep system prompt + provider/model/cwd) ---
|
|
2629
|
-
export async function clearSessionMessages(sessionId) {
|
|
3692
|
+
export async function clearSessionMessages(sessionId, options = {}) {
|
|
2630
3693
|
const session = loadSession(sessionId);
|
|
2631
3694
|
if (!session)
|
|
2632
3695
|
return false;
|
|
2633
3696
|
// Don't resurrect a closed session just to clear its messages.
|
|
2634
3697
|
if (session.closed === true) return false;
|
|
3698
|
+
const clearOptions = options && typeof options === 'object' ? options : {};
|
|
3699
|
+
const requestedCompactType = clearOptions.compactType ?? clearOptions.compact_type ?? clearOptions.type;
|
|
3700
|
+
const compactBeforeClear = requestedCompactType != null && requestedCompactType !== false && String(requestedCompactType).trim() !== '';
|
|
2635
3701
|
const keep = [];
|
|
2636
|
-
|
|
3702
|
+
let messages = Array.isArray(session.messages) ? session.messages : [];
|
|
2637
3703
|
const beforeMessageTokens = estimateMessagesTokens(messages);
|
|
3704
|
+
let clearCompactType = null;
|
|
3705
|
+
let clearCompactError = null;
|
|
3706
|
+
if (compactBeforeClear && messages.length >= 3) {
|
|
3707
|
+
clearCompactType = normalizeCompactType(requestedCompactType, DEFAULT_COMPACT_TYPE);
|
|
3708
|
+
session.compaction = {
|
|
3709
|
+
...(session.compaction || {}),
|
|
3710
|
+
type: clearCompactType,
|
|
3711
|
+
compactType: clearCompactType,
|
|
3712
|
+
};
|
|
3713
|
+
try {
|
|
3714
|
+
const compactResult = await runSessionCompaction(session, { mode: 'manual', force: true, sessionId });
|
|
3715
|
+
if (compactResult?.error) {
|
|
3716
|
+
clearCompactError = new Error(compactResult.error);
|
|
3717
|
+
}
|
|
3718
|
+
} catch (err) {
|
|
3719
|
+
clearCompactError = err;
|
|
3720
|
+
try { process.stderr.write(`[session] auto-clear pre-compact failed (sess=${sessionId}): ${err?.message || err}\n`); } catch { /* best-effort */ }
|
|
3721
|
+
}
|
|
3722
|
+
messages = Array.isArray(session.messages) ? session.messages : [];
|
|
3723
|
+
}
|
|
3724
|
+
if (compactBeforeClear && clearOptions.requireCompactSuccess === true) {
|
|
3725
|
+
const hasRetainedSummary = messages.some((m) => (
|
|
3726
|
+
m?.role === 'user'
|
|
3727
|
+
&& typeof m.content === 'string'
|
|
3728
|
+
&& m.content.startsWith(SUMMARY_PREFIX)
|
|
3729
|
+
));
|
|
3730
|
+
if (!hasRetainedSummary && !clearCompactError) {
|
|
3731
|
+
clearCompactError = new Error('compact produced no retained summary');
|
|
3732
|
+
}
|
|
3733
|
+
}
|
|
3734
|
+
if (clearCompactError && clearOptions.requireCompactSuccess === true) {
|
|
3735
|
+
const now = Date.now();
|
|
3736
|
+
session.compaction = {
|
|
3737
|
+
...(session.compaction || {}),
|
|
3738
|
+
lastStage: 'auto_clear_failed',
|
|
3739
|
+
lastCheckedAt: now,
|
|
3740
|
+
lastChanged: false,
|
|
3741
|
+
lastClearAt: session.compaction?.lastClearAt || null,
|
|
3742
|
+
lastClearCompactType: clearCompactType || session.compaction?.compactType || null,
|
|
3743
|
+
lastClearCompactError: clearCompactError?.message || String(clearCompactError),
|
|
3744
|
+
};
|
|
3745
|
+
session.updatedAt = now;
|
|
3746
|
+
await saveSessionAsync(session, { expectedGeneration: session.generation });
|
|
3747
|
+
throw new Error(`auto-clear compact failed; conversation kept: ${session.compaction.lastClearCompactError}`);
|
|
3748
|
+
}
|
|
3749
|
+
const preserveCompactSummary = compactBeforeClear && clearOptions.keepCompactSummary !== false;
|
|
2638
3750
|
for (let i = 0; i < messages.length; i += 1) {
|
|
2639
3751
|
const m = messages[i];
|
|
2640
3752
|
if (!m) continue;
|
|
2641
3753
|
if (m.role === 'system') {
|
|
3754
|
+
// BP1/BP2/BP3 all ride `role:'system'` blocks now (BP3 sessionMarker
|
|
3755
|
+
// moved off the `<system-reminder>` user wrapper), so the stable
|
|
3756
|
+
// memory/meta layer is preserved here unconditionally — no sentinel
|
|
3757
|
+
// scan / dummy-assistant pairing needed anymore.
|
|
2642
3758
|
keep.push(m);
|
|
2643
3759
|
continue;
|
|
2644
3760
|
}
|
|
2645
|
-
|
|
2646
|
-
m.role === 'user'
|
|
3761
|
+
if (preserveCompactSummary
|
|
3762
|
+
&& m.role === 'user'
|
|
2647
3763
|
&& typeof m.content === 'string'
|
|
2648
|
-
&& m.content.startsWith(
|
|
2649
|
-
&& m.content.includes('<!-- bp3-sentinel -->');
|
|
2650
|
-
if (stableContext) {
|
|
3764
|
+
&& m.content.startsWith(SUMMARY_PREFIX)) {
|
|
2651
3765
|
keep.push(m);
|
|
2652
|
-
const next = messages[i + 1];
|
|
2653
|
-
if (next?.role === 'assistant' && String(next.content || '').trim() === '.') {
|
|
2654
|
-
keep.push(next);
|
|
2655
|
-
i += 1;
|
|
2656
|
-
}
|
|
2657
3766
|
}
|
|
2658
3767
|
}
|
|
2659
3768
|
const afterMessageTokens = estimateMessagesTokens(keep);
|
|
2660
|
-
const
|
|
2661
|
-
const
|
|
2662
|
-
const afterTokens = afterMessageTokens + reserveTokens;
|
|
3769
|
+
const beforeTokens = estimateTranscriptContextUsage(messages, session.tools || []);
|
|
3770
|
+
const afterTokens = estimateTranscriptContextUsage(keep, session.tools || []);
|
|
2663
3771
|
const now = Date.now();
|
|
2664
3772
|
session.messages = keep;
|
|
2665
3773
|
session.totalInputTokens = 0;
|
|
@@ -2689,6 +3797,8 @@ export async function clearSessionMessages(sessionId) {
|
|
|
2689
3797
|
lastClearAfterTokens: afterTokens,
|
|
2690
3798
|
lastClearBeforeMessageTokens: beforeMessageTokens,
|
|
2691
3799
|
lastClearAfterMessageTokens: afterMessageTokens,
|
|
3800
|
+
lastClearCompactType: clearCompactType || session.compaction?.compactType || null,
|
|
3801
|
+
lastClearCompactError: clearCompactError?.message || null,
|
|
2692
3802
|
};
|
|
2693
3803
|
session.updatedAt = now;
|
|
2694
3804
|
await saveSessionAsync(session, { expectedGeneration: session.generation });
|
|
@@ -2698,6 +3808,9 @@ export async function compactSessionMessages(sessionId) {
|
|
|
2698
3808
|
const session = loadSession(sessionId);
|
|
2699
3809
|
if (!session) return null;
|
|
2700
3810
|
if (session.closed === true) return null;
|
|
3811
|
+
if (isSessionCompactionBlocked(sessionId)) {
|
|
3812
|
+
return { changed: false, reason: 'compact skipped: turn in progress' };
|
|
3813
|
+
}
|
|
2701
3814
|
const result = await runSessionCompaction(session, {
|
|
2702
3815
|
mode: 'manual',
|
|
2703
3816
|
force: true,
|
|
@@ -2725,7 +3838,7 @@ export async function updateSessionStatus(id, status) {
|
|
|
2725
3838
|
const session = loadSession(id);
|
|
2726
3839
|
if (!session) return false;
|
|
2727
3840
|
// Respect tombstones — don't resurrect a closed session just to update a
|
|
2728
|
-
// status label (
|
|
3841
|
+
// status label (agent handler emits running→idle/error around askSession).
|
|
2729
3842
|
if (session.closed === true) return false;
|
|
2730
3843
|
session.status = status;
|
|
2731
3844
|
session.updatedAt = Date.now();
|
|
@@ -2748,6 +3861,7 @@ export async function updateSessionStatus(id, status) {
|
|
|
2748
3861
|
*/
|
|
2749
3862
|
export function closeSession(id, reason = 'manual') {
|
|
2750
3863
|
if (!id) return false;
|
|
3864
|
+
_stopToolActivityHeartbeat(id);
|
|
2751
3865
|
// Prefer in-memory runtime session — allBashSessionIds may not be persisted
|
|
2752
3866
|
// yet for shells opened in the current turn (BL-bash-disk-sync).
|
|
2753
3867
|
const inMemory = _runtimeState.get(id)?.session;
|
|
@@ -2784,10 +3898,10 @@ export function closeSession(id, reason = 'manual') {
|
|
|
2784
3898
|
const durationMs = (typeof askStartedAt === 'number') ? (Date.now() - askStartedAt) : null;
|
|
2785
3899
|
const parts = [`session=${id}`, `reason=${reason}`];
|
|
2786
3900
|
if (durationMs != null) parts.push(`duration=${durationMs}ms`);
|
|
2787
|
-
if (
|
|
3901
|
+
if (process.env.MIXDOG_DEBUG_SESSION_LOG) process.stderr.write(`[agent-close] ${parts.join(' ')}\n`);
|
|
2788
3902
|
} catch { /* best-effort */ }
|
|
2789
3903
|
for (const bsid of allBashIds) {
|
|
2790
|
-
try {
|
|
3904
|
+
try { _closeBashSessionLazy(bsid, `agent-close:${id}`); } catch { /* ignore */ }
|
|
2791
3905
|
}
|
|
2792
3906
|
// Drop session-scoped read dedup cache so the Map doesn't accumulate
|
|
2793
3907
|
// entries across mcp-server lifetime.
|
|
@@ -2807,6 +3921,7 @@ export function closeSession(id, reason = 'manual') {
|
|
|
2807
3921
|
}
|
|
2808
3922
|
export function abortSessionTurn(id, reason = 'turn-abort') {
|
|
2809
3923
|
if (!id) return false;
|
|
3924
|
+
_stopToolActivityHeartbeat(id);
|
|
2810
3925
|
const entry = _runtimeState.get(id);
|
|
2811
3926
|
if (!entry || entry.closed) return false;
|
|
2812
3927
|
entry.stage = 'cancelling';
|
|
@@ -2863,12 +3978,12 @@ function sweepIdleSessions({ includeTombstones = true } = {}) {
|
|
|
2863
3978
|
} else {
|
|
2864
3979
|
_clearSessionRuntime(d.id);
|
|
2865
3980
|
if (d.bashSessionId) {
|
|
2866
|
-
try {
|
|
3981
|
+
try { _closeBashSessionLazy(d.bashSessionId, `idle-sweep:${d.id}`); } catch { /* ignore */ }
|
|
2867
3982
|
}
|
|
2868
3983
|
}
|
|
2869
|
-
process.stderr.write(`[
|
|
3984
|
+
process.stderr.write(`[agent-session] idle cleanup: closed ${d.id} (idle ${d.idleMinutes}m, owner=${d.owner})\n`);
|
|
2870
3985
|
}
|
|
2871
|
-
process.stderr.write(`[
|
|
3986
|
+
process.stderr.write(`[agent-session] idle sweep: cleaned ${cleaned} session(s), ${remaining} remaining\n`);
|
|
2872
3987
|
}
|
|
2873
3988
|
if (tombstonesCleaned > 0) {
|
|
2874
3989
|
for (const d of tombstoneDetails) {
|
|
@@ -2885,7 +4000,7 @@ function sweepIdleSessions({ includeTombstones = true } = {}) {
|
|
|
2885
4000
|
process.stderr.write(`[session-sweep] cleanup took ${elapsed}ms (idle=${cleaned}, tombstones=${tombstonesCleaned}, remaining=${remaining})\n`);
|
|
2886
4001
|
}
|
|
2887
4002
|
} catch (e) {
|
|
2888
|
-
process.stderr.write(`[
|
|
4003
|
+
process.stderr.write(`[agent-session] idle sweep error: ${e && e.message || e}\n`);
|
|
2889
4004
|
}
|
|
2890
4005
|
}
|
|
2891
4006
|
|