mixdog 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -23
- package/package.json +33 -27
- 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 +344 -0
- 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 +1223 -95
- 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 -7
- package/src/lib/rules-builder.cjs +240 -96
- package/src/lib/text-utils.cjs +1 -1
- package/src/mixdog-session-runtime.mjs +2325 -450
- 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 +118 -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 +377 -243
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +146 -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 +221 -70
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +477 -147
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +343 -496
- 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 +31 -11
- package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +41 -8
- package/src/runtime/agent/orchestrator/session/cache/post-edit-marks.mjs +4 -4
- package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +1 -1
- 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 +851 -674
- package/src/runtime/agent/orchestrator/session/manager.mjs +1593 -466
- package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +107 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +291 -46
- 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 +34 -7
- package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +19 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +9 -9
- 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/snapshot-store.mjs +1 -1
- 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 +152 -24
- package/src/runtime/channels/lib/scheduler.mjs +18 -14
- package/src/runtime/channels/lib/session-discovery.mjs +3 -2
- package/src/runtime/channels/lib/tool-format.mjs +0 -2
- package/src/runtime/channels/lib/transcript-discovery.mjs +3 -2
- package/src/runtime/channels/lib/webhook.mjs +1 -1
- package/src/runtime/channels/tool-defs.mjs +29 -29
- package/src/runtime/memory/index.mjs +635 -107
- 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/memory-cycle1.mjs +38 -17
- package/src/runtime/memory/lib/memory-cycle2.mjs +6 -7
- package/src/runtime/memory/lib/memory-cycle3.mjs +4 -4
- package/src/runtime/memory/lib/memory-ops-policy.mjs +2 -1
- 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 +15 -1
- package/src/runtime/memory/lib/pg/supervisor.mjs +12 -0
- package/src/runtime/memory/lib/runtime-fetcher.mjs +37 -3
- package/src/runtime/memory/lib/session-ingest.mjs +194 -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 +102 -466
- package/src/runtime/search/lib/web-tools.mjs +45 -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 +145 -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 -98
- 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 +2 -69
- package/src/standalone/usage-dashboard.mjs +96 -8
- package/src/tui/App.jsx +4798 -2153
- package/src/tui/components/AnsiText.jsx +39 -28
- package/src/tui/components/ContextPanel.jsx +87 -29
- 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 +367 -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 +617 -368
- package/src/tui/components/TurnDone.jsx +3 -3
- package/src/tui/components/UsagePanel.jsx +3 -5
- package/src/tui/components/tool-output-format.mjs +365 -0
- package/src/tui/components/tool-output-format.test.mjs +220 -0
- package/src/tui/dist/index.mjs +8915 -2418
- 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 +511 -68
- package/src/tui/markdown/format-token.test.mjs +216 -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 +746 -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 +3 -16
- package/src/vendor/statusline/bin/statusline-lib.mjs +8 -4
- 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/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/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/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 {
|
|
25
|
-
import { collectSkillsCached, buildSkillToolDefs, loadAgentTemplate, loadRoleTemplate, composeSystemPrompt, collectMixdogMd } from '../context/collect.mjs';
|
|
26
|
-
import { saveSession, saveSessionAsync, loadSession, listStoredSessions, sweepStaleSessions, markSessionClosed, publishHeartbeat, deleteHeartbeat, setLiveSession } from './store.mjs';
|
|
31
|
+
import { collectSkillsCached, buildSkillManifest, buildSkillToolDefs, composeSystemPrompt } from '../context/collect.mjs';
|
|
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',
|
|
@@ -251,9 +321,9 @@ const SESSION_ROUTE_TOOL_ORDER = [
|
|
|
251
321
|
'task',
|
|
252
322
|
];
|
|
253
323
|
const SESSION_ROUTE_TOOL_RANK = new Map(SESSION_ROUTE_TOOL_ORDER.map((name, index) => [name, index]));
|
|
254
|
-
const MODEL_HIDDEN_FILE_MUTATION_TOOLS = new Set(['edit', 'write']);
|
|
255
324
|
const FILESYSTEM_TOOL_NAMES = new Set([
|
|
256
325
|
'code_graph',
|
|
326
|
+
'find',
|
|
257
327
|
'glob',
|
|
258
328
|
'list',
|
|
259
329
|
'grep',
|
|
@@ -262,12 +332,115 @@ const FILESYSTEM_TOOL_NAMES = new Set([
|
|
|
262
332
|
]);
|
|
263
333
|
const READONLY_TOOL_NAMES = new Set([
|
|
264
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',
|
|
265
345
|
'glob',
|
|
266
346
|
'list',
|
|
267
347
|
'grep',
|
|
268
348
|
'read',
|
|
349
|
+
'explore',
|
|
350
|
+
'search',
|
|
351
|
+
'web_fetch',
|
|
269
352
|
]);
|
|
270
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
|
+
|
|
271
444
|
function orderSessionTools(tools) {
|
|
272
445
|
return tools.map((tool, index) => ({ tool, index }))
|
|
273
446
|
.sort((a, b) => {
|
|
@@ -280,18 +453,18 @@ function orderSessionTools(tools) {
|
|
|
280
453
|
}
|
|
281
454
|
|
|
282
455
|
const ALL_BUILTIN_SESSION_TOOLS = orderSessionTools(_dedupByName([
|
|
283
|
-
...BUILTIN_TOOLS
|
|
456
|
+
...BUILTIN_TOOLS,
|
|
284
457
|
...PATCH_TOOL_DEFS,
|
|
285
458
|
...CODE_GRAPH_TOOL_DEFS,
|
|
286
459
|
]));
|
|
287
460
|
|
|
288
|
-
function resolveSessionTools(toolSpec, skills, {
|
|
461
|
+
function resolveSessionTools(toolSpec, skills, { ownerIsAgentSession = false } = {}) {
|
|
289
462
|
const mcp = _getMcpTools();
|
|
290
|
-
//
|
|
463
|
+
// Agent sessions freeze the skill meta-tool into the schema
|
|
291
464
|
// unconditionally — concrete skill resolution is cwd-scoped at tool-call
|
|
292
465
|
// time (loop.mjs), so the schema bytes stay bit-identical across roles /
|
|
293
466
|
// cwds and the provider cache shard does not fragment.
|
|
294
|
-
const skillTools = buildSkillToolDefs(skills, {
|
|
467
|
+
const skillTools = buildSkillToolDefs(skills, { ownerIsAgentSession });
|
|
295
468
|
return _computeBaseTools(toolSpec, mcp, skillTools);
|
|
296
469
|
}
|
|
297
470
|
|
|
@@ -315,8 +488,8 @@ function _dedupByName(tools) {
|
|
|
315
488
|
return [...seen.values()];
|
|
316
489
|
}
|
|
317
490
|
|
|
318
|
-
//
|
|
319
|
-
// Tools with
|
|
491
|
+
// Agent visibility is declared per-tool via annotations.agentHidden.
|
|
492
|
+
// Tools with agentHidden:true are stripped from agent sessions at schema
|
|
320
493
|
// build time (see deny filtering below). No code-level name list needed.
|
|
321
494
|
|
|
322
495
|
function _computeBaseTools(toolSpec, mcp, skillTools) {
|
|
@@ -357,7 +530,7 @@ function _computeBaseTools(toolSpec, mcp, skillTools) {
|
|
|
357
530
|
// Name-pattern match: picks up `search` and any future tool
|
|
358
531
|
// whose name contains `search`. `recall` and `explore` deliberately do NOT match
|
|
359
532
|
// — they need `tools:mcp` (full mcp surface) or their own
|
|
360
|
-
// toolset id if a role wants targeted retrieval. Public
|
|
533
|
+
// toolset id if a role wants targeted retrieval. Public agent
|
|
361
534
|
// roles never reach the wrapper bodies regardless: see the
|
|
362
535
|
// isBlockedPublicWrapperCall guard in session/loop.mjs.
|
|
363
536
|
addMany(mcp.filter(t => /search/i.test(t?.name || '')));
|
|
@@ -404,7 +577,7 @@ let nextId = Date.now();
|
|
|
404
577
|
// this map trimmed to live models; older generations slow down reads
|
|
405
578
|
// without buying anything.
|
|
406
579
|
const CONTEXT_WINDOWS = {
|
|
407
|
-
// OpenAI GPT-5.x family
|
|
580
|
+
// OpenAI GPT-5.x family (openai / openai-oauth)
|
|
408
581
|
'gpt-5.5': 272000,
|
|
409
582
|
'gpt-5.4': 272000,
|
|
410
583
|
'gpt-5.4-mini': 272000,
|
|
@@ -419,12 +592,33 @@ const CONTEXT_WINDOWS = {
|
|
|
419
592
|
'gemini-3-pro': 1000000,
|
|
420
593
|
'gemini-3.5-flash': 1000000,
|
|
421
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,
|
|
422
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.
|
|
423
605
|
function guessContextWindow(model) {
|
|
424
606
|
if (CONTEXT_WINDOWS[model])
|
|
425
607
|
return CONTEXT_WINDOWS[model];
|
|
426
|
-
|
|
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'))
|
|
427
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;
|
|
428
622
|
return 128000;
|
|
429
623
|
}
|
|
430
624
|
function positiveContextWindow(value) {
|
|
@@ -445,26 +639,108 @@ function providerNameOf(provider) {
|
|
|
445
639
|
if (typeof provider === 'string') return provider.toLowerCase();
|
|
446
640
|
return String(provider?.name || provider?.id || '').toLowerCase();
|
|
447
641
|
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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],
|
|
458
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
|
+
};
|
|
459
735
|
}
|
|
460
736
|
function compactBufferTokensForSession(session, boundaryTokens) {
|
|
461
|
-
const cfg = session?.compaction || {};
|
|
737
|
+
const cfg = compactBufferConfigForBoundary(session?.compaction || {}, boundaryTokens);
|
|
462
738
|
const explicit = positiveContextWindow(cfg.bufferTokens ?? cfg.buffer)
|
|
463
|
-
|| positiveContextWindow(process.env.
|
|
739
|
+
|| positiveContextWindow(process.env.MIXDOG_AGENT_COMPACT_BUFFER_TOKENS)
|
|
464
740
|
|| 0;
|
|
465
741
|
return compactionBufferTokensForBoundary(boundaryTokens, {
|
|
466
742
|
explicitTokens: explicit,
|
|
467
|
-
ratio:
|
|
743
|
+
ratio: compactBufferRatioForConfig(cfg),
|
|
468
744
|
maxRatio: 0.25,
|
|
469
745
|
});
|
|
470
746
|
}
|
|
@@ -472,8 +748,8 @@ const COMPACT_TARGET_RATIO = 0.02;
|
|
|
472
748
|
const COMPACT_TARGET_MIN_TOKENS = 4_000;
|
|
473
749
|
const COMPACT_TARGET_MAX_TOKENS = 16_000;
|
|
474
750
|
function compactTargetRatio() {
|
|
475
|
-
const raw = process.env.
|
|
476
|
-
?? process.env.
|
|
751
|
+
const raw = process.env.MIXDOG_AGENT_COMPACT_TARGET_PERCENT
|
|
752
|
+
?? process.env.MIXDOG_COMPACT_TARGET_PERCENT
|
|
477
753
|
?? COMPACT_TARGET_RATIO;
|
|
478
754
|
const n = Number(raw);
|
|
479
755
|
if (!Number.isFinite(n) || n <= 0) return COMPACT_TARGET_RATIO;
|
|
@@ -483,8 +759,8 @@ function compactTargetTokensForBoundary(boundaryTokens) {
|
|
|
483
759
|
const boundary = positiveContextWindow(boundaryTokens);
|
|
484
760
|
if (!boundary) return null;
|
|
485
761
|
const explicit = positiveContextWindow(
|
|
486
|
-
process.env.
|
|
487
|
-
?? process.env.
|
|
762
|
+
process.env.MIXDOG_AGENT_COMPACT_TARGET_TOKENS
|
|
763
|
+
?? process.env.MIXDOG_COMPACT_TARGET_TOKENS,
|
|
488
764
|
);
|
|
489
765
|
if (explicit) return Math.max(1, Math.min(boundary, explicit));
|
|
490
766
|
const minTarget = Math.min(boundary, positiveContextWindow(process.env.MIXDOG_COMPACT_TARGET_MIN_TOKENS) || COMPACT_TARGET_MIN_TOKENS);
|
|
@@ -493,10 +769,10 @@ function compactTargetTokensForBoundary(boundaryTokens) {
|
|
|
493
769
|
return Math.max(1, Math.min(boundary, maxTarget, Math.max(minTarget, byRatio)));
|
|
494
770
|
}
|
|
495
771
|
function defaultEffectiveContextWindowPercent(provider) {
|
|
496
|
-
// Gateway/statusline route metadata reserves a universal
|
|
772
|
+
// Gateway/statusline route metadata reserves a universal 10% headroom from
|
|
497
773
|
// the raw catalog window. Keep session compaction on the same effective
|
|
498
774
|
// capacity so /context, the TUI statusline, and gateway telemetry agree.
|
|
499
|
-
return
|
|
775
|
+
return 90;
|
|
500
776
|
}
|
|
501
777
|
function resolveSessionContextMeta(provider, model, seed = {}) {
|
|
502
778
|
const info = typeof provider?.getCachedModelInfo === 'function'
|
|
@@ -526,7 +802,8 @@ function resolveSessionContextMeta(provider, model, seed = {}) {
|
|
|
526
802
|
);
|
|
527
803
|
const pct = boundedPercent(effectiveContextWindowPercent, 100);
|
|
528
804
|
const contextWindow = Math.max(1, Math.floor(rawContextWindow * pct / 100));
|
|
529
|
-
const
|
|
805
|
+
const compactBoundaryTokens = contextWindow;
|
|
806
|
+
const rawCompactLimit = positiveContextWindow(
|
|
530
807
|
seed.autoCompactTokenLimit
|
|
531
808
|
?? seed.auto_compact_token_limit
|
|
532
809
|
?? info?.autoCompactTokenLimit
|
|
@@ -534,11 +811,27 @@ function resolveSessionContextMeta(provider, model, seed = {}) {
|
|
|
534
811
|
?? catalogInfo?.autoCompactTokenLimit
|
|
535
812
|
?? catalogInfo?.auto_compact_token_limit,
|
|
536
813
|
);
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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;
|
|
542
835
|
return {
|
|
543
836
|
contextWindow,
|
|
544
837
|
rawContextWindow,
|
|
@@ -551,10 +844,36 @@ function compactTriggerForSession(session, boundaryTokens) {
|
|
|
551
844
|
const boundary = positiveContextWindow(boundaryTokens);
|
|
552
845
|
if (!boundary) return null;
|
|
553
846
|
const autoLimit = positiveContextWindow(session?.autoCompactTokenLimit ?? session?.compaction?.autoCompactTokenLimit);
|
|
554
|
-
|
|
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);
|
|
555
852
|
const buffer = compactBufferTokensForSession(session, boundary);
|
|
556
853
|
return Math.max(1, boundary - buffer);
|
|
557
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
|
+
}
|
|
558
877
|
function compactTargetBudget(boundaryTokens, reserveTokens, _sourceTokens = null, _ratio = null) {
|
|
559
878
|
const boundary = positiveContextWindow(boundaryTokens);
|
|
560
879
|
if (!boundary) return null;
|
|
@@ -564,12 +883,21 @@ function compactTargetBudget(boundaryTokens, reserveTokens, _sourceTokens = null
|
|
|
564
883
|
}
|
|
565
884
|
function semanticCompactionEnabledForSession(session) {
|
|
566
885
|
const cfg = session?.compaction || {};
|
|
886
|
+
if (process.env.MIXDOG_AGENT_COMPACT_SEMANTIC !== undefined) return envFlag('MIXDOG_AGENT_COMPACT_SEMANTIC', true);
|
|
567
887
|
if (process.env.MIXDOG_COMPACT_SEMANTIC !== undefined) return envFlag('MIXDOG_COMPACT_SEMANTIC', true);
|
|
568
|
-
if (process.env.MIXDOG_BRIDGE_COMPACT_SEMANTIC !== undefined) return envFlag('MIXDOG_BRIDGE_COMPACT_SEMANTIC', true);
|
|
569
888
|
if (cfg.semantic === false || cfg.semantic === 'false' || cfg.semantic === 'off') return false;
|
|
570
889
|
if (cfg.semantic === true || cfg.semantic === 'true' || cfg.semantic === 'on' || cfg.semantic === 'auto') return true;
|
|
571
890
|
return true;
|
|
572
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
|
+
}
|
|
573
901
|
function addCompactUsageToSession(session, usage) {
|
|
574
902
|
if (!session || !usage) return;
|
|
575
903
|
const inputTokens = usage.inputTokens || 0;
|
|
@@ -582,6 +910,73 @@ function addCompactUsageToSession(session, usage) {
|
|
|
582
910
|
session.totalCacheWriteTokens = (session.totalCacheWriteTokens || 0) + cacheWriteTokens;
|
|
583
911
|
session.tokensCumulative = (session.tokensCumulative || 0) + inputTokens + outputTokens;
|
|
584
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
|
+
}
|
|
585
980
|
async function runSessionCompaction(session, opts = {}) {
|
|
586
981
|
if (!session || session.closed === true) return null;
|
|
587
982
|
const mode = opts.mode === 'auto' ? 'auto' : 'manual';
|
|
@@ -598,17 +993,18 @@ async function runSessionCompaction(session, opts = {}) {
|
|
|
598
993
|
}
|
|
599
994
|
const reserveTokens = estimateRequestReserveTokens(session.tools || []);
|
|
600
995
|
const beforeMessageTokens = estimateMessagesTokens(messages);
|
|
601
|
-
const lastContextTokens = positiveContextWindow(session.lastContextTokens) || 0;
|
|
602
996
|
const triggerTokens = compactTriggerForSession(session, boundary)
|
|
603
997
|
|| positiveContextWindow(session.compaction?.triggerTokens)
|
|
604
998
|
|| boundary;
|
|
605
999
|
const bufferTokens = Math.max(0, boundary - triggerTokens);
|
|
606
1000
|
const bufferRatio = boundary ? (bufferTokens / boundary) : compactBufferRatioForSession(session);
|
|
607
|
-
const pressureTokens =
|
|
1001
|
+
const pressureTokens = estimateTranscriptContextUsage(messages, session.tools || []);
|
|
608
1002
|
const beforeTokens = pressureTokens;
|
|
1003
|
+
const compactType = compactTypeForSession(session);
|
|
609
1004
|
if (!force && pressureTokens < triggerTokens) return {
|
|
610
1005
|
changed: false,
|
|
611
1006
|
reason: 'below threshold',
|
|
1007
|
+
compactType,
|
|
612
1008
|
beforeMessages: messages.length,
|
|
613
1009
|
afterMessages: messages.length,
|
|
614
1010
|
beforeTokens,
|
|
@@ -628,13 +1024,43 @@ async function runSessionCompaction(session, opts = {}) {
|
|
|
628
1024
|
const budgetSourceTokens = force ? Math.max(pressureTokens, triggerTokens) : pressureTokens;
|
|
629
1025
|
const compactBudget = compactTargetBudget(boundary, reserveTokens, budgetSourceTokens);
|
|
630
1026
|
const budget = compactBudget || boundary;
|
|
1027
|
+
try { opts.onStageChange?.('compacting'); } catch { /* best-effort */ }
|
|
631
1028
|
const provider = opts.provider || getProvider(session.provider) || null;
|
|
632
1029
|
let compacted;
|
|
633
1030
|
let compactError = null;
|
|
634
1031
|
let semanticCompactResult = null;
|
|
635
1032
|
let semanticCompactError = null;
|
|
636
|
-
|
|
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)) {
|
|
637
1060
|
try {
|
|
1061
|
+
if (!semanticCompactionEnabledForSession(session)) {
|
|
1062
|
+
throw new Error('semantic compact is disabled for this session');
|
|
1063
|
+
}
|
|
638
1064
|
if (!provider || typeof provider.send !== 'function') {
|
|
639
1065
|
throw new Error(`semantic compact provider unavailable: ${session.provider || 'unknown'}`);
|
|
640
1066
|
}
|
|
@@ -663,22 +1089,14 @@ async function runSessionCompaction(session, opts = {}) {
|
|
|
663
1089
|
}
|
|
664
1090
|
} catch (err) {
|
|
665
1091
|
semanticCompactError = err;
|
|
1092
|
+
compactError = err;
|
|
666
1093
|
try {
|
|
667
|
-
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`);
|
|
668
1095
|
} catch { /* best-effort */ }
|
|
669
1096
|
}
|
|
670
1097
|
}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
} catch (err) {
|
|
674
|
-
try {
|
|
675
|
-
process.stderr.write(`[session] ${mode} compact fallback (sess=${session.id || 'unknown'}): ${err?.message || err}\n`);
|
|
676
|
-
} catch { /* best-effort */ }
|
|
677
|
-
try {
|
|
678
|
-
compacted = compactActiveTurn(messages, budget, { reserveTokens, force: true });
|
|
679
|
-
} catch (fallbackErr) {
|
|
680
|
-
compactError = fallbackErr;
|
|
681
|
-
}
|
|
1098
|
+
if (!compacted && !compactError) {
|
|
1099
|
+
compactError = new Error(`${compactType} compact produced no messages`);
|
|
682
1100
|
}
|
|
683
1101
|
if (!compacted) {
|
|
684
1102
|
const now = Date.now();
|
|
@@ -698,13 +1116,19 @@ async function runSessionCompaction(session, opts = {}) {
|
|
|
698
1116
|
lastPressureTokens: pressureTokens,
|
|
699
1117
|
lastCheckedAt: now,
|
|
700
1118
|
lastChanged: false,
|
|
1119
|
+
type: compactType,
|
|
1120
|
+
compactType,
|
|
1121
|
+
lastCompactType: compactType,
|
|
701
1122
|
lastSemantic: false,
|
|
702
1123
|
lastSemanticError: semanticCompactError?.message || null,
|
|
703
|
-
|
|
1124
|
+
lastRecallFastTrack: false,
|
|
1125
|
+
lastRecallFastTrackError: recallFastTrackError?.message || null,
|
|
1126
|
+
lastError: compactError?.message || semanticCompactError?.message || recallFastTrackError?.message || String(compactError || semanticCompactError || recallFastTrackError || 'compact failed'),
|
|
704
1127
|
};
|
|
705
1128
|
return {
|
|
706
1129
|
changed: false,
|
|
707
1130
|
error: session.compaction.lastError,
|
|
1131
|
+
compactType,
|
|
708
1132
|
beforeMessages: messages.length,
|
|
709
1133
|
afterMessages: messages.length,
|
|
710
1134
|
beforeTokens,
|
|
@@ -721,6 +1145,8 @@ async function runSessionCompaction(session, opts = {}) {
|
|
|
721
1145
|
reserveTokens,
|
|
722
1146
|
semanticCompact: false,
|
|
723
1147
|
semanticError: semanticCompactError?.message || null,
|
|
1148
|
+
recallFastTrack: false,
|
|
1149
|
+
recallFastTrackError: recallFastTrackError?.message || null,
|
|
724
1150
|
};
|
|
725
1151
|
}
|
|
726
1152
|
let beforeEncoded = '';
|
|
@@ -732,6 +1158,7 @@ async function runSessionCompaction(session, opts = {}) {
|
|
|
732
1158
|
const changed = beforeEncoded && afterEncoded
|
|
733
1159
|
? beforeEncoded !== afterEncoded
|
|
734
1160
|
: (compacted.length !== messages.length || afterMessageTokens !== beforeMessageTokens);
|
|
1161
|
+
const unchangedReason = changed ? null : (force ? 'nothing to compact' : 'below threshold');
|
|
735
1162
|
const now = Date.now();
|
|
736
1163
|
session.messages = compacted;
|
|
737
1164
|
session.providerState = undefined;
|
|
@@ -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,
|
|
@@ -766,6 +1199,8 @@ async function runSessionCompaction(session, opts = {}) {
|
|
|
766
1199
|
if (changed && mode === 'auto') session.lastContextTokensStaleAfterCompact = true;
|
|
767
1200
|
return {
|
|
768
1201
|
changed,
|
|
1202
|
+
reason: unchangedReason,
|
|
1203
|
+
compactType,
|
|
769
1204
|
beforeMessages: messages.length,
|
|
770
1205
|
afterMessages: compacted.length,
|
|
771
1206
|
beforeTokens,
|
|
@@ -782,20 +1217,20 @@ async function runSessionCompaction(session, opts = {}) {
|
|
|
782
1217
|
reserveTokens,
|
|
783
1218
|
semanticCompact: semanticCompactResult?.semantic === true,
|
|
784
1219
|
semanticError: semanticCompactError?.message || null,
|
|
1220
|
+
recallFastTrack: recallFastTrackResult?.recallFastTrack === true,
|
|
1221
|
+
recallFastTrackError: recallFastTrackError?.message || null,
|
|
785
1222
|
usage: semanticCompactResult?.usage || null,
|
|
786
1223
|
};
|
|
787
1224
|
}
|
|
788
|
-
async function autoCompactSessionAfterTurn(session, opts = {}) {
|
|
789
|
-
return runSessionCompaction(session, { ...opts, mode: 'auto', force: false });
|
|
790
|
-
}
|
|
791
1225
|
// Provider-scoped unified cache key. Goal: all orchestrator-internal
|
|
792
|
-
// dispatches (
|
|
1226
|
+
// dispatches (agent/maintenance/mcp/scheduler/webhook) targeting the
|
|
793
1227
|
// same provider land in a single server-side cache shard, so the
|
|
794
1228
|
// shared prefix (tools + system + pool system prompt) is reused
|
|
795
|
-
// regardless of role. Per-role / per-session differentiation lives
|
|
796
|
-
//
|
|
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.
|
|
797
1232
|
const PROVIDER_ALIAS = {
|
|
798
|
-
'openai-oauth': 'codex', // ChatGPT subscription (
|
|
1233
|
+
'openai-oauth': 'codex', // ChatGPT subscription (OpenAI OAuth backend)
|
|
799
1234
|
'anthropic-oauth': 'claude', // Claude Max subscription
|
|
800
1235
|
'openai': 'openai',
|
|
801
1236
|
'anthropic': 'anthropic',
|
|
@@ -810,9 +1245,10 @@ function providerCacheKey(provider, override) {
|
|
|
810
1245
|
}
|
|
811
1246
|
|
|
812
1247
|
// ── Prefetch permission guard ─────────────────────────────────────────────────
|
|
813
|
-
//
|
|
814
|
-
//
|
|
815
|
-
//
|
|
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.
|
|
816
1252
|
const _permEvalForPrefetch = (() => {
|
|
817
1253
|
const _req = createRequire(import.meta.url);
|
|
818
1254
|
try {
|
|
@@ -823,9 +1259,8 @@ const _permEvalForPrefetch = (() => {
|
|
|
823
1259
|
})();
|
|
824
1260
|
function _guardedPrefetchTool(toolName, toolArgs, session) {
|
|
825
1261
|
if (!_permEvalForPrefetch) return null;
|
|
826
|
-
//
|
|
827
|
-
//
|
|
828
|
-
// 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.
|
|
829
1264
|
const permissionMode = session?.permissionMode || 'default';
|
|
830
1265
|
const projectDir = session?.cwd || undefined;
|
|
831
1266
|
const userCwd = session?.cwd || undefined;
|
|
@@ -844,7 +1279,7 @@ function _guardedPrefetchTool(toolName, toolArgs, session) {
|
|
|
844
1279
|
|
|
845
1280
|
async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
|
|
846
1281
|
if (!explicitPrefetch || typeof explicitPrefetch !== 'object') return null;
|
|
847
|
-
if (session
|
|
1282
|
+
if (!isAgentOwner(session)) return null;
|
|
848
1283
|
const parts = [];
|
|
849
1284
|
const failed = [];
|
|
850
1285
|
const totalEntries = [];
|
|
@@ -886,7 +1321,7 @@ async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
|
|
|
886
1321
|
if (files.length > 0) {
|
|
887
1322
|
const _pfGuard = _guardedPrefetchTool('read', { path: files }, session);
|
|
888
1323
|
if (_pfGuard) {
|
|
889
|
-
process.stderr.write(`[
|
|
1324
|
+
process.stderr.write(`[agent-prefetch] files read blocked: ${_pfGuard}\n`);
|
|
890
1325
|
failed.push(...files);
|
|
891
1326
|
totalEntries.push(...files);
|
|
892
1327
|
} else {
|
|
@@ -930,7 +1365,7 @@ async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
|
|
|
930
1365
|
readArgs.n = Number.isFinite(opts.n) ? opts.n : 120;
|
|
931
1366
|
}
|
|
932
1367
|
const out = await executeInternalTool('read', readArgs).catch((e) => {
|
|
933
|
-
process.stderr.write(`[
|
|
1368
|
+
process.stderr.write(`[agent-prefetch] file read failed (${f}): ${e && e.message || e}\n`);
|
|
934
1369
|
return null;
|
|
935
1370
|
});
|
|
936
1371
|
if (out !== null) {
|
|
@@ -968,9 +1403,11 @@ async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
|
|
|
968
1403
|
parts.push(`### prefetch files\nread ${readParts.length}\n\n${readParts.join('\n\n')}`);
|
|
969
1404
|
}
|
|
970
1405
|
// Log hit/miss counters so dispatch telemetry shows prefetch effectiveness.
|
|
971
|
-
process.
|
|
972
|
-
|
|
973
|
-
|
|
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
|
+
}
|
|
974
1411
|
// Attach stats to session so post-hoc analyzers (inspect-session.mjs)
|
|
975
1412
|
// can see prefetch effectiveness without parsing stderr logs.
|
|
976
1413
|
if (session && typeof session === 'object') {
|
|
@@ -992,13 +1429,13 @@ async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
|
|
|
992
1429
|
totalEntries.push(symbol);
|
|
993
1430
|
const blocked = _guardedPrefetchTool('code_graph', cgArgs, session);
|
|
994
1431
|
if (blocked) {
|
|
995
|
-
process.stderr.write(`[
|
|
1432
|
+
process.stderr.write(`[agent-prefetch] callers(${symbol}) blocked: ${blocked}\n`);
|
|
996
1433
|
return Promise.resolve({ symbol, out: null, blocked: true });
|
|
997
1434
|
}
|
|
998
|
-
return
|
|
1435
|
+
return _executeCodeGraphToolLazy('code_graph', cgArgs, session?.cwd)
|
|
999
1436
|
.then(out => ({ symbol, out }))
|
|
1000
1437
|
.catch(e => {
|
|
1001
|
-
process.stderr.write(`[
|
|
1438
|
+
process.stderr.write(`[agent-prefetch] callers(${symbol}) failed: ${e && e.message || e}\n`);
|
|
1002
1439
|
return { symbol, out: null };
|
|
1003
1440
|
});
|
|
1004
1441
|
});
|
|
@@ -1023,13 +1460,13 @@ async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
|
|
|
1023
1460
|
totalEntries.push(symbol);
|
|
1024
1461
|
const blocked = _guardedPrefetchTool('code_graph', cgArgs, session);
|
|
1025
1462
|
if (blocked) {
|
|
1026
|
-
process.stderr.write(`[
|
|
1463
|
+
process.stderr.write(`[agent-prefetch] references(${symbol}) blocked: ${blocked}\n`);
|
|
1027
1464
|
return Promise.resolve({ symbol, out: null, blocked: true });
|
|
1028
1465
|
}
|
|
1029
|
-
return
|
|
1466
|
+
return _executeCodeGraphToolLazy('code_graph', cgArgs, session?.cwd)
|
|
1030
1467
|
.then(out => ({ symbol, out }))
|
|
1031
1468
|
.catch(e => {
|
|
1032
|
-
process.stderr.write(`[
|
|
1469
|
+
process.stderr.write(`[agent-prefetch] references(${symbol}) failed: ${e && e.message || e}\n`);
|
|
1033
1470
|
return { symbol, out: null };
|
|
1034
1471
|
});
|
|
1035
1472
|
});
|
|
@@ -1064,29 +1501,29 @@ async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
|
|
|
1064
1501
|
return `${warnLine}<prefetch>\n${parts.join('\n\n')}\n</prefetch>`;
|
|
1065
1502
|
}
|
|
1066
1503
|
|
|
1067
|
-
// ---
|
|
1504
|
+
// --- agent spawn (createSession) ---
|
|
1068
1505
|
// opts can pass either a `preset` object (from config.presets) or raw provider/model.
|
|
1069
1506
|
// Preset shape: { name, provider, model, effort?, fast?, tools? }
|
|
1070
1507
|
//
|
|
1071
|
-
//
|
|
1508
|
+
// Agent Runtime integration:
|
|
1072
1509
|
// opts.taskType / opts.role / opts.profileId — enables profile-aware routing.
|
|
1073
1510
|
// Rule-based SmartRouter resolves these synchronously; the resolved
|
|
1074
1511
|
// profile controls context filtering (skip.skills/memory/etc) and cache
|
|
1075
1512
|
// strategy. If no rule matches, falls back to classic preset behavior.
|
|
1076
1513
|
// opts.profile — pre-resolved profile (bypasses router; used by async
|
|
1077
|
-
// callers who already ran
|
|
1514
|
+
// callers who already ran AgentRuntime.resolve()).
|
|
1078
1515
|
// opts.providerCacheOpts — pre-resolved cache options merged into ask() sendOpts.
|
|
1079
1516
|
export function createSession(opts) {
|
|
1080
1517
|
const presetObj = opts.preset && typeof opts.preset === 'object' ? opts.preset : null;
|
|
1081
1518
|
|
|
1082
|
-
// ---
|
|
1519
|
+
// --- Agent Runtime profile resolution (best-effort, sync) ---
|
|
1083
1520
|
let profile = opts.profile || null;
|
|
1084
1521
|
let providerCacheOpts = opts.providerCacheOpts || null;
|
|
1085
1522
|
if (!profile && (opts.taskType || opts.role || opts.profileId)) {
|
|
1086
|
-
const
|
|
1087
|
-
if (
|
|
1523
|
+
const agentRuntime = getAgentRuntimeSync();
|
|
1524
|
+
if (agentRuntime) {
|
|
1088
1525
|
try {
|
|
1089
|
-
const resolved =
|
|
1526
|
+
const resolved = agentRuntime.resolveSync({
|
|
1090
1527
|
taskType: opts.taskType,
|
|
1091
1528
|
role: opts.role,
|
|
1092
1529
|
profileId: opts.profileId,
|
|
@@ -1098,10 +1535,10 @@ export function createSession(opts) {
|
|
|
1098
1535
|
providerCacheOpts = resolved.providerCacheOpts;
|
|
1099
1536
|
}
|
|
1100
1537
|
} catch (e) {
|
|
1101
|
-
//
|
|
1102
|
-
if (!
|
|
1103
|
-
|
|
1104
|
-
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`);
|
|
1105
1542
|
}
|
|
1106
1543
|
}
|
|
1107
1544
|
}
|
|
@@ -1129,132 +1566,108 @@ export function createSession(opts) {
|
|
|
1129
1566
|
throw new Error(`Provider "${providerName}" not found or not enabled`);
|
|
1130
1567
|
const id = `sess_${process.pid}_${nextId++}_${Date.now()}_${randomBytes(16).toString('hex')}`;
|
|
1131
1568
|
const messages = [];
|
|
1132
|
-
const
|
|
1133
|
-
const skills = opts.skipSkills ? [] : collectSkillsCached(opts.cwd);
|
|
1134
|
-
|
|
1135
|
-
// Bridge shared prefix (bit-identical across roles). Hidden roles reuse the
|
|
1136
|
-
// same shared bridge rules so the cache shard stays stable across bridge
|
|
1137
|
-
// callers. User-defined data (DATA_DIR roles/schedules/webhooks) is baked
|
|
1138
|
-
// into BP1 as a single fixed-value monolithic block so every role shares
|
|
1139
|
-
// one cache shard. A user edit invalidates BP1 once and the new prefix
|
|
1140
|
-
// re-warms across all roles together.
|
|
1141
|
-
const bridgeRulesRole = opts.role || profile?.taskType || null;
|
|
1142
|
-
const injectedRules = opts.skipBridgeRules ? '' : (opts.owner === 'bridge' ? _buildBridgeRules() : _buildLeadRules());
|
|
1143
|
-
const roleSpecific = opts.owner === 'bridge' && !opts.skipBridgeRules ? _buildRoleSpecific(bridgeRulesRole) : '';
|
|
1144
|
-
// mixdog.md user/project context (global + cwd ancestors, broad-to-specific).
|
|
1145
|
-
const projectContext = collectMixdogMd(opts.cwd);
|
|
1146
|
-
|
|
1147
|
-
// Role template (Phase B §4 — UI-managed). Reads <DATA_DIR>/roles/<role>.md
|
|
1148
|
-
// and parses frontmatter (description, permission). The template is
|
|
1149
|
-
// injected into the Tier 3 system-reminder so role differences never
|
|
1150
|
-
// touch the BP_2 cache prefix.
|
|
1569
|
+
const ownerIsAgent = isAgentOwner(opts.owner);
|
|
1151
1570
|
const resolvedRole = opts.role || profile?.taskType || null;
|
|
1152
|
-
const
|
|
1153
|
-
const
|
|
1154
|
-
|
|
1155
|
-
|
|
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);
|
|
1156
1577
|
|
|
1157
|
-
//
|
|
1158
|
-
//
|
|
1159
|
-
//
|
|
1160
|
-
|
|
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
|
|
1161
1593
|
// behaviour.
|
|
1162
|
-
const toolSpec =
|
|
1594
|
+
const toolSpec = ownerIsAgent
|
|
1163
1595
|
? 'full'
|
|
1164
1596
|
: (Array.isArray(profile?.tools) ? profile.tools : toolPreset);
|
|
1165
1597
|
|
|
1166
1598
|
// Prompt permission is metadata only. Preset tool restrictions must NOT
|
|
1167
|
-
// enter the prompt, or they split the shared
|
|
1599
|
+
// enter the prompt, or they split the shared agent cache tail; they map
|
|
1168
1600
|
// to toolPermission below and are enforced only at call time.
|
|
1169
1601
|
const permission = opts.permission
|
|
1170
|
-
|| roleTemplate?.permission
|
|
1171
1602
|
|| null;
|
|
1172
1603
|
const toolPermission = opts.permission
|
|
1173
1604
|
|| profile?.permission
|
|
1174
|
-
|| roleTemplate?.permission
|
|
1175
1605
|
|| permissionFromToolSpec(toolPreset)
|
|
1176
1606
|
|| null;
|
|
1177
|
-
let toolsForRouting = resolveSessionTools(toolSpec, skills, {
|
|
1178
|
-
// Fail-closed permission intersection: when a
|
|
1179
|
-
//
|
|
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
|
|
1180
1610
|
// resolved tool list with the permission's allow/deny lists. If the
|
|
1181
1611
|
// intersection produces an empty set the permission config is broken —
|
|
1182
1612
|
// fail closed (zero tools) rather than silently falling back to the full
|
|
1183
1613
|
// preset, which would grant the role more surface than declared.
|
|
1184
|
-
if (
|
|
1185
|
-
|
|
1186
|
-
? new Set(toolPermission.allow.map(n => String(n).toLowerCase()))
|
|
1187
|
-
: null;
|
|
1188
|
-
const denySet = Array.isArray(toolPermission.deny) && toolPermission.deny.length > 0
|
|
1189
|
-
? new Set(toolPermission.deny.map(n => String(n).toLowerCase()))
|
|
1190
|
-
: null;
|
|
1191
|
-
if (allowSet || denySet) {
|
|
1192
|
-
const filtered = toolsForRouting.filter(t => {
|
|
1193
|
-
const name = String(t?.name || '').toLowerCase();
|
|
1194
|
-
if (denySet && denySet.has(name)) return false;
|
|
1195
|
-
if (allowSet && !allowSet.has(name)) return false;
|
|
1196
|
-
return true;
|
|
1197
|
-
});
|
|
1198
|
-
// Fail-closed: an empty intersection means the permission config is
|
|
1199
|
-
// misconfigured — do not silently fall back to the full preset.
|
|
1200
|
-
toolsForRouting = filtered;
|
|
1201
|
-
if (filtered.length === 0) {
|
|
1202
|
-
process.stderr.write(`[session] WARN: role permission intersection produced 0 tools — failing closed (role=${opts.role || 'unknown'})
|
|
1203
|
-
`);
|
|
1204
|
-
}
|
|
1205
|
-
}
|
|
1614
|
+
if (ownerIsAgent) {
|
|
1615
|
+
toolsForRouting = applyToolPermissionNarrowing(toolsForRouting, toolPermission, opts.role || null);
|
|
1206
1616
|
}
|
|
1207
1617
|
|
|
1208
|
-
const { baseRules,
|
|
1618
|
+
const { baseRules, stableSystemContext, sessionMarker, volatileTail } = composeSystemPrompt({
|
|
1209
1619
|
userPrompt: opts.systemPrompt,
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
roleTemplate: roleTemplate || undefined,
|
|
1215
|
-
hasSkills: skills.length > 0,
|
|
1620
|
+
agentRules: injectedRules || undefined,
|
|
1621
|
+
roleRules: roleRules || undefined,
|
|
1622
|
+
metaContext: metaContext || undefined,
|
|
1623
|
+
skipRoleCatalog: !ownerIsAgent,
|
|
1216
1624
|
profile: profile || undefined,
|
|
1217
1625
|
role: resolvedRole,
|
|
1218
|
-
skipRoleReminder: opts.skipRoleReminder || false,
|
|
1219
|
-
permission,
|
|
1220
|
-
taskBrief: opts.taskBrief || null,
|
|
1221
1626
|
workflowContext: opts.workflowContext || null,
|
|
1222
1627
|
workspaceContext: opts.workspaceContext || null,
|
|
1223
1628
|
coreMemoryContext: opts.coreMemoryContext || null,
|
|
1224
|
-
|
|
1629
|
+
skillManifest: buildSkillManifest(skills),
|
|
1225
1630
|
tools: toolsForRouting,
|
|
1226
|
-
bashIsPersistent:
|
|
1631
|
+
bashIsPersistent: ownerIsAgent && toolsForRouting.some(t => t?.name === 'shell'),
|
|
1227
1632
|
// Effective cwd rides in tier3Reminder so explore-like tools know
|
|
1228
1633
|
// their search root without needing to shove "Override cwd:" into
|
|
1229
1634
|
// the user message body (that used to fragment the shard prefix).
|
|
1230
1635
|
cwd: opts.cwd || null,
|
|
1231
|
-
// BP2 catalog policy — explicit-cache providers see the unified
|
|
1232
|
-
// all-roles catalog; implicit-prefix-hash providers keep self-only.
|
|
1233
1636
|
provider: providerName || null,
|
|
1234
1637
|
});
|
|
1235
1638
|
// 4-BP layout (see composeSystemPrompt docs):
|
|
1236
|
-
// system block #1 = baseRules
|
|
1237
|
-
// system block #2 =
|
|
1238
|
-
//
|
|
1239
|
-
//
|
|
1240
|
-
//
|
|
1241
|
-
//
|
|
1242
|
-
//
|
|
1243
|
-
//
|
|
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.
|
|
1244
1650
|
if (baseRules) {
|
|
1245
1651
|
messages.push({ role: 'system', content: baseRules });
|
|
1246
1652
|
}
|
|
1247
|
-
if (
|
|
1248
|
-
messages.push({ role: 'system', content:
|
|
1653
|
+
if (stableSystemContext) {
|
|
1654
|
+
messages.push({ role: 'system', content: stableSystemContext });
|
|
1249
1655
|
}
|
|
1250
1656
|
if (sessionMarker) {
|
|
1251
|
-
|
|
1252
|
-
|
|
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' });
|
|
1253
1662
|
}
|
|
1254
1663
|
if (volatileTail) {
|
|
1255
1664
|
messages.push({ role: 'user', content: `<system-reminder>\n${volatileTail}\n</system-reminder>` });
|
|
1256
1665
|
messages.push({ role: 'assistant', content: '.' });
|
|
1257
1666
|
}
|
|
1667
|
+
if (roleSpecific) {
|
|
1668
|
+
messages.push({ role: 'user', content: `<system-reminder>\n${roleSpecific}\n</system-reminder>` });
|
|
1669
|
+
messages.push({ role: 'assistant', content: '.' });
|
|
1670
|
+
}
|
|
1258
1671
|
if (opts.files?.length) {
|
|
1259
1672
|
const fileContext = opts.files
|
|
1260
1673
|
.map(f => `### ${f.path}\n\`\`\`\n${f.content}\n\`\`\``)
|
|
@@ -1262,58 +1675,30 @@ export function createSession(opts) {
|
|
|
1262
1675
|
messages.push({ role: 'user', content: `Reference files:\n\n${fileContext}` });
|
|
1263
1676
|
messages.push({ role: 'assistant', content: '.' });
|
|
1264
1677
|
}
|
|
1265
|
-
let tools = toolsForRouting;
|
|
1266
|
-
|
|
1267
|
-
// Schema filtering applied after schema build:
|
|
1268
|
-
// - opts.schemaAllowedTools : declarative hidden-role schema profile
|
|
1269
|
-
// allowlist for tiny specialist roles where one-shot tool routing
|
|
1270
|
-
// beats the shared-schema cache win.
|
|
1271
|
-
// - opts.disallowedTools : per-call caller override (Anthropic
|
|
1272
|
-
// BuiltInAgentDefinition pattern)
|
|
1273
|
-
// - annotations.bridgeHidden : declarative per-tool flag (tools.json
|
|
1274
|
-
// and internal tool defs). Pool A (Lead) still sees all tools.
|
|
1275
|
-
//
|
|
1276
1678
|
const hasCallerAllow = Array.isArray(opts.schemaAllowedTools);
|
|
1277
|
-
const
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
process.stderr.write(`[session] schemaAllowedTools=${callerAllow.join(',')} kept ${tools.length}/${before} tools\n`);
|
|
1284
|
-
}
|
|
1285
|
-
}
|
|
1286
|
-
const callerDeny = Array.isArray(opts.disallowedTools) ? opts.disallowedTools.map(n => String(n)) : [];
|
|
1287
|
-
if (callerDeny.length) {
|
|
1288
|
-
const denySet = new Set(callerDeny);
|
|
1289
|
-
const before = tools.length;
|
|
1290
|
-
tools = tools.filter(t => !denySet.has(String(t?.name || '').toLowerCase()));
|
|
1291
|
-
if (tools.length !== before && !process.env.MIXDOG_QUIET_SESSION_LOG) {
|
|
1292
|
-
process.stderr.write(`[session] disallowedTools=${callerDeny.join(',')} stripped ${before - tools.length} tools\n`);
|
|
1293
|
-
}
|
|
1294
|
-
}
|
|
1295
|
-
if (opts.owner === 'bridge') {
|
|
1296
|
-
const before = tools.length;
|
|
1297
|
-
tools = tools.filter(t => !t?.annotations?.bridgeHidden);
|
|
1298
|
-
if (tools.length !== before && !process.env.MIXDOG_QUIET_SESSION_LOG) {
|
|
1299
|
-
process.stderr.write(`[session] bridgeHidden stripped ${before - tools.length} tools\n`);
|
|
1300
|
-
}
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
// Bridge tool canonicalization: keep route-sensitive tools in policy order
|
|
1304
|
-
// while preserving deterministic MCP/skill order for BP1 shard stability.
|
|
1305
|
-
if (opts.owner === 'bridge') {
|
|
1306
|
-
tools = orderSessionTools(tools);
|
|
1307
|
-
}
|
|
1679
|
+
const tools = finalizeSessionToolList(toolsForRouting, {
|
|
1680
|
+
schemaAllowedTools: hasCallerAllow ? opts.schemaAllowedTools : null,
|
|
1681
|
+
disallowedTools: opts.disallowedTools,
|
|
1682
|
+
ownerIsAgent,
|
|
1683
|
+
resolvedRole,
|
|
1684
|
+
});
|
|
1308
1685
|
|
|
1309
1686
|
// Unified-shard policy — no broad role-specific schema filter. Keep
|
|
1310
|
-
//
|
|
1687
|
+
// agent schemas shared unless a hidden-role schema profile explicitly
|
|
1311
1688
|
// passes schemaAllowedTools for a small specialist; broad role
|
|
1312
1689
|
// whitelists would fragment the cache shard.
|
|
1313
|
-
if (resolvedRole &&
|
|
1690
|
+
if (resolvedRole && process.env.MIXDOG_DEBUG_SESSION_LOG) {
|
|
1314
1691
|
process.stderr.write(`[session] role=${resolvedRole} permission=${permission || 'full'} toolPermission=${toolPermission || 'full'} tools=${tools.length}\n`);
|
|
1315
1692
|
}
|
|
1316
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;
|
|
1317
1702
|
const session = {
|
|
1318
1703
|
id,
|
|
1319
1704
|
provider: providerName,
|
|
@@ -1328,13 +1713,27 @@ export function createSession(opts) {
|
|
|
1328
1713
|
auto: opts.compaction?.auto !== false,
|
|
1329
1714
|
prune: opts.compaction?.prune === true,
|
|
1330
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),
|
|
1331
1718
|
model: opts.compaction?.model || null,
|
|
1332
1719
|
timeoutMs: positiveContextWindow(opts.compaction?.timeoutMs),
|
|
1333
1720
|
tailTurns: positiveContextWindow(opts.compaction?.tailTurns),
|
|
1334
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),
|
|
1335
1727
|
keepTokens: positiveContextWindow(opts.compaction?.keepTokens ?? opts.compaction?.keep?.tokens),
|
|
1336
1728
|
preserveRecentTokens: positiveContextWindow(opts.compaction?.preserveRecentTokens),
|
|
1337
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),
|
|
1338
1737
|
boundaryTokens: contextMeta.compactBoundaryTokens,
|
|
1339
1738
|
},
|
|
1340
1739
|
tools,
|
|
@@ -1346,40 +1745,42 @@ export function createSession(opts) {
|
|
|
1346
1745
|
owner: opts.owner || 'user',
|
|
1347
1746
|
mcpPid: process.pid,
|
|
1348
1747
|
scopeKey: opts.scopeKey || null,
|
|
1349
|
-
lane: opts.lane || '
|
|
1748
|
+
lane: opts.lane || 'agent',
|
|
1350
1749
|
cwd: opts.cwd,
|
|
1750
|
+
workflow: workflowMeta,
|
|
1351
1751
|
createdAt: Date.now(),
|
|
1352
1752
|
updatedAt: Date.now(),
|
|
1353
1753
|
lastHeartbeatAt: null,
|
|
1354
1754
|
totalInputTokens: 0,
|
|
1355
1755
|
totalOutputTokens: 0,
|
|
1356
|
-
// Refreshed on each completed ask() — surfaced by
|
|
1756
|
+
// Refreshed on each completed ask() — surfaced by agent type=list for
|
|
1357
1757
|
// debugging + consumed by store.mjs's idle-sweep to reclaim stalled
|
|
1358
|
-
//
|
|
1758
|
+
// agent sessions past RUNNING_STALL_MS.
|
|
1359
1759
|
lastUsedAt: Date.now(),
|
|
1360
1760
|
tokensCumulative: 0,
|
|
1361
1761
|
role: opts.role || null,
|
|
1362
1762
|
taskType: opts.taskType || null,
|
|
1363
1763
|
maxLoopIterations: Number.isFinite(opts.maxLoopIterations) ? opts.maxLoopIterations : null,
|
|
1364
|
-
//
|
|
1764
|
+
// Agent tag (auto worker{n} on spawn) persisted so the forked status
|
|
1365
1765
|
// process (statusline) + aggregator can read it from the session JSON.
|
|
1366
1766
|
// In-process send/close still resolve via _tagSessionRegistry.
|
|
1367
|
-
|
|
1767
|
+
agentTag: opts.agentTag || null,
|
|
1368
1768
|
// Prompt permission is separate from runtime toolPermission so preset
|
|
1369
|
-
// restrictions do not fragment the
|
|
1769
|
+
// restrictions do not fragment the agent cache prefix.
|
|
1370
1770
|
permission: permission || null,
|
|
1371
1771
|
toolPermission: toolPermission || null,
|
|
1372
|
-
|
|
1772
|
+
schemaAllowedTools: hasCallerAllow ? opts.schemaAllowedTools.map((n) => String(n)) : null,
|
|
1773
|
+
// Origin tag written into every agent-trace usage row so analytics
|
|
1373
1774
|
// can slice by (sourceType, sourceName) — e.g. maintenance/cycle1,
|
|
1374
1775
|
// scheduler/daily-standup, webhook/github-push, lead/worker.
|
|
1375
1776
|
sourceType: opts.sourceType || null,
|
|
1376
1777
|
sourceName: opts.sourceName || null,
|
|
1377
1778
|
// Provider-scoped unified cache key — one shard per provider,
|
|
1378
|
-
// shared across all roles / sources (
|
|
1779
|
+
// shared across all roles / sources (agent/maintenance/mcp/
|
|
1379
1780
|
// scheduler/webhook). Role or source-specific context must be
|
|
1380
1781
|
// injected into the message tail, not the shared prefix.
|
|
1381
1782
|
promptCacheKey: providerCacheKey(presetObj?.provider || opts.provider, opts.cacheKeyOverride),
|
|
1382
|
-
//
|
|
1783
|
+
// Agent shell continuity: when an agent session explicitly opts into
|
|
1383
1784
|
// persistent shell state (`bash` with `persistent:true`, or direct
|
|
1384
1785
|
// `bash_session`), the minted bash_session id is stored here so later
|
|
1385
1786
|
// opted-in `bash` calls can reuse the same shell state.
|
|
@@ -1388,7 +1789,7 @@ export function createSession(opts) {
|
|
|
1388
1789
|
// orchestrator session so closeSession can kill them all, not just
|
|
1389
1790
|
// the most recently recorded one.
|
|
1390
1791
|
allBashSessionIds: [],
|
|
1391
|
-
//
|
|
1792
|
+
// Agent Runtime metadata — optional. Applied on every ask() to merge
|
|
1392
1793
|
// profile-driven cache settings into provider sendOpts.
|
|
1393
1794
|
profileId: profile?.id || null,
|
|
1394
1795
|
permissionMode: opts.permissionMode ?? null,
|
|
@@ -1404,7 +1805,7 @@ export function createSession(opts) {
|
|
|
1404
1805
|
}
|
|
1405
1806
|
|
|
1406
1807
|
// ── Runtime liveness map ──────────────────────────────────────────────
|
|
1407
|
-
// In-memory only. Tracks per-session stage + stream heartbeat so
|
|
1808
|
+
// In-memory only. Tracks per-session stage + stream heartbeat so agent type=list
|
|
1408
1809
|
// can surface whether a session is actually alive vs stuck. Never persisted —
|
|
1409
1810
|
// heartbeats would otherwise churn the session JSON on every SSE delta.
|
|
1410
1811
|
// Entry shape: {
|
|
@@ -1414,6 +1815,7 @@ export function createSession(opts) {
|
|
|
1414
1815
|
// closed?: boolean, // flipped by closeSession()
|
|
1415
1816
|
// }
|
|
1416
1817
|
const _runtimeState = new Map();
|
|
1818
|
+
const _toolActivityHeartbeats = new Map();
|
|
1417
1819
|
const VALID_STAGES = new Set([
|
|
1418
1820
|
'connecting', 'requesting', 'streaming', 'tool_running', 'idle', 'error', 'done', 'cancelling',
|
|
1419
1821
|
]);
|
|
@@ -1425,6 +1827,33 @@ function _touchRuntime(id) {
|
|
|
1425
1827
|
}
|
|
1426
1828
|
return entry;
|
|
1427
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
|
+
|
|
1428
1857
|
export function updateSessionStage(id, stage) {
|
|
1429
1858
|
if (!id || !VALID_STAGES.has(stage)) return;
|
|
1430
1859
|
const entry = _touchRuntime(id);
|
|
@@ -1435,6 +1864,7 @@ export function updateSessionStage(id, stage) {
|
|
|
1435
1864
|
}
|
|
1436
1865
|
entry.lastProgressAt = now;
|
|
1437
1866
|
entry.updatedAt = now;
|
|
1867
|
+
if (stage !== 'tool_running') _stopToolActivityHeartbeat(id);
|
|
1438
1868
|
}
|
|
1439
1869
|
|
|
1440
1870
|
export function updateSessionRoute(id, route = {}) {
|
|
@@ -1500,7 +1930,11 @@ export function updateSessionRoute(id, route = {}) {
|
|
|
1500
1930
|
*/
|
|
1501
1931
|
export function markSessionAskStart(id) {
|
|
1502
1932
|
if (!id) return;
|
|
1933
|
+
_stopToolActivityHeartbeat(id);
|
|
1503
1934
|
const entry = _touchRuntime(id);
|
|
1935
|
+
entry.usageMetricsTurnIncremental = false;
|
|
1936
|
+
const sessionForTurn = entry.session ?? loadSession(id);
|
|
1937
|
+
if (sessionForTurn) bumpUsageMetricsTurnId(sessionForTurn);
|
|
1504
1938
|
entry.stage = 'connecting';
|
|
1505
1939
|
entry.lastStreamDeltaAt = null;
|
|
1506
1940
|
entry.lastToolCall = null;
|
|
@@ -1524,7 +1958,7 @@ export function markSessionAskStart(id) {
|
|
|
1524
1958
|
// Publish heartbeat immediately so the status aggregator picks the
|
|
1525
1959
|
// session up in the connecting / requesting window. Without this the
|
|
1526
1960
|
// .hb file only landed on the first stream chunk — producing a 3–10s
|
|
1527
|
-
// (xhigh: 30s+) invisible gap where
|
|
1961
|
+
// (xhigh: 30s+) invisible gap where agent sessions ran but the CC
|
|
1528
1962
|
// statusline showed no maintenance/agent badge. STREAM_FRESH_MS (5 min)
|
|
1529
1963
|
// still drops a session whose provider truly never returns a chunk;
|
|
1530
1964
|
// markSessionStreamDelta keeps refreshing once chunks arrive.
|
|
@@ -1542,6 +1976,7 @@ export async function markSessionStreamDelta(id) {
|
|
|
1542
1976
|
// path). Skip a missing, tombstoned, or aborted entry — never refresh liveness.
|
|
1543
1977
|
const entry = _runtimeState.get(id);
|
|
1544
1978
|
if (!entry || entry.closed || entry.controller?.signal?.aborted) return;
|
|
1979
|
+
_stopToolActivityHeartbeat(id);
|
|
1545
1980
|
const now = Date.now();
|
|
1546
1981
|
entry.lastStreamDeltaAt = now;
|
|
1547
1982
|
entry.lastProgressAt = now;
|
|
@@ -1571,9 +2006,14 @@ export function markSessionToolCall(id, toolName) {
|
|
|
1571
2006
|
entry.lastProgressAt = entry.toolStartedAt;
|
|
1572
2007
|
entry.updatedAt = entry.toolStartedAt;
|
|
1573
2008
|
publishHeartbeat(id, entry.toolStartedAt);
|
|
2009
|
+
_startToolActivityHeartbeat(id);
|
|
1574
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.
|
|
1575
2014
|
export function markSessionDone(id, { empty = false } = {}) {
|
|
1576
2015
|
if (!id) return;
|
|
2016
|
+
_stopToolActivityHeartbeat(id);
|
|
1577
2017
|
const entry = _touchRuntime(id);
|
|
1578
2018
|
entry.stage = 'done';
|
|
1579
2019
|
entry.lastError = null;
|
|
@@ -1608,6 +2048,7 @@ export function markSessionEmptyFinal(id) {
|
|
|
1608
2048
|
}
|
|
1609
2049
|
export function markSessionError(id, msg) {
|
|
1610
2050
|
if (!id) return;
|
|
2051
|
+
_stopToolActivityHeartbeat(id);
|
|
1611
2052
|
const entry = _touchRuntime(id);
|
|
1612
2053
|
entry.stage = 'error';
|
|
1613
2054
|
entry.lastError = msg ? String(msg).slice(0, 200) : null;
|
|
@@ -1622,9 +2063,11 @@ export function markSessionError(id, msg) {
|
|
|
1622
2063
|
entry.lastProgressAt = errTs;
|
|
1623
2064
|
entry.updatedAt = errTs;
|
|
1624
2065
|
deleteHeartbeat(id);
|
|
2066
|
+
_unlinkParentAbortListener(entry);
|
|
1625
2067
|
}
|
|
1626
2068
|
export function markSessionCancelled(id) {
|
|
1627
2069
|
if (!id) return;
|
|
2070
|
+
_stopToolActivityHeartbeat(id);
|
|
1628
2071
|
const entry = _touchRuntime(id);
|
|
1629
2072
|
entry.stage = 'done';
|
|
1630
2073
|
entry.lastError = null;
|
|
@@ -1637,11 +2080,24 @@ export function markSessionCancelled(id) {
|
|
|
1637
2080
|
entry.lastProgressAt = doneTs;
|
|
1638
2081
|
entry.updatedAt = doneTs;
|
|
1639
2082
|
deleteHeartbeat(id);
|
|
2083
|
+
_unlinkParentAbortListener(entry);
|
|
1640
2084
|
}
|
|
1641
2085
|
export function getSessionRuntime(id) {
|
|
1642
2086
|
return id ? (_runtimeState.get(id) || null) : null;
|
|
1643
2087
|
}
|
|
1644
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
|
+
|
|
1645
2101
|
export function getSessionProgressSnapshot(sessionId) {
|
|
1646
2102
|
const entry = _runtimeState.get(sessionId);
|
|
1647
2103
|
if (!entry) return null;
|
|
@@ -1681,29 +2137,126 @@ export function forEachSessionRuntime() {
|
|
|
1681
2137
|
}
|
|
1682
2138
|
|
|
1683
2139
|
// --- Incremental metric persistence (fix A) ---
|
|
1684
|
-
// Per-session idempotency tracking: sessionId → Set of seen
|
|
2140
|
+
// Per-session idempotency tracking: sessionId → Set of seen turn:epoch:iteration:source keys.
|
|
1685
2141
|
const _metricSeenIter = new Map();
|
|
1686
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
|
+
|
|
1687
2235
|
/**
|
|
1688
2236
|
* Persist incremental usage delta immediately after each provider.send iteration.
|
|
1689
|
-
* Idempotency key `sessionId:iterationIndex`
|
|
1690
|
-
*
|
|
2237
|
+
* Idempotency key `sessionId:turnId:epoch:iterationIndex:source` scopes retries
|
|
2238
|
+
* per ask, compaction epoch, iteration, and usage source.
|
|
1691
2239
|
*/
|
|
1692
2240
|
export async function persistIterationMetrics(delta) {
|
|
1693
2241
|
if (!delta || !delta.sessionId) return;
|
|
1694
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
|
+
}
|
|
1695
2250
|
let seen = _metricSeenIter.get(sessionId);
|
|
1696
2251
|
if (!seen) {
|
|
1697
2252
|
seen = new Set();
|
|
1698
2253
|
_metricSeenIter.set(sessionId, seen);
|
|
1699
2254
|
}
|
|
1700
|
-
const ikey =
|
|
2255
|
+
const ikey = usageMetricsIdempotencyKey(sessionId, session, delta);
|
|
1701
2256
|
const isReplay = seen.has(ikey);
|
|
1702
2257
|
seen.add(ikey);
|
|
1703
|
-
const runtimeEntry = _runtimeState.get(sessionId);
|
|
1704
|
-
const session = runtimeEntry?.session ?? loadSession(sessionId);
|
|
1705
|
-
if (!session || session.closed) return;
|
|
1706
2258
|
if (!isReplay) {
|
|
2259
|
+
if (runtimeEntry) runtimeEntry.usageMetricsTurnIncremental = true;
|
|
1707
2260
|
session.totalInputTokens = (session.totalInputTokens || 0) + (deltaInput || 0);
|
|
1708
2261
|
session.totalOutputTokens = (session.totalOutputTokens || 0) + (deltaOutput || 0);
|
|
1709
2262
|
session.tokensCumulative = (session.tokensCumulative || 0) + (deltaInput || 0) + (deltaOutput || 0);
|
|
@@ -1713,7 +2266,7 @@ export async function persistIterationMetrics(delta) {
|
|
|
1713
2266
|
// includes cached_read / cache_write in its terminal usage rollup).
|
|
1714
2267
|
session.totalCachedReadTokens = (session.totalCachedReadTokens || 0) + (deltaCachedRead || 0);
|
|
1715
2268
|
session.totalCacheWriteTokens = (session.totalCacheWriteTokens || 0) + (deltaCacheWrite || 0);
|
|
1716
|
-
// Window snapshot updated per iteration so
|
|
2269
|
+
// Window snapshot updated per iteration so agent type=list reflects the
|
|
1717
2270
|
// most-recent provider-reported input size even for short dispatches
|
|
1718
2271
|
// that finish before askSession's terminal save lands.
|
|
1719
2272
|
session.lastInputTokens = deltaInput || 0;
|
|
@@ -1739,6 +2292,14 @@ export async function persistIterationMetrics(delta) {
|
|
|
1739
2292
|
|
|
1740
2293
|
function standaloneStatusRouteInfo(session) {
|
|
1741
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;
|
|
1742
2303
|
return {
|
|
1743
2304
|
provider: session.provider,
|
|
1744
2305
|
model: session.model,
|
|
@@ -1748,7 +2309,7 @@ function standaloneStatusRouteInfo(session) {
|
|
|
1748
2309
|
contextWindow: session.contextWindow || null,
|
|
1749
2310
|
rawContextWindow: session.rawContextWindow || session.contextWindow || null,
|
|
1750
2311
|
effectiveContextWindowPercent: session.effectiveContextWindowPercent || null,
|
|
1751
|
-
autoCompactTokenLimit:
|
|
2312
|
+
autoCompactTokenLimit: explicitAutoCompactTokenLimit,
|
|
1752
2313
|
presetId: session.presetId || null,
|
|
1753
2314
|
presetName: session.presetName || null,
|
|
1754
2315
|
};
|
|
@@ -1763,9 +2324,17 @@ function recordStandaloneStatusTelemetry(session, result, durationMs) {
|
|
|
1763
2324
|
model: result.model,
|
|
1764
2325
|
serviceTier: result.serviceTier,
|
|
1765
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 };
|
|
1766
2335
|
try {
|
|
1767
2336
|
const summary = {
|
|
1768
|
-
...summarizeGatewayUsage(routeInfo, providerOut,
|
|
2337
|
+
...summarizeGatewayUsage(routeInfo, providerOut, _compactArg, durationMs),
|
|
1769
2338
|
requestKind: 'chat',
|
|
1770
2339
|
sessionId: session.id || null,
|
|
1771
2340
|
toolCount: result.toolCallsTotal ?? null,
|
|
@@ -1828,6 +2397,7 @@ export function getSessionLastProgressAt(sessionId) {
|
|
|
1828
2397
|
const entry = _runtimeState.get(sessionId);
|
|
1829
2398
|
if (!entry) return 0;
|
|
1830
2399
|
return Math.max(
|
|
2400
|
+
entry.lastProgressAt || 0,
|
|
1831
2401
|
entry.lastStreamDeltaAt || 0,
|
|
1832
2402
|
entry.toolStartedAt || 0,
|
|
1833
2403
|
entry.askStartedAt || 0,
|
|
@@ -1836,8 +2406,8 @@ export function getSessionLastProgressAt(sessionId) {
|
|
|
1836
2406
|
|
|
1837
2407
|
/**
|
|
1838
2408
|
* Link a parent AbortSignal to a sub-session's controller so that aborting
|
|
1839
|
-
* the parent (fan-out deadline or caller ESC) tears down the
|
|
1840
|
-
* 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
|
|
1841
2411
|
* askSession completes. No-op if the session runtime isn't found.
|
|
1842
2412
|
*
|
|
1843
2413
|
* @param {string} sessionId — the sub-session to abort
|
|
@@ -1854,15 +2424,27 @@ export function linkParentSignalToSession(sessionId, parentSignal) {
|
|
|
1854
2424
|
return new Error('parent signal aborted');
|
|
1855
2425
|
};
|
|
1856
2426
|
if (parentSignal.aborted) {
|
|
2427
|
+
_unlinkParentAbortListener(entry);
|
|
1857
2428
|
try { entry.controller.abort(abortReason()); } catch { /* ignore */ }
|
|
1858
2429
|
return;
|
|
1859
2430
|
}
|
|
1860
|
-
|
|
2431
|
+
_unlinkParentAbortListener(entry);
|
|
2432
|
+
const onParentAbort = () => {
|
|
1861
2433
|
try { entry.controller?.abort(abortReason()); } catch { /* ignore */ }
|
|
1862
|
-
}
|
|
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;
|
|
1863
2443
|
}
|
|
1864
2444
|
function _clearSessionRuntime(id) {
|
|
1865
2445
|
if (id) {
|
|
2446
|
+
_stopToolActivityHeartbeat(id);
|
|
2447
|
+
_unlinkParentAbortListener(_runtimeState.get(id));
|
|
1866
2448
|
_runtimeState.delete(id);
|
|
1867
2449
|
// R15: also drop the per-session metric-idempotency Set; otherwise it
|
|
1868
2450
|
// grows O(sessions x iterations) for the whole server lifetime since
|
|
@@ -1914,8 +2496,8 @@ export async function _api_call_with_interrupt(sessionId, fn) {
|
|
|
1914
2496
|
|
|
1915
2497
|
// Per-session mutex: queues concurrent askSession calls to prevent message loss
|
|
1916
2498
|
const _sessionLocks = new Map();
|
|
1917
|
-
// Per-session pending-message queue (
|
|
1918
|
-
// 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
|
|
1919
2501
|
// message here instead of rejecting; askSession drains the queue after each
|
|
1920
2502
|
// turn and runs the messages as the next user turn(s), preserving order — the
|
|
1921
2503
|
// queued send runs AFTER the in-flight prompt, which also closes the spawn
|
|
@@ -1928,11 +2510,16 @@ const _sessionLocks = new Map();
|
|
|
1928
2510
|
// Keeping the queue outside the session JSON avoids racing session saves that
|
|
1929
2511
|
// loaded before the send arrived.
|
|
1930
2512
|
//
|
|
1931
|
-
// Map<sessionId, string
|
|
1932
|
-
// 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.
|
|
1933
2518
|
const _sessionPendingMessages = new Map();
|
|
1934
2519
|
const PENDING_MESSAGES_FILE = 'session-pending-messages.json';
|
|
1935
2520
|
const PENDING_MESSAGES_MODE = 0o600;
|
|
2521
|
+
const _pendingPersistBuffers = new Map();
|
|
2522
|
+
let _pendingPersistImmediate = null;
|
|
1936
2523
|
|
|
1937
2524
|
function pendingMessagesPath() {
|
|
1938
2525
|
return join(resolvePluginData(), PENDING_MESSAGES_FILE);
|
|
@@ -1961,14 +2548,53 @@ function normalizePendingStore(raw) {
|
|
|
1961
2548
|
return out;
|
|
1962
2549
|
}
|
|
1963
2550
|
|
|
1964
|
-
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) {
|
|
1965
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;
|
|
1966
2592
|
let depth = 0;
|
|
1967
2593
|
try {
|
|
1968
2594
|
updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
|
|
1969
2595
|
const next = normalizePendingStore(raw);
|
|
1970
2596
|
const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
|
|
1971
|
-
q.push(
|
|
2597
|
+
q.push(...persistedMessages);
|
|
1972
2598
|
next.sessions[sessionId] = q;
|
|
1973
2599
|
next.updatedAt = Date.now();
|
|
1974
2600
|
depth = q.length;
|
|
@@ -1980,6 +2606,43 @@ function persistPendingMessage(sessionId, message) {
|
|
|
1980
2606
|
return depth;
|
|
1981
2607
|
}
|
|
1982
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
|
+
|
|
1983
2646
|
function drainPersistedPendingMessages(sessionId) {
|
|
1984
2647
|
if (!isValidPendingSessionId(sessionId)) return [];
|
|
1985
2648
|
let drained = [];
|
|
@@ -2000,25 +2663,33 @@ function drainPersistedPendingMessages(sessionId) {
|
|
|
2000
2663
|
}
|
|
2001
2664
|
|
|
2002
2665
|
export function enqueuePendingMessage(sessionId, message) {
|
|
2003
|
-
|
|
2666
|
+
const entry = pendingMessageQueueEntry(message);
|
|
2667
|
+
if (!sessionId || !entry) return 0;
|
|
2004
2668
|
let q = _sessionPendingMessages.get(sessionId);
|
|
2005
2669
|
if (!q) { q = []; _sessionPendingMessages.set(sessionId, q); }
|
|
2006
|
-
q.push(
|
|
2007
|
-
const
|
|
2008
|
-
return Math.max(q.length,
|
|
2670
|
+
q.push(entry);
|
|
2671
|
+
const bufferedDepth = schedulePendingMessagePersist(sessionId, entry);
|
|
2672
|
+
return Math.max(q.length, bufferedDepth || 0);
|
|
2009
2673
|
}
|
|
2010
2674
|
export function drainPendingMessages(sessionId) {
|
|
2011
2675
|
const q = _sessionPendingMessages.get(sessionId);
|
|
2012
2676
|
const memory = q && q.length > 0 ? q.slice() : [];
|
|
2013
2677
|
_sessionPendingMessages.delete(sessionId);
|
|
2014
|
-
const persisted = drainPersistedPendingMessages(sessionId);
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
if (
|
|
2019
|
-
const
|
|
2020
|
-
|
|
2021
|
-
|
|
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);
|
|
2022
2693
|
}
|
|
2023
2694
|
return out;
|
|
2024
2695
|
}
|
|
@@ -2036,6 +2707,10 @@ function promptContentText(content) {
|
|
|
2036
2707
|
return String(content ?? '');
|
|
2037
2708
|
}
|
|
2038
2709
|
|
|
2710
|
+
function hasModelVisiblePromptContent(prompt) {
|
|
2711
|
+
return !!promptContentText(prompt).trim();
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2039
2714
|
function promptContentBytes(content) {
|
|
2040
2715
|
try {
|
|
2041
2716
|
if (typeof content === 'string') return Buffer.byteLength(content, 'utf8');
|
|
@@ -2052,6 +2727,190 @@ function prefixUserTurnContent(content, contextBlock) {
|
|
|
2052
2727
|
}
|
|
2053
2728
|
return `${contextBlock}# Task\n${content}`;
|
|
2054
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
|
+
|
|
2055
2914
|
function acquireSessionLock(sessionId) {
|
|
2056
2915
|
let entry = _sessionLocks.get(sessionId);
|
|
2057
2916
|
if (!entry) {
|
|
@@ -2071,19 +2930,114 @@ function acquireSessionLock(sessionId) {
|
|
|
2071
2930
|
});
|
|
2072
2931
|
}
|
|
2073
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
|
+
|
|
2074
3028
|
export async function askSession(sessionId, prompt, context, onToolCall, cwdOverride, explicitPrefetch, askOpts = {}) {
|
|
2075
3029
|
const _askStartedAt = Date.now();
|
|
2076
3030
|
const _promptSrc = 'prompt';
|
|
2077
3031
|
const _prefetchFiles = (explicitPrefetch?.files?.length) || 0;
|
|
2078
3032
|
const _prefetchCallers = (explicitPrefetch?.callers?.length) || 0;
|
|
2079
3033
|
const _prefetchRefs = (explicitPrefetch?.references?.length) || 0;
|
|
2080
|
-
if (process.env.
|
|
2081
|
-
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`);
|
|
2082
3036
|
}
|
|
2083
3037
|
const unlock = await acquireSessionLock(sessionId);
|
|
2084
3038
|
const _lockWaitedMs = Date.now() - _askStartedAt;
|
|
2085
|
-
if (process.env.
|
|
2086
|
-
process.stderr.write(`[
|
|
3039
|
+
if (process.env.MIXDOG_DEBUG_AGENT) {
|
|
3040
|
+
process.stderr.write(`[agent-trace] lock-acquired waitedMs=${_lockWaitedMs}\n`);
|
|
2087
3041
|
}
|
|
2088
3042
|
// The mutex is held for the WHOLE askSession call, including any follow-up
|
|
2089
3043
|
// turns drained from the pending-message queue below — the single outer
|
|
@@ -2092,18 +3046,19 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2092
3046
|
// result, mirroring how a live chat returns the latest turn).
|
|
2093
3047
|
let _result;
|
|
2094
3048
|
// Local FIFO of follow-up prompts drained from the pending-message queue
|
|
2095
|
-
// after each turn — keeps queued `
|
|
3049
|
+
// after each turn — keeps queued `agent type=send` messages in order.
|
|
2096
3050
|
const _pendingTail = [];
|
|
2097
3051
|
// Hoisted so the outer finally (which runs once after the whole turn loop)
|
|
2098
3052
|
// can compare against the last turn's generation.
|
|
2099
3053
|
let askGeneration = 0;
|
|
2100
3054
|
try {
|
|
2101
3055
|
// Turn loop (pendingMessages pattern): run the current prompt, then drain
|
|
2102
|
-
// any `
|
|
3056
|
+
// any `agent type=send` messages that were queued while this turn was in
|
|
2103
3057
|
// flight and run them — in order — as the next user turn(s). Because the
|
|
2104
3058
|
// queued send always lands AFTER the in-flight prompt here, ordering is
|
|
2105
3059
|
// preserved and the spawn/connecting startup race disappears.
|
|
2106
3060
|
for (;;) {
|
|
3061
|
+
let _pwstTurnDrained = null;
|
|
2107
3062
|
// After the first turn, the next prompt comes from the drained queue.
|
|
2108
3063
|
// (On the first iteration _pendingTail is empty and `prompt` is the
|
|
2109
3064
|
// caller's original message.)
|
|
@@ -2113,6 +3068,23 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2113
3068
|
// prefetch is re-applied (those belonged to the original ask).
|
|
2114
3069
|
context = null;
|
|
2115
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;
|
|
2116
3088
|
}
|
|
2117
3089
|
// ── Synchronous pre-await setup (must happen before any await so
|
|
2118
3090
|
// closeSession() can't interleave between load and registration) ──
|
|
@@ -2123,6 +3095,11 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2123
3095
|
if (preSession.closed === true) {
|
|
2124
3096
|
throw new SessionClosedError(sessionId, 'session already closed');
|
|
2125
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);
|
|
2126
3103
|
askGeneration = typeof preSession.generation === 'number' ? preSession.generation : 0;
|
|
2127
3104
|
const runtime = _touchRuntime(sessionId);
|
|
2128
3105
|
// Fresh controller per ask — the previous ask's controller may have aborted.
|
|
@@ -2136,6 +3113,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2136
3113
|
// leaving stage='connecting' forever.
|
|
2137
3114
|
let activeSession = preSession;
|
|
2138
3115
|
let cancelledUserTurnContent = '';
|
|
3116
|
+
let _turnOutgoing = null;
|
|
2139
3117
|
try {
|
|
2140
3118
|
const session = activeSession;
|
|
2141
3119
|
const provider = getProvider(session.provider);
|
|
@@ -2154,6 +3132,8 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2154
3132
|
...(session.compaction || {}),
|
|
2155
3133
|
auto: session.compaction?.auto !== false,
|
|
2156
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),
|
|
2157
3137
|
boundaryTokens: contextMeta.compactBoundaryTokens,
|
|
2158
3138
|
bufferTokens: positiveContextWindow(session.compaction?.bufferTokens ?? session.compaction?.buffer) || session.compaction?.bufferTokens || null,
|
|
2159
3139
|
keepTokens: positiveContextWindow(session.compaction?.keepTokens ?? session.compaction?.keep?.tokens) || session.compaction?.keepTokens || null,
|
|
@@ -2190,7 +3170,8 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2190
3170
|
if (explicitPrefetchResult) {
|
|
2191
3171
|
_contextBlock += `# Prefetch\n${_capCtx(explicitPrefetchResult)}\n\n`;
|
|
2192
3172
|
}
|
|
2193
|
-
const
|
|
3173
|
+
const historyMessages = sanitizeSessionMessagesForModel(session.messages);
|
|
3174
|
+
const beforeCount = historyMessages.length + 1;
|
|
2194
3175
|
const promptTextForMetrics = promptContentText(prompt);
|
|
2195
3176
|
// Soft warning only; real size management (compaction primary,
|
|
2196
3177
|
// byte-budget trim as safety net) lives in agentLoop. Selecting a
|
|
@@ -2201,21 +3182,43 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2201
3182
|
process.stderr.write(`[session] Warning: prompt is very large (est. ${Math.round(promptTokenEstimate)} tokens vs ${softBudget} soft budget)\n`);
|
|
2202
3183
|
}
|
|
2203
3184
|
const effectiveCwd = cwdOverride || session.cwd;
|
|
2204
|
-
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
|
+
}
|
|
2205
3200
|
cancelledUserTurnContent = _userTurnContent;
|
|
2206
|
-
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;
|
|
2207
3210
|
// Per-turn injected-context trace row (complements kind:"usage").
|
|
2208
3211
|
// Cheap byte-length accounting — no hashing, no payload bodies.
|
|
2209
|
-
// Honors the same
|
|
2210
|
-
//
|
|
3212
|
+
// Honors the same MIXDOG_AGENT_TRACE_DISABLE gate as usage rows;
|
|
3213
|
+
// appendAgentTrace is a no-op when that env is set.
|
|
2211
3214
|
try {
|
|
2212
3215
|
const _ctxBytes = Buffer.byteLength(context || '', 'utf8');
|
|
2213
3216
|
const _prefetchBytes = Buffer.byteLength(explicitPrefetchResult || '', 'utf8');
|
|
2214
3217
|
const _promptBytes = promptContentBytes(prompt);
|
|
2215
3218
|
const _userTurnBytes = promptContentBytes(_userTurnContent);
|
|
2216
|
-
const _messagesBytes = Buffer.byteLength(JSON.stringify(
|
|
3219
|
+
const _messagesBytes = Buffer.byteLength(JSON.stringify(historyMessages || []), 'utf8');
|
|
2217
3220
|
const _totalBytes = _userTurnBytes + _messagesBytes;
|
|
2218
|
-
|
|
3221
|
+
appendAgentTrace({
|
|
2219
3222
|
kind: 'context',
|
|
2220
3223
|
sessionId,
|
|
2221
3224
|
model: session.model,
|
|
@@ -2227,26 +3230,36 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2227
3230
|
promptBytes: _promptBytes,
|
|
2228
3231
|
userTurnBytes: _userTurnBytes,
|
|
2229
3232
|
messagesBytes: _messagesBytes,
|
|
2230
|
-
messagesCount:
|
|
3233
|
+
messagesCount: historyMessages.length,
|
|
2231
3234
|
},
|
|
2232
3235
|
});
|
|
2233
3236
|
} catch { /* trace must never break the ask path */ }
|
|
2234
|
-
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) =>
|
|
2235
3245
|
agentLoop(provider, outgoing, session.model, session.tools, onToolCall, effectiveCwd, {
|
|
2236
3246
|
effort: session.effort || null,
|
|
2237
3247
|
fast: session.fast === true,
|
|
2238
3248
|
sessionId,
|
|
2239
3249
|
onTextDelta: typeof askOpts?.onTextDelta === 'function' ? askOpts.onTextDelta : undefined,
|
|
2240
3250
|
onReasoningDelta: typeof askOpts?.onReasoningDelta === 'function' ? askOpts.onReasoningDelta : undefined,
|
|
3251
|
+
onAssistantText: typeof askOpts?.onAssistantText === 'function' ? askOpts.onAssistantText : undefined,
|
|
2241
3252
|
onUsageDelta: (d) => {
|
|
2242
3253
|
persistIterationMetrics(d).catch(() => {});
|
|
2243
3254
|
try { askOpts?.onUsageDelta?.(d); } catch {}
|
|
2244
3255
|
},
|
|
2245
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,
|
|
2246
3259
|
// Mid-turn steering drain. agentLoop calls this at every
|
|
2247
3260
|
// tool-batch boundary (before the next provider.send) and
|
|
2248
3261
|
// injects any returned strings as user turns — so input
|
|
2249
|
-
// (user typing, `
|
|
3262
|
+
// (user typing, `agent type=send`) that arrives WHILE a
|
|
2250
3263
|
// long multi-tool turn is in flight is picked up on the
|
|
2251
3264
|
// model's very next iteration instead of waiting for the
|
|
2252
3265
|
// whole task to finish. The post-turn _pendingTail drain
|
|
@@ -2265,6 +3278,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2265
3278
|
return out;
|
|
2266
3279
|
},
|
|
2267
3280
|
onSteerMessage: typeof askOpts?.onSteerMessage === 'function' ? askOpts.onSteerMessage : undefined,
|
|
3281
|
+
notifyFn: typeof askOpts?.notifyFn === 'function' ? askOpts.notifyFn : undefined,
|
|
2268
3282
|
promptCacheKey: session.promptCacheKey || sessionId,
|
|
2269
3283
|
// Provider-scoped cache key (mixdog-codex, mixdog-claude…).
|
|
2270
3284
|
// Distinct from sessionId — providers that pool sockets
|
|
@@ -2276,7 +3290,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2276
3290
|
signal,
|
|
2277
3291
|
providerState: session.providerState ?? undefined,
|
|
2278
3292
|
session,
|
|
2279
|
-
//
|
|
3293
|
+
// Agent Runtime cache settings — merged last so session overrides
|
|
2280
3294
|
// don't get overridden by defaults. When session has no profile,
|
|
2281
3295
|
// providerCacheOpts is null and this spread is a no-op.
|
|
2282
3296
|
...(session.providerCacheOpts || {}),
|
|
@@ -2290,6 +3304,13 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2290
3304
|
},
|
|
2291
3305
|
}),
|
|
2292
3306
|
);
|
|
3307
|
+
} finally {
|
|
3308
|
+
if (priorToolApprovalHook === undefined) {
|
|
3309
|
+
delete session.toolApprovalHook;
|
|
3310
|
+
} else {
|
|
3311
|
+
session.toolApprovalHook = priorToolApprovalHook;
|
|
3312
|
+
}
|
|
3313
|
+
}
|
|
2293
3314
|
// Post-loop validation: if closeSession() landed while we were awaiting,
|
|
2294
3315
|
// drop the save so the tombstone on disk isn't overwritten.
|
|
2295
3316
|
const currentRuntime = _runtimeState.get(sessionId);
|
|
@@ -2300,10 +3321,16 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2300
3321
|
// Update and save. outgoing is mutated in place by agentLoop
|
|
2301
3322
|
// (compaction + safety trim), so its length reflects post-loop state.
|
|
2302
3323
|
const messagesDropped = Math.max(0, beforeCount - outgoing.length);
|
|
2303
|
-
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;
|
|
2304
3328
|
if (result.content || result.reasoningContent) {
|
|
2305
3329
|
session.messages.push({
|
|
2306
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).
|
|
2307
3334
|
content: result.content || '',
|
|
2308
3335
|
...(typeof result.reasoningContent === 'string' && result.reasoningContent
|
|
2309
3336
|
? { reasoningContent: result.reasoningContent }
|
|
@@ -2353,46 +3380,17 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2353
3380
|
}
|
|
2354
3381
|
session.updatedAt = Date.now();
|
|
2355
3382
|
session.lastUsedAt = Date.now();
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
// Cache totals — same `||0` undefined-safe accumulation pattern as
|
|
2363
|
-
// persistIterationMetrics so live + terminal paths stay in lock-step
|
|
2364
|
-
// and legacy sessions migrate lazily on first iteration.
|
|
2365
|
-
session.totalCachedReadTokens = (session.totalCachedReadTokens || 0) + (result.usage.cachedTokens || 0);
|
|
2366
|
-
session.totalCacheWriteTokens = (session.totalCacheWriteTokens || 0) + (result.usage.cacheWriteTokens || 0);
|
|
2367
|
-
// Window snapshot = the current context size, which is the LAST
|
|
2368
|
-
// single call — NOT result.usage (that is lastUsage, the per-turn
|
|
2369
|
-
// SUM accumulated with += across iterations in agentLoop). Use
|
|
2370
|
-
// lastTurnUsage (the final iteration's raw usage) so this reflects
|
|
2371
|
-
// "what's in the window now" rather than the lifetime sum.
|
|
2372
|
-
const _lastTurn = result.lastTurnUsage || result.usage || {};
|
|
2373
|
-
session.lastInputTokens = _lastTurn.inputTokens || 0;
|
|
2374
|
-
session.lastOutputTokens = _lastTurn.outputTokens || 0;
|
|
2375
|
-
session.lastCachedReadTokens = _lastTurn.cachedTokens || 0;
|
|
2376
|
-
session.lastCacheWriteTokens = _lastTurn.cacheWriteTokens || 0;
|
|
2377
|
-
// Provider-normalized footprint, identical formula to
|
|
2378
|
-
// persistIterationMetrics so both writers agree: Anthropic
|
|
2379
|
-
// input_tokens excludes cache (add it back), openai/grok/gemini
|
|
2380
|
-
// already include it.
|
|
2381
|
-
const _inputExcludesCache = providerInputExcludesCache(session.provider);
|
|
2382
|
-
session.lastContextTokens = _inputExcludesCache
|
|
2383
|
-
? (_lastTurn.inputTokens || 0) + (_lastTurn.cachedTokens || 0)
|
|
2384
|
-
: (_lastTurn.inputTokens || 0);
|
|
2385
|
-
session.lastContextTokensUpdatedAt = Date.now();
|
|
2386
|
-
session.lastContextTokensStaleAfterCompact = false;
|
|
2387
|
-
}
|
|
2388
|
-
// Smart Bridge cache stats — record hit/miss after every successful
|
|
2389
|
-
// ask so the registry reflects all bridge traffic, not just
|
|
2390
|
-
// 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
|
|
2391
3389
|
// metric recording never breaks the ask itself.
|
|
2392
3390
|
let prefixHashForLog = null;
|
|
2393
|
-
if (session.profileId && result.usage &&
|
|
3391
|
+
if (session.profileId && result.usage && _agentRuntimeApi) {
|
|
2394
3392
|
try {
|
|
2395
|
-
const profile =
|
|
3393
|
+
const profile = _agentRuntimeApi.getProfile(session.profileId);
|
|
2396
3394
|
if (profile) {
|
|
2397
3395
|
// Collect every leading system-role message (BP1, BP2, ...)
|
|
2398
3396
|
// until the first non-system message so the registry hash
|
|
@@ -2402,24 +3400,24 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2402
3400
|
if (m?.role !== 'system') break;
|
|
2403
3401
|
systemMsgs.push(typeof m.content === 'string' ? m.content : '');
|
|
2404
3402
|
}
|
|
2405
|
-
|
|
3403
|
+
_agentRuntimeApi.recordCall(profile, session.provider, {
|
|
2406
3404
|
systemPrompt: systemMsgs,
|
|
2407
3405
|
tools: session.tools || [],
|
|
2408
3406
|
usage: result.usage,
|
|
2409
3407
|
});
|
|
2410
|
-
const entry =
|
|
3408
|
+
const entry = _agentRuntimeApi.registry?.data?.profiles?.[session.profileId]?.[session.provider];
|
|
2411
3409
|
prefixHashForLog = entry?.prefixHash || null;
|
|
2412
3410
|
}
|
|
2413
3411
|
} catch {}
|
|
2414
3412
|
}
|
|
2415
|
-
// Append to
|
|
3413
|
+
// Append to the agent trace store with rich usage fields.
|
|
2416
3414
|
if (result.usage) {
|
|
2417
3415
|
const inputTokens = result.usage.inputTokens || 0;
|
|
2418
3416
|
const outputTokens = result.usage.outputTokens || 0;
|
|
2419
3417
|
const cacheReadTokens = result.usage.cachedTokens || 0;
|
|
2420
3418
|
const cacheWriteTokens = result.usage.cacheWriteTokens || 0;
|
|
2421
3419
|
// Unified total-prompt field. Anthropic = input+cache_read+cache_write
|
|
2422
|
-
// (additive); OpenAI/
|
|
3420
|
+
// (additive); OpenAI OAuth/API/Gemini = input_tokens already includes the
|
|
2423
3421
|
// cached portion (inclusive), so the fallback must not double-count.
|
|
2424
3422
|
const { isInclusiveProvider, computeCostUsd } = await import('../../../shared/llm/cost.mjs');
|
|
2425
3423
|
const inclusive = isInclusiveProvider(session.provider);
|
|
@@ -2459,29 +3457,61 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2459
3457
|
recordStandaloneStatusTelemetry(session, result, Date.now() - _askStartedAt);
|
|
2460
3458
|
}
|
|
2461
3459
|
// Persist opaque providerState for future stateful providers.
|
|
2462
|
-
// No provider currently emits it (
|
|
3460
|
+
// No provider currently emits it (openai-oauth is stateless per
|
|
2463
3461
|
// contract), so this branch is dormant — kept so a future
|
|
2464
3462
|
// Responses-API provider with stable continuation can plug in
|
|
2465
3463
|
// without reworking the session shape.
|
|
2466
3464
|
if (result.providerState !== undefined) {
|
|
2467
3465
|
session.providerState = result.providerState;
|
|
2468
3466
|
}
|
|
2469
|
-
const
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
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') {
|
|
2476
3474
|
try {
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
);
|
|
2482
|
-
} catch { /* best-effort */ }
|
|
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
|
+
});
|
|
3499
|
+
try {
|
|
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
|
+
}
|
|
2483
3514
|
}
|
|
2484
|
-
await saveSessionAsync(session, { expectedGeneration: askGeneration });
|
|
2485
3515
|
activeSession = session;
|
|
2486
3516
|
runtime.session = session;
|
|
2487
3517
|
// Tag empty-synthesis BEFORE markSessionDone so the watchdog
|
|
@@ -2492,47 +3522,63 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2492
3522
|
markSessionEmptyFinal(sessionId);
|
|
2493
3523
|
}
|
|
2494
3524
|
markSessionDone(sessionId, { empty: isEmptyFinal });
|
|
2495
|
-
_result =
|
|
2496
|
-
...result,
|
|
2497
|
-
trimmed: messagesDropped > 0,
|
|
2498
|
-
messagesDropped,
|
|
2499
|
-
postTurnCompact: postTurnCompact?.changed ? postTurnCompact : null,
|
|
2500
|
-
};
|
|
3525
|
+
_result = terminalResultPreview;
|
|
2501
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;
|
|
2502
3531
|
if (err instanceof SessionClosedError) {
|
|
2503
3532
|
const currentRuntime = _runtimeState.get(sessionId);
|
|
2504
|
-
if (!currentRuntime?.closed
|
|
2505
|
-
activeSession
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
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
|
+
}
|
|
2520
3558
|
markSessionCancelled(sessionId);
|
|
2521
3559
|
}
|
|
2522
3560
|
// Cancellation is not an error; propagate silently so callers
|
|
2523
3561
|
// can render it as "cancelled" rather than a red failure.
|
|
2524
3562
|
throw err;
|
|
2525
3563
|
}
|
|
3564
|
+
await persistCompactedOutgoingAfterAskFailure({
|
|
3565
|
+
sessionId,
|
|
3566
|
+
activeSession,
|
|
3567
|
+
askGeneration,
|
|
3568
|
+
turnOutgoing: _turnOutgoing,
|
|
3569
|
+
error: err,
|
|
3570
|
+
});
|
|
2526
3571
|
markSessionError(sessionId, err && err.message ? err.message : String(err));
|
|
2527
3572
|
throw err;
|
|
2528
3573
|
}
|
|
2529
|
-
// ── Turn complete. Drain the pending-message queue
|
|
2530
|
-
// 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
|
|
2531
3575
|
// turn was in flight runs next, in order, as a follow-up user turn.
|
|
2532
3576
|
// The mutex is still held, so a send racing this drain either landed
|
|
2533
3577
|
// before (picked up here) or enqueues for the next loop. When the
|
|
2534
3578
|
// queue is empty we return the latest turn's result. ──
|
|
2535
|
-
const _drained =
|
|
3579
|
+
const _drained = (_pwstTurnDrained && _pwstTurnDrained.length > 0)
|
|
3580
|
+
? _pwstTurnDrained
|
|
3581
|
+
: drainPendingMessages(sessionId);
|
|
2536
3582
|
if (_drained.length > 0) {
|
|
2537
3583
|
// Same merge rule as the mid-turn steering drain (loop.mjs) and
|
|
2538
3584
|
// the TUI engine.mjs drain(): a single drain batch is joined with
|
|
@@ -2540,11 +3586,9 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2540
3586
|
// Keeps every steering/follow-up path on identical
|
|
2541
3587
|
// merge-then-deliver semantics. Anything that arrives AFTER this
|
|
2542
3588
|
// drain enqueues for the next loop pass and is merged there.
|
|
2543
|
-
const _mergedTail = _drained
|
|
2544
|
-
|
|
2545
|
-
.
|
|
2546
|
-
if (_mergedTail.length > 0) {
|
|
2547
|
-
_pendingTail.push(_mergedTail);
|
|
3589
|
+
const _mergedTail = _mergePendingMessageEntries(_drained);
|
|
3590
|
+
if (_mergedTail?.content) {
|
|
3591
|
+
_pendingTail.push(_mergedTail.content);
|
|
2548
3592
|
const refreshed = loadSession(sessionId);
|
|
2549
3593
|
if (refreshed && refreshed.closed !== true) {
|
|
2550
3594
|
activeSession = refreshed;
|
|
@@ -2553,14 +3597,16 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2553
3597
|
continue;
|
|
2554
3598
|
}
|
|
2555
3599
|
}
|
|
3600
|
+
_unlinkParentAbortListener(_runtimeState.get(sessionId));
|
|
2556
3601
|
return _result;
|
|
2557
3602
|
}
|
|
2558
3603
|
} finally {
|
|
2559
3604
|
// Clear the controller only if it's still ours (closeSession may have
|
|
2560
|
-
// 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
|
|
2561
3606
|
// can still surface the final stage (done/error/cancelling).
|
|
2562
3607
|
const entry = _runtimeState.get(sessionId);
|
|
2563
3608
|
if (entry && entry.generation === askGeneration) {
|
|
3609
|
+
_unlinkParentAbortListener(entry);
|
|
2564
3610
|
entry.controller = null;
|
|
2565
3611
|
// Detach the live session reference; ask is over.
|
|
2566
3612
|
entry.session = null;
|
|
@@ -2568,16 +3614,17 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2568
3614
|
unlock();
|
|
2569
3615
|
}
|
|
2570
3616
|
}
|
|
2571
|
-
// Session lookup by scopeKey — used by CLI
|
|
3617
|
+
// Session lookup by scopeKey — used by CLI agent to resume a pinned
|
|
2572
3618
|
// scope session when the caller passes --scope (agent/<name>).
|
|
2573
3619
|
export function findSessionByScopeKey(scopeKey) {
|
|
2574
3620
|
if (!scopeKey) return null;
|
|
2575
|
-
const
|
|
3621
|
+
const summaries = listStoredSessionSummaries();
|
|
2576
3622
|
// Exclude tombstoned sessions (`closed === true`) so callers never receive
|
|
2577
3623
|
// a session whose controller was aborted by closeSession(). The `closed`
|
|
2578
3624
|
// bit is the authoritative tombstone flag; `status === 'error'` is not,
|
|
2579
3625
|
// since transient-error sessions remain resumable.
|
|
2580
|
-
|
|
3626
|
+
const summary = summaries.find(s => s.scopeKey === scopeKey && s.closed !== true) || null;
|
|
3627
|
+
return summary?.id ? loadSession(summary.id) : null;
|
|
2581
3628
|
}
|
|
2582
3629
|
// --- resume (reload tools for a stored session) ---
|
|
2583
3630
|
export async function resumeSession(sessionId, preset) {
|
|
@@ -2589,20 +3636,38 @@ export async function resumeSession(sessionId, preset) {
|
|
|
2589
3636
|
// than silently dropping the tool-refresh side effects.
|
|
2590
3637
|
if (session.closed === true) return null;
|
|
2591
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);
|
|
2592
3647
|
// Refresh tools (MCP connections may have changed).
|
|
2593
3648
|
// Re-resolve from profile.tools when the session stored a profileId —
|
|
2594
3649
|
// otherwise fall back to preset.tools. Same resolution order as
|
|
2595
3650
|
// createSession so resume and spawn produce identical BP_1 shapes.
|
|
2596
3651
|
const oldTools = session.tools || [];
|
|
2597
|
-
const
|
|
2598
|
-
|
|
2599
|
-
|
|
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) {
|
|
2600
3656
|
try {
|
|
2601
|
-
const profile =
|
|
2602
|
-
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;
|
|
2603
3659
|
} catch { /* ignore lookup failures, keep preset fallback */ }
|
|
2604
3660
|
}
|
|
2605
|
-
|
|
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
|
+
});
|
|
2606
3671
|
const newTools = session.tools;
|
|
2607
3672
|
const missing = oldTools.filter(t => !newTools.find(n => n.name === t.name));
|
|
2608
3673
|
if (missing.length) {
|
|
@@ -2617,47 +3682,92 @@ export function getSession(id) {
|
|
|
2617
3682
|
}
|
|
2618
3683
|
export function listSessions(opts = {}) {
|
|
2619
3684
|
const includeClosed = opts.includeClosed === true;
|
|
2620
|
-
const sessions =
|
|
3685
|
+
const sessions = listStoredSessionSummaries();
|
|
2621
3686
|
const hiddenIds = new Set([..._runtimeState.entries()].filter(([, e]) => e.listHidden).map(([id]) => id));
|
|
2622
3687
|
// Tombstoned sessions (closed===true) are excluded unless the caller opts in
|
|
2623
|
-
// (e.g.
|
|
3688
|
+
// (e.g. agent list includeClosed:true).
|
|
2624
3689
|
return sessions.filter(s => !hiddenIds.has(s.id) && (includeClosed || s.closed !== true));
|
|
2625
3690
|
}
|
|
2626
3691
|
// --- Clear messages (keep system prompt + provider/model/cwd) ---
|
|
2627
|
-
export async function clearSessionMessages(sessionId) {
|
|
3692
|
+
export async function clearSessionMessages(sessionId, options = {}) {
|
|
2628
3693
|
const session = loadSession(sessionId);
|
|
2629
3694
|
if (!session)
|
|
2630
3695
|
return false;
|
|
2631
3696
|
// Don't resurrect a closed session just to clear its messages.
|
|
2632
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() !== '';
|
|
2633
3701
|
const keep = [];
|
|
2634
|
-
|
|
3702
|
+
let messages = Array.isArray(session.messages) ? session.messages : [];
|
|
2635
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;
|
|
2636
3750
|
for (let i = 0; i < messages.length; i += 1) {
|
|
2637
3751
|
const m = messages[i];
|
|
2638
3752
|
if (!m) continue;
|
|
2639
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.
|
|
2640
3758
|
keep.push(m);
|
|
2641
3759
|
continue;
|
|
2642
3760
|
}
|
|
2643
|
-
|
|
2644
|
-
m.role === 'user'
|
|
3761
|
+
if (preserveCompactSummary
|
|
3762
|
+
&& m.role === 'user'
|
|
2645
3763
|
&& typeof m.content === 'string'
|
|
2646
|
-
&& m.content.startsWith(
|
|
2647
|
-
&& m.content.includes('<!-- bp3-sentinel -->');
|
|
2648
|
-
if (stableContext) {
|
|
3764
|
+
&& m.content.startsWith(SUMMARY_PREFIX)) {
|
|
2649
3765
|
keep.push(m);
|
|
2650
|
-
const next = messages[i + 1];
|
|
2651
|
-
if (next?.role === 'assistant' && String(next.content || '').trim() === '.') {
|
|
2652
|
-
keep.push(next);
|
|
2653
|
-
i += 1;
|
|
2654
|
-
}
|
|
2655
3766
|
}
|
|
2656
3767
|
}
|
|
2657
3768
|
const afterMessageTokens = estimateMessagesTokens(keep);
|
|
2658
|
-
const
|
|
2659
|
-
const
|
|
2660
|
-
const afterTokens = afterMessageTokens + reserveTokens;
|
|
3769
|
+
const beforeTokens = estimateTranscriptContextUsage(messages, session.tools || []);
|
|
3770
|
+
const afterTokens = estimateTranscriptContextUsage(keep, session.tools || []);
|
|
2661
3771
|
const now = Date.now();
|
|
2662
3772
|
session.messages = keep;
|
|
2663
3773
|
session.totalInputTokens = 0;
|
|
@@ -2687,6 +3797,8 @@ export async function clearSessionMessages(sessionId) {
|
|
|
2687
3797
|
lastClearAfterTokens: afterTokens,
|
|
2688
3798
|
lastClearBeforeMessageTokens: beforeMessageTokens,
|
|
2689
3799
|
lastClearAfterMessageTokens: afterMessageTokens,
|
|
3800
|
+
lastClearCompactType: clearCompactType || session.compaction?.compactType || null,
|
|
3801
|
+
lastClearCompactError: clearCompactError?.message || null,
|
|
2690
3802
|
};
|
|
2691
3803
|
session.updatedAt = now;
|
|
2692
3804
|
await saveSessionAsync(session, { expectedGeneration: session.generation });
|
|
@@ -2696,6 +3808,9 @@ export async function compactSessionMessages(sessionId) {
|
|
|
2696
3808
|
const session = loadSession(sessionId);
|
|
2697
3809
|
if (!session) return null;
|
|
2698
3810
|
if (session.closed === true) return null;
|
|
3811
|
+
if (isSessionCompactionBlocked(sessionId)) {
|
|
3812
|
+
return { changed: false, reason: 'compact skipped: turn in progress' };
|
|
3813
|
+
}
|
|
2699
3814
|
const result = await runSessionCompaction(session, {
|
|
2700
3815
|
mode: 'manual',
|
|
2701
3816
|
force: true,
|
|
@@ -2723,7 +3838,7 @@ export async function updateSessionStatus(id, status) {
|
|
|
2723
3838
|
const session = loadSession(id);
|
|
2724
3839
|
if (!session) return false;
|
|
2725
3840
|
// Respect tombstones — don't resurrect a closed session just to update a
|
|
2726
|
-
// status label (
|
|
3841
|
+
// status label (agent handler emits running→idle/error around askSession).
|
|
2727
3842
|
if (session.closed === true) return false;
|
|
2728
3843
|
session.status = status;
|
|
2729
3844
|
session.updatedAt = Date.now();
|
|
@@ -2746,6 +3861,7 @@ export async function updateSessionStatus(id, status) {
|
|
|
2746
3861
|
*/
|
|
2747
3862
|
export function closeSession(id, reason = 'manual') {
|
|
2748
3863
|
if (!id) return false;
|
|
3864
|
+
_stopToolActivityHeartbeat(id);
|
|
2749
3865
|
// Prefer in-memory runtime session — allBashSessionIds may not be persisted
|
|
2750
3866
|
// yet for shells opened in the current turn (BL-bash-disk-sync).
|
|
2751
3867
|
const inMemory = _runtimeState.get(id)?.session;
|
|
@@ -2782,10 +3898,10 @@ export function closeSession(id, reason = 'manual') {
|
|
|
2782
3898
|
const durationMs = (typeof askStartedAt === 'number') ? (Date.now() - askStartedAt) : null;
|
|
2783
3899
|
const parts = [`session=${id}`, `reason=${reason}`];
|
|
2784
3900
|
if (durationMs != null) parts.push(`duration=${durationMs}ms`);
|
|
2785
|
-
if (
|
|
3901
|
+
if (process.env.MIXDOG_DEBUG_SESSION_LOG) process.stderr.write(`[agent-close] ${parts.join(' ')}\n`);
|
|
2786
3902
|
} catch { /* best-effort */ }
|
|
2787
3903
|
for (const bsid of allBashIds) {
|
|
2788
|
-
try {
|
|
3904
|
+
try { _closeBashSessionLazy(bsid, `agent-close:${id}`); } catch { /* ignore */ }
|
|
2789
3905
|
}
|
|
2790
3906
|
// Drop session-scoped read dedup cache so the Map doesn't accumulate
|
|
2791
3907
|
// entries across mcp-server lifetime.
|
|
@@ -2805,6 +3921,7 @@ export function closeSession(id, reason = 'manual') {
|
|
|
2805
3921
|
}
|
|
2806
3922
|
export function abortSessionTurn(id, reason = 'turn-abort') {
|
|
2807
3923
|
if (!id) return false;
|
|
3924
|
+
_stopToolActivityHeartbeat(id);
|
|
2808
3925
|
const entry = _runtimeState.get(id);
|
|
2809
3926
|
if (!entry || entry.closed) return false;
|
|
2810
3927
|
entry.stage = 'cancelling';
|
|
@@ -2818,7 +3935,7 @@ export function abortSessionTurn(id, reason = 'turn-abort') {
|
|
|
2818
3935
|
|
|
2819
3936
|
// --- Periodic idle session cleanup ---
|
|
2820
3937
|
const CLEANUP_INTERVAL_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_INTERVAL_MS', 5 * 60 * 1000); // check every 5 minutes
|
|
2821
|
-
const CLEANUP_INITIAL_DELAY_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_INITIAL_DELAY_MS',
|
|
3938
|
+
const CLEANUP_INITIAL_DELAY_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_INITIAL_DELAY_MS', CLEANUP_INTERVAL_MS > 0 ? CLEANUP_INTERVAL_MS : 0);
|
|
2822
3939
|
const CLEANUP_SLOW_LOG_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_SLOW_LOG_MS', 250);
|
|
2823
3940
|
const TOMBSTONE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h — far longer than any realistic ask race window
|
|
2824
3941
|
let _cleanupTimer = null;
|
|
@@ -2861,12 +3978,12 @@ function sweepIdleSessions({ includeTombstones = true } = {}) {
|
|
|
2861
3978
|
} else {
|
|
2862
3979
|
_clearSessionRuntime(d.id);
|
|
2863
3980
|
if (d.bashSessionId) {
|
|
2864
|
-
try {
|
|
3981
|
+
try { _closeBashSessionLazy(d.bashSessionId, `idle-sweep:${d.id}`); } catch { /* ignore */ }
|
|
2865
3982
|
}
|
|
2866
3983
|
}
|
|
2867
|
-
process.stderr.write(`[
|
|
3984
|
+
process.stderr.write(`[agent-session] idle cleanup: closed ${d.id} (idle ${d.idleMinutes}m, owner=${d.owner})\n`);
|
|
2868
3985
|
}
|
|
2869
|
-
process.stderr.write(`[
|
|
3986
|
+
process.stderr.write(`[agent-session] idle sweep: cleaned ${cleaned} session(s), ${remaining} remaining\n`);
|
|
2870
3987
|
}
|
|
2871
3988
|
if (tombstonesCleaned > 0) {
|
|
2872
3989
|
for (const d of tombstoneDetails) {
|
|
@@ -2883,7 +4000,7 @@ function sweepIdleSessions({ includeTombstones = true } = {}) {
|
|
|
2883
4000
|
process.stderr.write(`[session-sweep] cleanup took ${elapsed}ms (idle=${cleaned}, tombstones=${tombstonesCleaned}, remaining=${remaining})\n`);
|
|
2884
4001
|
}
|
|
2885
4002
|
} catch (e) {
|
|
2886
|
-
process.stderr.write(`[
|
|
4003
|
+
process.stderr.write(`[agent-session] idle sweep error: ${e && e.message || e}\n`);
|
|
2887
4004
|
}
|
|
2888
4005
|
}
|
|
2889
4006
|
|
|
@@ -2922,7 +4039,17 @@ export function sweepTombstones() {
|
|
|
2922
4039
|
}
|
|
2923
4040
|
}
|
|
2924
4041
|
|
|
4042
|
+
function hasActiveRuntimeWork() {
|
|
4043
|
+
for (const [, entry] of _runtimeState) {
|
|
4044
|
+
if (!entry || entry.closed === true) continue;
|
|
4045
|
+
if (entry.controller && !entry.controller.signal?.aborted) return true;
|
|
4046
|
+
if (['connecting', 'requesting', 'streaming', 'tool_running', 'cancelling'].includes(entry.stage)) return true;
|
|
4047
|
+
}
|
|
4048
|
+
return false;
|
|
4049
|
+
}
|
|
4050
|
+
|
|
2925
4051
|
function _runCleanupCycle() {
|
|
4052
|
+
if (hasActiveRuntimeWork()) return;
|
|
2926
4053
|
sweepIdleSessions({ includeTombstones: true });
|
|
2927
4054
|
}
|
|
2928
4055
|
|