mixdog 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +31 -0
- package/.claude-plugin/plugin.json +20 -0
- package/.gitattributes +34 -0
- package/.mcp.json +14 -0
- package/ARCHITECTURE.md +77 -0
- package/CHANGELOG.md +7 -0
- package/CONTRIBUTING.md +45 -0
- package/DATA-FLOW.md +79 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/SECURITY.md +138 -0
- package/UNINSTALL.md +112 -0
- package/agents/maintenance.md +5 -0
- package/agents/memory-classification.md +30 -0
- package/agents/scheduler-task.md +18 -0
- package/agents/webhook-handler.md +27 -0
- package/agents/worker.md +24 -0
- package/bin/bridge +133 -0
- package/bin/statusline-launcher.mjs +78 -0
- package/bin/statusline-lib.mjs +550 -0
- package/bin/statusline.mjs +607 -0
- package/bun.lock +802 -0
- package/commands/config.md +16 -0
- package/commands/doctor.md +13 -0
- package/commands/setup.md +17 -0
- package/defaults/cycle3-review-prompt.md +90 -0
- package/defaults/hidden-roles.json +65 -0
- package/defaults/memory-chunk-prompt.md +63 -0
- package/defaults/memory-promote-prompt.md +135 -0
- package/defaults/mixdog-config.template.json +27 -0
- package/defaults/user-workflow.json +8 -0
- package/defaults/user-workflow.md +12 -0
- package/hooks/hooks.json +73 -0
- package/hooks/lib/active-instance.cjs +77 -0
- package/hooks/lib/permission-evaluator.cjs +411 -0
- package/hooks/lib/permission-route.cjs +63 -0
- package/hooks/lib/permission-rules.cjs +170 -0
- package/hooks/lib/settings-loader.cjs +116 -0
- package/hooks/post-tool-use.cjs +84 -0
- package/hooks/pre-mcp-sandbox.cjs +158 -0
- package/hooks/pre-tool-subagent.cjs +253 -0
- package/hooks/session-start.cjs +1372 -0
- package/hooks/turn-timer.cjs +82 -0
- package/lib/claude-md-writer.cjs +386 -0
- package/lib/config-cjs.cjs +61 -0
- package/lib/hook-pipe-path.cjs +10 -0
- package/lib/keychain-cjs.cjs +263 -0
- package/lib/plugin-paths.cjs +61 -0
- package/lib/rules-builder.cjs +241 -0
- package/lib/text-utils.cjs +61 -0
- package/native/README.md +117 -0
- package/native/prebuilt/linux-aarch64/mixdog-shim +0 -0
- package/native/prebuilt/linux-x86_64/mixdog-shim +0 -0
- package/native/prebuilt/macos-aarch64/mixdog-shim +0 -0
- package/native/prebuilt/macos-x86_64/mixdog-shim +0 -0
- package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
- package/package.json +107 -0
- package/prompts/code-review.txt +16 -0
- package/prompts/security-audit.txt +17 -0
- package/rules/bridge/00-common.md +39 -0
- package/rules/bridge/20-skip-protocol.md +18 -0
- package/rules/bridge/30-explorer.md +33 -0
- package/rules/bridge/40-cycle1-agent.md +52 -0
- package/rules/bridge/41-cycle2-agent.md +62 -0
- package/rules/bridge/42-cycle3-agent.md +44 -0
- package/rules/lead/00-tool-lead.md +61 -0
- package/rules/lead/01-general.md +23 -0
- package/rules/lead/02-channels.md +49 -0
- package/rules/lead/03-team.md +27 -0
- package/rules/lead/04-workflow.md +20 -0
- package/rules/shared/00-language.md +14 -0
- package/rules/shared/01-tool.md +138 -0
- package/scripts/bootstrap.mjs +184 -0
- package/scripts/bridge-unify-smoke.mjs +308 -0
- 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/builtin-utils-smoke.mjs +392 -0
- package/scripts/check-json.mjs +45 -0
- package/scripts/check-syntax-changed.mjs +102 -0
- package/scripts/check-syntax.mjs +58 -0
- package/scripts/code-graph-batch.test.mjs +33 -0
- package/scripts/config-preserve-smoke.mjs +180 -0
- package/scripts/doctor.mjs +484 -0
- package/scripts/edit-normalize-fuzz.mjs +130 -0
- package/scripts/edit-normalize-smoke.mjs +401 -0
- package/scripts/edit-operation-smoke.mjs +369 -0
- package/scripts/edit2-smoke.mjs +63 -0
- package/scripts/fuzzy-e2e.mjs +28 -0
- package/scripts/fuzzy-smoke.mjs +26 -0
- package/scripts/generate-runtime-manifest.mjs +166 -0
- package/scripts/guard-smoke.mjs +66 -0
- package/scripts/hidden-role-schema-smoke.mjs +162 -0
- package/scripts/hook-routing-smoke.mjs +29 -0
- package/scripts/inject-input.ps1 +204 -0
- package/scripts/io-complex-smoke.mjs +667 -0
- package/scripts/io-explore-bench.mjs +424 -0
- package/scripts/io-guardrails-smoke.mjs +205 -0
- package/scripts/io-mini-bench-baseline.json +11 -0
- package/scripts/io-mini-bench.mjs +216 -0
- package/scripts/io-route-harness.mjs +933 -0
- package/scripts/io-telemetry-report.mjs +691 -0
- package/scripts/mutation-bench.mjs +564 -0
- package/scripts/mutation-io-smoke.mjs +1081 -0
- package/scripts/native-patch-bridge-smoke.mjs +288 -0
- package/scripts/native-patch-smoke.mjs +304 -0
- package/scripts/patch-interior-context-smoke.mjs +49 -0
- package/scripts/patch-newline-utf8-smoke.mjs +157 -0
- package/scripts/perf-hook-smoke.mjs +71 -0
- package/scripts/permission-eval-smoke.mjs +426 -0
- package/scripts/prep-patch.mjs +53 -0
- package/scripts/prep-shim.mjs +96 -0
- package/scripts/provider-cache-smoke.mjs +687 -0
- package/scripts/report-runtime-health.mjs +132 -0
- package/scripts/run-mcp.mjs +1547 -0
- package/scripts/salvage-v4a-shatter.test.mjs +58 -0
- package/scripts/scoped-cache-io-smoke.mjs +103 -0
- package/scripts/shell-policy-round3-smoke.mjs +46 -0
- package/scripts/smoke-runtime-negative.ps1 +100 -0
- package/scripts/smoke-runtime-negative.sh +95 -0
- package/scripts/stall-policy-smoke.mjs +50 -0
- package/scripts/start-memory-worker.mjs +23 -0
- package/scripts/statusline-launcher-smoke.mjs +82 -0
- package/scripts/stress-atomic-write.mjs +1028 -0
- package/scripts/test-config-rmw-restore.mjs +122 -0
- package/scripts/test-fault-inject.mjs +164 -0
- package/scripts/test-large-file.mjs +174 -0
- package/scripts/tool-edge-smoke.mjs +209 -0
- package/scripts/uninstall.mjs +201 -0
- package/scripts/webhook-selfheal-smoke.mjs +29 -0
- package/scripts/write-overwrite-guard-smoke.mjs +56 -0
- package/server-main.mjs +3055 -0
- package/server.mjs +468 -0
- package/setup/config-merge.mjs +254 -0
- package/setup/install.mjs +120 -0
- package/setup/launch-core.mjs +507 -0
- package/setup/launch.mjs +101 -0
- package/setup/setup-server.mjs +3206 -0
- package/setup/setup.html +3693 -0
- package/skills/retro-skill-proposer/SKILL.md +92 -0
- package/skills/schedule-add/SKILL.md +77 -0
- package/skills/setup/SKILL.md +346 -0
- package/skills/webhook-add/SKILL.md +81 -0
- package/src/agent/bridge-stall-watchdog.mjs +337 -0
- package/src/agent/index.mjs +2138 -0
- package/src/agent/orchestrator/activity-bus.mjs +38 -0
- package/src/agent/orchestrator/ai-wrapped-dispatch.mjs +1010 -0
- package/src/agent/orchestrator/bridge-retry.mjs +220 -0
- package/src/agent/orchestrator/bridge-trace.mjs +583 -0
- package/src/agent/orchestrator/cache-mtime.mjs +58 -0
- package/src/agent/orchestrator/config.mjs +358 -0
- package/src/agent/orchestrator/context/collect.mjs +651 -0
- package/src/agent/orchestrator/dispatch-persist.mjs +549 -0
- package/src/agent/orchestrator/drain-registry.mjs +50 -0
- package/src/agent/orchestrator/explore-validator.mjs +8 -0
- package/src/agent/orchestrator/internal-roles.mjs +118 -0
- package/src/agent/orchestrator/internal-tools.mjs +88 -0
- package/src/agent/orchestrator/jobs.mjs +116 -0
- package/src/agent/orchestrator/mcp/client.mjs +364 -0
- package/src/agent/orchestrator/providers/anthropic-betas.mjs +21 -0
- package/src/agent/orchestrator/providers/anthropic-oauth.mjs +1745 -0
- package/src/agent/orchestrator/providers/anthropic.mjs +437 -0
- package/src/agent/orchestrator/providers/gemini.mjs +1175 -0
- package/src/agent/orchestrator/providers/grok-oauth.mjs +782 -0
- package/src/agent/orchestrator/providers/model-catalog.mjs +241 -0
- package/src/agent/orchestrator/providers/openai-compat.mjs +1467 -0
- package/src/agent/orchestrator/providers/openai-oauth-ws.mjs +1890 -0
- package/src/agent/orchestrator/providers/openai-oauth.mjs +1307 -0
- package/src/agent/orchestrator/providers/openai-ws.mjs +104 -0
- package/src/agent/orchestrator/providers/registry.mjs +192 -0
- package/src/agent/orchestrator/providers/retry-classifier.mjs +325 -0
- package/src/agent/orchestrator/session/abort-lookup.mjs +13 -0
- package/src/agent/orchestrator/session/cache/post-edit-marks.mjs +42 -0
- package/src/agent/orchestrator/session/cache/prefetch-cache.mjs +142 -0
- package/src/agent/orchestrator/session/cache/read-cache.mjs +319 -0
- package/src/agent/orchestrator/session/cache/scoped-cache-outcome.mjs +11 -0
- package/src/agent/orchestrator/session/cache/scoped-cache.mjs +361 -0
- package/src/agent/orchestrator/session/cache/util.mjs +49 -0
- package/src/agent/orchestrator/session/loop.mjs +1478 -0
- package/src/agent/orchestrator/session/manager.mjs +1975 -0
- package/src/agent/orchestrator/session/read-dedup.mjs +6 -0
- package/src/agent/orchestrator/session/result-classification.mjs +65 -0
- package/src/agent/orchestrator/session/save-session-worker.mjs +18 -0
- package/src/agent/orchestrator/session/store.mjs +624 -0
- package/src/agent/orchestrator/session/stream-watchdog.mjs +130 -0
- package/src/agent/orchestrator/session/tool-result-offload.mjs +166 -0
- package/src/agent/orchestrator/session/trim.mjs +491 -0
- package/src/agent/orchestrator/smart-bridge/CACHE-SHARD.md +115 -0
- package/src/agent/orchestrator/smart-bridge/bridge-llm.mjs +327 -0
- package/src/agent/orchestrator/smart-bridge/cache-obs.mjs +150 -0
- package/src/agent/orchestrator/smart-bridge/cache-strategy.mjs +228 -0
- package/src/agent/orchestrator/smart-bridge/index.mjs +215 -0
- package/src/agent/orchestrator/smart-bridge/profiles.mjs +37 -0
- package/src/agent/orchestrator/smart-bridge/registry.mjs +348 -0
- package/src/agent/orchestrator/smart-bridge/session-builder.mjs +116 -0
- package/src/agent/orchestrator/stall-policy.mjs +195 -0
- package/src/agent/orchestrator/tool-loop-guard.mjs +75 -0
- package/src/agent/orchestrator/tools/bash-policy-scan.mjs +77 -0
- package/src/agent/orchestrator/tools/bash-session.mjs +721 -0
- package/src/agent/orchestrator/tools/builtin/advisory-lock.mjs +171 -0
- package/src/agent/orchestrator/tools/builtin/arg-guard.mjs +455 -0
- package/src/agent/orchestrator/tools/builtin/atomic-write.mjs +236 -0
- package/src/agent/orchestrator/tools/builtin/bash-tool.mjs +480 -0
- package/src/agent/orchestrator/tools/builtin/binary-file.mjs +76 -0
- package/src/agent/orchestrator/tools/builtin/builtin-tools.mjs +256 -0
- package/src/agent/orchestrator/tools/builtin/cache-layers.mjs +386 -0
- package/src/agent/orchestrator/tools/builtin/cwd-utils.mjs +37 -0
- package/src/agent/orchestrator/tools/builtin/device-paths.mjs +154 -0
- package/src/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +292 -0
- package/src/agent/orchestrator/tools/builtin/diff-utils.mjs +109 -0
- package/src/agent/orchestrator/tools/builtin/edit-base-guard.mjs +58 -0
- package/src/agent/orchestrator/tools/builtin/edit-byte-plan.mjs +240 -0
- package/src/agent/orchestrator/tools/builtin/edit-byte-utils.mjs +113 -0
- package/src/agent/orchestrator/tools/builtin/edit-commit.mjs +74 -0
- package/src/agent/orchestrator/tools/builtin/edit-context-utils.mjs +242 -0
- package/src/agent/orchestrator/tools/builtin/edit-diagnostics.mjs +211 -0
- package/src/agent/orchestrator/tools/builtin/edit-engine.mjs +1364 -0
- package/src/agent/orchestrator/tools/builtin/edit-failure-context.mjs +126 -0
- package/src/agent/orchestrator/tools/builtin/edit-hint.mjs +141 -0
- package/src/agent/orchestrator/tools/builtin/edit-match-utils.mjs +194 -0
- package/src/agent/orchestrator/tools/builtin/edit-partial-write.mjs +60 -0
- package/src/agent/orchestrator/tools/builtin/edit-stale-refresh.mjs +168 -0
- package/src/agent/orchestrator/tools/builtin/edit-tool.mjs +173 -0
- package/src/agent/orchestrator/tools/builtin/edit-utf8-guard.mjs +48 -0
- package/src/agent/orchestrator/tools/builtin/fs-reachability.mjs +48 -0
- package/src/agent/orchestrator/tools/builtin/fuzzy-match.mjs +99 -0
- package/src/agent/orchestrator/tools/builtin/glob-walk.mjs +170 -0
- package/src/agent/orchestrator/tools/builtin/grep-formatting.mjs +113 -0
- package/src/agent/orchestrator/tools/builtin/hash-utils.mjs +6 -0
- package/src/agent/orchestrator/tools/builtin/list-formatting.mjs +7 -0
- package/src/agent/orchestrator/tools/builtin/list-tool.mjs +593 -0
- package/src/agent/orchestrator/tools/builtin/native-edit-runner.mjs +89 -0
- package/src/agent/orchestrator/tools/builtin/notebook-edit-tool.mjs +300 -0
- package/src/agent/orchestrator/tools/builtin/open-config-tool.mjs +26 -0
- package/src/agent/orchestrator/tools/builtin/path-diagnostics.mjs +152 -0
- package/src/agent/orchestrator/tools/builtin/path-locks.mjs +35 -0
- package/src/agent/orchestrator/tools/builtin/path-utils.mjs +201 -0
- package/src/agent/orchestrator/tools/builtin/read-args.mjs +103 -0
- package/src/agent/orchestrator/tools/builtin/read-batch.mjs +172 -0
- package/src/agent/orchestrator/tools/builtin/read-constants.mjs +40 -0
- package/src/agent/orchestrator/tools/builtin/read-formatting.mjs +118 -0
- package/src/agent/orchestrator/tools/builtin/read-image-resize.mjs +189 -0
- package/src/agent/orchestrator/tools/builtin/read-image.mjs +88 -0
- package/src/agent/orchestrator/tools/builtin/read-lines.mjs +12 -0
- package/src/agent/orchestrator/tools/builtin/read-mode-tool.mjs +455 -0
- package/src/agent/orchestrator/tools/builtin/read-open.mjs +190 -0
- package/src/agent/orchestrator/tools/builtin/read-range-index.mjs +271 -0
- package/src/agent/orchestrator/tools/builtin/read-ranges.mjs +26 -0
- package/src/agent/orchestrator/tools/builtin/read-single-tool.mjs +728 -0
- package/src/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +173 -0
- package/src/agent/orchestrator/tools/builtin/read-special-files.mjs +268 -0
- package/src/agent/orchestrator/tools/builtin/read-streaming.mjs +602 -0
- package/src/agent/orchestrator/tools/builtin/read-tool.mjs +530 -0
- package/src/agent/orchestrator/tools/builtin/read-windows.mjs +107 -0
- package/src/agent/orchestrator/tools/builtin/rename-tool.mjs +196 -0
- package/src/agent/orchestrator/tools/builtin/rg-runner.mjs +422 -0
- package/src/agent/orchestrator/tools/builtin/search-builders.mjs +158 -0
- package/src/agent/orchestrator/tools/builtin/search-tool.mjs +869 -0
- package/src/agent/orchestrator/tools/builtin/shell-analysis.mjs +653 -0
- package/src/agent/orchestrator/tools/builtin/shell-jobs.mjs +936 -0
- package/src/agent/orchestrator/tools/builtin/shell-output.mjs +36 -0
- package/src/agent/orchestrator/tools/builtin/shell-runtime.mjs +214 -0
- package/src/agent/orchestrator/tools/builtin/snapshot-helpers.mjs +143 -0
- package/src/agent/orchestrator/tools/builtin/snapshot-store.mjs +206 -0
- package/src/agent/orchestrator/tools/builtin/snapshot-validation.mjs +98 -0
- package/src/agent/orchestrator/tools/builtin/text-stats.mjs +69 -0
- package/src/agent/orchestrator/tools/builtin/windows-roots.mjs +23 -0
- package/src/agent/orchestrator/tools/builtin/write-tool.mjs +401 -0
- package/src/agent/orchestrator/tools/builtin.mjs +500 -0
- package/src/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +39 -0
- package/src/agent/orchestrator/tools/code-graph-tool-defs.mjs +24 -0
- package/src/agent/orchestrator/tools/code-graph.mjs +4095 -0
- package/src/agent/orchestrator/tools/cwd-tool.mjs +298 -0
- package/src/agent/orchestrator/tools/destructive-warning.mjs +323 -0
- package/src/agent/orchestrator/tools/edit-normalize.mjs +603 -0
- package/src/agent/orchestrator/tools/env-scrub.mjs +100 -0
- package/src/agent/orchestrator/tools/graph-binary-fetcher.mjs +144 -0
- package/src/agent/orchestrator/tools/graph-manifest.json +26 -0
- package/src/agent/orchestrator/tools/host-input.mjs +204 -0
- package/src/agent/orchestrator/tools/mutation-content-cache.mjs +67 -0
- package/src/agent/orchestrator/tools/mutation-planner.mjs +75 -0
- package/src/agent/orchestrator/tools/next-call-utils.mjs +48 -0
- package/src/agent/orchestrator/tools/patch-binary-fetcher.mjs +133 -0
- package/src/agent/orchestrator/tools/patch-manifest.json +26 -0
- package/src/agent/orchestrator/tools/patch-tool-defs.mjs +20 -0
- package/src/agent/orchestrator/tools/patch.mjs +2754 -0
- package/src/agent/orchestrator/tools/progress-message.mjs +118 -0
- package/src/agent/orchestrator/tools/result-compression.mjs +279 -0
- package/src/agent/orchestrator/tools/shell-command.mjs +865 -0
- package/src/agent/orchestrator/tools/shell-exec-policy.mjs +89 -0
- package/src/agent/orchestrator/tools/shell-policy-danger-target.mjs +27 -0
- package/src/agent/orchestrator/tools/shell-policy-imports.mjs +7 -0
- package/src/agent/orchestrator/tools/shell-policy.mjs +345 -0
- package/src/agent/orchestrator/tools/shell-snapshot.mjs +313 -0
- package/src/agent/orchestrator/workflow-store.mjs +93 -0
- package/src/agent/tool-defs.mjs +103 -0
- package/src/channels/backends/discord.mjs +784 -0
- package/src/channels/data/voice-runtime-manifest.json +138 -0
- package/src/channels/index.mjs +3229 -0
- package/src/channels/lib/cli-worker-host.mjs +12 -0
- package/src/channels/lib/config-lock.mjs +13 -0
- package/src/channels/lib/config.mjs +292 -0
- package/src/channels/lib/drop-trace.mjs +71 -0
- package/src/channels/lib/event-pipeline.mjs +81 -0
- package/src/channels/lib/event-queue.mjs +345 -0
- package/src/channels/lib/executor.mjs +168 -0
- package/src/channels/lib/format.mjs +188 -0
- package/src/channels/lib/holidays.mjs +138 -0
- package/src/channels/lib/hook-pipe-server.mjs +802 -0
- package/src/channels/lib/interaction-workflows.mjs +184 -0
- package/src/channels/lib/memory-client.mjs +149 -0
- package/src/channels/lib/output-forwarder.mjs +765 -0
- package/src/channels/lib/runtime-paths.mjs +479 -0
- package/src/channels/lib/scheduler.mjs +723 -0
- package/src/channels/lib/session-control.mjs +36 -0
- package/src/channels/lib/session-discovery.mjs +103 -0
- package/src/channels/lib/settings.mjs +11 -0
- package/src/channels/lib/state-file.mjs +68 -0
- package/src/channels/lib/status-snapshot.mjs +219 -0
- package/src/channels/lib/tool-format.mjs +140 -0
- package/src/channels/lib/transcript-discovery.mjs +195 -0
- package/src/channels/lib/voice-runtime-fetcher.mjs +734 -0
- package/src/channels/lib/webhook.mjs +1179 -0
- package/src/channels/lib/whisper-server.mjs +477 -0
- package/src/channels/tool-defs.mjs +170 -0
- package/src/daemon/host.mjs +118 -0
- package/src/daemon/mcp-transport.mjs +47 -0
- package/src/daemon/session.mjs +100 -0
- package/src/daemon/thin-client.mjs +71 -0
- package/src/daemon/transport.mjs +163 -0
- package/src/memory/data/runtime-manifest.json +40 -0
- package/src/memory/index.mjs +3305 -0
- package/src/memory/lib/agent-ipc.mjs +93 -0
- package/src/memory/lib/bridge-trace-queries.mjs +120 -0
- package/src/memory/lib/core-memory-store.mjs +330 -0
- package/src/memory/lib/embedding-provider.mjs +269 -0
- package/src/memory/lib/embedding-worker.mjs +323 -0
- package/src/memory/lib/llm-worker-host.mjs +17 -0
- package/src/memory/lib/memory-cycle.mjs +11 -0
- package/src/memory/lib/memory-cycle1.mjs +641 -0
- package/src/memory/lib/memory-cycle2.mjs +1284 -0
- package/src/memory/lib/memory-cycle3.mjs +540 -0
- package/src/memory/lib/memory-embed.mjs +299 -0
- package/src/memory/lib/memory-extraction.mjs +5 -0
- package/src/memory/lib/memory-maintenance-store.mjs +32 -0
- package/src/memory/lib/memory-ops-policy.mjs +190 -0
- package/src/memory/lib/memory-recall-id-patch.mjs +15 -0
- package/src/memory/lib/memory-recall-read-query.mjs +7 -0
- package/src/memory/lib/memory-recall-scope-filter.mjs +63 -0
- package/src/memory/lib/memory-recall-store.mjs +621 -0
- package/src/memory/lib/memory-retrievers.mjs +112 -0
- package/src/memory/lib/memory-score.mjs +71 -0
- package/src/memory/lib/memory-text-utils.mjs +58 -0
- package/src/memory/lib/memory.mjs +412 -0
- package/src/memory/lib/model-profile.mjs +85 -0
- package/src/memory/lib/pg/adapter.mjs +308 -0
- package/src/memory/lib/pg/process.mjs +360 -0
- package/src/memory/lib/pg/supervisor.mjs +396 -0
- package/src/memory/lib/project-id-resolver.mjs +86 -0
- package/src/memory/lib/runtime-fetcher.mjs +442 -0
- package/src/memory/lib/trace-store.mjs +728 -0
- package/src/memory/tool-defs.mjs +79 -0
- package/src/search/index.mjs +1173 -0
- package/src/search/lib/backends/anthropic-oauth.mjs +98 -0
- package/src/search/lib/backends/exa.mjs +50 -0
- package/src/search/lib/backends/firecrawl.mjs +61 -0
- package/src/search/lib/backends/gemini-api.mjs +83 -0
- package/src/search/lib/backends/grok-oauth.mjs +86 -0
- package/src/search/lib/backends/index.mjs +150 -0
- package/src/search/lib/backends/openai-api.mjs +144 -0
- package/src/search/lib/backends/openai-oauth.mjs +98 -0
- package/src/search/lib/backends/openai-web-search.mjs +76 -0
- package/src/search/lib/backends/tavily.mjs +55 -0
- package/src/search/lib/backends/xai-api.mjs +113 -0
- package/src/search/lib/cache.mjs +131 -0
- package/src/search/lib/config.mjs +192 -0
- package/src/search/lib/formatter.mjs +115 -0
- package/src/search/lib/provider-usage.mjs +67 -0
- package/src/search/lib/providers.mjs +47 -0
- package/src/search/lib/search-intent.mjs +109 -0
- package/src/search/lib/setup-handler.mjs +261 -0
- package/src/search/lib/state.mjs +201 -0
- package/src/search/lib/web-tools.mjs +1207 -0
- package/src/search/tool-defs.mjs +83 -0
- package/src/setup/defender-exclusion.mjs +183 -0
- package/src/shared/abort-controller.mjs +15 -0
- package/src/shared/atomic-file.mjs +420 -0
- package/src/shared/config.mjs +350 -0
- package/src/shared/daemon-recycle.mjs +108 -0
- package/src/shared/disable-claude-builtins.mjs +88 -0
- package/src/shared/err-text.mjs +12 -0
- package/src/shared/llm/cost.mjs +66 -0
- package/src/shared/llm/http-agent.mjs +123 -0
- package/src/shared/llm/index.mjs +41 -0
- package/src/shared/llm/pid-cleanup.mjs +27 -0
- package/src/shared/llm/usage-log.mjs +47 -0
- package/src/shared/plugin-paths.mjs +58 -0
- package/src/shared/schedules-store.mjs +70 -0
- package/src/shared/seed.mjs +119 -0
- package/src/shared/user-cwd.mjs +213 -0
- package/src/shared/user-data-guard.mjs +238 -0
- package/src/status/aggregator.mjs +584 -0
- package/src/status/server.mjs +413 -0
- package/tools.json +1653 -0
|
@@ -0,0 +1,1478 @@
|
|
|
1
|
+
import { classifyResultKind } from './result-classification.mjs';
|
|
2
|
+
import { executeMcpTool, isMcpTool, mcpToolHasField } from '../mcp/client.mjs';
|
|
3
|
+
import { canonicalizeBuiltinToolName, executeBuiltinTool, formatUnknownBuiltinToolMessage, isBuiltinTool } from '../tools/builtin.mjs';
|
|
4
|
+
import { executeBashSessionTool } from '../tools/bash-session.mjs';
|
|
5
|
+
import { executePatchTool } from '../tools/patch.mjs';
|
|
6
|
+
import { executeCodeGraphTool, isCodeGraphTool } from '../tools/code-graph.mjs';
|
|
7
|
+
import { executeInternalTool, isInternalTool } from '../internal-tools.mjs';
|
|
8
|
+
import { collectSkillsCached, loadSkillContent } from '../context/collect.mjs';
|
|
9
|
+
import { traceBridgeLoop, traceBridgeTool, traceBridgeTrim, estimateProviderPayloadBytes, messagePrefixHash } from '../bridge-trace.mjs';
|
|
10
|
+
import { markSessionToolCall, updateSessionStage, SessionClosedError, getSessionAbortSignal } from './manager.mjs';
|
|
11
|
+
import { trimMessages, estimateRequestReserveTokens } from './trim.mjs';
|
|
12
|
+
import { isContextOverflowError } from '../providers/retry-classifier.mjs';
|
|
13
|
+
import { classifyBashFileLookupCommand, stripSoftWarns } from '../tool-loop-guard.mjs';
|
|
14
|
+
import { maybeOffloadToolResult } from './tool-result-offload.mjs';
|
|
15
|
+
import { tryReadCached, setReadCached, invalidatePathForSession, markPostEdit, consumePostEditMark, clearReadDedupSession, extractTouchedPathsFromPatch, tryScopedToolCached, setScopedToolCached, clearScopedToolsForSession, clearScopedToolsForSessionPaths, invalidatePrefetchCache } from './read-dedup.mjs';
|
|
16
|
+
import { createScopedCacheOutcome } from './cache/scoped-cache-outcome.mjs';
|
|
17
|
+
import { createHash } from 'crypto';
|
|
18
|
+
|
|
19
|
+
// Tool-name classification for cross-turn read dedup.
|
|
20
|
+
// Strips the MCP prefix so direct calls and MCP-wrapped calls share the
|
|
21
|
+
// same cache.
|
|
22
|
+
function _stripMcpPrefix(name) {
|
|
23
|
+
return typeof name === 'string' && name.startsWith(MCP_TOOL_PREFIX)
|
|
24
|
+
? name.slice(MCP_TOOL_PREFIX.length) : name;
|
|
25
|
+
}
|
|
26
|
+
function _isReadTool(name) {
|
|
27
|
+
return _stripMcpPrefix(name) === 'read';
|
|
28
|
+
}
|
|
29
|
+
function _isScalarWriteEditTool(name) {
|
|
30
|
+
const n = _stripMcpPrefix(name);
|
|
31
|
+
return n === 'write' || n === 'edit';
|
|
32
|
+
}
|
|
33
|
+
function _isMutationTool(name) {
|
|
34
|
+
const n = _stripMcpPrefix(name);
|
|
35
|
+
return n === 'apply_patch' || n === 'write' || n === 'edit';
|
|
36
|
+
}
|
|
37
|
+
const SCOPED_CACHEABLE_TOOLS = new Set([
|
|
38
|
+
'code_graph',
|
|
39
|
+
'grep',
|
|
40
|
+
'list',
|
|
41
|
+
'glob',
|
|
42
|
+
]);
|
|
43
|
+
function _isScopedCacheableTool(name) {
|
|
44
|
+
const n = _stripMcpPrefix(name);
|
|
45
|
+
return SCOPED_CACHEABLE_TOOLS.has(n);
|
|
46
|
+
}
|
|
47
|
+
function _isBashTool(name) {
|
|
48
|
+
const n = _stripMcpPrefix(name);
|
|
49
|
+
return n === 'bash' || n === 'bash_session';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// classifyResultKind is imported from result-classification.mjs at the top of
|
|
53
|
+
// this file; import it from there directly rather than via this module.
|
|
54
|
+
|
|
55
|
+
// Canonical signature for intra-turn duplicate detection. Sorting keys
|
|
56
|
+
// produces a stable hash regardless of arg-object key order. Anything
|
|
57
|
+
// non-serializable falls back to String(args) — still deterministic for
|
|
58
|
+
// the model's typical structured-arg shape.
|
|
59
|
+
function _canonicalArgs(args) {
|
|
60
|
+
if (args == null || typeof args !== 'object') {
|
|
61
|
+
try { return JSON.stringify(args); } catch { return String(args); }
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const keys = Object.keys(args).sort();
|
|
65
|
+
const sorted = {};
|
|
66
|
+
for (const k of keys) sorted[k] = args[k];
|
|
67
|
+
return JSON.stringify(sorted);
|
|
68
|
+
} catch { return String(args); }
|
|
69
|
+
}
|
|
70
|
+
function _intraTurnSig(name, args) {
|
|
71
|
+
return createHash('sha256').update(`${name}:${_canonicalArgs(args)}`).digest('hex').slice(0, 16);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Shared pre-dispatch deny — single source of truth for role/scope/permission
|
|
75
|
+
// rejects. Called by BOTH the eager dispatch path (startEagerTool) and the
|
|
76
|
+
// serial dispatch path (executeTool body). Returns null when the call is
|
|
77
|
+
// allowed to proceed; otherwise returns the Error string the serial path
|
|
78
|
+
// would emit. The eager caller ignores the message body and just treats
|
|
79
|
+
// non-null as "do not start eager".
|
|
80
|
+
//
|
|
81
|
+
// Predicates are kept in the same order as the legacy serial branch so a
|
|
82
|
+
// bridge-owned control-plane tool fails on _bridgeOwned+_controlPlaneTool
|
|
83
|
+
// FIRST (not on permission/wrapper checks) — matches the prior wording.
|
|
84
|
+
// Bridge workers are sandboxed to code/research tools. They must never reach
|
|
85
|
+
// owner/host control surfaces: session management, the ENTIRE channels module
|
|
86
|
+
// (Discord messaging, schedules, webhook/config, channel-bridge toggle,
|
|
87
|
+
// command injection), or host input injection. Explicit name list (no imports)
|
|
88
|
+
// keeps this hot-path gate dependency-free; add new owner/channel tools here.
|
|
89
|
+
const WORKER_DENIED_TOOLS = new Set([
|
|
90
|
+
// session control-plane — unified into the single `bridge` tool
|
|
91
|
+
// (type=spawn|send|close|list). Denying the one name blocks all worker
|
|
92
|
+
// session control. Legacy names kept for defense-in-depth against any
|
|
93
|
+
// stale catalog entry that still advertises them.
|
|
94
|
+
'bridge', 'close_session', 'list_sessions', 'create_session',
|
|
95
|
+
// channels module (owner/Discord-facing)
|
|
96
|
+
'reply', 'react', 'edit_message', 'download_attachment', 'fetch',
|
|
97
|
+
'schedule_status', 'trigger_schedule', 'schedule_control',
|
|
98
|
+
'activate_channel_bridge', 'reload_config', 'inject_command',
|
|
99
|
+
// host input injection
|
|
100
|
+
'inject_input',
|
|
101
|
+
]);
|
|
102
|
+
function _preDispatchDeny(call, toolKind, sessionRef) {
|
|
103
|
+
const name = call?.name;
|
|
104
|
+
if (typeof name !== 'string' || !name) return null;
|
|
105
|
+
const _bridgeOwned = sessionRef?.scope?.startsWith?.('bridge:') || sessionRef?.owner === 'bridge';
|
|
106
|
+
const _controlPlaneTool = WORKER_DENIED_TOOLS.has(name);
|
|
107
|
+
if (_bridgeOwned && _controlPlaneTool) {
|
|
108
|
+
return `Error: control-plane tool "${name}" is Lead-only and not available to bridge workers.`;
|
|
109
|
+
}
|
|
110
|
+
const noToolRole = sessionRef?.role === 'cycle1-agent' || sessionRef?.role === 'cycle2-agent';
|
|
111
|
+
if (noToolRole) {
|
|
112
|
+
return `Error: tool "${name}" is not available in role "${sessionRef.role}". Re-emit the answer as pipe-separated text per the role's output format (first character a digit, NO tool_use blocks, NO JSON, NO prose, NO apology).`;
|
|
113
|
+
}
|
|
114
|
+
if (isBlockedHiddenWrapperCall(name, sessionRef)) {
|
|
115
|
+
return `Error: tool "${name}" is the wrapper your role (${sessionRef?.role || 'hidden'}) backs. Calling it would spawn another hidden agent of the same kind — use direct read/grep/glob/code_graph instead.`;
|
|
116
|
+
}
|
|
117
|
+
const effectivePermission = effectiveToolPermission(sessionRef);
|
|
118
|
+
const permissionBlocked = isBlockedByPermission(name, toolKind, effectivePermission);
|
|
119
|
+
if (permissionBlocked && effectivePermission === 'mcp') {
|
|
120
|
+
return `Error: tool "${name}" is not available on this session (permission=mcp). Use MCP/internal retrieval tools only.`;
|
|
121
|
+
}
|
|
122
|
+
if (permissionBlocked && effectivePermission === 'read') {
|
|
123
|
+
return `Error: tool "${name}" is not available on this session (permission=read). Use Mixdog MCP read/grep/glob/recall/search/explore instead.`;
|
|
124
|
+
}
|
|
125
|
+
if (permissionBlocked && effectivePermission && typeof effectivePermission === 'object') {
|
|
126
|
+
return `Error: tool "${name}" is not permitted on this session by the role's allow/deny permission policy.`;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
/** Exported for smoke tests — same runtime deny as the agent loop. */
|
|
131
|
+
export function preDispatchDenyForSession(sessionRef, call, toolKind = 'builtin') {
|
|
132
|
+
return _preDispatchDeny(call, toolKind, sessionRef);
|
|
133
|
+
}
|
|
134
|
+
import { compressToolResult, recordToolBatch } from '../tools/result-compression.mjs';
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
import { isHiddenRole } from '../internal-roles.mjs';
|
|
138
|
+
import { createRequire } from 'module';
|
|
139
|
+
import { readFileSync as _readFileSync } from 'fs';
|
|
140
|
+
import { fileURLToPath } from 'url';
|
|
141
|
+
import { dirname, resolve as resolvePath, isAbsolute } from 'path';
|
|
142
|
+
// Load the CJS permission evaluator. The hooks/ directory lives two levels
|
|
143
|
+
// above src/agent/orchestrator/session/, so we walk up from __dirname.
|
|
144
|
+
const _require = createRequire(import.meta.url);
|
|
145
|
+
const _hooksLib = resolvePath(dirname(fileURLToPath(import.meta.url)), '../../../../hooks/lib/permission-evaluator.cjs');
|
|
146
|
+
const { evaluatePermission: _evaluatePermission } = _require(_hooksLib);
|
|
147
|
+
const MCP_TOOL_PREFIX = 'mcp__plugin_mixdog_mixdog__';
|
|
148
|
+
const SAFETY_TRIM_PERCENT = 0.90;
|
|
149
|
+
// Stricter budget used for the one-shot retry after a provider rejects a send
|
|
150
|
+
// with a context-window-exceeded error. 0.60×contextWindow forces older
|
|
151
|
+
// non-system / non-latest turns to drop hard so the retry fits even when the
|
|
152
|
+
// pre-send estimate under-counted provider-side bytes (tool schemas, framing,
|
|
153
|
+
// provider-internal token accounting). Used exactly once per send; see the
|
|
154
|
+
// retry block around provider.send below.
|
|
155
|
+
const OVERFLOW_RETRY_TRIM_PERCENT = 0.60;
|
|
156
|
+
|
|
157
|
+
// Cache-hit results always inline the cached body. The earlier size-gated
|
|
158
|
+
// `[cache-hit-ref]` branch confused bridge workers whose context did not
|
|
159
|
+
// contain the referenced prior tool_result, triggering shell-cat detours.
|
|
160
|
+
// Hard iteration ceiling for every agent loop. Reset to 0 whenever the
|
|
161
|
+
// transcript is compacted (see the trim block below): a long task that keeps
|
|
162
|
+
// compacting can proceed past this count, while a tight NON-compacting loop
|
|
163
|
+
// still stops here and returns the accumulated transcript.
|
|
164
|
+
const MAX_LOOP_ITERATIONS = 200;
|
|
165
|
+
// Consecutive identical-AND-failing tool calls (same name+args, error result)
|
|
166
|
+
// tolerated across iterations before the loop refuses to re-execute and steers
|
|
167
|
+
// the model to change approach. Distinct from the hard iteration cap above:
|
|
168
|
+
// this catches tight deterministic-failure loops (e.g. a command that errors
|
|
169
|
+
// the same way every time) far earlier than 100 iterations.
|
|
170
|
+
const REPEAT_FAIL_LIMIT = 3;
|
|
171
|
+
const _HIDDEN_ROLES_JSON = resolvePath(dirname(fileURLToPath(import.meta.url)), '../../../../defaults/hidden-roles.json');
|
|
172
|
+
let _hiddenRolesCache = null;
|
|
173
|
+
function _getHiddenRoles() {
|
|
174
|
+
if (_hiddenRolesCache) return _hiddenRolesCache;
|
|
175
|
+
try {
|
|
176
|
+
_hiddenRolesCache = JSON.parse(_readFileSync(_HIDDEN_ROLES_JSON, 'utf8'));
|
|
177
|
+
} catch { _hiddenRolesCache = { roles: [] }; }
|
|
178
|
+
return _hiddenRolesCache;
|
|
179
|
+
}
|
|
180
|
+
// Transcript pairing guard. Anthropic 400-rejects when an assistant message
|
|
181
|
+
// ends with tool_use blocks and the next message isn't tool results for
|
|
182
|
+
// those exact ids. abort/timeout/error race in the loop body can leave a
|
|
183
|
+
// dangling assistant tool_use at the tail (e.g. the structure_probe loop
|
|
184
|
+
// running 12 deep then aborting between push-assistant and push-tool).
|
|
185
|
+
// Strip any trailing assistant tool_use that has no matching tool result
|
|
186
|
+
// so provider.send sees a valid transcript instead of leaking the 400 to
|
|
187
|
+
// the user. Repair runs every iteration but is a no-op on healthy paths.
|
|
188
|
+
function _ensureTranscriptPairing(msgs, sessionId) {
|
|
189
|
+
// Walk backwards to find the last assistant message that emitted
|
|
190
|
+
// tool_use, then validate that every id has a matching tool result
|
|
191
|
+
// inside the CONTIGUOUS tool-message block immediately following it.
|
|
192
|
+
// Earlier guard splice'd the entire tail — which silently deleted any
|
|
193
|
+
// user prompt appended after the dangling assistant by manager.mjs:
|
|
194
|
+
// when the guard fired with shape
|
|
195
|
+
// [..., assistant{a,b}, tool{a}, user{new prompt}]
|
|
196
|
+
// the splice removed user{new prompt} along with the orphan suffix.
|
|
197
|
+
// Fix: remove only assistant + the contiguous tool block; preserve
|
|
198
|
+
// anything past it (user / system / next assistant) untouched.
|
|
199
|
+
let popped = 0;
|
|
200
|
+
while (msgs.length > 0) {
|
|
201
|
+
let lastAssistantIdx = -1;
|
|
202
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
203
|
+
const m = msgs[i];
|
|
204
|
+
if (m?.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
|
|
205
|
+
lastAssistantIdx = i;
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (lastAssistantIdx === -1) break;
|
|
210
|
+
// Collect the contiguous tool messages directly after this assistant.
|
|
211
|
+
// Anything past that block is unrelated (next user prompt, system
|
|
212
|
+
// marker, etc.) and must survive the repair.
|
|
213
|
+
let toolBlockEnd = lastAssistantIdx + 1;
|
|
214
|
+
while (toolBlockEnd < msgs.length && msgs[toolBlockEnd]?.role === 'tool') {
|
|
215
|
+
toolBlockEnd += 1;
|
|
216
|
+
}
|
|
217
|
+
const toolBlock = msgs.slice(lastAssistantIdx + 1, toolBlockEnd);
|
|
218
|
+
const ids = msgs[lastAssistantIdx].toolCalls.map(c => c.id);
|
|
219
|
+
const matched = ids.every(id => toolBlock.some(m => m.toolCallId === id));
|
|
220
|
+
if (matched) break;
|
|
221
|
+
const removed = toolBlockEnd - lastAssistantIdx;
|
|
222
|
+
msgs.splice(lastAssistantIdx, removed);
|
|
223
|
+
popped += removed;
|
|
224
|
+
}
|
|
225
|
+
// Second sweep — catch dangling tool results that survived the
|
|
226
|
+
// contiguous-block splice. Anthropic strict spec requires every
|
|
227
|
+
// tool result to sit in a contiguous block right after the
|
|
228
|
+
// assistant whose toolCalls produced it; a `[..., assistant{a,b},
|
|
229
|
+
// tool{a}, user, tool{b}]` shape leaves tool{b} orphaned even
|
|
230
|
+
// after assistant + tool{a} are repaired by the loop above.
|
|
231
|
+
// Walk back from each tool message to the nearest non-tool
|
|
232
|
+
// ancestor; if it is not an assistant whose toolCalls include
|
|
233
|
+
// this id, drop the orphan.
|
|
234
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
235
|
+
const m = msgs[i];
|
|
236
|
+
if (m?.role !== 'tool') continue;
|
|
237
|
+
if (!m.toolCallId) {
|
|
238
|
+
msgs.splice(i, 1);
|
|
239
|
+
popped += 1;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
let prevIdx = i - 1;
|
|
243
|
+
while (prevIdx >= 0 && msgs[prevIdx]?.role === 'tool') prevIdx--;
|
|
244
|
+
const anchor = prevIdx >= 0 ? msgs[prevIdx] : null;
|
|
245
|
+
const anchorOk = anchor?.role === 'assistant'
|
|
246
|
+
&& Array.isArray(anchor.toolCalls)
|
|
247
|
+
&& anchor.toolCalls.some(c => c.id === m.toolCallId);
|
|
248
|
+
if (!anchorOk) {
|
|
249
|
+
msgs.splice(i, 1);
|
|
250
|
+
popped += 1;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (popped > 0 && sessionId) {
|
|
254
|
+
try { process.stderr.write(`[transcript-repair] sess=${sessionId} popped=${popped} dangling assistant tool_use\n`); } catch {}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Write-class tools that a permission=read session must not execute. The
|
|
259
|
+
// schema still advertises them to keep one unified shard; this runtime set
|
|
260
|
+
// is the fail-safe reject at call time.
|
|
261
|
+
const READ_BLOCKED_TOOLS = new Set([
|
|
262
|
+
'bash', 'bash_session',
|
|
263
|
+
'write',
|
|
264
|
+
'edit',
|
|
265
|
+
'apply_patch',
|
|
266
|
+
]);
|
|
267
|
+
const MCP_ONLY_ALLOWED_KINDS = new Set(['mcp', 'internal', 'skill']);
|
|
268
|
+
// Wrappers that hidden retrieval roles back. Hidden roles MUST NOT call
|
|
269
|
+
// these or they spawn another hidden agent of the same kind — nested chain
|
|
270
|
+
// + token burn. Block at call time; the role's rule prompt also says so.
|
|
271
|
+
const RETRIEVAL_WRAPPERS = new Set(['recall', 'search', 'explore']);
|
|
272
|
+
// Hidden roles that may call specific retrieval wrappers. Default policy
|
|
273
|
+
// blocks all hidden→wrapper calls; roles listed here have a documented
|
|
274
|
+
// need:
|
|
275
|
+
// - scheduler-task / webhook-handler: state-changing agents whose
|
|
276
|
+
// tasks routinely require both reach-back into past context
|
|
277
|
+
// (`recall`) and fresh external info (`search`).
|
|
278
|
+
const HIDDEN_ROLE_WRAPPER_ALLOWLIST = {
|
|
279
|
+
'scheduler-task': new Set(['recall', 'search']),
|
|
280
|
+
'webhook-handler': new Set(['recall', 'search']),
|
|
281
|
+
};
|
|
282
|
+
// Eager-dispatch: tools with readOnlyHint:true in their declaration are safe
|
|
283
|
+
// to execute during SSE parsing so tool work overlaps with the rest of the
|
|
284
|
+
// stream. Writes, bash, MCP and skills stay serial after send() returns.
|
|
285
|
+
function isEagerDispatchable(name, tools) {
|
|
286
|
+
if (!Array.isArray(tools)) return false;
|
|
287
|
+
const def = tools.find(t => t?.name === name);
|
|
288
|
+
return def?.annotations?.readOnlyHint === true;
|
|
289
|
+
}
|
|
290
|
+
// ── Bridge-worker permission enforcement ──────────────────────────────────────
|
|
291
|
+
// Mirrors the PreToolUse hook evaluation for tool calls that originate inside a
|
|
292
|
+
// bridge worker session. Worker dispatch previously bypassed the hook pipeline
|
|
293
|
+
// entirely; this guard closes that gap by running the same evaluator inline.
|
|
294
|
+
//
|
|
295
|
+
// `ask` is treated as deny here — forwarding `ask` decisions to the channel
|
|
296
|
+
// UI approval flow needs bidirectional prompt plumbing that does not exist.
|
|
297
|
+
function _checkWorkerPermission(toolName, toolInput, sessionRef) {
|
|
298
|
+
const bareToolName = _stripMcpPrefix(toolName);
|
|
299
|
+
if (sessionRef?.owner === 'bridge' && bareToolName === 'bash') {
|
|
300
|
+
const cmdClass = classifyBashFileLookupCommand(toolInput?.command);
|
|
301
|
+
if (cmdClass) {
|
|
302
|
+
return `Error: bridge worker bash file lookup blocked (${cmdClass}). Use Mixdog MCP read/grep/glob/list directly; bash is only for build/test/run/git-style commands.`;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// Even when no explicit permissionMode is propagated to the worker, run
|
|
306
|
+
// the evaluator under the most restrictive baseline ('default') so the
|
|
307
|
+
// bypass-proof hard-deny patterns (UNC paths, /etc, C:/Windows, etc.)
|
|
308
|
+
// and the user's settings.json deny rules still apply. Previously a
|
|
309
|
+
// missing permissionMode short-circuited to null and the worker
|
|
310
|
+
// ran ungated — a model could dispatch a bridge to read or write
|
|
311
|
+
// protected paths even when the same call would have been denied for
|
|
312
|
+
// the parent. Callers that genuinely need bypassPermissions can still
|
|
313
|
+
// forward it explicitly via session-builder; this only closes the
|
|
314
|
+
// silent default-to-bypass path.
|
|
315
|
+
const permissionMode = sessionRef?.permissionMode || 'default';
|
|
316
|
+
// Prefix bare mixdog tool names so the evaluator path-logic handles them correctly.
|
|
317
|
+
const fullName = toolName.startsWith(MCP_TOOL_PREFIX) || toolName.startsWith('mcp__')
|
|
318
|
+
? toolName
|
|
319
|
+
: `${MCP_TOOL_PREFIX}${toolName}`;
|
|
320
|
+
const projectDir = sessionRef?.cwd || undefined;
|
|
321
|
+
const userCwd = sessionRef?.cwd || undefined;
|
|
322
|
+
try {
|
|
323
|
+
const { decision, reason } = _evaluatePermission({
|
|
324
|
+
toolName: fullName,
|
|
325
|
+
toolInput: toolInput || {},
|
|
326
|
+
permissionMode,
|
|
327
|
+
projectDir,
|
|
328
|
+
userCwd,
|
|
329
|
+
});
|
|
330
|
+
if (decision === 'deny' || decision === 'ask') {
|
|
331
|
+
return `Error: tool "${toolName}" blocked by permission evaluator (decision=${decision}): ${reason}`;
|
|
332
|
+
}
|
|
333
|
+
} catch (err) {
|
|
334
|
+
// Evaluator errors must not crash the loop — log and allow.
|
|
335
|
+
try { process.stderr.write(`[permission-evaluator] error: ${err?.message}\n`); } catch {}
|
|
336
|
+
}
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
function effectiveToolPermission(sessionRef) {
|
|
340
|
+
return sessionRef?.toolPermission || sessionRef?.permission || null;
|
|
341
|
+
}
|
|
342
|
+
function isBlockedByPermission(toolName, toolKind, permission) {
|
|
343
|
+
if (permission === 'mcp') return !MCP_ONLY_ALLOWED_KINDS.has(toolKind);
|
|
344
|
+
if (permission === 'read') return READ_BLOCKED_TOOLS.has(toolName);
|
|
345
|
+
// Object-form {allow,deny} permission (role template / profile). The
|
|
346
|
+
// schema-level intersection in createSession only narrows the ADVERTISED
|
|
347
|
+
// tool list; it is not a runtime execution boundary. Enforce the same
|
|
348
|
+
// allow/deny here as the fail-safe so a tool call for a non-advertised
|
|
349
|
+
// (denied / out-of-allow) tool is rejected at dispatch time, matching
|
|
350
|
+
// the string-form ('read'/'mcp') guards. Names are compared bare +
|
|
351
|
+
// lowercased to mirror createSession's allow/deny set construction.
|
|
352
|
+
if (permission && typeof permission === 'object') {
|
|
353
|
+
const name = String(_stripMcpPrefix(toolName) || '').toLowerCase();
|
|
354
|
+
const deny = Array.isArray(permission.deny) && permission.deny.length > 0
|
|
355
|
+
? permission.deny.map(n => String(n).toLowerCase())
|
|
356
|
+
: null;
|
|
357
|
+
if (deny && deny.includes(name)) return true;
|
|
358
|
+
const allow = Array.isArray(permission.allow) && permission.allow.length > 0
|
|
359
|
+
? permission.allow.map(n => String(n).toLowerCase())
|
|
360
|
+
: null;
|
|
361
|
+
if (allow && !allow.includes(name)) return true;
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
function isBlockedHiddenWrapperCall(toolName, sessionRef) {
|
|
367
|
+
if (!RETRIEVAL_WRAPPERS.has(toolName)) return false;
|
|
368
|
+
if (sessionRef?.owner !== 'bridge') return false;
|
|
369
|
+
if (!isHiddenRole(sessionRef?.role)) return false;
|
|
370
|
+
const allow = HIDDEN_ROLE_WRAPPER_ALLOWLIST[sessionRef.role];
|
|
371
|
+
if (allow && allow.has(toolName)) return false;
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
function messagesArrayChanged(before, after) {
|
|
375
|
+
if (!Array.isArray(before) || !Array.isArray(after)) return before !== after;
|
|
376
|
+
if (before.length !== after.length) return true;
|
|
377
|
+
for (let i = 0; i < before.length; i += 1) {
|
|
378
|
+
if (before[i] !== after[i]) return true;
|
|
379
|
+
}
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
const SKILL_TOOL_NAMES = new Set(['skills_list', 'skill_view', 'skill_execute']);
|
|
383
|
+
const SPECIAL_TOOL_NAMES = new Set(['bash_session', 'apply_patch', 'code_graph']);
|
|
384
|
+
const BASH_SESSION_HEADER_RE = /\[session: ([^\]\r\n]+)\]/;
|
|
385
|
+
const STORED_TOOL_ARG_BODY_KEY_RE = /^(?:content|old_string|new_string|patch|rewrite)$/i;
|
|
386
|
+
const STORED_TOOL_ARG_LONG_KEY_RE = /^(?:command|script)$/i;
|
|
387
|
+
const STORED_TOOL_ARG_BODY_LIMIT = 2_000;
|
|
388
|
+
const STORED_TOOL_ARG_LONG_LIMIT = 8_000;
|
|
389
|
+
const STORED_TOOL_ARG_PREVIEW_HEAD = 360;
|
|
390
|
+
const STORED_TOOL_ARG_PREVIEW_TAIL = 160;
|
|
391
|
+
|
|
392
|
+
function compactStoredToolArgString(value, key = '') {
|
|
393
|
+
if (typeof value !== 'string') return value;
|
|
394
|
+
const isBody = STORED_TOOL_ARG_BODY_KEY_RE.test(key);
|
|
395
|
+
const isLong = isBody || STORED_TOOL_ARG_LONG_KEY_RE.test(key);
|
|
396
|
+
const limit = isBody ? STORED_TOOL_ARG_BODY_LIMIT : (isLong ? STORED_TOOL_ARG_LONG_LIMIT : Infinity);
|
|
397
|
+
if (value.length <= limit) return value;
|
|
398
|
+
const hash = createHash('sha256').update(value).digest('hex').slice(0, 16);
|
|
399
|
+
const head = value.slice(0, STORED_TOOL_ARG_PREVIEW_HEAD).replace(/\r\n/g, '\n');
|
|
400
|
+
const tail = value.slice(-STORED_TOOL_ARG_PREVIEW_TAIL).replace(/\r\n/g, '\n');
|
|
401
|
+
return `[mixdog compacted ${key || 'string'}: ${value.length} chars, sha256:${hash}]\n${head}\n... [middle omitted from stored tool-call args] ...\n${tail}`;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function compactStoredToolArgValue(value, key = '', depth = 0) {
|
|
405
|
+
if (value === null || value === undefined) return value;
|
|
406
|
+
if (typeof value === 'string') return compactStoredToolArgString(value, key);
|
|
407
|
+
if (typeof value !== 'object') return value;
|
|
408
|
+
if (depth >= 6) return Array.isArray(value) ? `[${value.length} items]` : '{...}';
|
|
409
|
+
if (Array.isArray(value)) {
|
|
410
|
+
return value.map((item) => compactStoredToolArgValue(item, key, depth + 1));
|
|
411
|
+
}
|
|
412
|
+
const out = {};
|
|
413
|
+
for (const [k, v] of Object.entries(value)) {
|
|
414
|
+
out[k] = compactStoredToolArgValue(v, k, depth + 1);
|
|
415
|
+
}
|
|
416
|
+
return out;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function compactToolCallsForHistory(calls) {
|
|
420
|
+
if (!Array.isArray(calls)) return calls;
|
|
421
|
+
return calls.map((call) => {
|
|
422
|
+
if (!call || typeof call !== 'object') return call;
|
|
423
|
+
return {
|
|
424
|
+
...call,
|
|
425
|
+
arguments: compactStoredToolArgValue(call.arguments),
|
|
426
|
+
};
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Restore the FULL body of ONE tool call inside a history assistant message
|
|
431
|
+
// whose toolCalls were compacted at push time. Used for a failed edit call so
|
|
432
|
+
// the model sees the original patch/old_string on retry instead of a
|
|
433
|
+
// `[mixdog compacted …]` placeholder it cannot act on. Must run BEFORE the
|
|
434
|
+
// message is first transmitted so it never mutates an already-cached prefix
|
|
435
|
+
// (the prompt cache is content-prefix matched).
|
|
436
|
+
//
|
|
437
|
+
// Only the compactable body/long keys (patch, old_string, new_string, content,
|
|
438
|
+
// rewrite, command, script) are restored, and at ANY depth — compaction is
|
|
439
|
+
// recursive (compactStoredToolArgValue), so batch shapes like edits[].old_string
|
|
440
|
+
// or writes[].content carry nested compacted bodies too. Every other field
|
|
441
|
+
// (e.g. `path`, which a tool may mutate in place during execution) is taken from
|
|
442
|
+
// the compacted snapshot captured at push time, before any mutation. The
|
|
443
|
+
// compacted args tree is built fresh by compactToolCallsForHistory and is not
|
|
444
|
+
// shared with originalCalls, so rebuilding it here is safe.
|
|
445
|
+
function restoreToolCallBodyForId(assistantMsg, originalCalls, callId) {
|
|
446
|
+
if (!assistantMsg || !Array.isArray(assistantMsg.toolCalls) || !callId) return;
|
|
447
|
+
if (!Array.isArray(originalCalls)) return;
|
|
448
|
+
const tc = assistantMsg.toolCalls.find((t) => t && t.id === callId);
|
|
449
|
+
const orig = originalCalls.find((c) => c && c.id === callId);
|
|
450
|
+
if (!tc || !orig) return;
|
|
451
|
+
if (!tc.arguments || typeof tc.arguments !== 'object'
|
|
452
|
+
|| !orig.arguments || typeof orig.arguments !== 'object') return;
|
|
453
|
+
tc.arguments = _restoreCompactedBodies(tc.arguments, orig.arguments, '');
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Recursively rebuild a compacted args tree: replace ONLY compactable body/long
|
|
457
|
+
// string fields (matched by key at any depth) with their full originals, and
|
|
458
|
+
// keep every other field from the compacted snapshot. tcVal and origVal share
|
|
459
|
+
// the same structure (compaction only shortens body strings), so the walk
|
|
460
|
+
// descends them in parallel; a missing or non-object origVal falls back to the
|
|
461
|
+
// compacted value rather than throwing.
|
|
462
|
+
function _restoreCompactedBodies(tcVal, origVal, key) {
|
|
463
|
+
if ((STORED_TOOL_ARG_BODY_KEY_RE.test(key) || STORED_TOOL_ARG_LONG_KEY_RE.test(key))
|
|
464
|
+
&& typeof origVal === 'string') {
|
|
465
|
+
return origVal;
|
|
466
|
+
}
|
|
467
|
+
if (Array.isArray(tcVal) && Array.isArray(origVal)) {
|
|
468
|
+
return tcVal.map((item, i) => _restoreCompactedBodies(item, origVal[i], key));
|
|
469
|
+
}
|
|
470
|
+
if (tcVal && typeof tcVal === 'object' && origVal && typeof origVal === 'object') {
|
|
471
|
+
const out = {};
|
|
472
|
+
for (const k of Object.keys(tcVal)) {
|
|
473
|
+
out[k] = (k in origVal) ? _restoreCompactedBodies(tcVal[k], origVal[k], k) : tcVal[k];
|
|
474
|
+
}
|
|
475
|
+
return out;
|
|
476
|
+
}
|
|
477
|
+
return tcVal;
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Execute a single tool call — routes to MCP or builtin.
|
|
481
|
+
*/
|
|
482
|
+
function getToolKind(name) {
|
|
483
|
+
if (SKILL_TOOL_NAMES.has(name)) return 'skill';
|
|
484
|
+
if (SPECIAL_TOOL_NAMES.has(name)) return 'builtin';
|
|
485
|
+
if (isMcpTool(name)) return 'mcp';
|
|
486
|
+
if (isInternalTool(name)) return 'internal';
|
|
487
|
+
if (isBuiltinTool(name)) return 'builtin';
|
|
488
|
+
return 'builtin';
|
|
489
|
+
}
|
|
490
|
+
function buildSkillsListResponse(cwd) {
|
|
491
|
+
const skills = collectSkillsCached(cwd);
|
|
492
|
+
const entries = skills.map(s => ({ name: s.name, description: s.description || '' }));
|
|
493
|
+
return JSON.stringify({ skills: entries });
|
|
494
|
+
}
|
|
495
|
+
function viewSkill(cwd, name) {
|
|
496
|
+
if (!name) return 'Error: skill name is required';
|
|
497
|
+
const content = loadSkillContent(name, cwd);
|
|
498
|
+
return content || `Error: skill "${name}" not found`;
|
|
499
|
+
}
|
|
500
|
+
function executeSkill(cwd, name, _args) {
|
|
501
|
+
if (!name) return 'Error: skill name is required';
|
|
502
|
+
const content = loadSkillContent(name, cwd);
|
|
503
|
+
return content || `Error: skill "${name}" not found`;
|
|
504
|
+
}
|
|
505
|
+
function extractBashSessionId(result) {
|
|
506
|
+
if (typeof result !== 'string') return null;
|
|
507
|
+
const match = BASH_SESSION_HEADER_RE.exec(result);
|
|
508
|
+
return match ? match[1] : null;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export function buildBridgeBashSessionArgs(args, sessionRef) {
|
|
512
|
+
if (sessionRef?.owner !== 'bridge') return null;
|
|
513
|
+
// run_in_background is a detached one-shot job, incompatible with the
|
|
514
|
+
// persistent bash session. Fall through to the background-job path
|
|
515
|
+
// (executeBuiltinTool -> startBackgroundShellJob) so the worker gets a
|
|
516
|
+
// [job: ...] id that job_wait can resolve — otherwise the persistent
|
|
517
|
+
// session returns a [session: ...] header and job_wait reports "job not found".
|
|
518
|
+
if (args?.run_in_background === true) return null;
|
|
519
|
+
const routedArgs = { ...(args || {}) };
|
|
520
|
+
const explicitSessionId = typeof routedArgs.session_id === 'string' && routedArgs.session_id.trim()
|
|
521
|
+
? routedArgs.session_id.trim()
|
|
522
|
+
: null;
|
|
523
|
+
const wantsPersistent = routedArgs.persistent === true || !!explicitSessionId;
|
|
524
|
+
if (!wantsPersistent) return null;
|
|
525
|
+
if (!explicitSessionId && sessionRef?.implicitBashSessionId) {
|
|
526
|
+
routedArgs.session_id = sessionRef.implicitBashSessionId;
|
|
527
|
+
} else if (explicitSessionId) {
|
|
528
|
+
routedArgs.session_id = explicitSessionId;
|
|
529
|
+
}
|
|
530
|
+
delete routedArgs.persistent;
|
|
531
|
+
return routedArgs;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function _scopedCacheOutcomeForCall(sessionRef, toolCallId, toolName, callerSessionId, executeOpts = {}) {
|
|
535
|
+
if (executeOpts.scopedCacheOutcome) {
|
|
536
|
+
if (sessionRef && toolCallId) {
|
|
537
|
+
if (!sessionRef._scopedCacheOutcomeByCallId) sessionRef._scopedCacheOutcomeByCallId = new Map();
|
|
538
|
+
sessionRef._scopedCacheOutcomeByCallId.set(toolCallId, executeOpts.scopedCacheOutcome);
|
|
539
|
+
}
|
|
540
|
+
return executeOpts.scopedCacheOutcome;
|
|
541
|
+
}
|
|
542
|
+
if (!callerSessionId || !toolCallId || !_isScopedCacheableTool(toolName)) return null;
|
|
543
|
+
const outcome = createScopedCacheOutcome();
|
|
544
|
+
if (sessionRef) {
|
|
545
|
+
if (!sessionRef._scopedCacheOutcomeByCallId) sessionRef._scopedCacheOutcomeByCallId = new Map();
|
|
546
|
+
sessionRef._scopedCacheOutcomeByCallId.set(toolCallId, outcome);
|
|
547
|
+
}
|
|
548
|
+
return outcome;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function executeTool(name, args, cwd, callerSessionId, sessionRef, executeOpts = {}) {
|
|
552
|
+
const scopedCacheOutcome = _scopedCacheOutcomeForCall(
|
|
553
|
+
sessionRef,
|
|
554
|
+
executeOpts.toolCallId,
|
|
555
|
+
name,
|
|
556
|
+
callerSessionId,
|
|
557
|
+
executeOpts,
|
|
558
|
+
);
|
|
559
|
+
const toolOpts = scopedCacheOutcome
|
|
560
|
+
? { ...executeOpts, scopedCacheOutcome }
|
|
561
|
+
: executeOpts;
|
|
562
|
+
if (name === 'skills_list') {
|
|
563
|
+
return buildSkillsListResponse(cwd);
|
|
564
|
+
}
|
|
565
|
+
if (name === 'skill_view') {
|
|
566
|
+
return viewSkill(cwd, args?.name);
|
|
567
|
+
}
|
|
568
|
+
if (name === 'skill_execute') {
|
|
569
|
+
return executeSkill(cwd, args?.name, args?.args);
|
|
570
|
+
}
|
|
571
|
+
if (isMcpTool(name)) {
|
|
572
|
+
// 24h trace data shows ~24% of external MCP calls are cwd-sensitive
|
|
573
|
+
// (bash / grep / read / list / glob etc.) but the worker session's
|
|
574
|
+
// cwd was previously dropped here. Inject cwd only when the tool's
|
|
575
|
+
// inputSchema declares the field — schemas without it would reject
|
|
576
|
+
// an unknown argument.
|
|
577
|
+
const needsCwdInjection = cwd
|
|
578
|
+
&& mcpToolHasField(name, 'cwd')
|
|
579
|
+
&& (args == null || args.cwd == null);
|
|
580
|
+
const finalArgs = needsCwdInjection ? { ...(args || {}), cwd } : args;
|
|
581
|
+
return executeMcpTool(name, finalArgs);
|
|
582
|
+
}
|
|
583
|
+
if (isCodeGraphTool(name)) {
|
|
584
|
+
// cwd chain: args.cwd (caller-explicit) → session cwd → undefined (handler throws)
|
|
585
|
+
const graphCwd = (typeof args?.cwd === 'string' && args.cwd.trim()) ? args.cwd.trim() : cwd;
|
|
586
|
+
return executeCodeGraphTool(name, args, graphCwd, null, toolOpts);
|
|
587
|
+
}
|
|
588
|
+
if (isInternalTool(name)) {
|
|
589
|
+
// callerSessionId propagates into server.mjs dispatchTool so that
|
|
590
|
+
// dispatchAiWrapped can detect and reject recursive calls from a
|
|
591
|
+
// hidden-role session (recall/search/explore → self).
|
|
592
|
+
return executeInternalTool(name, args, { callerSessionId, callerCwd: cwd });
|
|
593
|
+
}
|
|
594
|
+
if (name === 'bash') {
|
|
595
|
+
const routedArgs = buildBridgeBashSessionArgs(args, sessionRef);
|
|
596
|
+
if (!routedArgs) {
|
|
597
|
+
// clientHostPid scopes background shell-jobs to the dispatching
|
|
598
|
+
// terminal's claude.exe pid (bridge sessions store it on sessionRef);
|
|
599
|
+
// without it resolveJobOwnerHostPid falls back to the daemon-global env.
|
|
600
|
+
return executeBuiltinTool(name, args, cwd, { sessionId: callerSessionId, clientHostPid: sessionRef?.clientHostPid, ...toolOpts });
|
|
601
|
+
}
|
|
602
|
+
// Thread the session's AbortSignal so bridge type=close can interrupt the
|
|
603
|
+
// persistent child process. getSessionAbortSignal is imported at top of
|
|
604
|
+
// loop.mjs from manager.mjs; callerSessionId identifies the controller.
|
|
605
|
+
let _bashAbortSignal = null;
|
|
606
|
+
try { _bashAbortSignal = getSessionAbortSignal(callerSessionId); } catch { /* ignore */ }
|
|
607
|
+
const result = await executeBashSessionTool('bash_session', routedArgs, cwd, {
|
|
608
|
+
sessionId: callerSessionId,
|
|
609
|
+
abortSignal: _bashAbortSignal,
|
|
610
|
+
});
|
|
611
|
+
const bashSid = extractBashSessionId(result);
|
|
612
|
+
if (bashSid) {
|
|
613
|
+
sessionRef.implicitBashSessionId = bashSid;
|
|
614
|
+
// Track all persistent bash sessions for bulk teardown on close.
|
|
615
|
+
if (sessionRef.allBashSessionIds) {
|
|
616
|
+
if (!sessionRef.allBashSessionIds.includes(bashSid)) {
|
|
617
|
+
sessionRef.allBashSessionIds.push(bashSid);
|
|
618
|
+
}
|
|
619
|
+
} else {
|
|
620
|
+
sessionRef.allBashSessionIds = [bashSid];
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return result;
|
|
624
|
+
}
|
|
625
|
+
if (name === 'apply_patch') {
|
|
626
|
+
return executePatchTool(name, args, cwd, { sessionId: callerSessionId });
|
|
627
|
+
}
|
|
628
|
+
if (isBuiltinTool(name)) {
|
|
629
|
+
// clientHostPid threaded for the same per-terminal job-scope reason as
|
|
630
|
+
// the bash branch above (see resolveJobOwnerHostPid).
|
|
631
|
+
return executeBuiltinTool(name, args, cwd, { sessionId: callerSessionId, clientHostPid: sessionRef?.clientHostPid, ...toolOpts });
|
|
632
|
+
}
|
|
633
|
+
return formatUnknownBuiltinToolMessage(name, args, 'tool');
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Agent loop: send → tool_call → execute → re-send → repeat until text.
|
|
637
|
+
* sendOpts may include:
|
|
638
|
+
* - `effort` (provider-specific)
|
|
639
|
+
* - `fast` (boolean)
|
|
640
|
+
* - `sessionId` — enables runtime liveness markers (optional)
|
|
641
|
+
* - `signal` — AbortSignal; checked at each iteration boundary and after each
|
|
642
|
+
* tool. When aborted, throws SessionClosedError so the ask
|
|
643
|
+
* wrapper can propagate a clean cancellation.
|
|
644
|
+
* - `onStageChange(stage)` / `onStreamDelta()` — forwarded to provider.send for heartbeats
|
|
645
|
+
*/
|
|
646
|
+
// Source of truth: defaults/hidden-roles.json (loaded via _getHiddenRoles
|
|
647
|
+
// above). Build the name Set eagerly at module load so HIDDEN_ROLE_NAMES
|
|
648
|
+
// stays in sync with the declarative registry — no hardcoded duplicate.
|
|
649
|
+
const HIDDEN_ROLE_NAMES = new Set(
|
|
650
|
+
(_getHiddenRoles().roles || []).map((r) => r && r.name).filter((n) => typeof n === 'string' && n.length > 0)
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
// Stop reasons that signal the turn was cut short mid-synthesis (token cap,
|
|
654
|
+
// provider pause). Empty content + one of these reasons means the worker
|
|
655
|
+
// was not done — re-prompt instead of accepting empty as final.
|
|
656
|
+
// Covers Anthropic (pause_turn, max_tokens), OpenAI (length), Gemini
|
|
657
|
+
// (MAX_TOKENS, OTHER), and case variants.
|
|
658
|
+
const INCOMPLETE_STOP_REASONS = new Set([
|
|
659
|
+
'pause_turn', 'max_tokens', 'length', 'MAX_TOKENS', 'OTHER',
|
|
660
|
+
]);
|
|
661
|
+
|
|
662
|
+
export async function agentLoop(provider, messages, model, tools, onToolCall, cwd, sendOpts) {
|
|
663
|
+
let iterations = 0;
|
|
664
|
+
let toolCallsTotal = 0;
|
|
665
|
+
let lastUsage;
|
|
666
|
+
let firstTurnUsage;
|
|
667
|
+
let response;
|
|
668
|
+
let contractNudges = 0;
|
|
669
|
+
const opts = sendOpts || {};
|
|
670
|
+
const sessionId = opts.sessionId || null;
|
|
671
|
+
const signal = opts.signal || null;
|
|
672
|
+
const sessionRole = opts.session?.role;
|
|
673
|
+
const forcedFirstTool = opts.forcedFirstTool ?? null;
|
|
674
|
+
const forcedFirstToolDef = forcedFirstTool
|
|
675
|
+
? tools.find(tool => tool?.name === forcedFirstTool)
|
|
676
|
+
: null;
|
|
677
|
+
// Opaque providerState passthrough. The loop never inspects it; only the
|
|
678
|
+
// originating provider does. Seed from sendOpts.providerState if the
|
|
679
|
+
// manager restored one. No provider currently emits state (Codex OAuth is
|
|
680
|
+
// stateless per contract); field remains undefined end-to-end for now.
|
|
681
|
+
let providerState = opts.providerState ?? undefined;
|
|
682
|
+
const throwIfAborted = () => {
|
|
683
|
+
if (signal?.aborted) {
|
|
684
|
+
const reason = signal.reason instanceof Error ? signal.reason : null;
|
|
685
|
+
// Preserve any structured abort reason (SessionClosedError,
|
|
686
|
+
// StreamStalledAbortError, etc.). Fallback to SessionClosedError
|
|
687
|
+
// when the reason is not an Error instance.
|
|
688
|
+
if (reason) throw reason;
|
|
689
|
+
throw new SessionClosedError(sessionId || 'unknown', 'agent loop aborted');
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
const sessionRef = opts.session || null;
|
|
693
|
+
const maxLoopIterations = Number.isFinite(sessionRef?.maxLoopIterations)
|
|
694
|
+
? sessionRef.maxLoopIterations
|
|
695
|
+
: MAX_LOOP_ITERATIONS;
|
|
696
|
+
// Tool execution must use the session cwd even when the caller omitted the
|
|
697
|
+
// legacy positional cwd argument. Bridge workers always carry their cwd on
|
|
698
|
+
// sessionRef; falling through to pwd()/process.cwd() resolves relatives
|
|
699
|
+
// against the host/plugin root instead of the worker workspace.
|
|
700
|
+
cwd = cwd || sessionRef?.cwd || undefined;
|
|
701
|
+
while (true) {
|
|
702
|
+
throwIfAborted();
|
|
703
|
+
if (iterations >= maxLoopIterations) {
|
|
704
|
+
process.stderr.write(`[loop] hard iteration cap ${maxLoopIterations} reached (sess=${sessionId || 'unknown'}); stopping loop.\n`);
|
|
705
|
+
break;
|
|
706
|
+
}
|
|
707
|
+
if (sessionRef && typeof sessionRef.contextWindow === 'number') {
|
|
708
|
+
const safetyBudget = Math.floor(sessionRef.contextWindow * SAFETY_TRIM_PERCENT);
|
|
709
|
+
// Reserve headroom for the tool schemas + provider request framing
|
|
710
|
+
// that ride alongside `messages` but are invisible to the chars/4
|
|
711
|
+
// message estimate. Without this the budget is optimistic: a
|
|
712
|
+
// transcript that "fits" by message tokens can still overflow once
|
|
713
|
+
// the provider serializes N tool definitions into the same request.
|
|
714
|
+
const reserveTokens = estimateRequestReserveTokens(tools);
|
|
715
|
+
// Snapshot pre-trim shape so trim_meta can record the actual
|
|
716
|
+
// mutation (or no-op) for prefix-mutation forensics. Bytes are
|
|
717
|
+
// a best-effort JSON.stringify length — close enough to the
|
|
718
|
+
// payload we hand the provider for prefix-cache analysis.
|
|
719
|
+
const beforeCount = messages.length;
|
|
720
|
+
let beforeBytes = null;
|
|
721
|
+
try { beforeBytes = Buffer.byteLength(JSON.stringify(messages), 'utf8'); } catch { beforeBytes = null; }
|
|
722
|
+
const trimmed = trimMessages(messages, safetyBudget, { reserveTokens });
|
|
723
|
+
const trimChanged = messagesArrayChanged(messages, trimmed);
|
|
724
|
+
const pruneCount = Math.max(beforeCount - trimmed.length, 0);
|
|
725
|
+
if (trimChanged) {
|
|
726
|
+
messages.length = 0;
|
|
727
|
+
messages.push(...trimmed);
|
|
728
|
+
// Trimming the transcript invalidates the server-side
|
|
729
|
+
// conversation anchor (xAI Responses / Codex WS rely on
|
|
730
|
+
// previous_response_id which points at a now-mutated prefix).
|
|
731
|
+
// Drop providerState so the next send starts a fresh chain
|
|
732
|
+
// instead of triggering silent cache miss or hard mismatch.
|
|
733
|
+
providerState = undefined;
|
|
734
|
+
// Compaction shrank the transcript, so prior turns no longer
|
|
735
|
+
// pressure the window — reset the iteration counter so a
|
|
736
|
+
// steadily-compacting long task isn't killed by the cap,
|
|
737
|
+
// while a non-compacting tight loop still hits it.
|
|
738
|
+
iterations = 0;
|
|
739
|
+
}
|
|
740
|
+
let afterBytes = null;
|
|
741
|
+
try { afterBytes = Buffer.byteLength(JSON.stringify(messages), 'utf8'); } catch { afterBytes = null; }
|
|
742
|
+
traceBridgeTrim({
|
|
743
|
+
sessionId,
|
|
744
|
+
iteration: iterations + 1,
|
|
745
|
+
prune_count: pruneCount,
|
|
746
|
+
trim_changed: trimChanged,
|
|
747
|
+
input_prefix_hash: messagePrefixHash(messages),
|
|
748
|
+
before_count: beforeCount,
|
|
749
|
+
after_count: messages.length,
|
|
750
|
+
before_bytes: beforeBytes,
|
|
751
|
+
after_bytes: afterBytes,
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
const nextIteration = iterations + 1;
|
|
755
|
+
opts.iteration = nextIteration;
|
|
756
|
+
opts.providerState = providerState;
|
|
757
|
+
if (forcedFirstTool && toolCallsTotal === 0) {
|
|
758
|
+
opts.toolChoice = 'required';
|
|
759
|
+
} else {
|
|
760
|
+
delete opts.toolChoice;
|
|
761
|
+
}
|
|
762
|
+
const sendTools = forcedFirstToolDef && toolCallsTotal === 0 ? [forcedFirstToolDef] : tools;
|
|
763
|
+
// Eager-dispatch queue: when the provider streams a tool-call event,
|
|
764
|
+
// start read-only tools immediately so execution overlaps with the
|
|
765
|
+
// remaining SSE parse. Writes and unknown tools wait until send()
|
|
766
|
+
// returns and run serially in the call-order loop below.
|
|
767
|
+
const pending = new Map();
|
|
768
|
+
// Streaming-time intra-turn dedup. When the LLM emits two
|
|
769
|
+
// tool_use blocks with identical (name, args) signatures in
|
|
770
|
+
// sequence, the provider's onToolCall fires for both BEFORE
|
|
771
|
+
// the iter for-body runs, so the batch-level pre-pass would be
|
|
772
|
+
// too late to prevent the eager dispatch of the second one.
|
|
773
|
+
// Track signatures of in-flight eager calls and skip starting a
|
|
774
|
+
// second one for the same sig. The duplicate's executeTool is
|
|
775
|
+
// never invoked; the for-body's pre-pass marks it as a duplicate
|
|
776
|
+
// and emits a stub tool_result. The sig is NOT cleared when the
|
|
777
|
+
// eager promise settles (see finally below): a streaming onToolCall
|
|
778
|
+
// can deliver a same-turn identical call AFTER the first promise
|
|
779
|
+
// settles but BEFORE the deferred cache set (:1256), and the static
|
|
780
|
+
// pre-pass (:909) only runs after send() returns — so clearing the
|
|
781
|
+
// sig on settle would let that second streaming eager call
|
|
782
|
+
// re-execute. A fresh Map() is created per turn, so the sig set
|
|
783
|
+
// resets at the turn boundary without leaking across iterations.
|
|
784
|
+
const _eagerInFlightSigs = new Map();
|
|
785
|
+
let _mutationEpoch = 0;
|
|
786
|
+
const startEagerTool = (call) => {
|
|
787
|
+
if (!call?.id || pending.has(call.id) || !isEagerDispatchable(call.name, tools)) return null;
|
|
788
|
+
const _sig = _intraTurnSig(call.name, call.arguments);
|
|
789
|
+
if (_eagerInFlightSigs.has(_sig)) return null;
|
|
790
|
+
// Repeat-failure guard also gates eager dispatch (reviewer-flagged):
|
|
791
|
+
// streaming onToolCall / startEagerRun would otherwise re-run an
|
|
792
|
+
// identical read-only call that already failed REPEAT_FAIL_LIMIT
|
|
793
|
+
// times before the serial for-body guard runs. Returning null here
|
|
794
|
+
// lets the serial body push the [repeat-failure-guard] stub.
|
|
795
|
+
{
|
|
796
|
+
const _rfg = sessionRef?._repeatFailGuard;
|
|
797
|
+
if (_rfg && _rfg.sig === _sig && _rfg.count >= REPEAT_FAIL_LIMIT) return null;
|
|
798
|
+
}
|
|
799
|
+
const toolKind = getToolKind(call.name);
|
|
800
|
+
// Shared pre-dispatch deny: identical predicate runs in the
|
|
801
|
+
// serial path below. If any role/permission guard would reject
|
|
802
|
+
// this call there, never start it eagerly here.
|
|
803
|
+
if (_preDispatchDeny(call, toolKind, sessionRef) !== null) return null;
|
|
804
|
+
const entry = { startedAt: Date.now(), endedAt: null, mutationEpoch: _mutationEpoch };
|
|
805
|
+
_eagerInFlightSigs.set(_sig, call.id);
|
|
806
|
+
entry.promise = (async () => {
|
|
807
|
+
try {
|
|
808
|
+
const permBlocked = _checkWorkerPermission(call.name, call.arguments, sessionRef);
|
|
809
|
+
if (permBlocked !== null) return { ok: true, value: permBlocked };
|
|
810
|
+
return { ok: true, value: await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id }) };
|
|
811
|
+
} catch (error) {
|
|
812
|
+
return { ok: false, error };
|
|
813
|
+
}
|
|
814
|
+
})()
|
|
815
|
+
.finally(() => {
|
|
816
|
+
entry.endedAt = Date.now();
|
|
817
|
+
// Intentionally do NOT delete _sig here — see the block
|
|
818
|
+
// comment above. The sig must outlive promise settlement
|
|
819
|
+
// so a later same-turn streaming duplicate stays blocked
|
|
820
|
+
// at the _eagerInFlightSigs.has(_sig) guard until the turn
|
|
821
|
+
// boundary recreates the Map.
|
|
822
|
+
});
|
|
823
|
+
pending.set(call.id, entry);
|
|
824
|
+
return entry;
|
|
825
|
+
};
|
|
826
|
+
const startEagerRun = (calls, startIndex, dupSet) => {
|
|
827
|
+
for (let j = startIndex; j < calls.length; j += 1) {
|
|
828
|
+
const call = calls[j];
|
|
829
|
+
if (!call?.id || !isEagerDispatchable(call.name, tools)) break;
|
|
830
|
+
if (dupSet && dupSet.has(call.id)) continue;
|
|
831
|
+
if (!startEagerTool(call) && !pending.has(call.id)) break;
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
let _streamEagerBlocked = false;
|
|
835
|
+
opts.onToolCall = (call) => {
|
|
836
|
+
if (!isEagerDispatchable(call?.name, tools)) {
|
|
837
|
+
_streamEagerBlocked = true;
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
if (_streamEagerBlocked) return;
|
|
841
|
+
startEagerTool(call);
|
|
842
|
+
};
|
|
843
|
+
// Repair any dangling assistant tool_use left over from a prior
|
|
844
|
+
// abort/error path before the provider sees the transcript. No-op
|
|
845
|
+
// on the healthy iteration cycle (every assistant tool_use is
|
|
846
|
+
// followed by tool results in the same loop body below).
|
|
847
|
+
_ensureTranscriptPairing(messages, sessionId);
|
|
848
|
+
// Strip soft-warn markers from prior tool results before the next
|
|
849
|
+
// send. Marker bytes (Tool-budget(xN), Same-file reads(xN), etc.)
|
|
850
|
+
// mutate every turn with dynamic counters, so leaving them in the
|
|
851
|
+
// transcript breaks server-side prefix cache lookup on later turns.
|
|
852
|
+
// The current turn's marker (if any) is appended AFTER this strip,
|
|
853
|
+
// so the model still sees the self-correct hint on its own iteration.
|
|
854
|
+
for (let _i = 0; _i < messages.length; _i++) {
|
|
855
|
+
const _m = messages[_i];
|
|
856
|
+
if (_m && _m.role === 'tool' && typeof _m.content === 'string' && _m.content.includes('⚠')) {
|
|
857
|
+
const _stripped = stripSoftWarns(_m.content);
|
|
858
|
+
if (_stripped !== _m.content) _m.content = _stripped;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
const sendStartedAt = Date.now();
|
|
862
|
+
try {
|
|
863
|
+
response = await provider.send(messages, model, sendTools.length ? sendTools : undefined, opts);
|
|
864
|
+
} catch (sendErr) {
|
|
865
|
+
// Context-window-exceeded is a deterministic refusal: the request is
|
|
866
|
+
// simply too large. Retry ONCE with a stricter trim budget that
|
|
867
|
+
// force-drops older non-system / non-latest turns before surfacing
|
|
868
|
+
// the error. Unrelated errors (network, stall, auth, etc.) re-throw
|
|
869
|
+
// untouched — they are handled by the provider/bridge retry layers.
|
|
870
|
+
if (
|
|
871
|
+
!isContextOverflowError(sendErr)
|
|
872
|
+
|| !(sessionRef && typeof sessionRef.contextWindow === 'number')
|
|
873
|
+
) {
|
|
874
|
+
throw sendErr;
|
|
875
|
+
}
|
|
876
|
+
const overflowBudget = Math.floor(sessionRef.contextWindow * OVERFLOW_RETRY_TRIM_PERCENT);
|
|
877
|
+
const overflowReserve = estimateRequestReserveTokens(sendTools.length ? sendTools : tools);
|
|
878
|
+
const retrimmed = trimMessages(messages, overflowBudget, { reserveTokens: overflowReserve });
|
|
879
|
+
messages.length = 0;
|
|
880
|
+
messages.push(...retrimmed);
|
|
881
|
+
// The transcript prefix changed; the server-side conversation anchor
|
|
882
|
+
// (previous_response_id / WS continuation) is now invalid. Drop
|
|
883
|
+
// providerState so the retry starts a fresh chain instead of
|
|
884
|
+
// tripping a silent cache miss or hard mismatch.
|
|
885
|
+
providerState = undefined;
|
|
886
|
+
opts.providerState = undefined;
|
|
887
|
+
// Drop eager-dispatch state before the retry send. A tool_use
|
|
888
|
+
// streamed by the failed first send could otherwise orphan its
|
|
889
|
+
// eager result or be double-dispatched; force the retry's tools
|
|
890
|
+
// through the serial post-send path with a clean matching slate.
|
|
891
|
+
opts.onToolCall = undefined;
|
|
892
|
+
pending.clear();
|
|
893
|
+
_eagerInFlightSigs.clear();
|
|
894
|
+
try {
|
|
895
|
+
process.stderr.write(
|
|
896
|
+
`[loop] context overflow on send (sess=${sessionId || 'unknown'} iter=${nextIteration}); ` +
|
|
897
|
+
`retrying once at budget=${overflowBudget} reserve=${overflowReserve} ` +
|
|
898
|
+
`messages=${messages.length}\n`,
|
|
899
|
+
);
|
|
900
|
+
} catch { /* best-effort */ }
|
|
901
|
+
response = await provider.send(messages, model, sendTools.length ? sendTools : undefined, opts);
|
|
902
|
+
}
|
|
903
|
+
opts.onToolCall = undefined;
|
|
904
|
+
// Capture opaque state for the next turn (may be undefined — that's
|
|
905
|
+
// the stateless contract for providers that don't use continuation).
|
|
906
|
+
providerState = response?.providerState ?? undefined;
|
|
907
|
+
iterations = nextIteration;
|
|
908
|
+
traceBridgeLoop({
|
|
909
|
+
sessionId,
|
|
910
|
+
iteration: iterations,
|
|
911
|
+
sendMs: Date.now() - sendStartedAt,
|
|
912
|
+
messageCount: Array.isArray(messages) ? messages.length : 0,
|
|
913
|
+
bodyBytesEst: estimateProviderPayloadBytes(messages, model, sendTools),
|
|
914
|
+
});
|
|
915
|
+
// Accumulate usage across iterations — every billable slot, not just
|
|
916
|
+
// input/output. Anthropic cache_read/cache_write typically stay 0 on
|
|
917
|
+
// the first iteration and surge on later ones (warm prefix reuse),
|
|
918
|
+
// so aggregating only the head would silently drop most of the
|
|
919
|
+
// cache-side tokens.
|
|
920
|
+
if (response.usage) {
|
|
921
|
+
if (lastUsage) {
|
|
922
|
+
lastUsage.inputTokens += response.usage.inputTokens || 0;
|
|
923
|
+
lastUsage.outputTokens += response.usage.outputTokens || 0;
|
|
924
|
+
lastUsage.cachedTokens = (lastUsage.cachedTokens || 0) + (response.usage.cachedTokens || 0);
|
|
925
|
+
lastUsage.cacheWriteTokens = (lastUsage.cacheWriteTokens || 0) + (response.usage.cacheWriteTokens || 0);
|
|
926
|
+
lastUsage.promptTokens = (lastUsage.promptTokens || 0) + (response.usage.promptTokens || 0);
|
|
927
|
+
}
|
|
928
|
+
else {
|
|
929
|
+
lastUsage = {
|
|
930
|
+
inputTokens: response.usage.inputTokens || 0,
|
|
931
|
+
outputTokens: response.usage.outputTokens || 0,
|
|
932
|
+
cachedTokens: response.usage.cachedTokens || 0,
|
|
933
|
+
cacheWriteTokens: response.usage.cacheWriteTokens || 0,
|
|
934
|
+
promptTokens: response.usage.promptTokens || 0,
|
|
935
|
+
raw: response.usage.raw,
|
|
936
|
+
};
|
|
937
|
+
// Snapshot the first turn separately so callers can show
|
|
938
|
+
// iter1 vs final cache-hit ratios — first iter is the
|
|
939
|
+
// warm-prefix signal, final iter is the steady-state
|
|
940
|
+
// efficiency signal after tool-result accumulation.
|
|
941
|
+
firstTurnUsage = { ...lastUsage };
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
// Provider may have returned despite an abort (SDKs that don't honour
|
|
945
|
+
// signal) — bail before processing any of its output.
|
|
946
|
+
throwIfAborted();
|
|
947
|
+
// Incremental metric persistence (fix A): push per-iteration token delta
|
|
948
|
+
// immediately so watchdog / bridge type=list sees live totals mid-turn.
|
|
949
|
+
if (sessionId && opts.onUsageDelta && response.usage) {
|
|
950
|
+
try {
|
|
951
|
+
opts.onUsageDelta({
|
|
952
|
+
sessionId,
|
|
953
|
+
iterationIndex: iterations,
|
|
954
|
+
deltaInput: response.usage.inputTokens || 0,
|
|
955
|
+
deltaOutput: response.usage.outputTokens || 0,
|
|
956
|
+
// Cache delta carried alongside input/output so live metrics
|
|
957
|
+
// reflect the same token classes the terminal aggregate adds;
|
|
958
|
+
// additive — callers that ignore these fields keep working.
|
|
959
|
+
deltaCachedRead: response.usage.cachedTokens || 0,
|
|
960
|
+
deltaCacheWrite: response.usage.cacheWriteTokens || 0,
|
|
961
|
+
ts: Date.now(),
|
|
962
|
+
});
|
|
963
|
+
} catch { /* best-effort — never break the loop */ }
|
|
964
|
+
}
|
|
965
|
+
// No tool calls. For PUBLIC bridge workers, the bridge contract
|
|
966
|
+
// (rules/bridge/00-common.md) requires either a tool call or a
|
|
967
|
+
// `<final-answer>` wrapped reply.
|
|
968
|
+
// A text-only turn without those tags violates the contract (e.g.
|
|
969
|
+
// Opus 4.6 emits 'Now I'll polish…' preamble before its first tool
|
|
970
|
+
// call) and used to leave the session idle until the idle sweep
|
|
971
|
+
// collected it. Re-prompt the worker with a contract reminder; cap
|
|
972
|
+
// at 2 nudges so a model that never complies still terminates the
|
|
973
|
+
// loop. Hidden roles (cycle1-agent / cycle2-agent / explorer /
|
|
974
|
+
// scheduler-task / webhook-handler) are exempt:
|
|
975
|
+
// their own role rules define a different output contract (pipe-
|
|
976
|
+
// separated chunker output, structured pipe-format, etc.) and a
|
|
977
|
+
// text-only terminal turn is the correct shape — nudging them
|
|
978
|
+
// produces a contradictory user message that traps the model in a
|
|
979
|
+
// tool-call-blocked vs contract-required oscillation.
|
|
980
|
+
if (!response.toolCalls?.length) {
|
|
981
|
+
// No tool calls. Decide between final-answer accept vs nudge.
|
|
982
|
+
// - has content + non-hidden role → valid final, break.
|
|
983
|
+
// - empty content + hidden role → contract allows text-only
|
|
984
|
+
// terminal turn, break.
|
|
985
|
+
// - empty content + non-hidden role → one soft nudge. Repeated
|
|
986
|
+
// reminders waste turns and fragment the working context, so
|
|
987
|
+
// the second empty turn is accepted as terminal.
|
|
988
|
+
const hasContent = typeof response.content === 'string' && response.content.trim().length > 0;
|
|
989
|
+
const isHidden = HIDDEN_ROLE_NAMES.has(sessionRole);
|
|
990
|
+
const stopReason = response.stopReason ?? response.stop_reason ?? null;
|
|
991
|
+
const isIncompleteStop = stopReason && INCOMPLETE_STOP_REASONS.has(stopReason);
|
|
992
|
+
if (!hasContent && !isHidden) {
|
|
993
|
+
if (contractNudges >= 1) break;
|
|
994
|
+
contractNudges += 1;
|
|
995
|
+
let nudgeMsg;
|
|
996
|
+
if (isIncompleteStop) {
|
|
997
|
+
nudgeMsg = `[mixdog-runtime] Previous turn ended mid-synthesis (stopReason=${stopReason}) with empty content. Continue — emit <final-answer>...</final-answer> with your synthesis so far, or call more tools to finish.`;
|
|
998
|
+
} else {
|
|
999
|
+
nudgeMsg = '[mixdog-runtime] Your previous response was empty (no <final-answer> tag and no tool call). Either emit your final answer wrapped in <final-answer>...</final-answer> tags, or continue with tool calls. Do not return an empty turn.';
|
|
1000
|
+
}
|
|
1001
|
+
messages.push({ role: 'user', content: nudgeMsg });
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
break;
|
|
1005
|
+
}
|
|
1006
|
+
const calls = response.toolCalls;
|
|
1007
|
+
toolCallsTotal += calls.length;
|
|
1008
|
+
// Per-turn batch shape — one row per assistant turn so trace
|
|
1009
|
+
// consumers can derive multi-tool adoption ratio without scanning
|
|
1010
|
+
// every assistant message body.
|
|
1011
|
+
recordToolBatch(sessionId, calls.length);
|
|
1012
|
+
onToolCall?.(iterations, calls);
|
|
1013
|
+
// Append assistant message with tool calls. reasoningItems is the
|
|
1014
|
+
// OpenAI Responses API replay payload (encrypted_content blobs);
|
|
1015
|
+
// providers that ignore it just see an extra field and drop it,
|
|
1016
|
+
// openai-oauth.convertMessagesToResponsesInput emits matching
|
|
1017
|
+
// type:'reasoning' input items on the next turn to keep the Codex
|
|
1018
|
+
// server-side cache prefix stable.
|
|
1019
|
+
const _assistantTurnMsg = {
|
|
1020
|
+
role: 'assistant',
|
|
1021
|
+
content: response.content || '',
|
|
1022
|
+
toolCalls: compactToolCallsForHistory(calls),
|
|
1023
|
+
...(Array.isArray(response.reasoningItems) && response.reasoningItems.length
|
|
1024
|
+
? { reasoningItems: response.reasoningItems }
|
|
1025
|
+
: {}),
|
|
1026
|
+
...(typeof response.reasoningContent === 'string' && response.reasoningContent
|
|
1027
|
+
? { reasoningContent: response.reasoningContent }
|
|
1028
|
+
: {}),
|
|
1029
|
+
};
|
|
1030
|
+
messages.push(_assistantTurnMsg);
|
|
1031
|
+
// Execute each tool and append results.
|
|
1032
|
+
//
|
|
1033
|
+
// Intra-turn duplicate suppression: when an LLM emits two tool_use
|
|
1034
|
+
// blocks with identical (name, args) inside the SAME assistant turn,
|
|
1035
|
+
// re-executing wastes tokens. Restricted to tools with
|
|
1036
|
+
// `readOnlyHint:true` (= isEagerDispatchable) — bash/write/edit/
|
|
1037
|
+
// apply_patch may be intentional repeats with distinct side effects.
|
|
1038
|
+
// Pre-pass identifies duplicates BEFORE startEagerRun so eager
|
|
1039
|
+
// dispatch also skips them, not just the for-body.
|
|
1040
|
+
const _duplicateCallIds = new Set();
|
|
1041
|
+
const _dupFirstId = new Map();
|
|
1042
|
+
{
|
|
1043
|
+
const _firstIdBySig = new Map();
|
|
1044
|
+
for (const c of calls) {
|
|
1045
|
+
if (!c?.id) continue;
|
|
1046
|
+
if (!isEagerDispatchable(c.name, tools)) {
|
|
1047
|
+
_firstIdBySig.clear();
|
|
1048
|
+
continue;
|
|
1049
|
+
}
|
|
1050
|
+
const sig = _intraTurnSig(c.name, c.arguments);
|
|
1051
|
+
const first = _firstIdBySig.get(sig);
|
|
1052
|
+
if (first === undefined) {
|
|
1053
|
+
_firstIdBySig.set(sig, c.id);
|
|
1054
|
+
} else {
|
|
1055
|
+
_duplicateCallIds.add(c.id);
|
|
1056
|
+
_dupFirstId.set(c.id, first);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
// R15: per-turn scalar read-count Map. Lifetime = this turn's tool-call batch.
|
|
1061
|
+
// Declared between the duplicate-detection block and the for-loop so it resets
|
|
1062
|
+
for (let callIndex = 0; callIndex < calls.length; callIndex += 1) {
|
|
1063
|
+
const call = calls[callIndex];
|
|
1064
|
+
if (isBuiltinTool(call.name)) {
|
|
1065
|
+
call.name = canonicalizeBuiltinToolName(call.name);
|
|
1066
|
+
}
|
|
1067
|
+
if (_duplicateCallIds.has(call.id)) {
|
|
1068
|
+
const _firstId = _dupFirstId.get(call.id);
|
|
1069
|
+
const _stub = `[intra-turn-dedup] identical read-only \`${call.name}\` call was already executed in this same assistant turn as tool_use_id=${_firstId}. The first call's tool_result is in context immediately above; skipping re-execution to save tokens. If you needed a different slice of the file, narrow the next call (different path / offset / limit / pattern) so it has a distinct signature.`;
|
|
1070
|
+
messages.push({
|
|
1071
|
+
role: 'tool',
|
|
1072
|
+
content: _stub,
|
|
1073
|
+
toolCallId: call.id,
|
|
1074
|
+
});
|
|
1075
|
+
continue;
|
|
1076
|
+
}
|
|
1077
|
+
// Cross-iteration repeat-failure guard. Distinct from the
|
|
1078
|
+
// intra-turn dedup above (which spans ONE assistant turn and
|
|
1079
|
+
// resets every turn): when the model re-issues an IDENTICAL
|
|
1080
|
+
// (name,args) call that has already failed REPEAT_FAIL_LIMIT times
|
|
1081
|
+
// in a row across iterations, stop re-executing — the result will
|
|
1082
|
+
// not change, and each retry burns a full (often slow) LLM
|
|
1083
|
+
// round-trip until the hard iteration cap. Steer it to change
|
|
1084
|
+
// approach instead.
|
|
1085
|
+
const _repeatFailSig = _intraTurnSig(call.name, call.arguments);
|
|
1086
|
+
{
|
|
1087
|
+
const _rfg = sessionRef?._repeatFailGuard;
|
|
1088
|
+
if (_rfg && _rfg.sig === _repeatFailSig && _rfg.count >= REPEAT_FAIL_LIMIT) {
|
|
1089
|
+
messages.push({
|
|
1090
|
+
role: 'tool',
|
|
1091
|
+
content: `[repeat-failure-guard] This exact \`${call.name}\` call (identical arguments) has already failed ${_rfg.count} times in a row; not re-executing because the result will not change. Change approach: use different arguments, a different tool, or skip this step.`,
|
|
1092
|
+
toolCallId: call.id,
|
|
1093
|
+
});
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
if (sessionId) markSessionToolCall(sessionId, call.name);
|
|
1098
|
+
let result;
|
|
1099
|
+
let toolStartedAt;
|
|
1100
|
+
let toolEndedAt;
|
|
1101
|
+
const toolKind = getToolKind(call.name);
|
|
1102
|
+
// Cross-turn read dedup. Mirrors Anthropic Claude Code's
|
|
1103
|
+
// fileReadCache.ts: if the path's stat tuple (mtime/size/ino/dev)
|
|
1104
|
+
// is unchanged since a prior read in THIS session, return the cached
|
|
1105
|
+
// body instead of executing. Both scalar and array/object-array path
|
|
1106
|
+
// forms are cached — keyed by (abs, offset, limit, mode, n) per entry.
|
|
1107
|
+
//
|
|
1108
|
+
// Scoped-tool cache (grep/glob/list + graph lookups): same idea
|
|
1109
|
+
// but keyed by (toolName, canonical args) without per-file stat.
|
|
1110
|
+
// These tools scan many files so a single stat tuple cannot cover
|
|
1111
|
+
// them. The scoped cache registers dependency roots and write-class
|
|
1112
|
+
// tools evict entries whose root contains the touched path.
|
|
1113
|
+
let _readCacheHit = null;
|
|
1114
|
+
let _scopedCacheHit = null;
|
|
1115
|
+
let _executeOk = false;
|
|
1116
|
+
let _resultKind = 'normal';
|
|
1117
|
+
if (sessionId && _isReadTool(call.name)) {
|
|
1118
|
+
_readCacheHit = tryReadCached({ sessionId, args: call.arguments, cwd });
|
|
1119
|
+
} else if (sessionId && _isScopedCacheableTool(call.name)) {
|
|
1120
|
+
_scopedCacheHit = tryScopedToolCached({ sessionId, toolName: _stripMcpPrefix(call.name), args: call.arguments, cwd });
|
|
1121
|
+
}
|
|
1122
|
+
try {
|
|
1123
|
+
if (_readCacheHit !== null) {
|
|
1124
|
+
toolStartedAt = Date.now();
|
|
1125
|
+
toolEndedAt = toolStartedAt;
|
|
1126
|
+
const _body = _readCacheHit.content;
|
|
1127
|
+
// Return the cached body byte-for-byte instead of a
|
|
1128
|
+
// human-readable cache marker. The marker made public
|
|
1129
|
+
// bridge workers treat a successful cached read as a
|
|
1130
|
+
// meta instruction and repeat the same read loop.
|
|
1131
|
+
result = _body;
|
|
1132
|
+
_resultKind = 'cache-hit';
|
|
1133
|
+
_executeOk = true;
|
|
1134
|
+
} else if (_scopedCacheHit !== null) {
|
|
1135
|
+
toolStartedAt = Date.now();
|
|
1136
|
+
toolEndedAt = toolStartedAt;
|
|
1137
|
+
const _body = _scopedCacheHit.content;
|
|
1138
|
+
result = _body;
|
|
1139
|
+
_resultKind = 'scoped-cache-hit';
|
|
1140
|
+
_executeOk = true;
|
|
1141
|
+
} else {
|
|
1142
|
+
// Fallback for providers that don't stream tool calls early:
|
|
1143
|
+
// execute a contiguous read-only run in parallel, but never
|
|
1144
|
+
// cross a write/bash/MCP boundary that may change state.
|
|
1145
|
+
if (isEagerDispatchable(call.name, tools)) {
|
|
1146
|
+
startEagerRun(calls, callIndex, _duplicateCallIds);
|
|
1147
|
+
}
|
|
1148
|
+
let eager = pending.get(call.id);
|
|
1149
|
+
if (eager !== undefined && eager.mutationEpoch < _mutationEpoch) {
|
|
1150
|
+
pending.delete(call.id);
|
|
1151
|
+
eager = undefined;
|
|
1152
|
+
}
|
|
1153
|
+
if (eager !== undefined) {
|
|
1154
|
+
toolStartedAt = eager.startedAt;
|
|
1155
|
+
const settled = await eager.promise;
|
|
1156
|
+
if (!settled.ok) throw settled.error;
|
|
1157
|
+
result = settled.value;
|
|
1158
|
+
toolEndedAt = eager.endedAt ?? Date.now();
|
|
1159
|
+
const _eagerKind = classifyResultKind(result);
|
|
1160
|
+
if (_eagerKind === 'error') {
|
|
1161
|
+
_resultKind = 'error';
|
|
1162
|
+
_executeOk = false;
|
|
1163
|
+
} else {
|
|
1164
|
+
_executeOk = true;
|
|
1165
|
+
}
|
|
1166
|
+
} else {
|
|
1167
|
+
toolStartedAt = Date.now();
|
|
1168
|
+
// Runtime permission guard. Schema profiles may hide
|
|
1169
|
+
// tools for routing efficiency, but this remains the
|
|
1170
|
+
// safety boundary for any tool_use that still reaches
|
|
1171
|
+
// the loop. _preDispatchDeny is the SHARED helper used
|
|
1172
|
+
// by both the eager dispatch path (startEagerTool) and
|
|
1173
|
+
// this serial path — keeps the bridge-owned control-
|
|
1174
|
+
// plane reject, role guards, wrapper guards, and
|
|
1175
|
+
// permission guards consistent across both paths.
|
|
1176
|
+
const _denyMsg = _preDispatchDeny(call, toolKind, sessionRef);
|
|
1177
|
+
if (_denyMsg !== null) {
|
|
1178
|
+
result = _denyMsg;
|
|
1179
|
+
toolEndedAt = Date.now();
|
|
1180
|
+
_resultKind = 'error';
|
|
1181
|
+
} else {
|
|
1182
|
+
const permBlocked = _checkWorkerPermission(call.name, call.arguments, sessionRef);
|
|
1183
|
+
if (permBlocked !== null) {
|
|
1184
|
+
result = permBlocked;
|
|
1185
|
+
toolEndedAt = Date.now();
|
|
1186
|
+
_resultKind = 'error';
|
|
1187
|
+
} else {
|
|
1188
|
+
result = await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id });
|
|
1189
|
+
toolEndedAt = Date.now();
|
|
1190
|
+
// Boundary: tool-return string convention → structural kind.
|
|
1191
|
+
// The only prefix check in this codebase; downstream layers
|
|
1192
|
+
// operate on _resultKind.
|
|
1193
|
+
if (classifyResultKind(result) === 'error') {
|
|
1194
|
+
_resultKind = 'error';
|
|
1195
|
+
_executeOk = false;
|
|
1196
|
+
} else {
|
|
1197
|
+
_executeOk = true;
|
|
1198
|
+
}
|
|
1199
|
+
// _resultKind stays 'normal' when tool returned a non-error string.
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
} // close: else branch of _readCacheHit check
|
|
1204
|
+
}
|
|
1205
|
+
catch (err) {
|
|
1206
|
+
if (toolStartedAt === undefined) toolStartedAt = Date.now();
|
|
1207
|
+
toolEndedAt = Date.now();
|
|
1208
|
+
result = `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
1209
|
+
_resultKind = 'error';
|
|
1210
|
+
}
|
|
1211
|
+
// Update the cross-iteration repeat-failure guard with this call's
|
|
1212
|
+
// outcome: bump the consecutive-failure count for an identical
|
|
1213
|
+
// signature, or clear it the moment the same call succeeds.
|
|
1214
|
+
if (sessionRef) {
|
|
1215
|
+
const _failed = !_executeOk || _resultKind === 'error';
|
|
1216
|
+
if (_failed) {
|
|
1217
|
+
sessionRef._repeatFailGuard = (sessionRef._repeatFailGuard?.sig === _repeatFailSig)
|
|
1218
|
+
? { sig: _repeatFailSig, count: sessionRef._repeatFailGuard.count + 1 }
|
|
1219
|
+
: { sig: _repeatFailSig, count: 1 };
|
|
1220
|
+
} else if (sessionRef._repeatFailGuard?.sig === _repeatFailSig) {
|
|
1221
|
+
sessionRef._repeatFailGuard = null;
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
// A failed executed call keeps its FULL argument body in history so the
|
|
1225
|
+
// model can retry against the original (a large apply_patch `patch` /
|
|
1226
|
+
// edit `old_string` would otherwise be hidden behind a
|
|
1227
|
+
// `[mixdog compacted …]` placeholder). Restored IMMEDIATELY — not at end
|
|
1228
|
+
// of loop — so an abort or post-processing throw after this point cannot
|
|
1229
|
+
// leave a failed edit compacted. Cache-safe: _assistantTurnMsg is not
|
|
1230
|
+
// transmitted until the next provider.send. Early-continue paths (dedup /
|
|
1231
|
+
// repeat-failure-guard) never reach here and stay compacted.
|
|
1232
|
+
if ((!_executeOk || _resultKind === 'error') && call?.id) {
|
|
1233
|
+
restoreToolCallBodyForId(_assistantTurnMsg, calls, call.id);
|
|
1234
|
+
}
|
|
1235
|
+
// Cross-turn cache maintenance — gate on both _executeOk and _resultKind==='normal'.
|
|
1236
|
+
// _executeOk=false catches permission-blocked / catch-path / partial-fail results.
|
|
1237
|
+
// _resultKind==='normal' ensures cache-hit refs are never re-stored (structural,
|
|
1238
|
+
// no prefix sniffing).
|
|
1239
|
+
// NOTE: setReadCached / setScopedToolCached are deferred below (after
|
|
1240
|
+
// compressToolResult) so the cache holds the same content as conversation
|
|
1241
|
+
// history. Cache-hit refs point to a tool_use_id whose message body matches
|
|
1242
|
+
// exactly what's stored — no phantom full body.
|
|
1243
|
+
if (sessionId && _executeOk && _resultKind === 'normal') {
|
|
1244
|
+
const _toolBare = _stripMcpPrefix(call.name);
|
|
1245
|
+
if (_readCacheHit === null && _isReadTool(call.name)) {
|
|
1246
|
+
// Post-edit advisory: handle BOTH scalar and array forms
|
|
1247
|
+
// of args.path. The array form (path:[a,b,c] or
|
|
1248
|
+
// path:[{path:a},{path:b}]) was a coverage gap in R1 —
|
|
1249
|
+
// an LLM that edits X then reads [X,Y] should still see
|
|
1250
|
+
// the advisory for X.
|
|
1251
|
+
const _argsPath = call.arguments?.path;
|
|
1252
|
+
const _pathList = [];
|
|
1253
|
+
if (typeof _argsPath === 'string') {
|
|
1254
|
+
_pathList.push(_argsPath);
|
|
1255
|
+
} else if (typeof call.arguments?.file_path === 'string') {
|
|
1256
|
+
_pathList.push(call.arguments.file_path);
|
|
1257
|
+
} else if (Array.isArray(_argsPath)) {
|
|
1258
|
+
for (const _item of _argsPath) {
|
|
1259
|
+
if (typeof _item === 'string') _pathList.push(_item);
|
|
1260
|
+
else if (_item && typeof _item === 'object') {
|
|
1261
|
+
const _itemPath = typeof _item.path === 'string' ? _item.path : _item.file_path;
|
|
1262
|
+
if (typeof _itemPath === 'string') _pathList.push(_itemPath);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
const _marks = [];
|
|
1267
|
+
for (const _p of _pathList) {
|
|
1268
|
+
const _m = consumePostEditMark({ sessionId, path: _p, cwd });
|
|
1269
|
+
if (_m) _marks.push({ path: _p, mark: _m });
|
|
1270
|
+
}
|
|
1271
|
+
} else if (_toolBare === 'apply_patch') {
|
|
1272
|
+
// apply_patch's args are a unified-diff text in `patch`
|
|
1273
|
+
// (resolved against `base_path` or cwd). Parse the diff
|
|
1274
|
+
// headers (`--- a/path` / `+++ b/path`) to extract the
|
|
1275
|
+
// touched paths and invalidate / mark each one. Falls
|
|
1276
|
+
// back to a full session clear only when no paths could
|
|
1277
|
+
// be parsed (malformed diff or unknown format).
|
|
1278
|
+
const _argsBase = call.arguments?.base_path;
|
|
1279
|
+
const _patchBase = (typeof _argsBase === 'string' && _argsBase.length > 0)
|
|
1280
|
+
? (isAbsolute(_argsBase) ? _argsBase : resolvePath(cwd || process.cwd(), _argsBase))
|
|
1281
|
+
: (cwd || process.cwd());
|
|
1282
|
+
const _touched = extractTouchedPathsFromPatch(call.arguments?.patch);
|
|
1283
|
+
if (_touched.length > 0) {
|
|
1284
|
+
for (const _p of _touched) {
|
|
1285
|
+
invalidatePathForSession(sessionId, _p, _patchBase);
|
|
1286
|
+
markPostEdit({ sessionId, path: _p, cwd: _patchBase, toolName: 'apply_patch' });
|
|
1287
|
+
// R20: cross-dispatch prefetch cache invalidation.
|
|
1288
|
+
invalidatePrefetchCache(_p, _patchBase);
|
|
1289
|
+
}
|
|
1290
|
+
} else {
|
|
1291
|
+
clearReadDedupSession(sessionId);
|
|
1292
|
+
// R20: path unknown — can't target; no-op on prefetch cache
|
|
1293
|
+
// (stat-validation at lookup time will naturally reject stale entries).
|
|
1294
|
+
}
|
|
1295
|
+
// Targeted scoped-cache invalidation: only evict entries whose
|
|
1296
|
+
// dep paths intersect the touched set. Full wipe is the fallback
|
|
1297
|
+
// when no paths were extracted (D).
|
|
1298
|
+
if (_touched.length > 0) {
|
|
1299
|
+
clearScopedToolsForSessionPaths(sessionId, _touched, _patchBase);
|
|
1300
|
+
} else {
|
|
1301
|
+
clearScopedToolsForSession(sessionId);
|
|
1302
|
+
}
|
|
1303
|
+
} else if (_isScalarWriteEditTool(call.name)) {
|
|
1304
|
+
// Scalar `args.path` only: precise invalidate + advisory mark.
|
|
1305
|
+
// Array-form (`edits[]`/`writes[]`): the tool may have partial-
|
|
1306
|
+
// failed across paths and the result string aggregates;
|
|
1307
|
+
// full-clear instead of falsely marking every path.
|
|
1308
|
+
const _scalarPath = call.arguments?.path || call.arguments?.file_path;
|
|
1309
|
+
const _hasArrayForm = Array.isArray(call.arguments?.edits)
|
|
1310
|
+
|| Array.isArray(call.arguments?.writes);
|
|
1311
|
+
if (_hasArrayForm) {
|
|
1312
|
+
clearReadDedupSession(sessionId);
|
|
1313
|
+
clearScopedToolsForSession(sessionId);
|
|
1314
|
+
// R20: array-form — walk each entry, extract its path,
|
|
1315
|
+
// and invalidate the prefetch cache + mark post-edit for
|
|
1316
|
+
// every distinct touched path. Falls back to the top-
|
|
1317
|
+
// level `path` (or `file_path`) when an entry omits its
|
|
1318
|
+
// own path. This covers both edit edits[] and write
|
|
1319
|
+
// writes[] forms; entries without a resolvable path are
|
|
1320
|
+
// silently skipped (their stat-validation safety net at
|
|
1321
|
+
// next lookup still applies).
|
|
1322
|
+
const _topPath = call.arguments?.path || call.arguments?.file_path;
|
|
1323
|
+
const _entries = call.arguments?.edits || call.arguments?.writes || [];
|
|
1324
|
+
const _seenPaths = new Set();
|
|
1325
|
+
for (const _e of _entries) {
|
|
1326
|
+
const _ep = _e?.path || _e?.file_path || _topPath;
|
|
1327
|
+
if (typeof _ep === 'string' && _ep && !_seenPaths.has(_ep)) {
|
|
1328
|
+
_seenPaths.add(_ep);
|
|
1329
|
+
invalidatePathForSession(sessionId, _ep, cwd);
|
|
1330
|
+
markPostEdit({ sessionId, path: _ep, cwd, toolName: _toolBare });
|
|
1331
|
+
invalidatePrefetchCache(_ep, cwd);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
if (_seenPaths.size > 0) {
|
|
1335
|
+
clearScopedToolsForSessionPaths(sessionId, [..._seenPaths], cwd);
|
|
1336
|
+
}
|
|
1337
|
+
} else if (typeof _scalarPath === 'string') {
|
|
1338
|
+
invalidatePathForSession(sessionId, _scalarPath, cwd);
|
|
1339
|
+
markPostEdit({ sessionId, path: _scalarPath, cwd, toolName: _toolBare });
|
|
1340
|
+
// R20: cross-dispatch prefetch cache invalidation.
|
|
1341
|
+
invalidatePrefetchCache(_scalarPath, cwd);
|
|
1342
|
+
// Targeted scoped-cache invalidation for the single touched path (D).
|
|
1343
|
+
clearScopedToolsForSessionPaths(sessionId, [_scalarPath], cwd);
|
|
1344
|
+
} else {
|
|
1345
|
+
// No path extractable — full wipe fallback.
|
|
1346
|
+
clearScopedToolsForSession(sessionId);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
} // end _executeOk+_resultKind gate (scoped tool cache set)
|
|
1350
|
+
// E: mutation tools (apply_patch / write / edit) must invalidate caches
|
|
1351
|
+
// even on returned-error/partial-fail — the file state is unknown after
|
|
1352
|
+
// an error exit, and some tools report failure as an Error: result string
|
|
1353
|
+
// rather than throwing.
|
|
1354
|
+
// This block runs unconditionally (not gated on _executeOk or _resultKind).
|
|
1355
|
+
if (sessionId && (!_executeOk || _resultKind === 'error') && (_stripMcpPrefix(call.name) === 'apply_patch' || _isScalarWriteEditTool(call.name))) {
|
|
1356
|
+
clearReadDedupSession(sessionId);
|
|
1357
|
+
}
|
|
1358
|
+
if (_isMutationTool(call.name)) {
|
|
1359
|
+
_mutationEpoch += 1;
|
|
1360
|
+
}
|
|
1361
|
+
// Bash always clears scoped cache UNCONDITIONALLY — a mutating bash
|
|
1362
|
+
// that throws or fails partway can still leave stale find_symbol / grep entries.
|
|
1363
|
+
// Must not be gated on _executeOk or _resultKind.
|
|
1364
|
+
if (sessionId && _isBashTool(call.name)) {
|
|
1365
|
+
clearScopedToolsForSession(sessionId);
|
|
1366
|
+
}
|
|
1367
|
+
// R17 compression pipeline — correct ordering (compress → cache → push):
|
|
1368
|
+
// 1. compressToolResult: lossless ANSI/dedup/separator passes.
|
|
1369
|
+
// 2. setReadCached / setScopedToolCached: cache stores the SAME result that
|
|
1370
|
+
// goes into conversation history. Cache-hit refs point to the tool_use_id
|
|
1371
|
+
// whose message body matches — no phantom full body.
|
|
1372
|
+
// 3. offload → hint → message push.
|
|
1373
|
+
// Offload FIRST — before compress. Large RAW output goes to a disk sidecar
|
|
1374
|
+
// + ~2K preview before any in-place shrink (lossless compress) can reduce
|
|
1375
|
+
// it below the offload threshold and pre-empt the sidecar. When offload
|
|
1376
|
+
// fires it replaces `result` with a short preview stub (<2K) referencing
|
|
1377
|
+
// the on-disk path; the later compress is a no-op on that stub. compress
|
|
1378
|
+
// then only touches output that stayed inline (<= threshold).
|
|
1379
|
+
// Per-tool post-processing backstop. The executeTool try/catch
|
|
1380
|
+
// above terminates BEFORE offload/compress/trim/hint/cache writes/
|
|
1381
|
+
// trace/messages.push, so a maybeOffloadToolResult rejection (or
|
|
1382
|
+
// any downstream throw) would otherwise leave the assistant
|
|
1383
|
+
// tool_use message with no matching tool result. Wrap the whole
|
|
1384
|
+
// post-processing window through messages.push() in a catch; on
|
|
1385
|
+
// failure push a synthetic Error: tool result for this call.id
|
|
1386
|
+
// and skip the cache writes for it.
|
|
1387
|
+
let _postProcessOk = true;
|
|
1388
|
+
try {
|
|
1389
|
+
// Offload thresholds are keyed by BARE tool name
|
|
1390
|
+
// (INLINE_THRESHOLD_BY_TOOL: grep=20k, bash=30k, read=Infinity, ...),
|
|
1391
|
+
// so strip the MCP prefix exactly as the cache write below does.
|
|
1392
|
+
// Otherwise an mcp__..__grep name misses its 20k grep cap and
|
|
1393
|
+
// silently falls back to the 50k default — per-tool limits ignored.
|
|
1394
|
+
const _toolBare = _stripMcpPrefix(call.name);
|
|
1395
|
+
result = await maybeOffloadToolResult(sessionId, call.id, _toolBare, result);
|
|
1396
|
+
result = compressToolResult(call.name, call.arguments, result, { sessionId, toolKind });
|
|
1397
|
+
traceBridgeTool({
|
|
1398
|
+
sessionId,
|
|
1399
|
+
iteration: iterations,
|
|
1400
|
+
toolName: call.name,
|
|
1401
|
+
toolKind,
|
|
1402
|
+
toolMs: toolEndedAt - toolStartedAt,
|
|
1403
|
+
toolArgs: call.arguments,
|
|
1404
|
+
role: sessionRef?.role || null,
|
|
1405
|
+
model: sessionRef?.model || null,
|
|
1406
|
+
resultKind: _resultKind,
|
|
1407
|
+
resultText: result,
|
|
1408
|
+
});
|
|
1409
|
+
// Cache stores run AFTER compress+trim+offload+hint AND after all other
|
|
1410
|
+
// post-processing (trace) so stored content == history content. Placing
|
|
1411
|
+
// the cache writes immediately before messages.push ensures ANY throw
|
|
1412
|
+
// earlier in post-processing skips the cache entirely — no stale or
|
|
1413
|
+
// partial result is ever cached. Cache-hit refs pointing to an offloaded
|
|
1414
|
+
// tool_use will show the offload stub; LLM can still recover the full
|
|
1415
|
+
// body via the disk path in that stub.
|
|
1416
|
+
if (sessionId && _executeOk && _resultKind === 'normal') {
|
|
1417
|
+
if (_scopedCacheHit === null && _isScopedCacheableTool(call.name)) {
|
|
1418
|
+
const _outcome = sessionRef?._scopedCacheOutcomeByCallId?.get(call.id);
|
|
1419
|
+
setScopedToolCached({
|
|
1420
|
+
sessionId,
|
|
1421
|
+
toolName: _toolBare,
|
|
1422
|
+
args: call.arguments,
|
|
1423
|
+
cwd,
|
|
1424
|
+
content: result,
|
|
1425
|
+
toolUseId: call.id,
|
|
1426
|
+
complete: _outcome ? _outcome.complete : true,
|
|
1427
|
+
});
|
|
1428
|
+
sessionRef?._scopedCacheOutcomeByCallId?.delete(call.id);
|
|
1429
|
+
}
|
|
1430
|
+
if (_readCacheHit === null && _isReadTool(call.name)) {
|
|
1431
|
+
// Pass tool_use id so future cache-hits can reference the body's location in history.
|
|
1432
|
+
setReadCached({ sessionId, args: call.arguments, cwd, content: result, toolUseId: call.id });
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
messages.push({
|
|
1436
|
+
role: 'tool',
|
|
1437
|
+
content: result,
|
|
1438
|
+
toolCallId: call.id,
|
|
1439
|
+
toolKind: _resultKind,
|
|
1440
|
+
});
|
|
1441
|
+
} catch (postErr) {
|
|
1442
|
+
_postProcessOk = false;
|
|
1443
|
+
// Post-processing failed AFTER a successful exec: the result is
|
|
1444
|
+
// replaced with an error below, so preserve this call's full body
|
|
1445
|
+
// too for a clean retry (mirrors the failed-exec path above).
|
|
1446
|
+
if (call?.id) restoreToolCallBodyForId(_assistantTurnMsg, calls, call.id);
|
|
1447
|
+
const _postMsg = `Error: tool result post-processing failed for "${call.name}": ${postErr instanceof Error ? postErr.message : String(postErr)}`;
|
|
1448
|
+
// Always emit a matching tool result so the assistant
|
|
1449
|
+
// tool_use isn't orphaned. Cache writes are placed at the
|
|
1450
|
+
// end of the try block (immediately before messages.push),
|
|
1451
|
+
// so ANY throw in post-processing reaches this catch before
|
|
1452
|
+
// the cache is written — stale/partial results are never
|
|
1453
|
+
// cached. The next read on the same path/scope re-executes
|
|
1454
|
+
// naturally.
|
|
1455
|
+
messages.push({
|
|
1456
|
+
role: 'tool',
|
|
1457
|
+
content: _postMsg,
|
|
1458
|
+
toolCallId: call.id,
|
|
1459
|
+
toolKind: 'error',
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
// Soft-cancel after each tool: if close landed during execution,
|
|
1463
|
+
// discard the rest of the batch and skip the next provider.send.
|
|
1464
|
+
throwIfAborted();
|
|
1465
|
+
}
|
|
1466
|
+
// About to re-send with tool results — transition back to connecting for the next turn.
|
|
1467
|
+
if (sessionId) updateSessionStage(sessionId, 'connecting');
|
|
1468
|
+
}
|
|
1469
|
+
return {
|
|
1470
|
+
...response,
|
|
1471
|
+
usage: lastUsage || response.usage,
|
|
1472
|
+
lastTurnUsage: response.usage,
|
|
1473
|
+
firstTurnUsage: firstTurnUsage || response.usage,
|
|
1474
|
+
iterations,
|
|
1475
|
+
toolCallsTotal,
|
|
1476
|
+
providerState,
|
|
1477
|
+
};
|
|
1478
|
+
}
|