@sema-ai/core 1.275.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/CHANGELOG.md +5433 -0
- package/LICENSE +27 -0
- package/NOTICE.md +30 -0
- package/README.md +1040 -0
- package/dist/agents/agent-definition.d.ts +10 -0
- package/dist/agents/agent-definition.d.ts.map +1 -0
- package/dist/agents/agent-definition.js +18 -0
- package/dist/agents/agent-definition.js.map +1 -0
- package/dist/agents/builtin-agents.d.ts +69 -0
- package/dist/agents/builtin-agents.d.ts.map +1 -0
- package/dist/agents/builtin-agents.js +177 -0
- package/dist/agents/builtin-agents.js.map +1 -0
- package/dist/agents/cascade.d.ts +101 -0
- package/dist/agents/cascade.d.ts.map +1 -0
- package/dist/agents/cascade.js +189 -0
- package/dist/agents/cascade.js.map +1 -0
- package/dist/agents/observer.d.ts +209 -0
- package/dist/agents/observer.d.ts.map +1 -0
- package/dist/agents/observer.js +495 -0
- package/dist/agents/observer.js.map +1 -0
- package/dist/agents/repair-loop.d.ts +236 -0
- package/dist/agents/repair-loop.d.ts.map +1 -0
- package/dist/agents/repair-loop.js +293 -0
- package/dist/agents/repair-loop.js.map +1 -0
- package/dist/agents/session-util.d.ts +8 -0
- package/dist/agents/session-util.d.ts.map +1 -0
- package/dist/agents/session-util.js +9 -0
- package/dist/agents/session-util.js.map +1 -0
- package/dist/agents/subagent-steps.d.ts +93 -0
- package/dist/agents/subagent-steps.d.ts.map +1 -0
- package/dist/agents/subagent-steps.js +206 -0
- package/dist/agents/subagent-steps.js.map +1 -0
- package/dist/agents/subagent.d.ts +441 -0
- package/dist/agents/subagent.d.ts.map +1 -0
- package/dist/agents/subagent.js +2492 -0
- package/dist/agents/subagent.js.map +1 -0
- package/dist/agents/suspend-guard.d.ts +35 -0
- package/dist/agents/suspend-guard.d.ts.map +1 -0
- package/dist/agents/suspend-guard.js +48 -0
- package/dist/agents/suspend-guard.js.map +1 -0
- package/dist/agents/teacher.d.ts +137 -0
- package/dist/agents/teacher.d.ts.map +1 -0
- package/dist/agents/teacher.js +575 -0
- package/dist/agents/teacher.js.map +1 -0
- package/dist/agents/team.d.ts +211 -0
- package/dist/agents/team.d.ts.map +1 -0
- package/dist/agents/team.js +293 -0
- package/dist/agents/team.js.map +1 -0
- package/dist/agents/tool-filter.d.ts +29 -0
- package/dist/agents/tool-filter.d.ts.map +1 -0
- package/dist/agents/tool-filter.js +50 -0
- package/dist/agents/tool-filter.js.map +1 -0
- package/dist/agents/verify.d.ts +177 -0
- package/dist/agents/verify.d.ts.map +1 -0
- package/dist/agents/verify.js +305 -0
- package/dist/agents/verify.js.map +1 -0
- package/dist/bench/metrics.d.ts +714 -0
- package/dist/bench/metrics.d.ts.map +1 -0
- package/dist/bench/metrics.js +618 -0
- package/dist/bench/metrics.js.map +1 -0
- package/dist/bin/sema-tb.d.ts +3 -0
- package/dist/bin/sema-tb.d.ts.map +1 -0
- package/dist/bin/sema-tb.js +516 -0
- package/dist/bin/sema-tb.js.map +1 -0
- package/dist/bin/tb-env.d.ts +13 -0
- package/dist/bin/tb-env.d.ts.map +1 -0
- package/dist/bin/tb-env.js +26 -0
- package/dist/bin/tb-env.js.map +1 -0
- package/dist/brain/anthropic.d.ts +46 -0
- package/dist/brain/anthropic.d.ts.map +1 -0
- package/dist/brain/anthropic.js +830 -0
- package/dist/brain/anthropic.js.map +1 -0
- package/dist/brain/circuit-breaker.d.ts +46 -0
- package/dist/brain/circuit-breaker.d.ts.map +1 -0
- package/dist/brain/circuit-breaker.js +173 -0
- package/dist/brain/circuit-breaker.js.map +1 -0
- package/dist/brain/degrading.d.ts +98 -0
- package/dist/brain/degrading.d.ts.map +1 -0
- package/dist/brain/degrading.js +249 -0
- package/dist/brain/degrading.js.map +1 -0
- package/dist/brain/errors.d.ts +31 -0
- package/dist/brain/errors.d.ts.map +1 -0
- package/dist/brain/errors.js +58 -0
- package/dist/brain/errors.js.map +1 -0
- package/dist/brain/failover.d.ts +18 -0
- package/dist/brain/failover.d.ts.map +1 -0
- package/dist/brain/failover.js +118 -0
- package/dist/brain/failover.js.map +1 -0
- package/dist/brain/model-presets.d.ts +36 -0
- package/dist/brain/model-presets.d.ts.map +1 -0
- package/dist/brain/model-presets.js +54 -0
- package/dist/brain/model-presets.js.map +1 -0
- package/dist/brain/openai.d.ts +43 -0
- package/dist/brain/openai.d.ts.map +1 -0
- package/dist/brain/openai.js +784 -0
- package/dist/brain/openai.js.map +1 -0
- package/dist/brain/reasoning.d.ts +138 -0
- package/dist/brain/reasoning.d.ts.map +1 -0
- package/dist/brain/reasoning.js +146 -0
- package/dist/brain/reasoning.js.map +1 -0
- package/dist/brain/repetition.d.ts +60 -0
- package/dist/brain/repetition.d.ts.map +1 -0
- package/dist/brain/repetition.js +121 -0
- package/dist/brain/repetition.js.map +1 -0
- package/dist/brain/request-params.d.ts +29 -0
- package/dist/brain/request-params.d.ts.map +1 -0
- package/dist/brain/request-params.js +78 -0
- package/dist/brain/request-params.js.map +1 -0
- package/dist/brain/retry.d.ts +18 -0
- package/dist/brain/retry.d.ts.map +1 -0
- package/dist/brain/retry.js +42 -0
- package/dist/brain/retry.js.map +1 -0
- package/dist/brain/routing.d.ts +19 -0
- package/dist/brain/routing.d.ts.map +1 -0
- package/dist/brain/routing.js +44 -0
- package/dist/brain/routing.js.map +1 -0
- package/dist/brain/status-sink.d.ts +46 -0
- package/dist/brain/status-sink.d.ts.map +1 -0
- package/dist/brain/status-sink.js +36 -0
- package/dist/brain/status-sink.js.map +1 -0
- package/dist/brain/stream-engine.d.ts +85 -0
- package/dist/brain/stream-engine.d.ts.map +1 -0
- package/dist/brain/stream-engine.js +391 -0
- package/dist/brain/stream-engine.js.map +1 -0
- package/dist/brain/timeout.d.ts +44 -0
- package/dist/brain/timeout.d.ts.map +1 -0
- package/dist/brain/timeout.js +41 -0
- package/dist/brain/timeout.js.map +1 -0
- package/dist/brain/tool-call-repair.d.ts +15 -0
- package/dist/brain/tool-call-repair.d.ts.map +1 -0
- package/dist/brain/tool-call-repair.js +67 -0
- package/dist/brain/tool-call-repair.js.map +1 -0
- package/dist/brain/walltime.d.ts +14 -0
- package/dist/brain/walltime.d.ts.map +1 -0
- package/dist/brain/walltime.js +14 -0
- package/dist/brain/walltime.js.map +1 -0
- package/dist/core/arg-summary.d.ts +19 -0
- package/dist/core/arg-summary.d.ts.map +1 -0
- package/dist/core/arg-summary.js +96 -0
- package/dist/core/arg-summary.js.map +1 -0
- package/dist/core/ask-question.d.ts +113 -0
- package/dist/core/ask-question.d.ts.map +1 -0
- package/dist/core/ask-question.js +246 -0
- package/dist/core/ask-question.js.map +1 -0
- package/dist/core/auto-compaction.d.ts +341 -0
- package/dist/core/auto-compaction.d.ts.map +1 -0
- package/dist/core/auto-compaction.js +495 -0
- package/dist/core/auto-compaction.js.map +1 -0
- package/dist/core/auto-promote.d.ts +126 -0
- package/dist/core/auto-promote.d.ts.map +1 -0
- package/dist/core/auto-promote.js +171 -0
- package/dist/core/auto-promote.js.map +1 -0
- package/dist/core/background-shell.d.ts +128 -0
- package/dist/core/background-shell.d.ts.map +1 -0
- package/dist/core/background-shell.js +42 -0
- package/dist/core/background-shell.js.map +1 -0
- package/dist/core/cache-break-detector.d.ts +63 -0
- package/dist/core/cache-break-detector.d.ts.map +1 -0
- package/dist/core/cache-break-detector.js +0 -0
- package/dist/core/cache-break-detector.js.map +1 -0
- package/dist/core/canonical-json.d.ts +45 -0
- package/dist/core/canonical-json.d.ts.map +1 -0
- package/dist/core/canonical-json.js +132 -0
- package/dist/core/canonical-json.js.map +1 -0
- package/dist/core/checkpoint-store.d.ts +1101 -0
- package/dist/core/checkpoint-store.d.ts.map +1 -0
- package/dist/core/checkpoint-store.js +514 -0
- package/dist/core/checkpoint-store.js.map +1 -0
- package/dist/core/consolidate-scope.d.ts +94 -0
- package/dist/core/consolidate-scope.d.ts.map +1 -0
- package/dist/core/consolidate-scope.js +158 -0
- package/dist/core/consolidate-scope.js.map +1 -0
- package/dist/core/context-edit.d.ts +82 -0
- package/dist/core/context-edit.d.ts.map +1 -0
- package/dist/core/context-edit.js +149 -0
- package/dist/core/context-edit.js.map +1 -0
- package/dist/core/context-guard.d.ts +43 -0
- package/dist/core/context-guard.d.ts.map +1 -0
- package/dist/core/context-guard.js +171 -0
- package/dist/core/context-guard.js.map +1 -0
- package/dist/core/dynamic-recall.d.ts +100 -0
- package/dist/core/dynamic-recall.d.ts.map +1 -0
- package/dist/core/dynamic-recall.js +108 -0
- package/dist/core/dynamic-recall.js.map +1 -0
- package/dist/core/exec-gate.d.ts +74 -0
- package/dist/core/exec-gate.d.ts.map +1 -0
- package/dist/core/exec-gate.js +143 -0
- package/dist/core/exec-gate.js.map +1 -0
- package/dist/core/exec-output-tail.d.ts +37 -0
- package/dist/core/exec-output-tail.d.ts.map +1 -0
- package/dist/core/exec-output-tail.js +63 -0
- package/dist/core/exec-output-tail.js.map +1 -0
- package/dist/core/file-snapshot-store.d.ts +137 -0
- package/dist/core/file-snapshot-store.d.ts.map +1 -0
- package/dist/core/file-snapshot-store.js +319 -0
- package/dist/core/file-snapshot-store.js.map +1 -0
- package/dist/core/git-worktree-env.d.ts +41 -0
- package/dist/core/git-worktree-env.d.ts.map +1 -0
- package/dist/core/git-worktree-env.js +64 -0
- package/dist/core/git-worktree-env.js.map +1 -0
- package/dist/core/hooks.d.ts +372 -0
- package/dist/core/hooks.d.ts.map +1 -0
- package/dist/core/hooks.js +269 -0
- package/dist/core/hooks.js.map +1 -0
- package/dist/core/lsp-diagnostics.d.ts +74 -0
- package/dist/core/lsp-diagnostics.d.ts.map +1 -0
- package/dist/core/lsp-diagnostics.js +125 -0
- package/dist/core/lsp-diagnostics.js.map +1 -0
- package/dist/core/lsp-protocol.d.ts +28 -0
- package/dist/core/lsp-protocol.d.ts.map +1 -0
- package/dist/core/lsp-protocol.js +179 -0
- package/dist/core/lsp-protocol.js.map +1 -0
- package/dist/core/lsp-session.d.ts +56 -0
- package/dist/core/lsp-session.d.ts.map +1 -0
- package/dist/core/lsp-session.js +185 -0
- package/dist/core/lsp-session.js.map +1 -0
- package/dist/core/lsp.d.ts +132 -0
- package/dist/core/lsp.d.ts.map +1 -0
- package/dist/core/lsp.js +147 -0
- package/dist/core/lsp.js.map +1 -0
- package/dist/core/mcp.d.ts +294 -0
- package/dist/core/mcp.d.ts.map +1 -0
- package/dist/core/mcp.js +992 -0
- package/dist/core/mcp.js.map +1 -0
- package/dist/core/media-byte-cap.d.ts +34 -0
- package/dist/core/media-byte-cap.d.ts.map +1 -0
- package/dist/core/media-byte-cap.js +76 -0
- package/dist/core/media-byte-cap.js.map +1 -0
- package/dist/core/memory-engine/data-plane.d.ts +103 -0
- package/dist/core/memory-engine/data-plane.d.ts.map +1 -0
- package/dist/core/memory-engine/data-plane.js +272 -0
- package/dist/core/memory-engine/data-plane.js.map +1 -0
- package/dist/core/memory-engine/dual-root.d.ts +40 -0
- package/dist/core/memory-engine/dual-root.d.ts.map +1 -0
- package/dist/core/memory-engine/dual-root.js +95 -0
- package/dist/core/memory-engine/dual-root.js.map +1 -0
- package/dist/core/memory-engine/engine.d.ts +161 -0
- package/dist/core/memory-engine/engine.d.ts.map +1 -0
- package/dist/core/memory-engine/engine.js +1110 -0
- package/dist/core/memory-engine/engine.js.map +1 -0
- package/dist/core/memory-engine/file-backend.d.ts +162 -0
- package/dist/core/memory-engine/file-backend.d.ts.map +1 -0
- package/dist/core/memory-engine/file-backend.js +948 -0
- package/dist/core/memory-engine/file-backend.js.map +1 -0
- package/dist/core/memory-engine/frontmatter.d.ts +24 -0
- package/dist/core/memory-engine/frontmatter.d.ts.map +1 -0
- package/dist/core/memory-engine/frontmatter.js +135 -0
- package/dist/core/memory-engine/frontmatter.js.map +1 -0
- package/dist/core/memory-engine/index.d.ts +13 -0
- package/dist/core/memory-engine/index.d.ts.map +1 -0
- package/dist/core/memory-engine/index.js +28 -0
- package/dist/core/memory-engine/index.js.map +1 -0
- package/dist/core/memory-engine/layout.d.ts +123 -0
- package/dist/core/memory-engine/layout.d.ts.map +1 -0
- package/dist/core/memory-engine/layout.js +643 -0
- package/dist/core/memory-engine/layout.js.map +1 -0
- package/dist/core/memory-engine/memory-backend-contract.d.ts +22 -0
- package/dist/core/memory-engine/memory-backend-contract.d.ts.map +1 -0
- package/dist/core/memory-engine/memory-backend-contract.js +271 -0
- package/dist/core/memory-engine/memory-backend-contract.js.map +1 -0
- package/dist/core/memory-engine/migrate.d.ts +17 -0
- package/dist/core/memory-engine/migrate.d.ts.map +1 -0
- package/dist/core/memory-engine/migrate.js +35 -0
- package/dist/core/memory-engine/migrate.js.map +1 -0
- package/dist/core/memory-engine/scan.d.ts +20 -0
- package/dist/core/memory-engine/scan.d.ts.map +1 -0
- package/dist/core/memory-engine/scan.js +84 -0
- package/dist/core/memory-engine/scan.js.map +1 -0
- package/dist/core/memory-engine/scope-contract.d.ts +104 -0
- package/dist/core/memory-engine/scope-contract.d.ts.map +1 -0
- package/dist/core/memory-engine/scope-contract.js +288 -0
- package/dist/core/memory-engine/scope-contract.js.map +1 -0
- package/dist/core/memory-engine/sync-client.d.ts +100 -0
- package/dist/core/memory-engine/sync-client.d.ts.map +1 -0
- package/dist/core/memory-engine/sync-client.js +328 -0
- package/dist/core/memory-engine/sync-client.js.map +1 -0
- package/dist/core/memory-engine/sync.d.ts +97 -0
- package/dist/core/memory-engine/sync.d.ts.map +1 -0
- package/dist/core/memory-engine/sync.js +91 -0
- package/dist/core/memory-engine/sync.js.map +1 -0
- package/dist/core/memory-engine/types.d.ts +279 -0
- package/dist/core/memory-engine/types.d.ts.map +1 -0
- package/dist/core/memory-engine/types.js +9 -0
- package/dist/core/memory-engine/types.js.map +1 -0
- package/dist/core/memory-recall.d.ts +261 -0
- package/dist/core/memory-recall.d.ts.map +1 -0
- package/dist/core/memory-recall.js +600 -0
- package/dist/core/memory-recall.js.map +1 -0
- package/dist/core/memory-vector.d.ts +24 -0
- package/dist/core/memory-vector.d.ts.map +1 -0
- package/dist/core/memory-vector.js +51 -0
- package/dist/core/memory-vector.js.map +1 -0
- package/dist/core/memory.d.ts +502 -0
- package/dist/core/memory.d.ts.map +1 -0
- package/dist/core/memory.js +881 -0
- package/dist/core/memory.js.map +1 -0
- package/dist/core/message-utils.d.ts +7 -0
- package/dist/core/message-utils.d.ts.map +1 -0
- package/dist/core/message-utils.js +10 -0
- package/dist/core/message-utils.js.map +1 -0
- package/dist/core/oracle-isolation.d.ts +85 -0
- package/dist/core/oracle-isolation.d.ts.map +1 -0
- package/dist/core/oracle-isolation.js +121 -0
- package/dist/core/oracle-isolation.js.map +1 -0
- package/dist/core/permission-rules.d.ts +102 -0
- package/dist/core/permission-rules.d.ts.map +1 -0
- package/dist/core/permission-rules.js +357 -0
- package/dist/core/permission-rules.js.map +1 -0
- package/dist/core/present-plan-tool.d.ts +28 -0
- package/dist/core/present-plan-tool.d.ts.map +1 -0
- package/dist/core/present-plan-tool.js +119 -0
- package/dist/core/present-plan-tool.js.map +1 -0
- package/dist/core/pricing.d.ts +48 -0
- package/dist/core/pricing.d.ts.map +1 -0
- package/dist/core/pricing.js +46 -0
- package/dist/core/pricing.js.map +1 -0
- package/dist/core/property-harness.d.ts +126 -0
- package/dist/core/property-harness.d.ts.map +1 -0
- package/dist/core/property-harness.js +192 -0
- package/dist/core/property-harness.js.map +1 -0
- package/dist/core/push-queue.d.ts +10 -0
- package/dist/core/push-queue.d.ts.map +1 -0
- package/dist/core/push-queue.js +42 -0
- package/dist/core/push-queue.js.map +1 -0
- package/dist/core/remote-env.d.ts +432 -0
- package/dist/core/remote-env.d.ts.map +1 -0
- package/dist/core/remote-env.js +77 -0
- package/dist/core/remote-env.js.map +1 -0
- package/dist/core/roles.d.ts +74 -0
- package/dist/core/roles.d.ts.map +1 -0
- package/dist/core/roles.js +272 -0
- package/dist/core/roles.js.map +1 -0
- package/dist/core/runner/active-skill-scope.d.ts +64 -0
- package/dist/core/runner/active-skill-scope.d.ts.map +1 -0
- package/dist/core/runner/active-skill-scope.js +197 -0
- package/dist/core/runner/active-skill-scope.js.map +1 -0
- package/dist/core/runner/assemble-result.d.ts +191 -0
- package/dist/core/runner/assemble-result.d.ts.map +1 -0
- package/dist/core/runner/assemble-result.js +250 -0
- package/dist/core/runner/assemble-result.js.map +1 -0
- package/dist/core/runner/call-cap.d.ts +130 -0
- package/dist/core/runner/call-cap.d.ts.map +1 -0
- package/dist/core/runner/call-cap.js +151 -0
- package/dist/core/runner/call-cap.js.map +1 -0
- package/dist/core/runner/grounding-signal.d.ts +12 -0
- package/dist/core/runner/grounding-signal.d.ts.map +1 -0
- package/dist/core/runner/grounding-signal.js +94 -0
- package/dist/core/runner/grounding-signal.js.map +1 -0
- package/dist/core/runner/image.d.ts +23 -0
- package/dist/core/runner/image.d.ts.map +1 -0
- package/dist/core/runner/image.js +151 -0
- package/dist/core/runner/image.js.map +1 -0
- package/dist/core/runner/memory-consolidation.d.ts +112 -0
- package/dist/core/runner/memory-consolidation.d.ts.map +1 -0
- package/dist/core/runner/memory-consolidation.js +372 -0
- package/dist/core/runner/memory-consolidation.js.map +1 -0
- package/dist/core/runner/prepare-task.d.ts +583 -0
- package/dist/core/runner/prepare-task.d.ts.map +1 -0
- package/dist/core/runner/prepare-task.js +3612 -0
- package/dist/core/runner/prepare-task.js.map +1 -0
- package/dist/core/runner/prompt-suggestions.d.ts +33 -0
- package/dist/core/runner/prompt-suggestions.d.ts.map +1 -0
- package/dist/core/runner/prompt-suggestions.js +116 -0
- package/dist/core/runner/prompt-suggestions.js.map +1 -0
- package/dist/core/runner/runtask.d.ts +274 -0
- package/dist/core/runner/runtask.d.ts.map +1 -0
- package/dist/core/runner/runtask.js +4606 -0
- package/dist/core/runner/runtask.js.map +1 -0
- package/dist/core/runner/session-rule-policy.d.ts +28 -0
- package/dist/core/runner/session-rule-policy.d.ts.map +1 -0
- package/dist/core/runner/session-rule-policy.js +110 -0
- package/dist/core/runner/session-rule-policy.js.map +1 -0
- package/dist/core/runner/strict-output-schema.d.ts +36 -0
- package/dist/core/runner/strict-output-schema.d.ts.map +1 -0
- package/dist/core/runner/strict-output-schema.js +365 -0
- package/dist/core/runner/strict-output-schema.js.map +1 -0
- package/dist/core/runner/synthetic-tools.d.ts +72 -0
- package/dist/core/runner/synthetic-tools.d.ts.map +1 -0
- package/dist/core/runner/synthetic-tools.js +203 -0
- package/dist/core/runner/synthetic-tools.js.map +1 -0
- package/dist/core/runner/tool-disclosure.d.ts +117 -0
- package/dist/core/runner/tool-disclosure.d.ts.map +1 -0
- package/dist/core/runner/tool-disclosure.js +0 -0
- package/dist/core/runner/tool-disclosure.js.map +1 -0
- package/dist/core/runner/turn-attachments.d.ts +381 -0
- package/dist/core/runner/turn-attachments.d.ts.map +1 -0
- package/dist/core/runner/turn-attachments.js +596 -0
- package/dist/core/runner/turn-attachments.js.map +1 -0
- package/dist/core/runner/usage-accounting.d.ts +35 -0
- package/dist/core/runner/usage-accounting.d.ts.map +1 -0
- package/dist/core/runner/usage-accounting.js +45 -0
- package/dist/core/runner/usage-accounting.js.map +1 -0
- package/dist/core/runtime.d.ts +9 -0
- package/dist/core/runtime.d.ts.map +1 -0
- package/dist/core/runtime.js +17 -0
- package/dist/core/runtime.js.map +1 -0
- package/dist/core/safety-axis-vocab.d.ts +33 -0
- package/dist/core/safety-axis-vocab.d.ts.map +1 -0
- package/dist/core/safety-axis-vocab.js +35 -0
- package/dist/core/safety-axis-vocab.js.map +1 -0
- package/dist/core/safety-merge-corpus.d.ts +48 -0
- package/dist/core/safety-merge-corpus.d.ts.map +1 -0
- package/dist/core/safety-merge-corpus.js +203 -0
- package/dist/core/safety-merge-corpus.js.map +1 -0
- package/dist/core/scheduler.d.ts +126 -0
- package/dist/core/scheduler.d.ts.map +1 -0
- package/dist/core/scheduler.js +54 -0
- package/dist/core/scheduler.js.map +1 -0
- package/dist/core/secret-env.d.ts +23 -0
- package/dist/core/secret-env.d.ts.map +1 -0
- package/dist/core/secret-env.js +35 -0
- package/dist/core/secret-env.js.map +1 -0
- package/dist/core/select-model.d.ts +26 -0
- package/dist/core/select-model.d.ts.map +1 -0
- package/dist/core/select-model.js +58 -0
- package/dist/core/select-model.js.map +1 -0
- package/dist/core/sensitive-path-policy.d.ts +24 -0
- package/dist/core/sensitive-path-policy.d.ts.map +1 -0
- package/dist/core/sensitive-path-policy.js +96 -0
- package/dist/core/sensitive-path-policy.js.map +1 -0
- package/dist/core/session-policy-store.d.ts +102 -0
- package/dist/core/session-policy-store.d.ts.map +1 -0
- package/dist/core/session-policy-store.js +177 -0
- package/dist/core/session-policy-store.js.map +1 -0
- package/dist/core/session-reconcile.d.ts +63 -0
- package/dist/core/session-reconcile.d.ts.map +1 -0
- package/dist/core/session-reconcile.js +71 -0
- package/dist/core/session-reconcile.js.map +1 -0
- package/dist/core/session-store.d.ts +99 -0
- package/dist/core/session-store.d.ts.map +1 -0
- package/dist/core/session-store.js +298 -0
- package/dist/core/session-store.js.map +1 -0
- package/dist/core/session.d.ts +144 -0
- package/dist/core/session.d.ts.map +1 -0
- package/dist/core/session.js +56 -0
- package/dist/core/session.js.map +1 -0
- package/dist/core/spec-contract.d.ts +147 -0
- package/dist/core/spec-contract.d.ts.map +1 -0
- package/dist/core/spec-contract.js +340 -0
- package/dist/core/spec-contract.js.map +1 -0
- package/dist/core/strategy-store.d.ts +50 -0
- package/dist/core/strategy-store.d.ts.map +1 -0
- package/dist/core/strategy-store.js +100 -0
- package/dist/core/strategy-store.js.map +1 -0
- package/dist/core/stub-env.d.ts +35 -0
- package/dist/core/stub-env.d.ts.map +1 -0
- package/dist/core/stub-env.js +69 -0
- package/dist/core/stub-env.js.map +1 -0
- package/dist/core/task-notification.d.ts +115 -0
- package/dist/core/task-notification.d.ts.map +1 -0
- package/dist/core/task-notification.js +247 -0
- package/dist/core/task-notification.js.map +1 -0
- package/dist/core/task-outcome.d.ts +80 -0
- package/dist/core/task-outcome.d.ts.map +1 -0
- package/dist/core/task-outcome.js +40 -0
- package/dist/core/task-outcome.js.map +1 -0
- package/dist/core/task-registry.d.ts +349 -0
- package/dist/core/task-registry.d.ts.map +1 -0
- package/dist/core/task-registry.js +1751 -0
- package/dist/core/task-registry.js.map +1 -0
- package/dist/core/tighten-task-spec.d.ts +27 -0
- package/dist/core/tighten-task-spec.d.ts.map +1 -0
- package/dist/core/tighten-task-spec.js +95 -0
- package/dist/core/tighten-task-spec.js.map +1 -0
- package/dist/core/tool-detach.d.ts +31 -0
- package/dist/core/tool-detach.d.ts.map +1 -0
- package/dist/core/tool-detach.js +62 -0
- package/dist/core/tool-detach.js.map +1 -0
- package/dist/core/tool-errors.d.ts +72 -0
- package/dist/core/tool-errors.d.ts.map +1 -0
- package/dist/core/tool-errors.js +194 -0
- package/dist/core/tool-errors.js.map +1 -0
- package/dist/core/tool-name-aliases.d.ts +5 -0
- package/dist/core/tool-name-aliases.d.ts.map +1 -0
- package/dist/core/tool-name-aliases.js +130 -0
- package/dist/core/tool-name-aliases.js.map +1 -0
- package/dist/core/tool-policy.d.ts +295 -0
- package/dist/core/tool-policy.d.ts.map +1 -0
- package/dist/core/tool-policy.js +567 -0
- package/dist/core/tool-policy.js.map +1 -0
- package/dist/core/tool-result-budget.d.ts +44 -0
- package/dist/core/tool-result-budget.d.ts.map +1 -0
- package/dist/core/tool-result-budget.js +140 -0
- package/dist/core/tool-result-budget.js.map +1 -0
- package/dist/core/tool-result-store.d.ts +136 -0
- package/dist/core/tool-result-store.d.ts.map +1 -0
- package/dist/core/tool-result-store.js +219 -0
- package/dist/core/tool-result-store.js.map +1 -0
- package/dist/core/tools.d.ts +14 -0
- package/dist/core/tools.d.ts.map +1 -0
- package/dist/core/tools.js +98 -0
- package/dist/core/tools.js.map +1 -0
- package/dist/core/trace.d.ts +340 -0
- package/dist/core/trace.d.ts.map +1 -0
- package/dist/core/trace.js +12 -0
- package/dist/core/trace.js.map +1 -0
- package/dist/core/types.d.ts +2828 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +2 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/untrusted-egress.d.ts +32 -0
- package/dist/core/untrusted-egress.d.ts.map +1 -0
- package/dist/core/untrusted-egress.js +49 -0
- package/dist/core/untrusted-egress.js.map +1 -0
- package/dist/core/untrusted-text.d.ts +64 -0
- package/dist/core/untrusted-text.d.ts.map +1 -0
- package/dist/core/untrusted-text.js +119 -0
- package/dist/core/untrusted-text.js.map +1 -0
- package/dist/core/warm-resume.d.ts +23 -0
- package/dist/core/warm-resume.d.ts.map +1 -0
- package/dist/core/warm-resume.js +28 -0
- package/dist/core/warm-resume.js.map +1 -0
- package/dist/core/with-retry.d.ts +43 -0
- package/dist/core/with-retry.d.ts.map +1 -0
- package/dist/core/with-retry.js +59 -0
- package/dist/core/with-retry.js.map +1 -0
- package/dist/core/workflow-journal-store.d.ts +44 -0
- package/dist/core/workflow-journal-store.d.ts.map +1 -0
- package/dist/core/workflow-journal-store.js +47 -0
- package/dist/core/workflow-journal-store.js.map +1 -0
- package/dist/core/workflow-run-store-contract.d.ts +31 -0
- package/dist/core/workflow-run-store-contract.d.ts.map +1 -0
- package/dist/core/workflow-run-store-contract.js +191 -0
- package/dist/core/workflow-run-store-contract.js.map +1 -0
- package/dist/core/workflow-run-store.d.ts +149 -0
- package/dist/core/workflow-run-store.d.ts.map +1 -0
- package/dist/core/workflow-run-store.js +110 -0
- package/dist/core/workflow-run-store.js.map +1 -0
- package/dist/engine/compaction/compaction.d.ts +139 -0
- package/dist/engine/compaction/compaction.d.ts.map +1 -0
- package/dist/engine/compaction/compaction.js +1030 -0
- package/dist/engine/compaction/compaction.js.map +1 -0
- package/dist/engine/compaction/utils.d.ts +45 -0
- package/dist/engine/compaction/utils.d.ts.map +1 -0
- package/dist/engine/compaction/utils.js +203 -0
- package/dist/engine/compaction/utils.js.map +1 -0
- package/dist/engine/execution-env/kill-tree.d.ts +20 -0
- package/dist/engine/execution-env/kill-tree.d.ts.map +1 -0
- package/dist/engine/execution-env/kill-tree.js +183 -0
- package/dist/engine/execution-env/kill-tree.js.map +1 -0
- package/dist/engine/execution-env/node-execution-env.d.ts +133 -0
- package/dist/engine/execution-env/node-execution-env.d.ts.map +1 -0
- package/dist/engine/execution-env/node-execution-env.js +1031 -0
- package/dist/engine/execution-env/node-execution-env.js.map +1 -0
- package/dist/engine/harness/agent-harness.d.ts +174 -0
- package/dist/engine/harness/agent-harness.d.ts.map +1 -0
- package/dist/engine/harness/agent-harness.js +1053 -0
- package/dist/engine/harness/agent-harness.js.map +1 -0
- package/dist/engine/harness/messages.d.ts +17 -0
- package/dist/engine/harness/messages.d.ts.map +1 -0
- package/dist/engine/harness/messages.js +122 -0
- package/dist/engine/harness/messages.js.map +1 -0
- package/dist/engine/harness/types.d.ts +713 -0
- package/dist/engine/harness/types.d.ts.map +1 -0
- package/dist/engine/harness/types.js +100 -0
- package/dist/engine/harness/types.js.map +1 -0
- package/dist/engine/llm/diagnostics.d.ts +23 -0
- package/dist/engine/llm/diagnostics.d.ts.map +1 -0
- package/dist/engine/llm/diagnostics.js +32 -0
- package/dist/engine/llm/diagnostics.js.map +1 -0
- package/dist/engine/llm/event-stream.d.ts +25 -0
- package/dist/engine/llm/event-stream.d.ts.map +1 -0
- package/dist/engine/llm/event-stream.js +103 -0
- package/dist/engine/llm/event-stream.js.map +1 -0
- package/dist/engine/llm/index.d.ts +12 -0
- package/dist/engine/llm/index.d.ts.map +1 -0
- package/dist/engine/llm/index.js +12 -0
- package/dist/engine/llm/index.js.map +1 -0
- package/dist/engine/llm/types.d.ts +753 -0
- package/dist/engine/llm/types.d.ts.map +1 -0
- package/dist/engine/llm/types.js +2 -0
- package/dist/engine/llm/types.js.map +1 -0
- package/dist/engine/llm/validation.d.ts +8 -0
- package/dist/engine/llm/validation.d.ts.map +1 -0
- package/dist/engine/llm/validation.js +318 -0
- package/dist/engine/llm/validation.js.map +1 -0
- package/dist/engine/loop/agent-loop.d.ts +74 -0
- package/dist/engine/loop/agent-loop.d.ts.map +1 -0
- package/dist/engine/loop/agent-loop.js +1490 -0
- package/dist/engine/loop/agent-loop.js.map +1 -0
- package/dist/engine/loop/runtime-deps.d.ts +17 -0
- package/dist/engine/loop/runtime-deps.d.ts.map +1 -0
- package/dist/engine/loop/runtime-deps.js +21 -0
- package/dist/engine/loop/runtime-deps.js.map +1 -0
- package/dist/engine/loop/types.d.ts +609 -0
- package/dist/engine/loop/types.d.ts.map +1 -0
- package/dist/engine/loop/types.js +2 -0
- package/dist/engine/loop/types.js.map +1 -0
- package/dist/engine/lsp/frame-decoder.d.ts +16 -0
- package/dist/engine/lsp/frame-decoder.d.ts.map +1 -0
- package/dist/engine/lsp/frame-decoder.js +53 -0
- package/dist/engine/lsp/frame-decoder.js.map +1 -0
- package/dist/engine/lsp/node-lsp-manager.d.ts +105 -0
- package/dist/engine/lsp/node-lsp-manager.d.ts.map +1 -0
- package/dist/engine/lsp/node-lsp-manager.js +343 -0
- package/dist/engine/lsp/node-lsp-manager.js.map +1 -0
- package/dist/engine/lsp/stdio-lsp-transport.d.ts +37 -0
- package/dist/engine/lsp/stdio-lsp-transport.d.ts.map +1 -0
- package/dist/engine/lsp/stdio-lsp-transport.js +185 -0
- package/dist/engine/lsp/stdio-lsp-transport.js.map +1 -0
- package/dist/engine/session/import-validate.d.ts +44 -0
- package/dist/engine/session/import-validate.d.ts.map +1 -0
- package/dist/engine/session/import-validate.js +92 -0
- package/dist/engine/session/import-validate.js.map +1 -0
- package/dist/engine/session/memory-repo.d.ts +25 -0
- package/dist/engine/session/memory-repo.d.ts.map +1 -0
- package/dist/engine/session/memory-repo.js +59 -0
- package/dist/engine/session/memory-repo.js.map +1 -0
- package/dist/engine/session/memory-storage.d.ts +14 -0
- package/dist/engine/session/memory-storage.d.ts.map +1 -0
- package/dist/engine/session/memory-storage.js +17 -0
- package/dist/engine/session/memory-storage.js.map +1 -0
- package/dist/engine/session/repo-utils.d.ts +11 -0
- package/dist/engine/session/repo-utils.d.ts.map +1 -0
- package/dist/engine/session/repo-utils.js +40 -0
- package/dist/engine/session/repo-utils.js.map +1 -0
- package/dist/engine/session/session.d.ts +34 -0
- package/dist/engine/session/session.d.ts.map +1 -0
- package/dist/engine/session/session.js +227 -0
- package/dist/engine/session/session.js.map +1 -0
- package/dist/engine/session/storage-base.d.ts +38 -0
- package/dist/engine/session/storage-base.d.ts.map +1 -0
- package/dist/engine/session/storage-base.js +157 -0
- package/dist/engine/session/storage-base.js.map +1 -0
- package/dist/engine/session/timestamps.d.ts +3 -0
- package/dist/engine/session/timestamps.d.ts.map +1 -0
- package/dist/engine/session/timestamps.js +15 -0
- package/dist/engine/session/timestamps.js.map +1 -0
- package/dist/engine/session/uuid.d.ts +2 -0
- package/dist/engine/session/uuid.d.ts.map +1 -0
- package/dist/engine/session/uuid.js +50 -0
- package/dist/engine/session/uuid.js.map +1 -0
- package/dist/index.d.ts +160 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +374 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/harness.d.ts +26 -0
- package/dist/internal/harness.d.ts.map +1 -0
- package/dist/internal/harness.js +24 -0
- package/dist/internal/harness.js.map +1 -0
- package/dist/internal/llm.d.ts +9 -0
- package/dist/internal/llm.d.ts.map +1 -0
- package/dist/internal/llm.js +8 -0
- package/dist/internal/llm.js.map +1 -0
- package/dist/orchestration/builtin-workflows.d.ts +44 -0
- package/dist/orchestration/builtin-workflows.d.ts.map +1 -0
- package/dist/orchestration/builtin-workflows.js +165 -0
- package/dist/orchestration/builtin-workflows.js.map +1 -0
- package/dist/orchestration/dev-vm-script-runner.d.ts +24 -0
- package/dist/orchestration/dev-vm-script-runner.d.ts.map +1 -0
- package/dist/orchestration/dev-vm-script-runner.js +106 -0
- package/dist/orchestration/dev-vm-script-runner.js.map +1 -0
- package/dist/orchestration/goal.d.ts +97 -0
- package/dist/orchestration/goal.d.ts.map +1 -0
- package/dist/orchestration/goal.js +248 -0
- package/dist/orchestration/goal.js.map +1 -0
- package/dist/orchestration/run-spec.d.ts +63 -0
- package/dist/orchestration/run-spec.d.ts.map +1 -0
- package/dist/orchestration/run-spec.js +274 -0
- package/dist/orchestration/run-spec.js.map +1 -0
- package/dist/orchestration/run-workflow-tool.d.ts +187 -0
- package/dist/orchestration/run-workflow-tool.d.ts.map +1 -0
- package/dist/orchestration/run-workflow-tool.js +612 -0
- package/dist/orchestration/run-workflow-tool.js.map +1 -0
- package/dist/orchestration/workflow-governance.d.ts +70 -0
- package/dist/orchestration/workflow-governance.d.ts.map +1 -0
- package/dist/orchestration/workflow-governance.js +176 -0
- package/dist/orchestration/workflow-governance.js.map +1 -0
- package/dist/orchestration/workflow-meta.d.ts +41 -0
- package/dist/orchestration/workflow-meta.d.ts.map +1 -0
- package/dist/orchestration/workflow-meta.js +426 -0
- package/dist/orchestration/workflow-meta.js.map +1 -0
- package/dist/orchestration/workflow-observe.d.ts +77 -0
- package/dist/orchestration/workflow-observe.d.ts.map +1 -0
- package/dist/orchestration/workflow-observe.js +177 -0
- package/dist/orchestration/workflow-observe.js.map +1 -0
- package/dist/orchestration/workflow-primitives.d.ts +31 -0
- package/dist/orchestration/workflow-primitives.d.ts.map +1 -0
- package/dist/orchestration/workflow-primitives.js +104 -0
- package/dist/orchestration/workflow-primitives.js.map +1 -0
- package/dist/orchestration/workflow-sandbox-conformance.d.ts +34 -0
- package/dist/orchestration/workflow-sandbox-conformance.d.ts.map +1 -0
- package/dist/orchestration/workflow-sandbox-conformance.js +301 -0
- package/dist/orchestration/workflow-sandbox-conformance.js.map +1 -0
- package/dist/orchestration/workflow-script-runner.d.ts +127 -0
- package/dist/orchestration/workflow-script-runner.d.ts.map +1 -0
- package/dist/orchestration/workflow-script-runner.js +48 -0
- package/dist/orchestration/workflow-script-runner.js.map +1 -0
- package/dist/orchestration/workflow-script-store.d.ts +99 -0
- package/dist/orchestration/workflow-script-store.d.ts.map +1 -0
- package/dist/orchestration/workflow-script-store.js +133 -0
- package/dist/orchestration/workflow-script-store.js.map +1 -0
- package/dist/orchestration/workflow-size-guideline.d.ts +50 -0
- package/dist/orchestration/workflow-size-guideline.d.ts.map +1 -0
- package/dist/orchestration/workflow-size-guideline.js +78 -0
- package/dist/orchestration/workflow-size-guideline.js.map +1 -0
- package/dist/orchestration/workflow.d.ts +624 -0
- package/dist/orchestration/workflow.d.ts.map +1 -0
- package/dist/orchestration/workflow.js +1596 -0
- package/dist/orchestration/workflow.js.map +1 -0
- package/dist/prompts/default.d.ts +474 -0
- package/dist/prompts/default.d.ts.map +1 -0
- package/dist/prompts/default.js +676 -0
- package/dist/prompts/default.js.map +1 -0
- package/dist/prompts/supervisor.d.ts +60 -0
- package/dist/prompts/supervisor.d.ts.map +1 -0
- package/dist/prompts/supervisor.js +162 -0
- package/dist/prompts/supervisor.js.map +1 -0
- package/dist/scenarios/env.d.ts +41 -0
- package/dist/scenarios/env.d.ts.map +1 -0
- package/dist/scenarios/env.js +65 -0
- package/dist/scenarios/env.js.map +1 -0
- package/dist/scenarios/full-body.d.ts +47 -0
- package/dist/scenarios/full-body.d.ts.map +1 -0
- package/dist/scenarios/full-body.js +36 -0
- package/dist/scenarios/full-body.js.map +1 -0
- package/dist/scenarios/scenario-registry.d.ts +107 -0
- package/dist/scenarios/scenario-registry.d.ts.map +1 -0
- package/dist/scenarios/scenario-registry.js +192 -0
- package/dist/scenarios/scenario-registry.js.map +1 -0
- package/dist/scenarios/teacher-quickstart.d.ts +41 -0
- package/dist/scenarios/teacher-quickstart.d.ts.map +1 -0
- package/dist/scenarios/teacher-quickstart.js +32 -0
- package/dist/scenarios/teacher-quickstart.js.map +1 -0
- package/dist/server/http.d.ts +36 -0
- package/dist/server/http.d.ts.map +1 -0
- package/dist/server/http.js +144 -0
- package/dist/server/http.js.map +1 -0
- package/dist/stores/file/checkpoint-store.d.ts +62 -0
- package/dist/stores/file/checkpoint-store.d.ts.map +1 -0
- package/dist/stores/file/checkpoint-store.js +250 -0
- package/dist/stores/file/checkpoint-store.js.map +1 -0
- package/dist/stores/file/file-snapshot-store.d.ts +43 -0
- package/dist/stores/file/file-snapshot-store.d.ts.map +1 -0
- package/dist/stores/file/file-snapshot-store.js +374 -0
- package/dist/stores/file/file-snapshot-store.js.map +1 -0
- package/dist/stores/file/fs-atomic.d.ts +95 -0
- package/dist/stores/file/fs-atomic.d.ts.map +1 -0
- package/dist/stores/file/fs-atomic.js +415 -0
- package/dist/stores/file/fs-atomic.js.map +1 -0
- package/dist/stores/file/index.d.ts +98 -0
- package/dist/stores/file/index.d.ts.map +1 -0
- package/dist/stores/file/index.js +109 -0
- package/dist/stores/file/index.js.map +1 -0
- package/dist/stores/file/memory-store.d.ts +95 -0
- package/dist/stores/file/memory-store.d.ts.map +1 -0
- package/dist/stores/file/memory-store.js +393 -0
- package/dist/stores/file/memory-store.js.map +1 -0
- package/dist/stores/file/session-policy-store.d.ts +24 -0
- package/dist/stores/file/session-policy-store.d.ts.map +1 -0
- package/dist/stores/file/session-policy-store.js +123 -0
- package/dist/stores/file/session-policy-store.js.map +1 -0
- package/dist/stores/file/session-store.d.ts +34 -0
- package/dist/stores/file/session-store.d.ts.map +1 -0
- package/dist/stores/file/session-store.js +175 -0
- package/dist/stores/file/session-store.js.map +1 -0
- package/dist/stores/file/tool-result-store.d.ts +23 -0
- package/dist/stores/file/tool-result-store.d.ts.map +1 -0
- package/dist/stores/file/tool-result-store.js +61 -0
- package/dist/stores/file/tool-result-store.js.map +1 -0
- package/dist/stores/file/workflow-journal-store.d.ts +36 -0
- package/dist/stores/file/workflow-journal-store.d.ts.map +1 -0
- package/dist/stores/file/workflow-journal-store.js +157 -0
- package/dist/stores/file/workflow-journal-store.js.map +1 -0
- package/dist/stores/file/workflow-run-store.d.ts +58 -0
- package/dist/stores/file/workflow-run-store.d.ts.map +1 -0
- package/dist/stores/file/workflow-run-store.js +178 -0
- package/dist/stores/file/workflow-run-store.js.map +1 -0
- package/dist/stores/pg.d.ts +210 -0
- package/dist/stores/pg.d.ts.map +1 -0
- package/dist/stores/pg.js +652 -0
- package/dist/stores/pg.js.map +1 -0
- package/dist/tools/fs/encoding.d.ts +51 -0
- package/dist/tools/fs/encoding.d.ts.map +1 -0
- package/dist/tools/fs/encoding.js +74 -0
- package/dist/tools/fs/encoding.js.map +1 -0
- package/dist/tools/fs/gh-rate-limit.d.ts +19 -0
- package/dist/tools/fs/gh-rate-limit.d.ts.map +1 -0
- package/dist/tools/fs/gh-rate-limit.js +35 -0
- package/dist/tools/fs/gh-rate-limit.js.map +1 -0
- package/dist/tools/fs/index.d.ts +246 -0
- package/dist/tools/fs/index.d.ts.map +1 -0
- package/dist/tools/fs/index.js +2130 -0
- package/dist/tools/fs/index.js.map +1 -0
- package/dist/tools/fs/pdf.d.ts +106 -0
- package/dist/tools/fs/pdf.d.ts.map +1 -0
- package/dist/tools/fs/pdf.js +178 -0
- package/dist/tools/fs/pdf.js.map +1 -0
- package/dist/tools/fs/repo-map.d.ts +15 -0
- package/dist/tools/fs/repo-map.d.ts.map +1 -0
- package/dist/tools/fs/repo-map.js +251 -0
- package/dist/tools/fs/repo-map.js.map +1 -0
- package/dist/tools/fs/safety.d.ts +168 -0
- package/dist/tools/fs/safety.d.ts.map +1 -0
- package/dist/tools/fs/safety.js +479 -0
- package/dist/tools/fs/safety.js.map +1 -0
- package/dist/tools/fs/search.d.ts +118 -0
- package/dist/tools/fs/search.d.ts.map +1 -0
- package/dist/tools/fs/search.js +650 -0
- package/dist/tools/fs/search.js.map +1 -0
- package/dist/tools/gitea-issue.d.ts +39 -0
- package/dist/tools/gitea-issue.d.ts.map +1 -0
- package/dist/tools/gitea-issue.js +91 -0
- package/dist/tools/gitea-issue.js.map +1 -0
- package/dist/tools/monitor.d.ts +64 -0
- package/dist/tools/monitor.d.ts.map +1 -0
- package/dist/tools/monitor.js +135 -0
- package/dist/tools/monitor.js.map +1 -0
- package/dist/tools/scheduler-tools.d.ts +48 -0
- package/dist/tools/scheduler-tools.d.ts.map +1 -0
- package/dist/tools/scheduler-tools.js +449 -0
- package/dist/tools/scheduler-tools.js.map +1 -0
- package/dist/tools/sql-adapters.d.ts +32 -0
- package/dist/tools/sql-adapters.d.ts.map +1 -0
- package/dist/tools/sql-adapters.js +31 -0
- package/dist/tools/sql-adapters.js.map +1 -0
- package/dist/tools/sql.d.ts +36 -0
- package/dist/tools/sql.d.ts.map +1 -0
- package/dist/tools/sql.js +131 -0
- package/dist/tools/sql.js.map +1 -0
- package/dist/tools/task-list.d.ts +26 -0
- package/dist/tools/task-list.d.ts.map +1 -0
- package/dist/tools/task-list.js +352 -0
- package/dist/tools/task-list.js.map +1 -0
- package/dist/tools/todo.d.ts +11 -0
- package/dist/tools/todo.d.ts.map +1 -0
- package/dist/tools/todo.js +126 -0
- package/dist/tools/todo.js.map +1 -0
- package/dist/tools/web.d.ts +59 -0
- package/dist/tools/web.d.ts.map +1 -0
- package/dist/tools/web.js +607 -0
- package/dist/tools/web.js.map +1 -0
- package/dist/tools/worktree.d.ts +88 -0
- package/dist/tools/worktree.d.ts.map +1 -0
- package/dist/tools/worktree.js +434 -0
- package/dist/tools/worktree.js.map +1 -0
- package/docs/A1-ATTRIBUTION-ROUND1-2026-07-09.md +181 -0
- package/docs/AB-ORACLE-GATE-VERDICT-2026-07-10.md +100 -0
- package/docs/ANCHOR-GAP-INVENTORY-2026-07-09.md +143 -0
- package/docs/ARCHITECTURE.md +125 -0
- package/docs/BUGHUNT-2026-07-05-NIGHT.md +98 -0
- package/docs/BUILD.md +63 -0
- package/docs/CANCEL-ASYNC-ATTRIBUTION-2026-07-09.md +98 -0
- package/docs/CC-198-VS-201-DRIFT-2026-07-09.md +70 -0
- package/docs/CC-BASELINE-FAKE-ZERO-AUDIT-2026-07-09.md +51 -0
- package/docs/CC-DRIFT-198-206-AGENT-TYPES-2026-07-10.md +82 -0
- package/docs/CC-DRIFT-198-206-CHANGELOG-2026-07-10.md +82 -0
- package/docs/CC-DRIFT-198-206-REMINDERS-2026-07-10.md +96 -0
- package/docs/CC-DRIFT-198-206-ROADMAP-2026-07-10.md +110 -0
- package/docs/CC-DRIFT-198-206-TOOL-INVENTORY-2026-07-10.md +53 -0
- package/docs/CC-DRIFT-198-206-USAGE-CONSTANTS-2026-07-10.md +64 -0
- package/docs/CC-DRIFT-198-206-WORKFLOW-2026-07-10.md +83 -0
- package/docs/CC-PARITY-CHECKLIST.md +220 -0
- package/docs/CC-PARITY-DEEP-SWEEP-2026-07-07.md +149 -0
- package/docs/CC-SUBAGENT-PROMPT-STUDY-2026-07-09.md +219 -0
- package/docs/CC-TASK-REMINDER-CADENCE-STUDY-2026-07-09.md +396 -0
- package/docs/CC-TOOL-PARITY-SWEEP-2026-07.md +56 -0
- package/docs/CC-TOOL-PROMPT-DEEP-DIFF-2026-07-08.md +274 -0
- package/docs/CC-TOOLSURFACE-DEEP-DIVE-2026-07-07.md +197 -0
- package/docs/CC206-OBSERVER-ANCHORS-2026-07-11.md +463 -0
- package/docs/CC207-CORE-SWEEP-BACKLOG-2026-07-12.md +155 -0
- package/docs/CENTER-CONTROL-PLANE-SEAM.md +86 -0
- package/docs/CLEANUP-CONTRACT-ANCHOR-2026-07-09.md +83 -0
- package/docs/COLD-START-RSI-2026-07-09.md +72 -0
- package/docs/COMPACTION-LIVE-AUDIT-2026-07-09.md +114 -0
- package/docs/CONTEXT-lifecycle-extension-points-and-stop-attribution.md +71 -0
- package/docs/COREWARS-POLYGLOT-TRIAGE-2026-07-09.md +85 -0
- package/docs/DEADTARGET-OVERFULL-HBOX-2026-07-10.md +92 -0
- package/docs/DEADTARGET-PATH-TRACING-REVERSE-2026-07-10.md +87 -0
- package/docs/DEADTARGET-WINNING-AVG-COREWARS-2026-07-10.md +96 -0
- package/docs/DEEPSEEK-API-LIMITS-PROBE-2026-07-09.md +118 -0
- package/docs/DESIGN-140-MOCK-VERIFY-2026-07-11.md +79 -0
- package/docs/DESIGN-140-VERIFICATION-2026-07-11.md +43 -0
- package/docs/DESIGN-DRAFT-workflow-collab-2026-07-11.md +56 -0
- package/docs/DESIGN-ORACLE-GROUNDING-GATE-2026-07-10.md +167 -0
- package/docs/DESIGN-TFRAG-RECONCILE-SCAFFOLD-2026-07-09.md +102 -0
- package/docs/EXP-PA2-PE-DESIGN-2026-07-11.md +220 -0
- package/docs/EXT-DOGFOOD-2026-07-12.md +50 -0
- package/docs/FINAL-VERIFY-SURFACE-AUDIT-2026-07-09.md +44 -0
- package/docs/FRAG-RECON-INVESTIGATION-2026-07-09.md +183 -0
- package/docs/HANDOFF.md +121 -0
- package/docs/KNOWN-ISSUES.md +96 -0
- package/docs/LEGW-WINDOW-TAX-2026-07-10.md +82 -0
- package/docs/LIVE-PROBE-BATCH-2026-07-11.md +207 -0
- package/docs/M2-CERT-ATTRIBUTION-2026-07-10.md +61 -0
- package/docs/M25-LEG-VARIANCE-ATTRIBUTION-2026-07-10.md +81 -0
- package/docs/MAILMAN-TIMEOUT-ATTRIBUTION-2026-07-10.md +88 -0
- package/docs/MEMORY-BACKEND-DIALECT-NOTES.md +69 -0
- package/docs/MTEB-ESCAPE-HATCH-ATTRIBUTION-2026-07-09.md +57 -0
- package/docs/MTEB-LEADERBOARD-ATTRIBUTION-2026-07-10.md +134 -0
- package/docs/ORACLE-GATE-NEGATIVE-SAMPLE-2026-07-11.md +28 -0
- package/docs/PARITY-CONTEXT-TRANSACTIONS-2026-07-10.md +67 -0
- package/docs/PARITY-PROGRESS-MATRIX-2026-07-10.md +37 -0
- package/docs/PARITY-SPOT-AGENT-TYPES-2026-07-10.md +70 -0
- package/docs/PARITY-SPOT-REMINDERS-2026-07-10.md +144 -0
- package/docs/PARITY-SPOT-TOOL-INVENTORY-2026-07-10.md +61 -0
- package/docs/PARITY-SPOT-USAGE-CONSTANTS-2026-07-10.md +95 -0
- package/docs/PARITY-SPOT-WORKFLOW-2026-07-10.md +93 -0
- package/docs/PASSWORD-RECOVERY-PANEL-VERDICT-2026-07-10.md +62 -0
- package/docs/R8-R9-FINALIZE-COORDINATION-2026-07-09.md +124 -0
- package/docs/REPL-CODE-MODE-EVALUATION-2026-07-10.md +187 -0
- package/docs/REPORT-1249-TARGETED.md +48 -0
- package/docs/RESEARCH-3P-MEMORY-SWAP-2026-07-12.md +273 -0
- package/docs/ROADMAP.md +111 -0
- package/docs/RSI-CLOUD-DEPLOY-2026-07-06.md +289 -0
- package/docs/RSI-PROCESS-AI-COLDSTART-2026-07-11.md +68 -0
- package/docs/S18-THOROUGHNESS-AUDIT-2026-07-09.md +113 -0
- package/docs/SERVICE-INTEGRATION-GUIDE.md +298 -0
- package/docs/SUBAGENT-STREAM-RESILIENCE-PARITY-2026-07-10.md +118 -0
- package/docs/SWEBENCH-PRO-RECON-2026-07-11.md +31 -0
- package/docs/TB-EFFICIENCY-TELEMETRY-2026-07-08.md +114 -0
- package/docs/TB-FAILURE-AUTOPSY-2026-07-05-RERUN.md +67 -0
- package/docs/TB-FAIRNESS-AUDIT-2026-07-08.md +141 -0
- package/docs/TB-FULL-1257-2026-07-08.md +77 -0
- package/docs/TB-LEG-GAP-ATTRIBUTION-1261-1263-2026-07-11.md +154 -0
- package/docs/TB-REGRESSION-DAEMON-KILL-2026-07-08.md +58 -0
- package/docs/TB-RSI-CROSSCHECK-2026-07-06.md +68 -0
- package/docs/TB-RSI-LEDGER.md +30 -0
- package/docs/TB-STABLE-SET-2026-07-08.md +127 -0
- package/docs/TB21-RECON-2026-07-11.md +28 -0
- package/docs/TEST-DESIGN-BATCH-1-2-3.md +97 -0
- package/docs/THIRD-PARTY-INTEGRATION.md +45 -0
- package/docs/TOKEN-ESTIMATE-CALIBRATION-LIVE-2026-07-09.md +102 -0
- package/docs/audit-2026-06-10-/347/254/254/344/272/214/350/275/256-/347/251/272/347/231/275/345/214/272/344/270/216/345/277/230/346/216/245/347/272/277.md +147 -0
- package/docs/audit-2026-06-10-/350/256/241/347/256/227bug/346/211/253/346/217/217.md +163 -0
- package/docs/audit-2026-06-11-/344/277/256/345/244/215/345/244/215/345/256/241/344/270/216/345/233/236/345/275/222/344/270/223/346/211/253.md +95 -0
- package/docs/audit-2026-06-12-design71-P1/346/275/234/344/274/217/351/235/242/350/243/201/345/206/263/344/270/216/345/220/236/351/224/231/345/256/241/350/256/241.md +45 -0
- package/docs/audit-2026-06-12-/345/205/254/345/274/200/346/227/213/351/222/256/346/264/273/346/200/247/344/270/216vendored/346/255/273/350/267/257.md +63 -0
- package/docs/audit-2026-06-15/ADDENDUM-second-pass.md +88 -0
- package/docs/audit-2026-06-15/ARCHITECTURE-AND-ROADMAP.md +187 -0
- package/docs/audit-2026-06-15/DOC-CORRECTIONS.md +105 -0
- package/docs/audit-2026-06-15/INDEX.md +49 -0
- package/docs/audit-2026-06-15/ORCHESTRATION-STATUS.md +98 -0
- package/docs/audit-2026-06-15/PRODUCTION-ISSUES.md +189 -0
- package/docs/audit-2026-06-15/REFERENCES-literature.md +139 -0
- package/docs/audit-2026-06-15/VENDOR-REFACTOR-PLAN.md +96 -0
- package/docs/cc-probe-198/README.md +72 -0
- package/docs/cc-probe-198/STABILITY-AUDIT.md +118 -0
- package/docs/cc-probe-198/live-schema-monitor-worktree-2026-07-09.json +56 -0
- package/docs/cc-probe-198/mount-variants.json +536 -0
- package/docs/cc-probe-198/plan-mode-run.json +1 -0
- package/docs/cc-probe-198/probe-bash-bg.json +224 -0
- package/docs/cc-probe-198/probe-edit-mismatch.json +120 -0
- package/docs/cc-probe-198/probe-read-missing.json +62 -0
- package/docs/cc-probe-198/probe-search-empty.json +127 -0
- package/docs/cc-probe-198/probe-task-family.json +227 -0
- package/docs/cc-probe-198/probes.status +6 -0
- package/docs/cc-probe-198/raw-capture-interactive.json +948 -0
- package/docs/cc-probe-198/raw-capture-print.json +787 -0
- package/docs/cc-probe-198/raw-capture2-interactive.json +948 -0
- package/docs/cc-probe-198/raw-capture2-print.json +787 -0
- package/docs/cc-probe-198/system-prompt.txt +135 -0
- package/docs/cc-probe-198/tools-array.json +962 -0
- package/docs/durability-boundary.md +63 -0
- package/docs/fix-2026-06-10-/350/256/241/347/256/227bug/344/277/256/345/244/215-search/346/211/271.md +75 -0
- package/docs/fix-2026-06-11-/347/254/254/344/272/214/350/275/256-/344/277/256/345/244/215-search/346/211/271.md +62 -0
- package/docs/parity-sweep/REPORT-DYNAMIC.md +97 -0
- package/docs/parity-sweep/REPORT-STATIC-GGB.md +127 -0
- package/docs/parity-sweep/REPORT-STATIC-RWE.md +133 -0
- package/docs/vendor-history.md +385 -0
- package/docs/vendored-upstream-diff-2026-07.md +83 -0
- package/package.json +70 -0
|
@@ -0,0 +1,3612 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { AgentHarness, DEFAULT_CHARS_PER_TOKEN, estimateContextTokens } from "../../internal/harness.js";
|
|
3
|
+
// Per-process salt for the `prompt.assembled` manifest hashes (codex [E]: dictionary-preimage guard for
|
|
4
|
+
// short/guessable blocks). Process-lifetime constant → same-process drift detection works; never persisted.
|
|
5
|
+
const PROMPT_HASH_SALT = randomBytes(16);
|
|
6
|
+
import { resolveModel, resolveTaskModel, roleModelIfSet } from "../roles.js";
|
|
7
|
+
import { primaryActivityArg } from "../arg-summary.js";
|
|
8
|
+
import { materializeMcpTools } from "../mcp.js";
|
|
9
|
+
import { brainToRuntime } from "../runtime.js";
|
|
10
|
+
import { MemoryEngine } from "../memory-engine/engine.js";
|
|
11
|
+
import { deriveControlPlaneDir, deriveProjectControlDir, deriveProjectMemoryDir, deriveRepoMemoryDir, drainMemoryAnnouncements, enqueueMemoryAnnouncement, lookupProjectIdHint, recordProjectIdHint, resolveMemoryEngineRoot } from "../memory-engine/layout.js";
|
|
12
|
+
import { assertScopeContractPlacement, parseScopeKey, resolveProjectId } from "../memory-engine/scope-contract.js";
|
|
13
|
+
import { classifyScopePlanes, derivePersonalControlDir, derivePersonalMemoryDir, mergeHarvestReports, mergeInjections, needsDualRoots, parsedProjectPlane } from "../memory-engine/dual-root.js";
|
|
14
|
+
import { FileMemoryEngineBackend } from "../memory-engine/file-backend.js";
|
|
15
|
+
import { Session, isSessionConflict, hasSessionFork } from "../session.js";
|
|
16
|
+
import { createAgentTranscriptTool, createSendMessageTool, createSubagentWorktreeHelper, forkGovernanceDenial, AGENT_TRANSCRIPT_TOOL_NAME, SEND_MESSAGE_TOOL_NAME, SubagentRetainLedger } from "../../agents/subagent.js";
|
|
17
|
+
import { combinePolicies, createTranscriptIntegrityPolicy, createUnverifiableDeletePolicy, resolveAsk } from "../tool-policy.js";
|
|
18
|
+
import { ActiveSkillScope, createActiveSkillScopePolicy } from "./active-skill-scope.js";
|
|
19
|
+
import { computeCallCap, createCallCapRef, softExecDeadlineMs } from "./call-cap.js";
|
|
20
|
+
import { CHANGED_FILES_MTIME_EPS_MS } from "./turn-attachments.js";
|
|
21
|
+
import { emitTrace } from "../trace.js";
|
|
22
|
+
import { createSessionRulePolicy } from "./session-rule-policy.js";
|
|
23
|
+
import { cloneObserverInput, formatHookFeedback, runToolGate } from "../hooks.js";
|
|
24
|
+
import { reconcileInterruptedSession } from "../session-reconcile.js";
|
|
25
|
+
import { CacheBreakDetector, toolsToFingerprintInputs } from "../cache-break-detector.js";
|
|
26
|
+
import { reservedCollisions, reservedFor } from "../../brain/request-params.js";
|
|
27
|
+
import { defineTool } from "../tools.js";
|
|
28
|
+
import { pathToUri } from "../lsp-protocol.js";
|
|
29
|
+
import { DEFAULT_TOOL_RESULT_THRESHOLD_CHARS, firstPartyOffloadPolicy, InMemoryToolResultStore, RunnerSharedToolResultStore, ScopedToolResultStore, isVolatileOffloadStore, OFFLOAD_TOOL_NAME, makeReadToolResultTool, withToolResultOffload, } from "../tool-result-store.js";
|
|
30
|
+
import { OUTPUT_TOOL_NAME, SKILL_TOOL_NAME, buildSkillsBlock, makeOutputTool, makeReportBlockedTool, makeSkillTool, normalizeSkills } from "./synthetic-tools.js";
|
|
31
|
+
import { compileOutputSchema } from "./strict-output-schema.js";
|
|
32
|
+
import { TOOL_SEARCH_NAME, buildDeferredRegistry, classifyDeferred, extractDiscoveredToolNames, makePlaceholderTool, makeToolSearchTool, } from "./tool-disclosure.js";
|
|
33
|
+
import { composeMemoryBlock, normalizeMemorySpec } from "../memory.js";
|
|
34
|
+
import {} from "./memory-consolidation.js";
|
|
35
|
+
import { composeSystemPrompt, composeConstitution, defaultPromptProvider, buildEnvironmentContext, buildGitSnapshot, formatLocalDate, isValidTimeZone, CYBER_RISK, URL_SAFETY, DEFAULT_SYSTEM_PROMPT, PROJECT_CONTEXT_FRAMING } from "../../prompts/default.js";
|
|
36
|
+
import { clearStaleToolResults, dropEmptyFailureAssistants, editBudget } from "../context-edit.js";
|
|
37
|
+
import { capAggregateToolResults } from "../tool-result-budget.js";
|
|
38
|
+
import { capAggregateMediaBytes, AGGREGATE_MEDIA_BUDGET_BYTES } from "../media-byte-cap.js";
|
|
39
|
+
import { dropOrphanToolResults, guardBudget, trimToBudget } from "../context-guard.js";
|
|
40
|
+
import { StubExecutionEnv } from "../stub-env.js";
|
|
41
|
+
import { hasDestroy, isIsolated, isRemoteExecutionEnv, isSuspendable } from "../remote-env.js";
|
|
42
|
+
import { hasBackgroundShell } from "../background-shell.js";
|
|
43
|
+
import { createTaskOutputTool, createTaskStopTool, defaultTaskRegistry } from "../task-registry.js";
|
|
44
|
+
import { createMonitorTool } from "../../tools/monitor.js";
|
|
45
|
+
import { createWorktreeTools } from "../../tools/worktree.js";
|
|
46
|
+
import { bashReversibilityProbe, createHandsToolkit, seedReadFileStateFromContext, HAND_TOOL_EFFECTS, pdfModelCapabilitiesOf } from "../../tools/fs/index.js";
|
|
47
|
+
import { decodeTextBytes } from "../../tools/fs/encoding.js";
|
|
48
|
+
import { createAskUserQuestionTool } from "../ask-question.js";
|
|
49
|
+
import { createSchedulerTools } from "../../tools/scheduler-tools.js";
|
|
50
|
+
import { createPresentPlanTool, createEnterPlanModeTool, PRESENT_PLAN_TOOL_NAME, ENTER_PLAN_MODE_TOOL_NAME } from "../present-plan-tool.js";
|
|
51
|
+
import { isSelfOrchestrationActive, selfOrchestrationFailClosedReason } from "../../orchestration/workflow-script-runner.js";
|
|
52
|
+
import { createRunWorkflowTool, RUN_WORKFLOW_TOOL_NAME } from "../../orchestration/run-workflow-tool.js";
|
|
53
|
+
import { createLspTool, gitCheckIgnoreFilter } from "../lsp.js";
|
|
54
|
+
import { resolveKey } from "../../tools/fs/safety.js";
|
|
55
|
+
import { BINDING_CHECKPOINT_VERSION, mintCheckpointToken, RESOURCE_CHECKPOINT_VERSION, buildRiskDescriptor, debitLedger, remainingBudgetMicroUsd, } from "../checkpoint-store.js";
|
|
56
|
+
import { boundInputHashOf } from "../canonical-json.js";
|
|
57
|
+
/**
|
|
58
|
+
* How many times to re-wake + re-reconcile a resumed session when a concurrent writer (another
|
|
59
|
+
* instance) wins the optimistic lock during reconciliation. Bounded so a persistently-contended
|
|
60
|
+
* session fails fast (with `errorCode="conflict"`) rather than spinning.
|
|
61
|
+
*/
|
|
62
|
+
const RECONCILE_MAX_RETRIES = 3;
|
|
63
|
+
/** design/72 §2.2 (B): default suspend-loop cap. Loose by design — a real human-approval flow re-suspends
|
|
64
|
+
* rarely, so 5 cleanly separates a normal resume (1–2 suspends) from a pathological restart loop (a model
|
|
65
|
+
* re-issuing the gated call every resume). A task/deployment that genuinely needs more overrides via
|
|
66
|
+
* `TaskSpec.maxSuspends` / `RunnerDeps.maxSuspends`. (Briefly raised to 100 on 2026-06-14, reverted 06-15 —
|
|
67
|
+
* same misguided net-inflation as DEFAULT_MAX_TURNS; the cap is a runaway guard, not the long-run knob.) */
|
|
68
|
+
const DEFAULT_MAX_SUSPENDS = 5;
|
|
69
|
+
/** design/74 (search [136] HIGH-1): default resource-SLICE cap — the resumable-long-run analogue of
|
|
70
|
+
* `DEFAULT_MAX_SUSPENDS`, kept SEPARATE so a legitimate multi-slice resource run (the whole point of
|
|
71
|
+
* design/74) is not capped by the restart-loop safety. High (the design/74 spec's worker-suspend ceiling, here implemented as maxSlices): the $ ledger /
|
|
72
|
+
* turns / walltime are the real bounds, this only backstops a $0-progress slice loop. Override via
|
|
73
|
+
* `TaskSpec.resourceSuspend.maxSlices` (the leader sizes it to budget/slice). */
|
|
74
|
+
const DEFAULT_MAX_SLICES = 100;
|
|
75
|
+
/** design/96 §D (S4, P0): the "ultra" reasoning tiers that warrant the high-intensity ORCHESTRATION_AWARENESS
|
|
76
|
+
* nudge — the TOP of the vendor ThinkingLevel ladder (off|minimal|low|medium|high|xhigh|max). Membership-based
|
|
77
|
+
* (not a `=== "max"` point-check) so a future top tier slots in by adding it here, and so the threshold reads
|
|
78
|
+
* as "ultra tiers" rather than a magic string. Caller-declared via the resolved `thinking`, never keyword-scanned. */
|
|
79
|
+
const ULTRA_REASONING_TIERS = new Set(["xhigh", "max"]);
|
|
80
|
+
/** design/74 (search [136] HIGH-2): default resource-checkpoint retention (30 days, the spec's
|
|
81
|
+
* `RESOURCE_SUSPEND_TTL_SEC`). Stamped onto the checkpoint `deadline` so the durable reaper can GC an
|
|
82
|
+
* ORPHANED resource suspend; a legitimate auto-resume fires long before this. Override via
|
|
83
|
+
* `TaskSpec.resourceSuspend.ttlMs`. */
|
|
84
|
+
const DEFAULT_RESOURCE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
85
|
+
/** design/80 D-2 (BLOCKER): abandonment TTL stamped on an UNATTENDED human/irreversible_ask suspend (no
|
|
86
|
+
* `durableApproval` = no operator/resolver wired). Without it the checkpoint has `deadline=undefined` and the
|
|
87
|
+
* reaper (which filters on a non-null deadline) can NEVER GC it → a pinned session + a stranded paused VM +
|
|
88
|
+
* an orphan checkpoint leak forever. A `durableApproval` suspend keeps the operator's `ttlMs` (undefined =
|
|
89
|
+
* unbounded by the operator's choice; D-D `terminalAt` is the eventual liveness backstop there). 30 days. */
|
|
90
|
+
const DEFAULT_UNATTENDED_APPROVAL_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
91
|
+
/** 飞轮 [520]③ — per-deployment dedupe for the LOUD ungated-write warning below: keyed by the RunnerDeps
|
|
92
|
+
* object (a deployment's stable identity), holding the ungated tool-set shapes already warned about.
|
|
93
|
+
* WeakMap: a discarded deployment's entry goes with it. */
|
|
94
|
+
const ungatedWarnedShapes = new WeakMap();
|
|
95
|
+
/** design/77 §4.4: the multi-tenant scope used when a durable suspend fires for an IRREVERSIBLE tool in an
|
|
96
|
+
* unattended deployment that did NOT opt into `durableApproval` (so there is no caller-supplied scope). The
|
|
97
|
+
* checkpoint carries this scope; resume reads it back from the checkpoint (`cp.scope`), so it is
|
|
98
|
+
* self-consistent without needing the original `TaskSpec`. Kept distinct from any tenant key to make an
|
|
99
|
+
* unattended irreversible suspend auditable as such. */
|
|
100
|
+
export const DEFAULT_IRREVERSIBLE_SCOPE = "irreversible";
|
|
101
|
+
/**
|
|
102
|
+
* From the resumed/active transcript, the batch position of `currentId` (design/45 §4.ter): the tool-call
|
|
103
|
+
* ids of the assistant message that issued it (the batch), and the subset already resolved (executed
|
|
104
|
+
* before the suspend — #1..k-1). ID-based, not positional (council Question #1): immune to reordering.
|
|
105
|
+
*/
|
|
106
|
+
function batchContextAt(messages, currentId) {
|
|
107
|
+
let batch = [];
|
|
108
|
+
for (const m of messages) {
|
|
109
|
+
if (m.role === "assistant") {
|
|
110
|
+
const ids = m.content.filter((c) => c.type === "toolCall").map((c) => c.id);
|
|
111
|
+
if (ids.includes(currentId))
|
|
112
|
+
batch = ids; // the latest assistant msg holding currentId wins
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const resolved = new Set(messages.filter((m) => m.role === "toolResult").map((m) => m.toolCallId));
|
|
116
|
+
const completedCallIds = batch.filter((id) => id !== currentId && resolved.has(id));
|
|
117
|
+
return { batchToolCallIds: batch, completedCallIds };
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Wrap a storage so a `SessionError("conflict")` from any append/leaf write flips `ref.hit`. The
|
|
121
|
+
* harness flattens a mid-run storage failure into a generic error (and, since a lost CAS makes every
|
|
122
|
+
* subsequent append fail, even its failure-reporting append fails) — so the conflict code is gone by
|
|
123
|
+
* the time the task result is assembled. Catching it here, at the storage boundary, keeps it
|
|
124
|
+
* recoverable as `TaskResult.errorCode = "conflict"`. Per-task and non-mutating (a Proxy over the
|
|
125
|
+
* shared storage), so it never stacks across tasks on a cached session.
|
|
126
|
+
*/
|
|
127
|
+
function detectConflicts(storage, ref) {
|
|
128
|
+
const guard = (fn) => async (...args) => {
|
|
129
|
+
try {
|
|
130
|
+
return await fn(...args);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
if (isSessionConflict(err)) {
|
|
134
|
+
ref.hit = true;
|
|
135
|
+
}
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
return new Proxy(storage, {
|
|
140
|
+
get(target, prop, receiver) {
|
|
141
|
+
const value = Reflect.get(target, prop, receiver);
|
|
142
|
+
if (typeof value !== "function") {
|
|
143
|
+
return value;
|
|
144
|
+
}
|
|
145
|
+
const bound = value.bind(target);
|
|
146
|
+
return prop === "appendEntry" || prop === "setLeafId"
|
|
147
|
+
? guard(bound)
|
|
148
|
+
: bound;
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
/** Race a promise against an abort signal; on abort, resolve with `onAbort()`. Cleans up its listener. */
|
|
153
|
+
function raceAbort(p, signal, onAbort) {
|
|
154
|
+
if (signal.aborted)
|
|
155
|
+
return Promise.resolve(onAbort());
|
|
156
|
+
return new Promise((resolve) => {
|
|
157
|
+
let settled = false;
|
|
158
|
+
const finish = (v) => {
|
|
159
|
+
if (settled)
|
|
160
|
+
return;
|
|
161
|
+
settled = true;
|
|
162
|
+
// Necessary even with `{ once }`: `p` usually settles first, leaving the listener dangling on the
|
|
163
|
+
// signal (a leaked closure per check) until the signal is GC'd. Remove it explicitly.
|
|
164
|
+
signal.removeEventListener("abort", onAbortEvent);
|
|
165
|
+
resolve(v);
|
|
166
|
+
};
|
|
167
|
+
const onAbortEvent = () => finish(onAbort());
|
|
168
|
+
signal.addEventListener("abort", onAbortEvent, { once: true });
|
|
169
|
+
p.then(finish, () => finish(onAbort()));
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Build everything a task run needs (council design/34 ②: a free function with EXPLICIT deps, not a
|
|
174
|
+
* Runner method — testable and decoupled). Resolves the model/role/thinking, acquires + reconciles the
|
|
175
|
+
* session (with the conflict-detecting Proxy + bounded re-wake on a lost CAS), assembles the tool set
|
|
176
|
+
* (caller tools + offload wrap + report_blocked + submit_output + MCP + memory + read_tool_result),
|
|
177
|
+
* builds the system prompt, constructs the harness, wires the tool-policy gate + context-edit/guard
|
|
178
|
+
* hooks + external-signal cancellation, and sets up the cache-break detector. **No run-loop control
|
|
179
|
+
* flow** lives here — that stays in the Runner loop.
|
|
180
|
+
*/
|
|
181
|
+
/** Best-effort cached-view drop on a prepare-phase throw (B-17-safe: `forget` only — NEVER `release`,
|
|
182
|
+
* whose contract permits history deletion). No-op for stores without `forget`. */
|
|
183
|
+
async function forgetQuietly(sessions, sessionId) {
|
|
184
|
+
try {
|
|
185
|
+
if (sessions.forget)
|
|
186
|
+
await sessions.forget(sessionId);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
/* best-effort; the TTL sweep is the backstop */
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
export async function prepareTask(spec, deps, sessions, resume,
|
|
193
|
+
/** @deprecated design/138 S4 — served only the legacy selective/dynamic recall paths (retired);
|
|
194
|
+
* ignored. Positional slot kept one major so the runtask call site compiles unchanged. */
|
|
195
|
+
_memorySelector, internals,
|
|
196
|
+
/** design/98 §3.1 (S8c): a TRUSTED self-reference to the Runner, passed by the Runner itself (never a
|
|
197
|
+
* TaskSpec field) so the `run_workflow` tool can execute child tasks via `runner.runTask`. Undefined when
|
|
198
|
+
* prepareTask is exercised standalone (then run_workflow is simply not mounted). */
|
|
199
|
+
runnerSelf) {
|
|
200
|
+
// design/99 §E18: config guards BEFORE acquiring anything (a throw here needs no session cleanup). resume-at
|
|
201
|
+
// needs a session to branch, and cannot combine with a durable `resume` (which sets the leaf from its checkpoint).
|
|
202
|
+
if (spec.resumeAt !== undefined) {
|
|
203
|
+
// Order matters: a durable `resume` already sets the leaf from its checkpoint, so resume+resumeAt is a caller
|
|
204
|
+
// bug REGARDLESS of sessionId — check it FIRST (the resume path may not have populated spec.sessionId yet).
|
|
205
|
+
if (resume) {
|
|
206
|
+
const e = new Error("resumeAt cannot be combined with a durable resume");
|
|
207
|
+
e.code = "resume_at.conflicts_resume";
|
|
208
|
+
throw e;
|
|
209
|
+
}
|
|
210
|
+
if (!spec.sessionId) {
|
|
211
|
+
const e = new Error("resumeAt requires a sessionId (the session to branch)");
|
|
212
|
+
e.code = "resume_at.no_session";
|
|
213
|
+
throw e;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// Resolve the model (and a role-supplied default thinking level) from spec.model or the role map.
|
|
217
|
+
const resolvedRole = resolveTaskModel(spec, deps);
|
|
218
|
+
const model = resolvedRole.model;
|
|
219
|
+
// design/111 — thinking resolution chain: caller-explicit > role default > MODEL default > undefined(off).
|
|
220
|
+
// `model.defaultThinking` lets a model "think by default" without every task/role re-specifying; the
|
|
221
|
+
// per-endpoint clamp (`resolveReasoning`) still bounds the resolved tier.
|
|
222
|
+
const thinking = spec.thinking ?? resolvedRole.thinking ?? model.defaultThinking;
|
|
223
|
+
// Compaction model: explicit wins; otherwise the `summarize` role if configured; else fall back
|
|
224
|
+
// to the main model inside maybeCompact (unchanged).
|
|
225
|
+
const compModel = spec.compactionModel
|
|
226
|
+
? resolveModel(spec.compactionModel, deps.models)
|
|
227
|
+
: roleModelIfSet("summarize", spec, deps);
|
|
228
|
+
// Acquire the session and, when resuming, reconcile any interrupted (orphan) tool call before
|
|
229
|
+
// the turn. Both are retried together on a F2 optimistic-lock conflict: a concurrent writer on
|
|
230
|
+
// another instance can move the leaf during reconcile's append, and the fix can't be applied to
|
|
231
|
+
// a now-stale in-memory view — so we drop it and **re-wake**. Bounded; exhaustion → the conflict
|
|
232
|
+
// propagates and the task fails with `errorCode="conflict"` for the caller to retry.
|
|
233
|
+
const toolEffects = new Map();
|
|
234
|
+
// design/70: egress-marked tools (external writes — the gate never auto-allows them). Collected on the
|
|
235
|
+
// same pass as effects; validated here (BEFORE session acquire, so the throw needs no cleanup).
|
|
236
|
+
const egressTools = new Set();
|
|
237
|
+
// design/77 §4 (Gate 4): tools whose effect is irreversible (`always`, or `maybe` either explicitly or by
|
|
238
|
+
// declaring a `reversibilityProbe`). Collected on the SAME pass as egress — this set drives BOTH activation
|
|
239
|
+
// predicates (a no-policy/no-hook/no-egress unattended deployment must still register the gate for an
|
|
240
|
+
// irreversible tool, else it would silently auto-allow) and the per-call `irreversibility` gate wiring.
|
|
241
|
+
const irreversibleTools = new Set();
|
|
242
|
+
// Static tier resolution (read from the spec at prepare-time — not a runtime arg, so the model cannot
|
|
243
|
+
// monkey-patch it): an explicit `irreversibility`, else `"maybe"` when a `reversibilityProbe` is declared.
|
|
244
|
+
const irreversibilityTier = new Map();
|
|
245
|
+
const reversibilityProbes = new Map();
|
|
246
|
+
// design/80 D-E: set true when `bash` was tightened SOLELY by the coarse `shellGate` (no explicit per-tool
|
|
247
|
+
// mark) — drives the mint-site riskDescriptor's `shell` axis (severity 3, NOT the irreversible-marked 4).
|
|
248
|
+
let shellGatedBash = false;
|
|
249
|
+
// design/135 G2: same coarse-shellGate marker for the Monitor tool (its `command` is an arbitrary shell
|
|
250
|
+
// script — the tighten and the riskDescriptor grading mirror Bash exactly; 修类不修点).
|
|
251
|
+
let shellGatedMonitor = false;
|
|
252
|
+
for (const t of spec.tools ?? []) {
|
|
253
|
+
// design/110 F1 (① MCP-namespace alignment): a caller tool name MUST NOT contain `__`. That separator is
|
|
254
|
+
// reserved for the MCP namespace (`mcp__<server>__<tool>`); allowing it lets `canonicalToolName`'s old→new
|
|
255
|
+
// durable-resume heuristic mis-rewrite a persisted `my__tool` into `mcp__my__tool`, which never matches the
|
|
256
|
+
// live roster → a stranded orphan tool-call / approval across an upgrade. Reject up front — this runs BEFORE
|
|
257
|
+
// session acquire, so the throw needs no cleanup. (Injected first-party tools are added later, not via spec.tools.)
|
|
258
|
+
if (t.name.includes("__")) {
|
|
259
|
+
const e = new Error(`Tool name "${t.name}" is invalid: "__" is reserved for the MCP namespace (mcp__<server>__<tool>) and must not appear in a caller tool name.`);
|
|
260
|
+
e.code = "config.tool_name_invalid";
|
|
261
|
+
throw e;
|
|
262
|
+
}
|
|
263
|
+
if (t.effect) {
|
|
264
|
+
toolEffects.set(t.name, t.effect);
|
|
265
|
+
}
|
|
266
|
+
const tier = t.irreversibility ?? (t.reversibilityProbe ? "maybe" : undefined);
|
|
267
|
+
if (tier !== undefined) {
|
|
268
|
+
irreversibilityTier.set(t.name, tier);
|
|
269
|
+
if (tier === "always" || tier === "maybe")
|
|
270
|
+
irreversibleTools.add(t.name);
|
|
271
|
+
}
|
|
272
|
+
if (t.reversibilityProbe)
|
|
273
|
+
reversibilityProbes.set(t.name, t.reversibilityProbe);
|
|
274
|
+
if (t.egress) {
|
|
275
|
+
// design/70 council minor#1: an egress tool must be repeat-unsafe. An explicit read/idempotent
|
|
276
|
+
// would let wake/resume reconciliation auto-retry an interrupted EXTERNAL write (e.g. open the
|
|
277
|
+
// same PR twice). Omitted `effect` is fine — the default IS `write`.
|
|
278
|
+
if (t.effect !== undefined && t.effect !== "write") {
|
|
279
|
+
const e = new Error(`Tool "${t.name}" declares egress:true with effect:"${t.effect}" — an egress tool (external write) must have effect:"write" (or omit effect; write is the default).`);
|
|
280
|
+
e.code = "config.egress_requires_write_effect";
|
|
281
|
+
throw e;
|
|
282
|
+
}
|
|
283
|
+
egressTools.add(t.name);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// The injected reader is read-only; register it up front so an interrupted call reconciles as
|
|
287
|
+
// safe-to-retry instead of falling to the conservative "write" default (the tool is added later).
|
|
288
|
+
toolEffects.set(OFFLOAD_TOOL_NAME, "read");
|
|
289
|
+
// Same for the hand band (design/44): register every hand tool's effect up front (the tools mount
|
|
290
|
+
// later) so an interrupted hand call reconciles by its true effect, not the conservative "write".
|
|
291
|
+
// A per-task `executionEnvFactory` (design/48 remote seam) activates the hand exactly like a static
|
|
292
|
+
// `executionEnv`, so gate on either — this only needs to know the hand is enabled, not the env instance.
|
|
293
|
+
if (deps.executionEnvFactory || deps.executionEnv) {
|
|
294
|
+
for (const [name, effect] of Object.entries(HAND_TOOL_EFFECTS))
|
|
295
|
+
toolEffects.set(name, effect);
|
|
296
|
+
// Q6 (design/102 §3.6, release-gate review): `bash` + `bash_readonly` merged to the single name "Bash" with one
|
|
297
|
+
// `HAND_TOOL_EFFECTS` Bash:"write". Under `handsReadOnly` the MOUNTED Bash is the read-only band (effect:read).
|
|
298
|
+
// Set its effect HERE — before the wake-time `reconcileInterruptedSession` (~:683) AND the path/skill policies
|
|
299
|
+
// (active-skill-scope / session-rule-policy) read `toolEffects` — else a read-only Bash orphan is mis-classified
|
|
300
|
+
// as write (over-conservative reconcile note) and a read-only Bash command is FALSE-DENIED under allowPaths
|
|
301
|
+
// confinement (each Bash def still carries its own `effect` for the live gate; this is the by-name fallback).
|
|
302
|
+
if (spec.handsReadOnly === true)
|
|
303
|
+
toolEffects.set("Bash", "read");
|
|
304
|
+
}
|
|
305
|
+
// design/108 (review): register the plan pair's effects HERE — BEFORE the wake-time `reconcileInterruptedSession`
|
|
306
|
+
// (~:690), same as Bash above — so an orphaned present_plan/enter_plan_mode call (both effect:"read") reconciles
|
|
307
|
+
// as INTERRUPTED_SAFE, not mis-classified write→UNKNOWN. (The post-assembly sync below also covers them for the
|
|
308
|
+
// live plan-mode gate; this early set is purely for reconcile/path-policy reads that run before tool assembly.)
|
|
309
|
+
if (spec.enablePlanMode === true) {
|
|
310
|
+
toolEffects.set(PRESENT_PLAN_TOOL_NAME, "read");
|
|
311
|
+
toolEffects.set(ENTER_PLAN_MODE_TOOL_NAME, "read");
|
|
312
|
+
}
|
|
313
|
+
// design/114 Phase3 (M2): `requireExistingSession` without a `sessionId` is a caller error — there is no
|
|
314
|
+
// session to require. Silently creating a fresh one is exactly the footgun the flag exists to prevent, so
|
|
315
|
+
// fail loud with the same code a genuinely-missing session yields.
|
|
316
|
+
if (spec.requireExistingSession && !spec.sessionId) {
|
|
317
|
+
const e = new Error(`requireExistingSession requires a sessionId — cannot require an existing session without one (design/114 Phase3)`);
|
|
318
|
+
e.code = "resume.session_not_found";
|
|
319
|
+
throw e;
|
|
320
|
+
}
|
|
321
|
+
let acquired;
|
|
322
|
+
let session;
|
|
323
|
+
let conflictRef;
|
|
324
|
+
for (let attempt = 0;; attempt++) {
|
|
325
|
+
try {
|
|
326
|
+
// design/114 Phase3: `requireExistingSession` makes acquire FAIL LOUD on a missing session (never the
|
|
327
|
+
// silent create-on-miss that turns a reuse warm-resume into a fresh empty run). Map the store's `not_found`
|
|
328
|
+
// to a clear, caller-facing errorCode rather than a raw storage error.
|
|
329
|
+
acquired = await sessions.acquire(spec.sessionId, spec.requireExistingSession ? { requireExisting: true } : undefined);
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
if (spec.requireExistingSession && err?.code === "not_found") {
|
|
333
|
+
const e = new Error(`requireExistingSession: session "${spec.sessionId}" does not exist — refusing a silent fresh run (design/114 Phase3)`);
|
|
334
|
+
e.code = "resume.session_not_found";
|
|
335
|
+
throw e;
|
|
336
|
+
}
|
|
337
|
+
throw err;
|
|
338
|
+
}
|
|
339
|
+
// Wrap the storage so a lost-CAS conflict on any append is recoverable (the harness would
|
|
340
|
+
// otherwise flatten it). Per-task Proxy; the underlying storage is unchanged.
|
|
341
|
+
conflictRef = { hit: false };
|
|
342
|
+
session = new Session(detectConflicts(acquired.session.getStorage(), conflictRef));
|
|
343
|
+
if (!spec.sessionId) {
|
|
344
|
+
break; // fresh session — nothing persisted yet, nothing to reconcile
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
if (resume) {
|
|
348
|
+
// design/45 resume: rewind the branch leaf to the suspension point BEFORE reconcile. This
|
|
349
|
+
// discards the suspend abort's off-branch writes, and (with `suspendedBatch`) makes wake-
|
|
350
|
+
// reconcile SKIP the pending batch instead of orphan-closing it — the resume engine resolves
|
|
351
|
+
// those calls itself. Goes through the conflict proxy, so a lost CAS re-wakes like any append.
|
|
352
|
+
await session.getStorage().setLeafId(resume.leafId);
|
|
353
|
+
}
|
|
354
|
+
else if (spec.resumeAt !== undefined) {
|
|
355
|
+
// design/99 §E18: branch the session at an arbitrary prior MESSAGE entry, then run the new objective as
|
|
356
|
+
// the next turn. Validate the target is a real message-boundary entry on THIS session first (a non-
|
|
357
|
+
// message entry would make the reconcile below orphan-close a mid-batch call). `setLeafId` is
|
|
358
|
+
// non-destructive (appends a `leaf` entry) and goes through the same conflict proxy as a durable resume.
|
|
359
|
+
const entry = await session.getEntry(spec.resumeAt);
|
|
360
|
+
if (!entry) {
|
|
361
|
+
const e = new Error(`resumeAt entry "${spec.resumeAt}" not found in session "${spec.sessionId}"`);
|
|
362
|
+
e.code = "resume_at.not_found";
|
|
363
|
+
throw e;
|
|
364
|
+
}
|
|
365
|
+
if (entry.type !== "message" && entry.type !== "custom_message") {
|
|
366
|
+
const e = new Error(`resumeAt entry "${spec.resumeAt}" is a "${entry.type}" entry; resume-at requires a message boundary`);
|
|
367
|
+
e.code = "resume_at.not_a_message";
|
|
368
|
+
throw e;
|
|
369
|
+
}
|
|
370
|
+
// §E18: a resume-at target must be a SETTLED turn boundary (a user message or a finished assistant turn).
|
|
371
|
+
// Two intra-turn shapes are stored as `type:"message"` and slip the type guard above — reject both.
|
|
372
|
+
if (entry.type === "message") {
|
|
373
|
+
const msg = entry.message;
|
|
374
|
+
// A `toolResult` is stored as a `type:"message"` entry (so it slips the type guard above) but it sits
|
|
375
|
+
// INSIDE a turn — it is the result of a tool call, not a turn boundary. Branching at a tool-result
|
|
376
|
+
// preserves the very tool effect the caller most likely wants to redo, and `reconcileInterruptedSession`
|
|
377
|
+
// would mishandle the open batch. Reject it (service [260]① — fail-loud for ALL deployments, not only
|
|
378
|
+
// those with a service-side capture-gate). Valid targets: a user message or a finished assistant turn.
|
|
379
|
+
if (msg?.role === "toolResult") {
|
|
380
|
+
const e = new Error(`resumeAt entry "${spec.resumeAt}" is a tool-result (mid-turn); resume-at requires a settled turn boundary (a user message or a finished assistant turn)`);
|
|
381
|
+
e.code = "resume_at.not_a_message";
|
|
382
|
+
throw e;
|
|
383
|
+
}
|
|
384
|
+
// An assistant message carrying an open `toolCall` is likewise an INTRA-turn boundary, not a turn
|
|
385
|
+
// boundary — branching there leaves the call unresolved (its toolResults are off-branch children), so
|
|
386
|
+
// `reconcileInterruptedSession` below would inject a synthetic [INTERRUPTED] for a call that actually
|
|
387
|
+
// completed. Only branch at user messages or finished assistant-text turns.
|
|
388
|
+
if (msg?.role === "assistant" && Array.isArray(msg.content) && msg.content.some((p) => p?.type === "toolCall")) {
|
|
389
|
+
const e = new Error(`resumeAt entry "${spec.resumeAt}" ends a turn mid-tool-call; resume-at requires a settled turn boundary`);
|
|
390
|
+
e.code = "resume_at.not_a_message";
|
|
391
|
+
throw e;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
await session.getStorage().setLeafId(spec.resumeAt);
|
|
395
|
+
}
|
|
396
|
+
await reconcileInterruptedSession(session, toolEffects, resume?.suspendedBatch);
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
catch (err) {
|
|
400
|
+
if (isSessionConflict(err) && attempt < RECONCILE_MAX_RETRIES) {
|
|
401
|
+
// Drop the STALE CACHED VIEW only, then re-wake. This must be `forget`, NEVER `release` (audit
|
|
402
|
+
// B-17): with the default in-memory store's `evict: "delete"`, release deleted the whole session
|
|
403
|
+
// from the repo — the re-acquire then created an EMPTY same-id session and the task continued on
|
|
404
|
+
// it (silent total context loss). A custom store WITHOUT `forget` gets NO fallback (B-17 council:
|
|
405
|
+
// its `release` contract permits deletion, so falling back would recreate the bug there) — the
|
|
406
|
+
// retry then re-acquires the same cached view and, if the conflict persists, exhausts the budget
|
|
407
|
+
// and propagates `errorCode="conflict"` to the caller. Fail-safe beats data loss.
|
|
408
|
+
if (sessions.forget) {
|
|
409
|
+
await Promise.resolve(sessions.forget(spec.sessionId)).catch(() => undefined);
|
|
410
|
+
}
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
// Conflict budget exhausted / non-conflict reconcile failure: the caller never gets a Prepared to
|
|
414
|
+
// release, so drop our cached view on the way out (1.93.0 throw-cleanup gap; forget = history-safe).
|
|
415
|
+
await Promise.resolve(sessions.forget ? sessions.forget(acquired.sessionId) : undefined).catch(() => undefined);
|
|
416
|
+
throw err;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const sessionId = acquired.sessionId;
|
|
420
|
+
const hostTaskId = spec.taskId ?? sessionId;
|
|
421
|
+
const taskScope = spec.principal ?? "default";
|
|
422
|
+
defaultTaskRegistry.maybeGc(); // throttled sweep (design/115 review B5): at most once/min, not per prepare
|
|
423
|
+
// B-17 + 1.93.0 throw-cleanup contract: ANY prepare throw below leaves the acquired session unreachable
|
|
424
|
+
// to the caller (no Prepared is returned, so finish()/release never run). Forget the cached view on the
|
|
425
|
+
// way out — deliberately NOT release(): on the default in-memory store an existing session's history
|
|
426
|
+
// would be deleted by a transient prepare failure (the same silent-empty-rewake class as B-17).
|
|
427
|
+
// Catch-on-throw-ONLY, mirroring the ownedEnv teardown (design/48 council BUG#1 precedent).
|
|
428
|
+
const forgetOnThrow = () => forgetQuietly(sessions, sessionId);
|
|
429
|
+
// Request-param passthrough (design/40): the brain strips any `extraBody` key it owns (core wins), but
|
|
430
|
+
// the brain has no `onError`. Warn here, once at load time, if a deployment put a reserved key in
|
|
431
|
+
// `extraBody` so its silent drop is visible — the brain still enforces it regardless.
|
|
432
|
+
const extraBodyCollisions = reservedCollisions(model.extraBody, reservedFor(model.api));
|
|
433
|
+
if (extraBodyCollisions.length > 0) {
|
|
434
|
+
deps.onError?.(new Error(`model.extraBody contains reserved key(s) [${extraBodyCollisions.join(", ")}] that core owns and sets itself — they are ignored. Remove them from extraBody.`), { phase: "config", sessionId });
|
|
435
|
+
}
|
|
436
|
+
// Accumulate usage of any nested sub-runs (sub-agents) a tool spawns, so delegated cost (the
|
|
437
|
+
// multi-agent "~15×") shows up in the parent's `stats.nested`. Injected into each task tool's
|
|
438
|
+
// execute context; `createSubagentTool` reports its child's subtree total through it.
|
|
439
|
+
const nestedStats = { tokens: 0, turns: 0, tasks: 0, costUsd: 0, costMicroUsd: 0 };
|
|
440
|
+
if (resume) {
|
|
441
|
+
// design/45 §4.bis: re-seed pre-suspend nested sub-agent cost so delegated cost isn't lost on resume.
|
|
442
|
+
nestedStats.tokens = resume.seed.nestedStats.tokens;
|
|
443
|
+
nestedStats.turns = resume.seed.nestedStats.turns;
|
|
444
|
+
nestedStats.tasks = resume.seed.nestedStats.tasks;
|
|
445
|
+
nestedStats.costUsd = resume.seed.nestedStats.costUsd;
|
|
446
|
+
nestedStats.costMicroUsd = resume.seed.nestedStats.costMicroUsd;
|
|
447
|
+
}
|
|
448
|
+
const reportUsage = (u) => {
|
|
449
|
+
nestedStats.tokens += u.tokens;
|
|
450
|
+
nestedStats.turns += u.turns;
|
|
451
|
+
nestedStats.tasks += u.tasks;
|
|
452
|
+
nestedStats.costUsd += u.costUsd ?? 0;
|
|
453
|
+
nestedStats.costMicroUsd += u.costMicroUsd ?? 0;
|
|
454
|
+
};
|
|
455
|
+
// Large-tool-result offload (design/30): wrap caller (and below, MCP) tools so an oversized text
|
|
456
|
+
// result is moved to the store and replaced with a preview + ref before it reaches the session.
|
|
457
|
+
// An EXPLICIT global (spec/deps) is a deployment's coarse knob and wins over the CC per-tool defaults
|
|
458
|
+
// table below; only Read's structural exemption outranks it (see firstPartyOffloadPolicy).
|
|
459
|
+
const explicitGlobalThreshold = spec.toolResultThresholdChars ?? deps.toolResultThresholdChars;
|
|
460
|
+
const offloadThreshold = explicitGlobalThreshold ?? DEFAULT_TOOL_RESULT_THRESHOLD_CHARS;
|
|
461
|
+
const offloadEnabled = Number.isFinite(offloadThreshold) && offloadThreshold > 0;
|
|
462
|
+
// impl-review 2026-07-03 (codex 2 BLOCKER on the A2 shared fallback): the Runner-SHARED fallback
|
|
463
|
+
// instance is wrapped in a per-task SCOPED view — refs namespaced by trust scope (tenant B can't
|
|
464
|
+
// deref tenant A's ref; same-scope cross-task deref still works) and isEmpty() reflecting only THIS
|
|
465
|
+
// task's offloads (the D-2 durable-suspend gate's per-task semantics). An INJECTED store is used
|
|
466
|
+
// as-is (its deployment owns scoping); a per-prepare fresh InMemory (no runner self) stays task-local.
|
|
467
|
+
const rawOffloadStore = offloadEnabled ? (deps.toolResultStore ?? new InMemoryToolResultStore()) : undefined;
|
|
468
|
+
const offloadStore = rawOffloadStore instanceof RunnerSharedToolResultStore
|
|
469
|
+
? new ScopedToolResultStore(rawOffloadStore, taskScope)
|
|
470
|
+
: rawOffloadStore;
|
|
471
|
+
const maybeOffload = (tool, perTool) => {
|
|
472
|
+
if (!offloadStore || perTool?.offload === false)
|
|
473
|
+
return tool;
|
|
474
|
+
return withToolResultOffload(tool, offloadStore, perTool?.offloadThresholdChars ?? offloadThreshold, sessionId);
|
|
475
|
+
};
|
|
476
|
+
// First-party mounts (band/task/monitor/worktree/SendMessage): CC per-tool persistence thresholds
|
|
477
|
+
// (PARITY-SPOT-USAGE-CONSTANTS F1/F2). Read's exemption is structural (its own paging gate owns the
|
|
478
|
+
// bound — offloading would re-truncate a page the tool already sized); the numeric table applies only
|
|
479
|
+
// when the deployment did NOT set an explicit global threshold (coarse knob wins).
|
|
480
|
+
const firstPartyOffload = (tool) => {
|
|
481
|
+
const policy = firstPartyOffloadPolicy(tool.name);
|
|
482
|
+
if (policy.offload === false)
|
|
483
|
+
return maybeOffload(tool, policy);
|
|
484
|
+
return maybeOffload(tool, explicitGlobalThreshold === undefined ? policy : undefined);
|
|
485
|
+
};
|
|
486
|
+
// MCP mounts: a server-declared `_meta["anthropic/maxResultSizeChars"]` (capped 500K in mcp.ts) is the
|
|
487
|
+
// server's own contract and wins over our defaults; else CC's MCP base declares 1e5 → min(1e5, 50K).
|
|
488
|
+
// An explicit deployment global still outranks both (caller = trust root, design F).
|
|
489
|
+
const mcpOffload = (tool) => {
|
|
490
|
+
if (explicitGlobalThreshold !== undefined)
|
|
491
|
+
return maybeOffload(tool);
|
|
492
|
+
return maybeOffload(tool, { offloadThresholdChars: tool.mcpMaxResultSizeChars ?? 50_000 });
|
|
493
|
+
};
|
|
494
|
+
if (offloadStore && (spec.tools ?? []).some((t) => t.name === OFFLOAD_TOOL_NAME)) {
|
|
495
|
+
// Same fail-fast as submit_output: a caller tool of the reserved name would shadow the injected reader.
|
|
496
|
+
const e = new Error(`Tool name "${OFFLOAD_TOOL_NAME}" is reserved when large-result offload is enabled.`);
|
|
497
|
+
e.code = "config.reserved_tool_name";
|
|
498
|
+
await forgetOnThrow();
|
|
499
|
+
throw e;
|
|
500
|
+
}
|
|
501
|
+
// Holds the harness once built, so a tool's execute can read the model the parent is CURRENTLY on
|
|
502
|
+
// (design/38 1B: `ToolExecuteContext.model` snapshot — a delegation tool uses it for `inherit`). Also
|
|
503
|
+
// breaks the harness↔tool_search build cycle (design/36 rematerialize). Populated before any run.
|
|
504
|
+
// The agent's "hand" execution env (design/44): injected, or the no-I/O stub (current default). Shared
|
|
505
|
+
// by the harness and the file tool band so both act on the same fs/shell.
|
|
506
|
+
// design/48 remote seam: a trusted control-plane `executionEnvFactory` mints a FRESH env per task (one
|
|
507
|
+
// isolated container per task in the remote model). It wins over a static `executionEnv`; the env it
|
|
508
|
+
// produces is `ownedEnv` — the Runner destroys it on task end (`finish()`), since this task owns its
|
|
509
|
+
// lifetime. Order: factory(ctx) → static executionEnv → no-I/O stub.
|
|
510
|
+
let ownedEnv;
|
|
511
|
+
// P-8 fix: hoist `mcp` to function scope so the prepare-throw catch (below) can dispose it. The MCP clients (stdio
|
|
512
|
+
// subprocesses / HTTP transports) are materialized INSIDE the big try; a throw after materialize (egress check /
|
|
513
|
+
// reserved-name guard / a throwing await) would otherwise strand them — the catch only disposed ownedEnv.
|
|
514
|
+
let mcp;
|
|
515
|
+
try {
|
|
516
|
+
ownedEnv = deps.executionEnvFactory
|
|
517
|
+
? await deps.executionEnvFactory({
|
|
518
|
+
sessionId,
|
|
519
|
+
taskId: spec.taskId,
|
|
520
|
+
...(internals?.isolation ? { isolation: internals.isolation } : {}),
|
|
521
|
+
// Sub-agent cwd inheritance (CC parity): let a TOC factory root the child at the parent's cwd.
|
|
522
|
+
...(internals?.parentCwd ? { parentCwd: internals.parentCwd } : {}),
|
|
523
|
+
})
|
|
524
|
+
: undefined;
|
|
525
|
+
}
|
|
526
|
+
catch (factoryErr) {
|
|
527
|
+
await forgetOnThrow(); // factory threw before any env existed — only the session view needs cleanup
|
|
528
|
+
throw factoryErr;
|
|
529
|
+
}
|
|
530
|
+
const executionEnv = ownedEnv ?? deps.executionEnv ?? new StubExecutionEnv();
|
|
531
|
+
const handsEnabled = ownedEnv !== undefined || deps.executionEnv !== undefined;
|
|
532
|
+
// design/97 CORE-6 audit MAJOR (codex): the task's effective root. For a worktree-isolated agent the factory
|
|
533
|
+
// rooted the env's cwd AT the worktree, so the root is the env's own cwd — `deps.rootPath` (a Runner-level base
|
|
534
|
+
// repo default) must NOT override it, or the agent's hands AND its LSP / prompt env-facts / session-rule
|
|
535
|
+
// policies would point at the SHARED base repo, defeating isolation. Computed ONCE here and used at every
|
|
536
|
+
// root-resolution site below (previously only the hands band applied the isolation rule).
|
|
537
|
+
const taskRootPath = internals?.isolation === "worktree" || !deps.rootPath ? executionEnv.cwd : deps.rootPath;
|
|
538
|
+
// Created early (before ANY env operation) so the resume VM-restore below can use it as the resumeVM
|
|
539
|
+
// abortSignal. The live listener that catches LATER external aborts is wired after the harness exists (it
|
|
540
|
+
// needs `harness.abort`); a signal already aborted at prepare start is propagated here so the restore honors it.
|
|
541
|
+
const abortController = new AbortController();
|
|
542
|
+
if (spec.signal?.aborted)
|
|
543
|
+
abortController.abort();
|
|
544
|
+
// NOTE: the LIVE `spec.signal` listener is attached below, after the harness exists. An abort that fires
|
|
545
|
+
// between this check and that listener is caught by the late check; the resume restore below additionally
|
|
546
|
+
// composes `spec.signal` into its abortSignal so a hung restore in that window is still short-circuited.
|
|
547
|
+
// design/48 (council BUG, minor/high): if a later prepare step throws AFTER the factory minted
|
|
548
|
+
// `ownedEnv`, it never reaches finish()'s teardown → a leaked remote container. Catch-on-throw-ONLY
|
|
549
|
+
// (deliberately NOT try/finally: on success the task owns the env and MUST keep it). Best-effort +
|
|
550
|
+
// destroy() is contractually idempotent. Mirrors the service reaper as belt-and-suspenders.
|
|
551
|
+
try {
|
|
552
|
+
// design/49 v1.5 + service[26]/[29] fix: restore the remote workspace snapshot (`resumeVM`) BEFORE any
|
|
553
|
+
// other env operation. The hand-toolkit setup below calls `executionEnv.canonicalPath()` (and tools later
|
|
554
|
+
// do fs/exec); a LAZY-CONNECT adapter would otherwise `doConnect` a FRESH empty sandbox on that first
|
|
555
|
+
// touch, after which a `resumeVM` (commonly idempotency-guarded to skip when already connected) is a no-op
|
|
556
|
+
// → the snapshot is never restored and the resumed task silently runs on an empty workspace (gated-write
|
|
557
|
+
// files lost). Restoring FIRST makes the snapshot the env's first and only connection. POST-CAS, so any
|
|
558
|
+
// failure here = resolved-but-unfinished (design/49 §4②): throw `resume.env_failed` → the prepare-throw
|
|
559
|
+
// catch destroys `ownedEnv` → `runTaskStream` surfaces a failed TaskResult (the human approval was
|
|
560
|
+
// consumed — re-initiate; the pre-CAS guard already rejected this resume if no factory was wired).
|
|
561
|
+
// abortSignal on resumeVM: a hung restore (network partition) cannot pin the resuming worker (BUG#2).
|
|
562
|
+
if (resume?.workspaceHandle !== undefined) {
|
|
563
|
+
const failResume = (message, cause) => {
|
|
564
|
+
const e = new Error(message, cause ? { cause } : undefined);
|
|
565
|
+
e.code = "resume.env_failed";
|
|
566
|
+
throw e;
|
|
567
|
+
};
|
|
568
|
+
const handle = resume.workspaceHandle;
|
|
569
|
+
if (ownedEnv === undefined || !isRemoteExecutionEnv(ownedEnv)) {
|
|
570
|
+
failResume("resume needs a RemoteExecutionEnv from executionEnvFactory to restore the workspace snapshot");
|
|
571
|
+
}
|
|
572
|
+
else if (handle.snapshotId === undefined) {
|
|
573
|
+
// service [500]② durable-park-only resume: a handle WITHOUT a snapshotId was minted by the park-only
|
|
574
|
+
// suspend degrade (non-suspendable remote env — SSH/ADB host; capabilities.suspendable:false). The
|
|
575
|
+
// suspend leg never paused a VM: the workspace persists on the target itself, and the factory above
|
|
576
|
+
// just reconnected to it — so there is nothing to restore; skip resumeVM/postResumeInit and proceed.
|
|
577
|
+
// (Before [500]② every remote suspend stamped a snapshotId, so this branch was a hard failResume.)
|
|
578
|
+
//
|
|
579
|
+
// codex review hardening (1.257.2): a missing snapshotId is only LEGITIMATE when the handle says so
|
|
580
|
+
// (`restoreMode:"park_only"`, stamped at park mint) — or, tolerance for park handles minted by
|
|
581
|
+
// 1.257.1 before the field existed, when the resumed env is itself non-suspendable. Any OTHER
|
|
582
|
+
// combination (no mode + suspendable env) = a snapshot handle that LOST its snapshotId (corrupt
|
|
583
|
+
// checkpoint / capability drift) → fail-closed rather than silently running on an unrestored fs.
|
|
584
|
+
// Deployment contract (unchanged, documented): the factory reconnects park-only workspaces by
|
|
585
|
+
// session identity — the engine has no seam to verify workspace identity beyond this guard.
|
|
586
|
+
if (handle.restoreMode !== "park_only" && isSuspendable(ownedEnv)) {
|
|
587
|
+
failResume("checkpoint workspaceHandle has no snapshotId and is not a park_only handle, but the resumed env is suspendable — refusing to resume on a possibly-unrestored workspace (corrupt checkpoint?)");
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
// Compose `spec.signal` into the restore's abortSignal (council finding): the live external-cancel
|
|
592
|
+
// listener is attached only after the harness exists (below), so without this a caller cancel that
|
|
593
|
+
// fires DURING the restore would not reach `abortController` until after it returns — a hung restore
|
|
594
|
+
// could pin the worker until the provider's own timeout. `AbortSignal.any` covers that window.
|
|
595
|
+
const restoreSignal = spec.signal
|
|
596
|
+
? AbortSignal.any([abortController.signal, spec.signal])
|
|
597
|
+
: abortController.signal;
|
|
598
|
+
const restored = await ownedEnv.resumeVM(handle.snapshotId, { abortSignal: restoreSignal });
|
|
599
|
+
if (!restored.ok) {
|
|
600
|
+
failResume(`resumeVM failed (${restored.error.code}): ${restored.error.message}`, restored.error);
|
|
601
|
+
}
|
|
602
|
+
const init = await ownedEnv.postResumeInit();
|
|
603
|
+
if (!init.ok) {
|
|
604
|
+
failResume(`postResumeInit failed (${init.error.code}): ${init.error.message}`, init.error);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
// design/101 §E19: when this task BRANCHED at a prior message (E18 `resumeAt`) AND rewind is enabled, restore
|
|
609
|
+
// the working tree to that message's snapshot. MUST be here (AFTER env resolution at :697 + the remote
|
|
610
|
+
// resumeVM block above) — at the `setLeafId` site in the acquire loop there is no env yet (grounding BLOCKER).
|
|
611
|
+
// GATE-SPLIT (service [263]): gate on STORE-PRESENCE + `handsEnabled`, NOT env locality. `applyManifest` runs
|
|
612
|
+
// against ANY ExecutionEnv's FileSystem ops (local OR remote host/e2b/k8s/…); the old `!isRemoteExecutionEnv`
|
|
613
|
+
// blocked E19 for host adapters (no VM) + cloud. `handsEnabled` (Opus M2) stays as the "real fs env" proxy —
|
|
614
|
+
// a stub-env deployment with rewindFiles on would otherwise enumerate_failed + spuriously warn.
|
|
615
|
+
// `not_found` (the target entry was never snapshotted — e.g. not a turn-end leaf) is an expected no-op; any
|
|
616
|
+
// OTHER failure is surfaced via onError(phase:"rewind") and prepare PROCEEDS — the session branch already
|
|
617
|
+
// committed, so the files are honestly reported as not-fully-restored rather than silently wrong.
|
|
618
|
+
// design/101 §E19 + R8 (CC Rewind modes): restore the working tree to a prior message's snapshot. Two
|
|
619
|
+
// entrypoints, mutually exclusive by construction:
|
|
620
|
+
// - `rewindFiles` + `resumeAt` → CC "both": the setLeafId fork above already moved the conversation; restore
|
|
621
|
+
// code to that SAME target. (`resumeAt` alone, rewindFiles false = CC "conversation" — no restore here.)
|
|
622
|
+
// - `rewindFilesTo` (honored ONLY when `resumeAt` is ABSENT) → CC "code"-only: restore code to that target
|
|
623
|
+
// WITHOUT a fork — the fork block (above) keys on `resumeAt`, so the leaf is untouched, conversation continues.
|
|
624
|
+
const rewindTarget = spec.resumeAt !== undefined ? (spec.rewindFiles ? spec.resumeAt : undefined) : spec.rewindFilesTo;
|
|
625
|
+
if (rewindTarget !== undefined && deps.fileSnapshotStore && handsEnabled) {
|
|
626
|
+
const restoreRoot = taskRootPath;
|
|
627
|
+
// Compose spec.signal (the live external-cancel listener attaches only after the harness exists below) so a
|
|
628
|
+
// caller cancel during a hung restore is honored, mirroring the remote resumeVM block above.
|
|
629
|
+
const restoreSignal = spec.signal ? AbortSignal.any([abortController.signal, spec.signal]) : abortController.signal;
|
|
630
|
+
const restored = await deps.fileSnapshotStore.restore(sessionId, rewindTarget, executionEnv, restoreRoot, restoreSignal);
|
|
631
|
+
if (!restored.ok) {
|
|
632
|
+
if (restored.error.code === "not_found") {
|
|
633
|
+
// Opus M4: OBSERVABLE no-op — the target entry was never snapshotted (v1 snapshots only turn-end
|
|
634
|
+
// leaves), so the session branch committed but the files are UNCHANGED. Surface it so a shell can tell
|
|
635
|
+
// the user "no file snapshot at this point" rather than believing the tree was rewound.
|
|
636
|
+
try {
|
|
637
|
+
deps.onError?.(new Error(`rewind-files: no snapshot for entry "${rewindTarget}" — files left unchanged`), { phase: "rewind", sessionId });
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
/* a throwing onError must not break prepare */
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
else {
|
|
644
|
+
// Opus M5: a real restore failure leaves a HALF-CONVERGED tree — FAIL the task (prepare-throw →
|
|
645
|
+
// status:"failed" + errorCode) rather than run the agent on a tree it believes is clean. The session
|
|
646
|
+
// branch already committed (non-destructive leaf); the caller can retry (restore is re-convergent).
|
|
647
|
+
const e = new Error(`rewind-files restore failed (${restored.error.code}): ${restored.error.message}`);
|
|
648
|
+
e.code = "rewind.restore_failed";
|
|
649
|
+
throw e;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
const harnessRef = {};
|
|
654
|
+
// design/77 §3 Gate-3: the per-task LIFO scope stack. Declared HERE (before the tool wrap below) so the
|
|
655
|
+
// wrapped `execute` can hand a delegation tool a live snapshot via `ctx.activeSkillScope()`. Frames are
|
|
656
|
+
// pushed later: the inherited-from-parent seed (design/77 §7) and the `skill` tool on a manifest load.
|
|
657
|
+
const skillScope = new ActiveSkillScope();
|
|
658
|
+
// design/99 (nested-subagent live tree, dual-review MINOR): the opt-in display sink. WRAPPED to forward ONLY
|
|
659
|
+
// `task_progress` — so a (3rd-party) tool that calls `ctx.forwardEvent` cannot push other event types onto the
|
|
660
|
+
// deployment's display channel — and EXPOSED on the tool ctx only when the deployment opted in (no `forwardEvent`
|
|
661
|
+
// key otherwise, so a non-forwarding run's ctx is unchanged). The forwarded tick is still an UNTRUSTED display
|
|
662
|
+
// hint (a tool could call `ctx.forwardEvent` with a forged `task_progress` / `parentTaskId`). Its blast is
|
|
663
|
+
// STRUCTURALLY bounded to the forwarding run's OWN scope: the deployment's fleet projection attributes the
|
|
664
|
+
// tick to THIS run (scope = run.scope), so a forged `parentTaskId` can at worst mis-nest a node within that
|
|
665
|
+
// scope's view — never cross-run / cross-tenant — i.e. cosmetic. A deployment MAY additionally validate
|
|
666
|
+
// `parentTaskId` against its known runs as defense-in-depth, but is NOT required to for isolation (service
|
|
667
|
+
// [305] #3 ruling: doc-as-LOW — the scope bound, not parentTaskId validation, is the load-bearing guarantee).
|
|
668
|
+
const forwardSink = internals?.onForwardEvent;
|
|
669
|
+
const forwardEvent = forwardSink
|
|
670
|
+
? (e) => {
|
|
671
|
+
if (e.type === "task_progress")
|
|
672
|
+
forwardSink(e);
|
|
673
|
+
// Blackboard 2026-07-03 (subagent viewing pane): the spec-level opt-in widens the render
|
|
674
|
+
// channel to the child's CONTENT events (still filtered — a tool can never push other
|
|
675
|
+
// types onto the deployment's display channel). Same UNTRUSTED-RAW/redact contract as the
|
|
676
|
+
// main stream's tool events (§E1); the child stream stays out of the model context.
|
|
677
|
+
else if (spec.forwardSubagentEvents === true &&
|
|
678
|
+
(e.type === "text_delta" || e.type === "reasoning_delta" || e.type === "tool_start" || e.type === "tool_end")) {
|
|
679
|
+
forwardSink(e);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
: undefined;
|
|
683
|
+
// design/80 D-B: a tool's `ctx.requestReview()` / `present_plan` sets `pending` here; the run loop honors it
|
|
684
|
+
// at the next clean turn boundary (mints a `plan_review` checkpoint). First request in a batch wins. Declared
|
|
685
|
+
// BEFORE the tool-ctx build so the ctx closure can capture `requestReview`.
|
|
686
|
+
const reviewRequestRef = {};
|
|
687
|
+
const requestReview = (opts) => {
|
|
688
|
+
if (reviewRequestRef.pending === undefined) {
|
|
689
|
+
reviewRequestRef.pending = opts?.reason !== undefined ? { reason: opts.reason } : {};
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
// design/105: clean run-stop binding to the harness's existing one-way flag — exposed as the ctx primitive
|
|
693
|
+
// (for spec.tools) and still threaded to the scheduler-tool factory's context slot (design/136 §2.2: the
|
|
694
|
+
// Sleep tool that consumed it there was retired; the primitive itself is load-bearing for spec.tools).
|
|
695
|
+
const requestStopAfterTurn = () => {
|
|
696
|
+
harnessRef.current?.requestStopAfterTurn();
|
|
697
|
+
};
|
|
698
|
+
// design/108: mid-run read-only plan mode — a run-local one-way flag the first-party `enter_plan_mode` tool sets
|
|
699
|
+
// (via ctx) and the tool-call handler below enforces (reject write-effect tools). Run-local (NOT durable): a
|
|
700
|
+
// later approve-resume re-prepares with this clear, so the approved plan can write. NOT in the safety gate —
|
|
701
|
+
// plan mode is a fidelity mode, not a security boundary (the real gates apply independently).
|
|
702
|
+
const planModeRef = { active: false };
|
|
703
|
+
const enterPlanMode = () => {
|
|
704
|
+
planModeRef.active = true;
|
|
705
|
+
};
|
|
706
|
+
// design/122 D1: the parent-run subagent-retain LEDGER — created ONLY when the spec opts in
|
|
707
|
+
// (`retainSubagentSessions`; default OFF ⇒ undefined ⇒ the ctx gains no key and delegation is
|
|
708
|
+
// byte-identical). Threaded to delegation tools as the TRUSTED `ctx.subagentRetain`; the Runner
|
|
709
|
+
// disposes it (abort in-flight resumes + unpin + release every retained session) in the task's
|
|
710
|
+
// terminal `finally` (D4).
|
|
711
|
+
const subagentRetain = spec.retainSubagentSessions ? new SubagentRetainLedger(spec.retainSubagentSessions) : undefined;
|
|
712
|
+
// design/135 §0 — the Agent tool's per-call `isolation:"worktree"` capability: built ONCE over the run's
|
|
713
|
+
// trusted env + task root (lazy — no I/O until a delegation actually mints a worktree). Write-capable runs
|
|
714
|
+
// only: a read-only run must not create worktrees, so the ctx key is simply absent there and the Agent tool
|
|
715
|
+
// reports isolation as honestly unavailable (design/135: silent degrade is the disease this batch kills).
|
|
716
|
+
const worktreeIsolation = spec.handsReadOnly !== true ? createSubagentWorktreeHelper(executionEnv, taskRootPath) : undefined;
|
|
717
|
+
// design/136 §6 盲点①: the run's fork-governance verdict. Declared BEFORE the ctx-injection map below
|
|
718
|
+
// (whose execute closures read it at tool-call time) but ASSIGNED after runtimeCaps resolve (~:1290) —
|
|
719
|
+
// prepare completes before any tool executes, so the closure never observes the pre-assignment value.
|
|
720
|
+
let agentForkDenial;
|
|
721
|
+
// OBS-2: the observer gate verdict (RuntimeCaps.allowObservers === true, EXPLICIT opt-in — types.ts
|
|
722
|
+
// polarity note). Declared here, ASSIGNED after runtimeCaps resolve (same call-time-closure posture
|
|
723
|
+
// as agentForkDenial above); the delegation tool reads it through ctx at each call.
|
|
724
|
+
let observersActive = false;
|
|
725
|
+
const tools = (spec.tools ?? []).map((t) => maybeOffload(defineTool({
|
|
726
|
+
...t,
|
|
727
|
+
// `?.` is type-level optionality (a tool can run outside a Runner); at task runtime harnessRef is
|
|
728
|
+
// always populated, so this resolves to the parent's current model — never undefined here.
|
|
729
|
+
// `activeSkillScope`: a read-only snapshot of THIS (parent) task's live skill-manifest frames so a
|
|
730
|
+
// delegation tool can propagate them into a spawned subagent (design/77 §3/§7). Captured lazily at
|
|
731
|
+
// call time → reflects manifests active at the spawn moment (LIFO grows/shrinks during the run).
|
|
732
|
+
execute: (args, ctx) => t.execute(args, {
|
|
733
|
+
...ctx,
|
|
734
|
+
reportUsage,
|
|
735
|
+
model: harnessRef.current?.getModel(),
|
|
736
|
+
thinkingLevel: harnessRef.current?.getThinkingLevel(), // the model snapshot's companion (same call-time semantics)
|
|
737
|
+
principal: spec.principal,
|
|
738
|
+
clientContext: spec.clientContext, // design/112 C1/C5: inherit user TZ/email down the delegation tree
|
|
739
|
+
parentCwd: taskRootPath, // sub-agent cwd inheritance (CC parity) — delegation tools thread this down
|
|
740
|
+
activeSkillScope: () => skillScope.active(),
|
|
741
|
+
// design/99 (nested-subagent live tree): the host run's canonical taskId (SAME fallback as runtask's
|
|
742
|
+
// `taskId = spec.taskId ?? sessionId`, so a child's parentTaskId matches the parent's task_progress
|
|
743
|
+
// node id), plus the opt-in display sink — `forwardEvent` is OMITTED unless the deployment opted in
|
|
744
|
+
// (a non-forwarding run's ctx gains no sink key). A delegation tool threads both into the child.
|
|
745
|
+
taskId: hostTaskId,
|
|
746
|
+
// design/129: the session id + the deployment's background-lifetime scope, threaded read-only
|
|
747
|
+
// so a background child can register session-scoped (outlive the turn) when opted in.
|
|
748
|
+
sessionId,
|
|
749
|
+
...(spec.backgroundScope !== undefined ? { backgroundScope: spec.backgroundScope } : {}),
|
|
750
|
+
// design/129-B ([444]): the process-level background-child observer — per-leg sinks go stale
|
|
751
|
+
// at leg settle; this one is wired once at Runner construction and never does.
|
|
752
|
+
...(deps.onBackgroundChildEvent ? { onBackgroundChildEvent: deps.onBackgroundChildEvent } : {}),
|
|
753
|
+
// design/115 P3: the run-local notification lane, so a caller-mounted subagent tool's
|
|
754
|
+
// background completions reach THIS task's next turn (same lane as workflow/bash).
|
|
755
|
+
...(internals?.onTaskNotification ? { onTaskNotification: internals.onTaskNotification } : {}),
|
|
756
|
+
...(forwardEvent ? { forwardEvent } : {}),
|
|
757
|
+
// Subagent steer verb (blackboard 2026-07-03): the deployment's steer-handle sink.
|
|
758
|
+
...(internals?.onSubagentSpawn ? { onSubagentSpawn: internals.onSubagentSpawn } : {}),
|
|
759
|
+
// design/122 D1: the retain ledger (present only when `spec.retainSubagentSessions` opted in).
|
|
760
|
+
...(subagentRetain ? { subagentRetain } : {}),
|
|
761
|
+
// design/135 §0: the Agent tool's worktree-isolation capability (write-capable runs only) + the
|
|
762
|
+
// inside-fork marker (so a forked child's Agent tool refuses a nested `subagent_type:"fork"`).
|
|
763
|
+
...(worktreeIsolation ? { worktreeIsolation } : {}),
|
|
764
|
+
...(internals?.insideFork === true ? { insideFork: true } : {}),
|
|
765
|
+
// design/136 §6 盲点① + §2.1.a: thread the fork-GOVERNANCE verdict (read at call time — see
|
|
766
|
+
// the `agentForkDenial` declaration above) so Agent(subagent_type:"fork") answers to the
|
|
767
|
+
// enableFork/allowFork governance axes. Present only when DENIED.
|
|
768
|
+
...(agentForkDenial !== undefined ? { forkAccess: { denied: agentForkDenial } } : {}),
|
|
769
|
+
// OBS-2: the observer auto-spawn gate (read at call time — see the `observersActive`
|
|
770
|
+
// declaration above). Present only when the principal's caps EXPLICITLY opted in.
|
|
771
|
+
...(observersActive ? { observersAllowed: true } : {}),
|
|
772
|
+
// design/80 D-B: the general "yield to a human plan-review" primitive. Always present (it only sets
|
|
773
|
+
// a flag); the EFFECT is gated at the turn boundary — honored ONLY when `suspendForReview` exists (a
|
|
774
|
+
// checkpointStore is wired). A read-only/headless run sets the flag but the boundary drops it (no-op).
|
|
775
|
+
requestReview,
|
|
776
|
+
// design/105: clean run-stop after the current turn — a thin binding to the harness's existing one-way
|
|
777
|
+
// flag (NOT abort, so no orphan reconcile). E.g. a tool that persisted a delayed self-wake intent
|
|
778
|
+
// ends the run cleanly so the daemon owns the re-wake.
|
|
779
|
+
requestStopAfterTurn,
|
|
780
|
+
// design/108 (review fix): expose the plan-mode primitive ONLY when the deployment opted in
|
|
781
|
+
// (`enablePlanMode`). Otherwise a non-plan task's tool could call `ctx.enterPlanMode()` and — if the
|
|
782
|
+
// tool-call gate happens to be registered (e.g. a policy task) — flip the run read-only. Strictly
|
|
783
|
+
// opt-in keeps non-plan tasks byte-identical (no reachable way to set the flag).
|
|
784
|
+
...(spec.enablePlanMode === true ? { enterPlanMode } : {}),
|
|
785
|
+
}),
|
|
786
|
+
}), t));
|
|
787
|
+
const blockedRef = {};
|
|
788
|
+
if (spec.enableBlockedReport !== false) {
|
|
789
|
+
tools.push(makeReportBlockedTool(blockedRef));
|
|
790
|
+
}
|
|
791
|
+
// design/80 D-B: mount the first-party `present_plan` tool ONLY when the task opted into plan mode. It is a
|
|
792
|
+
// thin caller of the `requestReview` primitive (which the engine honors at the turn boundary). When
|
|
793
|
+
// `enablePlanMode` is unset the tool is NOT mounted, NOT disclosed, and a non-plan-mode task is byte-identical.
|
|
794
|
+
if (spec.enablePlanMode === true) {
|
|
795
|
+
tools.push(defineTool(createPresentPlanTool(requestReview)));
|
|
796
|
+
// design/108: the model-callable `enter_plan_mode` (CC EnterPlanMode parity) — self-impose mid-run read-only
|
|
797
|
+
// plan mode (enforced by the tool-call handler's plan-mode short-circuit). Mounted alongside present_plan (the
|
|
798
|
+
// exit half). Sets `planModeRef` via the captured `enterPlanMode`. (Effect:"read"; lands in `toolEffects` via
|
|
799
|
+
// the early set above + the post-assembly sync, so the plan-mode write-deny doesn't block the read-only exit.)
|
|
800
|
+
//
|
|
801
|
+
// (review fix) Gate on a wired checkpointStore: the ONLY exit from plan mode is present_plan → plan_review →
|
|
802
|
+
// human approve-resume, which REQUIRES a checkpointStore. Without one, present_plan's pause is dropped (no-op)
|
|
803
|
+
// while `planModeRef.active` persists → the model would be trapped read-only until the turn limit. So we do
|
|
804
|
+
// NOT offer `enter_plan_mode` when it cannot be exited. (present_plan stays mounted — its no-op degradation is
|
|
805
|
+
// harmless: it's just plan text, no flag is set.) The user-driven path (enablePlanMode + handsReadOnly, often
|
|
806
|
+
// headless) is unaffected: that run is read-only by construction and never needed `enter_plan_mode`.
|
|
807
|
+
if ((spec.checkpointStore ?? deps.checkpointStore) !== undefined) {
|
|
808
|
+
tools.push(defineTool(createEnterPlanModeTool(enterPlanMode)));
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
// design/99 §K (service [305]/center [318]): per-principal runtime ENTITLEMENTS, resolved server-side by
|
|
812
|
+
// the deployment (the SERVICE maps center `GET /api/config/effective?principal=` → these caps). Resolved
|
|
813
|
+
// here ONCE. Entitlements are NOT a caller-facing TaskSpec field — an untrusted caller must never
|
|
814
|
+
// self-grant — so the engine pulls them from a TRUSTED deployment resolver keyed by `spec.principal`
|
|
815
|
+
// (symmetric with `sessionPolicyStore.getRules`). `undefined` (no resolver / unset cap) = NO per-principal
|
|
816
|
+
// restriction (tighten-only: a cap only ever DENIES/forces; absence falls back to the deployment default).
|
|
817
|
+
let runtimeCaps;
|
|
818
|
+
if (deps.runtimeCapsResolver) {
|
|
819
|
+
try {
|
|
820
|
+
runtimeCaps = (await deps.runtimeCapsResolver(spec.principal)) ?? undefined;
|
|
821
|
+
}
|
|
822
|
+
catch (err) {
|
|
823
|
+
// FAIL CLOSED, never open (security review BLOCKER): an entitlement-resolver outage must NOT silently
|
|
824
|
+
// GRANT a capability center may have denied. Deny EVERY optional per-principal privilege (workflows AND
|
|
825
|
+
// fork — both gate on `!== false`, so an undefined cap here would AUTO-GRANT) + surface the failure so the
|
|
826
|
+
// operator can repair the resolver. The deployment-level gates (capability + toolPolicy) still apply, and a
|
|
827
|
+
// remote policy `ask` with no forced durable gate falls to a sync onAsk → headless deny (more restrictive).
|
|
828
|
+
// We do NOT force the durable gate on error (that could strand an autonomous run on an absent operator);
|
|
829
|
+
// `{allowWorkflows:false, allowFork:false}` is the safe, minimal, SYMMETRIC degrade (service [356]②).
|
|
830
|
+
deps.onError?.(err instanceof Error ? err : new Error(String(err)), { phase: "config", sessionId });
|
|
831
|
+
runtimeCaps = { allowWorkflows: false, allowFork: false };
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
// design/136 §6 盲点① (1.256) + §2.1.a (BREAKING batch) — the run's fork-GOVERNANCE verdict, computed
|
|
835
|
+
// ONCE from the shared predicate (subagent.ts `forkGovernanceDenial`) and threaded to the Agent tool as
|
|
836
|
+
// `ctx.forkAccess` in the spec.tools ctx-injection map ABOVE (~:1204, same lane as `insideFork`; the
|
|
837
|
+
// closure reads this at CALL time, long after this assignment — prepare completes before any tool
|
|
838
|
+
// executes). `enableFork` is opt-OUT since the standalone Fork tool's removal: only an EXPLICIT
|
|
839
|
+
// `enableFork:false` / `allowFork:false` denies (undefined = Agent-fork available, bounded by the
|
|
840
|
+
// durable session capability — CC's default posture).
|
|
841
|
+
agentForkDenial = forkGovernanceDenial(spec.enableFork, runtimeCaps?.allowFork);
|
|
842
|
+
// OBS-2: `=== true` EXPLICIT opt-in (polarity note on RuntimeCaps.allowObservers — undefined ships dark).
|
|
843
|
+
observersActive = runtimeCaps?.allowObservers === true;
|
|
844
|
+
// design/98 §1.2/§3.1 (S8c): mount the `run_workflow` tool ONLY when self-orchestration is active (opt-in
|
|
845
|
+
// + hard sandbox + governance baseline) AND a trusted Runner self-ref is wired. The SAME gate
|
|
846
|
+
// (`selfOrchestrationActive`) drives the orchestration-prompt injection below (§6.3 lockstep). The runner
|
|
847
|
+
// is a TRUSTED internal (the Runner passes `this`); it is never a TaskSpec field. design/99 §K: a
|
|
848
|
+
// per-principal `allowWorkflows:false` entitlement DENIES it server-side even on a capable deployment
|
|
849
|
+
// (the third stage of the workflows gate: task opt-in ∧ deployment capability ∧ principal entitlement).
|
|
850
|
+
const deploymentWorkflowReady = runnerSelf !== undefined && isSelfOrchestrationActive(spec, deps);
|
|
851
|
+
const selfOrchestrationActive = deploymentWorkflowReady && runtimeCaps?.allowWorkflows !== false;
|
|
852
|
+
// Observability (security review MINOR): distinguish an ENTITLEMENT denial from a deployment misconfig — an
|
|
853
|
+
// opted-in run on a CAPABLE deployment gated purely by `allowWorkflows:false` emits a (governance, NOT
|
|
854
|
+
// misconfiguration) operator note, so "capable + opted-in + no workflow" is never silently confusing.
|
|
855
|
+
if (deploymentWorkflowReady && runtimeCaps?.allowWorkflows === false) {
|
|
856
|
+
deps.onError?.(new Error(`self-orchestration DENIED for principal "${spec.principal ?? ""}" by runtimeCaps.allowWorkflows=false ` +
|
|
857
|
+
`(per-principal entitlement governance, not a misconfiguration — the run_workflow tool is not mounted)`), { phase: "config", sessionId });
|
|
858
|
+
}
|
|
859
|
+
let workflowToolsActive = false;
|
|
860
|
+
if (selfOrchestrationActive && runnerSelf && deps.workflowScriptRunner && deps.workflowGovernanceBaseline) {
|
|
861
|
+
workflowToolsActive = true;
|
|
862
|
+
toolEffects.set(RUN_WORKFLOW_TOOL_NAME, "write");
|
|
863
|
+
tools.push(await createRunWorkflowTool({
|
|
864
|
+
runner: runnerSelf,
|
|
865
|
+
scriptRunner: deps.workflowScriptRunner,
|
|
866
|
+
governanceBaseline: deps.workflowGovernanceBaseline,
|
|
867
|
+
models: deps.models,
|
|
868
|
+
agents: deps.agents, // F4 agentType registry (deployment SHADOW over built-ins)
|
|
869
|
+
builtinAgents: deps.builtinAgents,
|
|
870
|
+
store: deps.workflowRunStore,
|
|
871
|
+
journalStore: deps.workflowJournalStore, // CORE-9 Part A: durable resume for tool-launched workflows
|
|
872
|
+
scriptStore: deps.workflowScriptStore, // B5/F2 + design/140 1a: scriptPath/name surface + defaultArgs registry
|
|
873
|
+
builtinWorkflows: deps.builtinWorkflows, // design/140 1c: built-in named workflows opt-out
|
|
874
|
+
onAgentSpawn: deps.onWorkflowAgentSpawn, // CORE-9 Part B: steerable handle sink (opt-in)
|
|
875
|
+
scope: taskScope,
|
|
876
|
+
notifier: deps.workflowCompletionNotifier,
|
|
877
|
+
originatingSessionId: sessionId, // service [368]: lookup-free completion-inbox routing
|
|
878
|
+
taskRegistry: defaultTaskRegistry,
|
|
879
|
+
taskNotification: internals?.onTaskNotification,
|
|
880
|
+
taskOwner: hostTaskId,
|
|
881
|
+
limits: deps.workflowLimits,
|
|
882
|
+
sourceTaskId: hostTaskId,
|
|
883
|
+
principal: spec.principal,
|
|
884
|
+
// TRUSTED nesting depth (cross-process): a workflow-child task carries it so its run_workflow is
|
|
885
|
+
// rejected by the one-level guard. In-process nesting is caught by the engine's AsyncLocalStorage.
|
|
886
|
+
workflowDepth: internals?.workflowDepth,
|
|
887
|
+
// Sub-agent cwd inheritance (CC parity): every agent this workflow spawns carries the HOST task's
|
|
888
|
+
// effective root so a TOC env factory roots it there instead of an empty sandbox.
|
|
889
|
+
parentCwd: taskRootPath,
|
|
890
|
+
// Thinking inheritance: a spawned agent with no explicit script/baseline thinking inherits the
|
|
891
|
+
// HOST task's CURRENT level (call-time getter — mid-run degrade/boost included).
|
|
892
|
+
parentThinking: () => harnessRef.current?.getThinkingLevel() ?? thinking,
|
|
893
|
+
}));
|
|
894
|
+
// design/136 §2.3: the standalone WorkflowStatus poll tool was retired — the POLL companion to
|
|
895
|
+
// Workflow is TaskOutput (mounted below with `workflowStore: deps.workflowRunStore`, live alias
|
|
896
|
+
// "WorkflowStatus"), which carries the field superset + delimitUntrusted fencing + the same
|
|
897
|
+
// cross-replica store fallback. No separate mount here.
|
|
898
|
+
}
|
|
899
|
+
// design/45: holder the durable-suspend gate writes (token+gate); read by the run loop. Plus the
|
|
900
|
+
// hand's read-file state, captured here so the suspend closure can serialize it into the checkpoint
|
|
901
|
+
// (it lives in the hands-toolkit closure, not Prepared — §15.2 net-add #8). Undefined when no hand.
|
|
902
|
+
const suspendRef = {};
|
|
903
|
+
// design/76 §2.5: the DUAL holder for a dry-run `{kind:"needs_review"}` pause. The commit-side
|
|
904
|
+
// discriminant (`publishCommittedSuspend`) routes a committed checkpoint into THIS or `suspendRef` by
|
|
905
|
+
// `gate.kind` — never both, so assemble-result's slot 8.6 `needs_review` branch is never dead (v4 MAJOR-A).
|
|
906
|
+
const reviewRef = {};
|
|
907
|
+
const suspendLoopRef = { hit: false }; // design/72 §2.2 (B): set when a re-suspend is refused (loop cap)
|
|
908
|
+
const compactionReuseRef = { consecutive: 0 }; // design/84 Seam C: run-scoped consecutive provider-reuse counter
|
|
909
|
+
const trimPressureRef = { droppedMessages: false }; // design/123 D4: request-layer trim dropped messages → force next boundary compaction
|
|
910
|
+
// design/80 D-E-core (A3): the run loop sets `.get` (after `stats` exists) so an approval suspend can debit
|
|
911
|
+
// this leg's live cumulative spend onto the durable approval ledger. Captured by the `suspendAsk` closure.
|
|
912
|
+
const liveSpendRef = {};
|
|
913
|
+
// design/91: human-review timing. `now` is the injectable wall-clock (default Date.now) — ALL human-time
|
|
914
|
+
// timestamps (synchronous resolveAsk wait, checkpoint.suspendedAt, resume latency) read it, so a test can
|
|
915
|
+
// pin them with a controllable clock (design/87 §4.3 flaky-guard: no bare Date.now in a hard-gate test).
|
|
916
|
+
// `humanReviewRef` is the per-task accumulator: seeded from the resumed checkpoint's carried prior-leg
|
|
917
|
+
// burden (cross-leg accumulation, like suspendCount/ledger), then appended to by each synchronous ask
|
|
918
|
+
// here (resolveAskBound) and by the durable-resume latency (runtask). Snapshotted onto a re-suspend's
|
|
919
|
+
// checkpoint (`humanReview`) so the chain reports the WHOLE burden. **Budget-EXCLUDED** — never a cost/gate input.
|
|
920
|
+
const now = deps.now ?? Date.now;
|
|
921
|
+
const humanReviewRef = {
|
|
922
|
+
count: resume?.priorHumanReview?.count ?? 0,
|
|
923
|
+
totalWaitMs: resume?.priorHumanReview?.totalWaitMs ?? 0,
|
|
924
|
+
gates: resume?.priorHumanReview?.gates ? [...resume.priorHumanReview.gates] : [],
|
|
925
|
+
};
|
|
926
|
+
// design/72 §2.2 (B): suspend-loop cap. `priorSuspendCount` is how many times this task already
|
|
927
|
+
// suspended (carried on the resumed checkpoint); the next suspend would be `+1`. Past `maxSuspends`
|
|
928
|
+
// we fail the run rather than mint another checkpoint — a restart-prone model re-issuing the gated
|
|
929
|
+
// call every resume can otherwise suspend forever. Default deliberately loose (a real HITL flow
|
|
930
|
+
// rarely re-suspends): legit multi-step approvals stay well under it.
|
|
931
|
+
const priorSuspendCount = resume?.priorSuspendCount ?? 0;
|
|
932
|
+
const maxSuspends = spec.maxSuspends ?? deps.maxSuspends ?? DEFAULT_MAX_SUSPENDS;
|
|
933
|
+
// design/74 (search [136] HIGH-1): the resource-SLICE cap — INDEPENDENT of the restart-loop `maxSuspends`.
|
|
934
|
+
// A resource suspend is counted on the ledger's `sliceCount` (priorLedger below), NOT the shared
|
|
935
|
+
// `suspendCount`: a legitimate long resource run must not trip the (low, default 5) restart-loop safety,
|
|
936
|
+
// and restart-loop suspends must not consume the slice allowance. The cross-slice $ ledger is the PRIMARY
|
|
937
|
+
// bound; this is a backstop against a $0-progress slice loop. Default 100 (the design/74 spec's worker-suspend ceiling).
|
|
938
|
+
const maxSlices = spec.resourceSuspend?.maxSlices ?? DEFAULT_MAX_SLICES;
|
|
939
|
+
// design/74 Slice 4: the cross-slice budget ledger carried forward from the resumed checkpoint (undefined on
|
|
940
|
+
// the first slice), plus the human total ($→micro) seeded ONLY on the first slice (later slices read the
|
|
941
|
+
// frozen total off the ledger — `debitLedger` keeps `prior` wins). `prepared.resourceLedger` exposes the
|
|
942
|
+
// prior ledger so the run loop can size this slice's effective ceiling = min(maxCostUsd, remaining).
|
|
943
|
+
const priorLedger = resume?.priorLedger;
|
|
944
|
+
const resourceTotal = spec.resourceSuspend
|
|
945
|
+
? {
|
|
946
|
+
totalBudgetMicroUsd: spec.resourceSuspend.totalBudgetUsd !== undefined ? Math.round(spec.resourceSuspend.totalBudgetUsd * 1e6) : undefined,
|
|
947
|
+
}
|
|
948
|
+
: undefined;
|
|
949
|
+
// design/80 D-E-core (Part A): the read-only per-leg budget snapshot a STATELESS policy self-limits
|
|
950
|
+
// against (a fresh in-memory counter resets every resume leg). Computed ONCE here from the DURABLE state
|
|
951
|
+
// (the resumed resource ledger + the durable suspend chain) and frozen onto every `ToolCallRequest` the
|
|
952
|
+
// gate builds (per-leg-immutable, read-only — the policy cannot write it back). The prior ledger
|
|
953
|
+
// (`priorLedger`, carried on the resumed checkpoint regardless of gate kind — design/80 attaches it to
|
|
954
|
+
// approval suspends too, A3) gives cumulative spend + remaining across legs; on the FIRST leg (no ledger)
|
|
955
|
+
// the remaining falls back to the configured total so the policy still sees the leg-1 ceiling (mirrors
|
|
956
|
+
// runtask's first-slice `remainingMicroUsd`). NEVER falsy-test the remaining (0 = exhausted, not unlimited).
|
|
957
|
+
const budgetSnapshot = {
|
|
958
|
+
resourceRemainingMicroUsd: priorLedger
|
|
959
|
+
? remainingBudgetMicroUsd(priorLedger)
|
|
960
|
+
: resourceTotal?.totalBudgetMicroUsd,
|
|
961
|
+
resourceSpentMicroUsd: priorLedger?.spentMicroUsd ?? 0,
|
|
962
|
+
suspendCount: priorSuspendCount,
|
|
963
|
+
};
|
|
964
|
+
let readFileStateForCheckpoint;
|
|
965
|
+
// parity-204 (CC seededFromContext): set inside the hands block below (needs the env + canonical
|
|
966
|
+
// roots), consumed by the loadProjectMemory block — undefined on a hands-less task (nothing to seed).
|
|
967
|
+
let seedContextFiles;
|
|
968
|
+
// design/99 §E13: the per-task logical cwd ref, OWNED here (not inside createHandsToolkit) when a real
|
|
969
|
+
// shell is mounted, so the Runner can observe `cd` moves and emit `workspace_changed`. Undefined when no
|
|
970
|
+
// shell / readOnly (no `cd` possible).
|
|
971
|
+
let handsCwdRef;
|
|
972
|
+
// codex 终审 1.255 F2: the EnterWorktree session's shared serializable state — owned HERE (like
|
|
973
|
+
// handsCwdRef) so the durable-suspend serialization below can stamp it into the checkpoint and a
|
|
974
|
+
// resume can re-seed it. Undefined ⇔ worktree tools not mounted (no tracked cwd).
|
|
975
|
+
let worktreeSessionRef;
|
|
976
|
+
let backgroundTaskToolsActive = false;
|
|
977
|
+
// design/74 Slice 3c: forward-declared at TASK scope (assigned inside the tool-gate block below, which is
|
|
978
|
+
// widened to run when resource-suspend is eligible even with no policy). Lives here — not block-local — so
|
|
979
|
+
// the Prepared return can expose it to the run loop's turn-boundary trigger. Undefined ⇒ not eligible. The
|
|
980
|
+
// `sliceSpend` arg (Slice 4) is THIS slice's cost/tokens/turns, debited onto the ledger at suspend.
|
|
981
|
+
let suspendForResource;
|
|
982
|
+
// design/80 D-B: forward-declared (like `suspendForResource`) so it is in scope at the Prepared return;
|
|
983
|
+
// ASSIGNED inside the suspend-setup block below (where the commit-saga helpers live).
|
|
984
|
+
let suspendForReview;
|
|
985
|
+
// Structured output (1.41): inject `submit_output` whose params ARE the schema. Reject a caller tool
|
|
986
|
+
// of the same name up front (the harness tool Map is last-write-wins → silent shadowing otherwise).
|
|
987
|
+
const outputRef = {};
|
|
988
|
+
if (resume?.seed.outputRef) {
|
|
989
|
+
// design/45 §4.bis: restore a pre-suspend submit_output so resume doesn't lose the structured result.
|
|
990
|
+
outputRef.value = resume.seed.outputRef.value;
|
|
991
|
+
outputRef.set = resume.seed.outputRef.set;
|
|
992
|
+
}
|
|
993
|
+
if (spec.outputSchema) {
|
|
994
|
+
if ((spec.tools ?? []).some((t) => t.name === OUTPUT_TOOL_NAME)) {
|
|
995
|
+
const e = new Error(`Tool name "${OUTPUT_TOOL_NAME}" is reserved when TaskSpec.outputSchema is set.`);
|
|
996
|
+
e.code = "config.reserved_tool_name";
|
|
997
|
+
throw e;
|
|
998
|
+
}
|
|
999
|
+
// CC 2.1.205 parity (strict-output-schema.ts): validate + strict-derive the caller's schema.
|
|
1000
|
+
// Invalid schema → FAIL FAST to the caller (CC: Ajv `validateSchema` error surfaces as a thrown
|
|
1001
|
+
// TypeError in workflow `agent({schema})` / a hard CLI error — never a silent no-validation run).
|
|
1002
|
+
// In-subset schema → the strict form (additionalProperties:false stamped) is what the model sees.
|
|
1003
|
+
// Valid-but-unsupported → serve as-is, with an observable warning (CC: telemetry outcome=fallback).
|
|
1004
|
+
const compiled = compileOutputSchema(spec.outputSchema);
|
|
1005
|
+
if ("error" in compiled) {
|
|
1006
|
+
const e = new Error(`TaskSpec.outputSchema is not a valid JSON Schema: ${compiled.error}`);
|
|
1007
|
+
e.code = "config.invalid_output_schema";
|
|
1008
|
+
throw e;
|
|
1009
|
+
}
|
|
1010
|
+
if (!compiled.strict) {
|
|
1011
|
+
try {
|
|
1012
|
+
deps.onError?.(new Error(`structured-output: strict schema derivation fell back to non-strict (${compiled.fallbackReason}) — the schema is served to the model as-is`), { phase: "config", sessionId });
|
|
1013
|
+
}
|
|
1014
|
+
catch {
|
|
1015
|
+
/* a throwing onError must not break prepare */
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
tools.push(makeOutputTool(outputRef, spec.outputSchema, compiled.strict ? compiled.modelSchema : undefined));
|
|
1019
|
+
}
|
|
1020
|
+
mcp = spec.mcp?.length
|
|
1021
|
+
? await materializeMcpTools(spec.mcp, spec.principal, deps.onElicit, deps.mcpImageResizer) // design/62 principal → MCP header; §E23 elicit; design/116 image-resize seam
|
|
1022
|
+
: { tools: [], toolAxes: [], warnings: [], serverInstructions: [], instructionsDelta: { pendingAdds: [], pendingRemovals: [] }, statuses: [], dispose: async () => { } };
|
|
1023
|
+
// Fail-open (design/29): servers that couldn't connect were skipped, not thrown — surface each as
|
|
1024
|
+
// an operational warning so a brick'd MCP server is visible without failing the task.
|
|
1025
|
+
for (const w of mcp.warnings)
|
|
1026
|
+
deps.onError?.(w, { phase: "mcp", sessionId });
|
|
1027
|
+
// Reserved-name fail-fast (codex review): an injected MCP tool name/alias (esp. the first-party global resource
|
|
1028
|
+
// tools ListMcpResourcesTool/ReadMcpResourceTool) colliding with a caller tool would SILENTLY shadow (harness Map is
|
|
1029
|
+
// last-write-wins) — reject up front, like submit_output / read_tool_result do above.
|
|
1030
|
+
{
|
|
1031
|
+
const callerNames = new Set((spec.tools ?? []).flatMap((t) => [t.name, ...(t.aliases ?? [])]));
|
|
1032
|
+
const clash = mcp.tools.find((t) => [t.name, ...(t.aliases ?? [])].some((name) => callerNames.has(name)));
|
|
1033
|
+
if (clash) {
|
|
1034
|
+
await mcp.dispose();
|
|
1035
|
+
const e = new Error(`Tool name "${clash.name}" is reserved by an injected MCP tool — a caller tool of the same name would silently shadow it.`);
|
|
1036
|
+
e.code = "config.reserved_tool_name";
|
|
1037
|
+
throw e;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
tools.push(...mcp.tools.map((t) => mcpOffload(t))); // MCP results can be large too (per-tool: _meta-declared → 50K default)
|
|
1041
|
+
// MAJOR-1 fix (design/77 §4 / design/70): a materialized MCP tool carries its safety axes in a sidecar
|
|
1042
|
+
// (the vendored AgentTool has no axis fields). Fold them into the SAME irreversibleTools/egressTools sets
|
|
1043
|
+
// that `spec.tools` fed above — so a `destructiveHint` MCP tool REGISTERS the gate + tightens (else a
|
|
1044
|
+
// no-policy deployment would silently auto-allow it). TIGHTEN-ONLY: a server's hints can add an ask/egress,
|
|
1045
|
+
// never remove one. The effect comes from the axis (`read` for a `readOnlyHint` tool — so it is NOT treated
|
|
1046
|
+
// as write-capable: no spurious LOUD ungated-write warning, no path-confine deny under a skill); otherwise
|
|
1047
|
+
// it defaults to `write` (the gate's repeat-unsafe default) for any other tightened tool.
|
|
1048
|
+
for (const axis of mcp.toolAxes) {
|
|
1049
|
+
if (axis.irreversibility === "always") {
|
|
1050
|
+
irreversibilityTier.set(axis.name, "always");
|
|
1051
|
+
irreversibleTools.add(axis.name);
|
|
1052
|
+
}
|
|
1053
|
+
if (axis.egress) {
|
|
1054
|
+
// SAME invariant as first-party tools above (design/70 council minor#1): an egress tool must be
|
|
1055
|
+
// repeat-UNSAFE — an effect:read/idempotent would let wake/resume reconciliation auto-replay an
|
|
1056
|
+
// interrupted EXTERNAL write. A caller `toolAxes` that lowers a server open-world tool's effect WITHOUT
|
|
1057
|
+
// clearing egress reaches this (the server hint set egress, the override set effect:read). Fail-fast,
|
|
1058
|
+
// exactly like first-party, so the caller resolves it: `egress:false` to vouch a pure read, or
|
|
1059
|
+
// `effect:"write"`. (design F — the MCP fold previously skipped this validation = the bypass.)
|
|
1060
|
+
if (axis.effect !== undefined && axis.effect !== "write") {
|
|
1061
|
+
const e = new Error(`MCP tool "${axis.name}" resolves to egress:true with effect:"${axis.effect}" — an egress tool (external write) must have effect:"write". Clear egress (toolAxes egress:false) if it is a pure read, or set effect:"write".`);
|
|
1062
|
+
e.code = "config.egress_requires_write_effect";
|
|
1063
|
+
throw e;
|
|
1064
|
+
}
|
|
1065
|
+
egressTools.add(axis.name);
|
|
1066
|
+
}
|
|
1067
|
+
if (!toolEffects.has(axis.name))
|
|
1068
|
+
toolEffects.set(axis.name, axis.effect ?? "write");
|
|
1069
|
+
}
|
|
1070
|
+
// design/130 P1+P2: wall-clock-aware per-call cap + finalize estimation state. The ref exists
|
|
1071
|
+
// when EITHER knob is on (codex delta ⑤: P2's est() needs the sample EWMAs even with the cap
|
|
1072
|
+
// disabled — `callCapByDeadline:false` must not silently kill the finalize); the cap PROVIDER
|
|
1073
|
+
// on the harness below is wired only when P1 itself is on. Inert until the run loop arms
|
|
1074
|
+
// `deadlineMs` (non-resource-suspend tasks only, codex ⑤). Static ceiling: the spec cap when
|
|
1075
|
+
// set, else the model's maxTokens — a mid-run degrade tightens it via the ref (codex delta ②).
|
|
1076
|
+
// P2b: hoisted ABOVE the hands toolkit so the shell's soft tool-deadline clamp can close over it.
|
|
1077
|
+
const callCapOn = spec.limits?.callCapByDeadline !== false;
|
|
1078
|
+
const callCapRef = spec.limits?.timeoutSec &&
|
|
1079
|
+
spec.limits.timeoutSec > 0 &&
|
|
1080
|
+
(callCapOn || spec.limits.gracefulFinalize !== false)
|
|
1081
|
+
? createCallCapRef()
|
|
1082
|
+
: undefined;
|
|
1083
|
+
if (callCapRef) {
|
|
1084
|
+
callCapRef.staticCapTokens =
|
|
1085
|
+
spec.limits?.maxOutputTokens !== undefined && spec.limits.maxOutputTokens > 0
|
|
1086
|
+
? spec.limits.maxOutputTokens
|
|
1087
|
+
: model.maxTokens;
|
|
1088
|
+
}
|
|
1089
|
+
// design/130 P2b: the soft tool-deadline clamp handed to the hands toolkit — inert until runtask
|
|
1090
|
+
// arms BOTH deadlineMs and cushionMs on the ref (softExecDeadlineMs returns undefined before).
|
|
1091
|
+
const execClamp = callCapRef
|
|
1092
|
+
? {
|
|
1093
|
+
deadlineMs: () => softExecDeadlineMs(callCapRef),
|
|
1094
|
+
onClamp: () => {
|
|
1095
|
+
callCapRef.toolClamps = (callCapRef.toolClamps ?? 0) + 1; // → stats.mechanisms.toolClamps
|
|
1096
|
+
},
|
|
1097
|
+
}
|
|
1098
|
+
: undefined;
|
|
1099
|
+
// The "hand" (design/44): injecting `executionEnv` is the opt-in — mount the hand tool band over it,
|
|
1100
|
+
// closure-bound to a FRESH per-task read state (§11 ruling A) + the canonicalized containment root
|
|
1101
|
+
// (§4 inv 7). `includeShell` gates the shell tool on a real (non-stub) shell so a no-op bash never
|
|
1102
|
+
// reaches the model (S1); `handsReadOnly` mounts the effect:read-only band — the verifier boundary
|
|
1103
|
+
// (§6). Large read/grep results offload like any other tool. Default stub (no inject) → no hand tools.
|
|
1104
|
+
// design/119: hoisted so the # Environment block announces the EFFECTIVE canonical allow-roots,
|
|
1105
|
+
// not the raw spec values (a skipped/dead entry must not be advertised to the model).
|
|
1106
|
+
// design/138 S2-C — the memory write gate seam. The hands toolkit is built BEFORE the memory
|
|
1107
|
+
// engine session (which owns the writable-root coordinate), so the toolkit gets a late-bound ref:
|
|
1108
|
+
// until the session block below installs the gate, every write passes untouched (byte-compat);
|
|
1109
|
+
// afterwards Write/Edit/NotebookEdit run their FINAL text through MemoryEngine.gateWrite (memory
|
|
1110
|
+
// domain only — one prefix comparison for everything else).
|
|
1111
|
+
const memoryWriteGateRef = {};
|
|
1112
|
+
const additionalRootsCanonical = [];
|
|
1113
|
+
if (handsEnabled) {
|
|
1114
|
+
// design/97 CORE-6: an isolated (worktree) agent's env IS the worktree (the factory rooted its cwd there),
|
|
1115
|
+
// so use the env's own cwd — `deps.rootPath` (a Runner-level default repo root) must NOT override it, else
|
|
1116
|
+
// the agent's hands would confine to the shared base repo, defeating the isolation.
|
|
1117
|
+
const rootRaw = taskRootPath;
|
|
1118
|
+
const canon = await executionEnv.canonicalPath(rootRaw);
|
|
1119
|
+
const rootCanonical = canon.ok ? canon.value : rootRaw;
|
|
1120
|
+
// design/119 (CC --add-dir parity): canonicalize the extra allowed roots once (containment
|
|
1121
|
+
// compares canonical keys, so a raw/symlinked entry here would silently never match).
|
|
1122
|
+
for (const dir of spec.additionalDirectories ?? []) {
|
|
1123
|
+
if (!dir || !dir.trim())
|
|
1124
|
+
continue; // an empty entry must never reach the allowlist (within("") = allow-all)
|
|
1125
|
+
const c = await executionEnv.canonicalPath(dir);
|
|
1126
|
+
// Fail-closed (codex review MAJOR): a directory that fails to canonicalize (missing, or a
|
|
1127
|
+
// dangling/rejected symlink) is SKIPPED, never admitted raw — a raw prefix in the allowlist
|
|
1128
|
+
// plus the ancestor-canonicalize fallback would let a symlinked entry smuggle writes outside
|
|
1129
|
+
// every root. Skipping only narrows access (the primary root still applies).
|
|
1130
|
+
if (c.ok) {
|
|
1131
|
+
additionalRootsCanonical.push(c.value);
|
|
1132
|
+
}
|
|
1133
|
+
else {
|
|
1134
|
+
deps.onError?.(new Error(`additionalDirectories entry skipped (cannot canonicalize): ${dir}`), {
|
|
1135
|
+
phase: "config",
|
|
1136
|
+
sessionId,
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
// per-task; design/45 serializes this into a Checkpoint and re-seeds it on resume (§15.2 #8) — else
|
|
1141
|
+
// a resumed `edit_file` is rejected "file not read in this task".
|
|
1142
|
+
const readFileState = new Map(resume?.seed.readFileState ?? []);
|
|
1143
|
+
readFileStateForCheckpoint = readFileState; // expose to the suspend closure (§15.2 net-add #8)
|
|
1144
|
+
// parity-204 (CC 2.1.204 seededFromContext, cc204-bundle @17917159): the deployment declares
|
|
1145
|
+
// startup-seeded context files via ProjectMemoryLoad.seededFiles (loadProjectMemory block below);
|
|
1146
|
+
// seed the hands' read state through the SAME resolveKey containment as a real Read, so the
|
|
1147
|
+
// canonical keys collide with real Read/Edit entries and an out-of-root declaration is silently
|
|
1148
|
+
// skipped (best-effort, never blocks the task — matching the loadProjectMemory posture).
|
|
1149
|
+
seedContextFiles = async (files) => {
|
|
1150
|
+
for (const f of files) {
|
|
1151
|
+
const rk = await resolveKey(executionEnv, rootCanonical, f.path, undefined, undefined, additionalRootsCanonical);
|
|
1152
|
+
if (rk.ok)
|
|
1153
|
+
seedReadFileStateFromContext(readFileState, rk.key, f.content);
|
|
1154
|
+
}
|
|
1155
|
+
};
|
|
1156
|
+
const handsIncludeShell = !(executionEnv instanceof StubExecutionEnv);
|
|
1157
|
+
const handsReadOnly = spec.handsReadOnly === true;
|
|
1158
|
+
backgroundTaskToolsActive = handsIncludeShell && !handsReadOnly && hasBackgroundShell(executionEnv);
|
|
1159
|
+
if (handsIncludeShell && !handsReadOnly) {
|
|
1160
|
+
handsCwdRef = { current: rootCanonical }; // §E13: observable `cd`
|
|
1161
|
+
// codex 终审 1.255 F2: a durable resume restores the pre-suspend LOGICAL cwd (a `cd` or an
|
|
1162
|
+
// EnterWorktree switch) — without this the cwd silently reset to the task root while the model's
|
|
1163
|
+
// history says otherwise. The directory is plain on-disk state that survived the suspend; if it
|
|
1164
|
+
// was removed externally, tools fail with their ordinary honest ENOENT texts (no special case).
|
|
1165
|
+
if (resume?.seed.handsCwd !== undefined)
|
|
1166
|
+
handsCwdRef.current = resume.seed.handsCwd;
|
|
1167
|
+
}
|
|
1168
|
+
const band = createHandsToolkit(executionEnv, readFileState, rootCanonical, {
|
|
1169
|
+
...(additionalRootsCanonical.length > 0 ? { additionalRoots: additionalRootsCanonical } : {}),
|
|
1170
|
+
includeShell: handsIncludeShell,
|
|
1171
|
+
readOnly: handsReadOnly,
|
|
1172
|
+
...(handsCwdRef ? { cwdRef: handsCwdRef } : {}),
|
|
1173
|
+
taskRegistry: defaultTaskRegistry,
|
|
1174
|
+
taskOwner: hostTaskId,
|
|
1175
|
+
taskScope,
|
|
1176
|
+
// 黑板 [636]① (design/129, same unconditional posture as the Monitor mount): inside a session a
|
|
1177
|
+
// background command registers session-resident and survives the run teardown — the per-task
|
|
1178
|
+
// ISOLATED env case is handled at the teardown (envDying settle + empty keep-alive list), not here.
|
|
1179
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
1180
|
+
// design/116 §7 G2b: a finished background command notifies the SOURCE task ("next" boundary interrupt).
|
|
1181
|
+
...(internals?.onTaskNotification !== undefined ? { taskNotification: internals.onTaskNotification } : {}),
|
|
1182
|
+
// design/116 detach: mid-flight ctrl+b — Bash threads the per-call detach signal into env.exec.
|
|
1183
|
+
...(internals?.detachHub !== undefined ? { detachHub: internals.detachHub } : {}),
|
|
1184
|
+
// design/130 P2b: foreground shell timeouts clamp to the task's soft tool deadline.
|
|
1185
|
+
...(execClamp !== undefined ? { execClamp } : {}),
|
|
1186
|
+
// PDF degradation chain v2: Read forks on the SERVING model's capability profile — a model without
|
|
1187
|
+
// native document input degrades to pdftotext text extraction / rendered pages IN the tool (the
|
|
1188
|
+
// layer holding env.exec), instead of a brain-level placeholder.
|
|
1189
|
+
pdfModelCapabilities: pdfModelCapabilitiesOf(model),
|
|
1190
|
+
// design/141 件2 — RunnerDeps.hands: the deployment's SAFE hands subset, synthesized here (the
|
|
1191
|
+
// factory itself stays unexported — self-assembly into spec.tools would be name-shadowed by this
|
|
1192
|
+
// band). Runner-internal fields (registry/detachHub/execClamp/cwdRef/mount) are NOT reachable.
|
|
1193
|
+
...(deps.hands?.bashReadonlyAllow !== undefined ? { bashReadonlyAllow: deps.hands.bashReadonlyAllow } : {}),
|
|
1194
|
+
...(deps.hands?.commitCoAuthor !== undefined ? { commitCoAuthor: deps.hands.commitCoAuthor } : {}),
|
|
1195
|
+
...(deps.hands?.readImageDownsampler !== undefined ? { readImageDownsampler: deps.hands.readImageDownsampler } : {}),
|
|
1196
|
+
// design/138 S2-C: late-bound memory write gate — COMPOSED with the deployment's hands.beforeWrite
|
|
1197
|
+
// (design/141 件2): the deployment hook judges first (a rejection short-circuits; a throw is the
|
|
1198
|
+
// band's fail-closed), then the engine's memory-scan gate ALWAYS runs. Deployment config can only
|
|
1199
|
+
// tighten the write path, never bypass the engine gate.
|
|
1200
|
+
beforeWrite: async (w) => {
|
|
1201
|
+
const deploymentGate = deps.hands?.beforeWrite;
|
|
1202
|
+
if (deploymentGate) {
|
|
1203
|
+
const verdict = await deploymentGate(w);
|
|
1204
|
+
if (verdict !== undefined && verdict.ok === false)
|
|
1205
|
+
return verdict;
|
|
1206
|
+
}
|
|
1207
|
+
return memoryWriteGateRef.current?.(w);
|
|
1208
|
+
},
|
|
1209
|
+
mountBackgroundTaskTools: false,
|
|
1210
|
+
});
|
|
1211
|
+
// design/141 Q4 — hands 同名 shadow 保护: this band pushes AFTER spec.tools and the harness tool
|
|
1212
|
+
// Map is last-write-wins, so a caller tool sharing a hands name (Read/Write/Bash/…) is SILENTLY
|
|
1213
|
+
// dropped — the deployment loses its tool with no trace (and the duplicate name rides into the
|
|
1214
|
+
// default activeToolNames, duplicating the band tool's definition in the provider payload). The
|
|
1215
|
+
// shadow direction is the SAFE one (the gated built-in stays in force: beforeWrite/shellGate keep
|
|
1216
|
+
// judging), so this is ONE operator-visible warning per prepare (onError phase "config", the
|
|
1217
|
+
// design/138 S4 posture) — NOT the reserved-name fail-fast: 下游可能已依赖现状,先 warn 观察
|
|
1218
|
+
// (design/141 §5). Name+alias matching mirrors the MCP clash check above (~:1558). CC 无此锚:
|
|
1219
|
+
// 其三方工具结构性带 mcp__ 前缀,与内置名不可能相撞——自研口径。
|
|
1220
|
+
{
|
|
1221
|
+
const bandNames = new Set(band.flatMap((t) => [t.name, ...(t.aliases ?? [])]));
|
|
1222
|
+
const shadowed = (spec.tools ?? []).filter((t) => [t.name, ...(t.aliases ?? [])].some((n) => bandNames.has(n)));
|
|
1223
|
+
if (shadowed.length > 0) {
|
|
1224
|
+
const names = shadowed.map((t) => `"${t.name}"`).join(", ");
|
|
1225
|
+
deps.onError?.(new Error(`spec.tools ${shadowed.length > 1 ? "entries" : "entry"} ${names} collide${shadowed.length > 1 ? "" : "s"} with the built-in hands tool band — the built-in wins (registered later; the harness tool Map is last-write-wins) and the caller tool never dispatches this task. Rename the caller tool, or don't inject executionEnv if you intend to replace the band.`), { phase: "config", sessionId });
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
tools.push(...band.map((t) => firstPartyOffload(t)));
|
|
1229
|
+
// (Q6 read-only-Bash effect override is hoisted to the toolEffects seeding above, so the wake-time reconcile
|
|
1230
|
+
// sees it too — design/102 §3.6.)
|
|
1231
|
+
// design/80 D-2 (part-1): gate the hands `bash` per the deployment's shellGate doctrine — a full shell is
|
|
1232
|
+
// treated as egress+irreversible (a command can `curl`/`git push`/`rm`, which the per-tool axis marks
|
|
1233
|
+
// otherwise miss). Fold into the SAME irreversibleTools/irreversibilityTier/reversibilityProbes the
|
|
1234
|
+
// gate reads below (the irreversibility tighten covers egress too via the classifier). "always" → every
|
|
1235
|
+
// command tightens to an irreversible_ask suspend (fail-closed); "classify" → a provably-benign single
|
|
1236
|
+
// allowlisted read-only command auto-allows, everything else (writes, egress, pipes, unknown) tightens.
|
|
1237
|
+
// No effect under `handsReadOnly` (mounts the already-allowlisted bash_readonly) or with no real shell.
|
|
1238
|
+
const shellGate = spec.shellGate ?? "off";
|
|
1239
|
+
if (shellGate !== "off" && !(executionEnv instanceof StubExecutionEnv) && spec.handsReadOnly !== true) {
|
|
1240
|
+
// design/80 D-E: `bash`'s irreversibility comes from the COARSE shellGate tighten UNLESS the deployment
|
|
1241
|
+
// also wired an explicit per-tool mark on a tool literally named `bash` (egress, or an irreversibility
|
|
1242
|
+
// tier set in the spec loop above). The coarse case grades as the `shell` axis (severity 3) at the mint,
|
|
1243
|
+
// never the `irreversible` axis (4) of an explicitly-marked tool — capture that BEFORE shellGate sets the
|
|
1244
|
+
// tier (else its own `set` would look like an explicit mark).
|
|
1245
|
+
shellGatedBash = !egressTools.has("Bash") && !irreversibilityTier.has("Bash");
|
|
1246
|
+
irreversibilityTier.set("Bash", shellGate === "always" ? "always" : "maybe");
|
|
1247
|
+
irreversibleTools.add("Bash");
|
|
1248
|
+
if (shellGate === "classify")
|
|
1249
|
+
reversibilityProbes.set("Bash", bashReversibilityProbe());
|
|
1250
|
+
// design/135 G2: Monitor runs an arbitrary shell `command` through the SAME env seam — it must sit
|
|
1251
|
+
// behind the SAME shellGate tighten (else it becomes a policy side-door around a gated Bash). The
|
|
1252
|
+
// probe reuses bashReversibilityProbe verbatim (same `command` arg shape). Marked only when the
|
|
1253
|
+
// Monitor tool will actually mount (backgroundTaskToolsActive), mirroring the Bash-present premise.
|
|
1254
|
+
if (backgroundTaskToolsActive) {
|
|
1255
|
+
shellGatedMonitor = !egressTools.has("Monitor") && !irreversibilityTier.has("Monitor");
|
|
1256
|
+
irreversibilityTier.set("Monitor", shellGate === "always" ? "always" : "maybe");
|
|
1257
|
+
irreversibleTools.add("Monitor");
|
|
1258
|
+
if (shellGate === "classify")
|
|
1259
|
+
reversibilityProbes.set("Monitor", bashReversibilityProbe());
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
if (backgroundTaskToolsActive || workflowToolsActive) {
|
|
1264
|
+
toolEffects.set("TaskOutput", "read");
|
|
1265
|
+
toolEffects.set("TaskStop", "write");
|
|
1266
|
+
tools.push(firstPartyOffload(createTaskOutputTool({ registry: defaultTaskRegistry, owner: hostTaskId, scope: taskScope, sessionId, workflowStore: deps.workflowRunStore })), // design/129: session dimension for cross-turn poll of session-scoped bg
|
|
1267
|
+
firstPartyOffload(createTaskStopTool({ registry: defaultTaskRegistry, owner: hostTaskId, scope: taskScope, sessionId, workflowStore: deps.workflowRunStore })));
|
|
1268
|
+
// 深挖 G3 — SendMessage: continue a completed background agent with its context intact (the design/122
|
|
1269
|
+
// resume face made MODEL-VISIBLE). Mounted with the same background surface as TaskOutput/TaskStop
|
|
1270
|
+
// (the a* ids it addresses come from that lane) + a runner self (the resume executes child runs).
|
|
1271
|
+
// Mounted even with retain OFF (clay 拍 ①+②): the call then returns the honest not-retained text.
|
|
1272
|
+
// A caller-supplied tool of the same name wins (skip, not throw — this mount is unconditional within
|
|
1273
|
+
// the gate, unlike the opt-in Fork whose collision is a config error).
|
|
1274
|
+
if (runnerSelf && !(spec.tools ?? []).some((t) => t.name === SEND_MESSAGE_TOOL_NAME)) {
|
|
1275
|
+
tools.push(firstPartyOffload(createSendMessageTool({
|
|
1276
|
+
runner: runnerSelf,
|
|
1277
|
+
registry: defaultTaskRegistry,
|
|
1278
|
+
...(subagentRetain ? { retain: subagentRetain } : {}),
|
|
1279
|
+
owner: hostTaskId,
|
|
1280
|
+
scope: taskScope,
|
|
1281
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
1282
|
+
...(internals?.onTaskNotification !== undefined ? { notify: internals.onTaskNotification } : {}),
|
|
1283
|
+
...(internals?.onSubagentSpawn !== undefined ? { sink: internals.onSubagentSpawn } : {}),
|
|
1284
|
+
})));
|
|
1285
|
+
// 黑板 [558] C — AgentTranscript: read a retained background child's recent tool steps ON DEMAND
|
|
1286
|
+
// (pull face, does not inflate context). Same background surface + self-runner (reads sessions),
|
|
1287
|
+
// same skip-on-collision posture. Complements the stop-time residual the notification carries.
|
|
1288
|
+
if (!(spec.tools ?? []).some((t) => t.name === AGENT_TRANSCRIPT_TOOL_NAME)) {
|
|
1289
|
+
tools.push(firstPartyOffload(createAgentTranscriptTool({
|
|
1290
|
+
runner: runnerSelf,
|
|
1291
|
+
registry: defaultTaskRegistry,
|
|
1292
|
+
owner: hostTaskId,
|
|
1293
|
+
scope: taskScope,
|
|
1294
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
1295
|
+
})));
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
// design/135 G2 (审计 [485]①): the Monitor tool — watch a background command's stdout as batched
|
|
1300
|
+
// notification events (the自主循环 companion to Bash run_in_background). Same preconditions as the
|
|
1301
|
+
// background half above (real shell + write hands + background-capable env); same security posture as
|
|
1302
|
+
// Bash (arbitrary shell ⇒ effect:"write", folded into the shellGate tighten above). Events ride the
|
|
1303
|
+
// run-local task-notification lane at "later" priority; the m* handle gets TaskOutput/TaskStop for free.
|
|
1304
|
+
if (backgroundTaskToolsActive) {
|
|
1305
|
+
toolEffects.set("Monitor", "write");
|
|
1306
|
+
tools.push(firstPartyOffload(createMonitorTool(executionEnv, {
|
|
1307
|
+
registry: defaultTaskRegistry,
|
|
1308
|
+
owner: hostTaskId,
|
|
1309
|
+
scope: taskScope,
|
|
1310
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
1311
|
+
...(internals?.onTaskNotification !== undefined ? { onTaskNotification: internals.onTaskNotification } : {}),
|
|
1312
|
+
...(handsCwdRef !== undefined ? { cwdRef: handsCwdRef } : {}),
|
|
1313
|
+
})));
|
|
1314
|
+
}
|
|
1315
|
+
// 审计[485]③ G4: EnterWorktree/ExitWorktree — session-level git-worktree isolation (CC parity). Mounted
|
|
1316
|
+
// only with real WRITE hands over a tracked cwd (`handsCwdRef` exists ⇔ real shell + !handsReadOnly —
|
|
1317
|
+
// the same premise family as the Monitor/background mounts): the tools' whole point is switching the
|
|
1318
|
+
// shared mutable cwd, which read-only/stub hands don't have. Git-ness is probed honestly at Enter time
|
|
1319
|
+
// (subagent-worktree-helper posture — no per-task prepare-time git exec); effect:"write" ⇒ plan mode
|
|
1320
|
+
// gates Enter/Exit like every other write tool.
|
|
1321
|
+
if (handsCwdRef !== undefined) {
|
|
1322
|
+
toolEffects.set("EnterWorktree", "write");
|
|
1323
|
+
toolEffects.set("ExitWorktree", "write");
|
|
1324
|
+
// codex 终审 1.255 F2: seed the session ref from the checkpoint (Enter→suspend→resume→Exit works:
|
|
1325
|
+
// the worktree dir survived on disk; Exit re-derives removal without any Enter-leg closure).
|
|
1326
|
+
worktreeSessionRef = { ...(resume?.seed.activeWorktree ? { current: { ...resume.seed.activeWorktree } } : {}) };
|
|
1327
|
+
tools.push(...createWorktreeTools(executionEnv, { repoRoot: taskRootPath, cwdRef: handsCwdRef, session: worktreeSessionRef }).map((t) => firstPartyOffload(t)));
|
|
1328
|
+
}
|
|
1329
|
+
// Inject the reader once, so the model can fetch any offloaded result (design/30). Not itself offloaded.
|
|
1330
|
+
if (offloadStore)
|
|
1331
|
+
tools.push(makeReadToolResultTool(offloadStore));
|
|
1332
|
+
// AskUserQuestion (design/64 §5): mount only when a content-ask seam is wired (deployment opt-in).
|
|
1333
|
+
// The model issues the question; the tool routes it to onQuestion (a real human/UI) and returns the
|
|
1334
|
+
// choice. Headless deployments simply don't wire onQuestion, so the tool isn't offered.
|
|
1335
|
+
const onQuestion = spec.onQuestion ?? deps.onQuestion;
|
|
1336
|
+
if (onQuestion)
|
|
1337
|
+
// design S1d §1.2: pass Runner-held source identity so an aggregating onQuestion can attribute per worker.
|
|
1338
|
+
tools.push(createAskUserQuestionTool(onQuestion, { principal: spec.principal, sourceTaskId: sessionId }));
|
|
1339
|
+
// design/105: scheduler tools (CronCreate/CronDelete/CronList — design/136 §2.2: Sleep retired) — mounted only when the env carries an
|
|
1340
|
+
// injected scheduler daemon backend (`hasScheduler`, checked inside the factory → returns [] otherwise = INERT).
|
|
1341
|
+
// Runner-held identity (principal/scope/sessionId) is closure-captured, NOT a model arg (防自排提权, mirrors
|
|
1342
|
+
// AskUserQuestion/present_plan). Write tools → not in the read-only verifier band (`handsReadOnly`).
|
|
1343
|
+
if (spec.handsReadOnly !== true) {
|
|
1344
|
+
const schedScope = sessionId ?? spec.principal ?? spec.taskId ?? "default";
|
|
1345
|
+
tools.push(...createSchedulerTools(executionEnv, {
|
|
1346
|
+
scope: schedScope,
|
|
1347
|
+
...(spec.principal !== undefined ? { principal: spec.principal } : {}),
|
|
1348
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
1349
|
+
requestStopAfterTurn,
|
|
1350
|
+
}));
|
|
1351
|
+
}
|
|
1352
|
+
// design/136 §2.1: the standalone FORK tool was retired — fork lives ONLY in `Agent(subagent_type:
|
|
1353
|
+
// "fork")` (1.254 收编 + the [417]① background lane migrated in this batch). Governance travels as
|
|
1354
|
+
// `ctx.forkAccess` (computed above from the shared `forkGovernanceDenial` predicate, opt-OUT default:
|
|
1355
|
+
// only an explicit `enableFork:false` / `runtimeCaps.allowFork:false` denies, honestly, at call time).
|
|
1356
|
+
// Observability (mirrors allowWorkflows): a fork-CAPABLE run (session + fork-capable durable store,
|
|
1357
|
+
// not itself a forked child) denied purely by the per-principal `runtimeCaps.allowFork=false`
|
|
1358
|
+
// entitlement emits a governance (NOT misconfiguration) operator note, so "capable + no fork" is
|
|
1359
|
+
// never silently confusing. A `denied:"task"` (explicit enableFork:false) is the deployment's own
|
|
1360
|
+
// choice — no note.
|
|
1361
|
+
if (internals?.insideFork !== true &&
|
|
1362
|
+
runnerSelf &&
|
|
1363
|
+
sessionId !== undefined &&
|
|
1364
|
+
hasSessionFork(runnerSelf.sessions) &&
|
|
1365
|
+
agentForkDenial === "principal" // ⇔ runtimeCaps.allowFork === false (shared predicate, design/136 §6 盲点①)
|
|
1366
|
+
) {
|
|
1367
|
+
deps.onError?.(new Error(`Fork DENIED for principal "${spec.principal ?? ""}" by runtimeCaps.allowFork=false ` +
|
|
1368
|
+
`(per-principal entitlement governance, not a misconfiguration — Agent(subagent_type:"fork") will refuse honestly)`), { phase: "config", sessionId });
|
|
1369
|
+
}
|
|
1370
|
+
// LSP code intelligence (design/64 §13.1): mounted only when a manager is wired (deployment opt-in).
|
|
1371
|
+
// Results are filtered through git check-ignore (CC parity); no server for a language → graceful degrade.
|
|
1372
|
+
const lspManager = spec.lspManager ?? deps.lspManager;
|
|
1373
|
+
if (lspManager) {
|
|
1374
|
+
const lspRoot = taskRootPath;
|
|
1375
|
+
// Pass the per-task env (the agent's sandbox) so the manager can be stateless — no sessionId→env
|
|
1376
|
+
// registry needed (design/64 §13.1 wiring, answering service[59]). Env injection stays off TaskSpec
|
|
1377
|
+
// (design/44 §7 Q4): it flows from the trusted Runner env here, never from untrusted caller input.
|
|
1378
|
+
tools.push(createLspTool(lspManager, { isPathIgnored: gitCheckIgnoreFilter(executionEnv, lspRoot), env: executionEnv }));
|
|
1379
|
+
}
|
|
1380
|
+
// design/121 (CC 198 <new-diagnostics> parity): the diagnostics lane. Preconditions = a manager
|
|
1381
|
+
// whose sessions FEED a registry + write-capable hands on the roster (CC's same gate) + not opted
|
|
1382
|
+
// out. When live: ① every successful edit/write nudges the file's LSP session (didChange push, so
|
|
1383
|
+
// the server re-analyzes and pushes publishDiagnostics — the session's own sync is lazy and only
|
|
1384
|
+
// runs on the next LSP op) and clears the file's delivered-set (a persisting problem resurfaces
|
|
1385
|
+
// against the new text); ② runtask drains the registry at each turn boundary (injection + frame).
|
|
1386
|
+
const lspDiagnostics = spec.lspDiagnostics !== false && lspManager?.diagnostics !== undefined && handsEnabled && spec.handsReadOnly !== true
|
|
1387
|
+
? lspManager.diagnostics
|
|
1388
|
+
: undefined;
|
|
1389
|
+
const nudgeLspOnEdit = lspDiagnostics
|
|
1390
|
+
? (rawPath) => {
|
|
1391
|
+
// The tool details carry the RAW arg path — resolve like the fs band does (relative against
|
|
1392
|
+
// the task's live cwd, else the root) so the LSP session/URI sees the same canonical file.
|
|
1393
|
+
// [K-PLATFORM-SWEEP] MAJOR#3: FORM-aware absolute check + family-consistent join — the raw arg
|
|
1394
|
+
// path comes from the ExecutionEnv lane and may be the OTHER family than the host (host
|
|
1395
|
+
// node:path isAbsolute/resolve mangles a cross-family path). Same philosophy as fs/safety.ts.
|
|
1396
|
+
const baseDir = handsCwdRef?.current ?? taskRootPath;
|
|
1397
|
+
const isAbsForm = rawPath.startsWith("/") || /^[A-Za-z]:[\\/]/.test(rawPath);
|
|
1398
|
+
const joinSep = baseDir.includes("\\") ? "\\" : "/";
|
|
1399
|
+
// codex 1.237 F2: normalize dot-segments FAMILY-AGNOSTICALLY after the join — a raw `./x` or
|
|
1400
|
+
// `a/../x` arg otherwise mints a URI (`/root/./x`) that diverges from the session's canonical
|
|
1401
|
+
// key (delivered diagnostics never clear; didChange hits a duplicate doc). Pure string walk.
|
|
1402
|
+
const normalizeSegments = (pth) => {
|
|
1403
|
+
const isUnc = pth.startsWith("\\\\");
|
|
1404
|
+
const drive = /^[A-Za-z]:/.exec(pth)?.[0] ?? "";
|
|
1405
|
+
const rest = pth.slice(drive.length + (isUnc ? 2 : 0));
|
|
1406
|
+
const sep = pth.includes("\\") ? "\\" : "/";
|
|
1407
|
+
const abs = rest.startsWith("/") || rest.startsWith("\\");
|
|
1408
|
+
const outSegs = [];
|
|
1409
|
+
for (const seg of rest.split(/[\\/]+/)) {
|
|
1410
|
+
if (!seg || seg === ".")
|
|
1411
|
+
continue;
|
|
1412
|
+
if (seg === "..") {
|
|
1413
|
+
if (outSegs.length > 0 && outSegs[outSegs.length - 1] !== "..")
|
|
1414
|
+
outSegs.pop();
|
|
1415
|
+
else if (!abs)
|
|
1416
|
+
outSegs.push("..");
|
|
1417
|
+
continue;
|
|
1418
|
+
}
|
|
1419
|
+
outSegs.push(seg);
|
|
1420
|
+
}
|
|
1421
|
+
return (isUnc ? sep + sep : "") + drive + (abs ? sep : "") + outSegs.join(sep);
|
|
1422
|
+
};
|
|
1423
|
+
const filePath = normalizeSegments(isAbsForm ? rawPath : baseDir.replace(/[\\/]+$/, "") + joinSep + rawPath);
|
|
1424
|
+
lspDiagnostics.fileEdited(pathToUri(filePath));
|
|
1425
|
+
// Fire-and-forget: sessionFor() resolves the file's language server (spawning at most the
|
|
1426
|
+
// same set the LSP tool would); a language with no server degrades silently.
|
|
1427
|
+
void lspManager
|
|
1428
|
+
.sessionFor(filePath, undefined, executionEnv)
|
|
1429
|
+
.then((session) => session?.notifyFileChanged?.(filePath))
|
|
1430
|
+
.catch(() => undefined);
|
|
1431
|
+
}
|
|
1432
|
+
: undefined;
|
|
1433
|
+
// Long-term memory (L2): inject the scope(s)' memory block + a `remember` tool (opt-in, scoped).
|
|
1434
|
+
// design/84 Seam A: normalize the (backward-compatible) memory union ONCE to a canonical
|
|
1435
|
+
// {scopes, writeScope, enabled}; every consumer below reads ONLY this, never the raw union (zero narrowing).
|
|
1436
|
+
const memorySpec = normalizeMemorySpec(spec.memory);
|
|
1437
|
+
// design/138 S1 — the NEW memory-engine path. `deps.memoryBackend` is THE switch: when configured
|
|
1438
|
+
// (and the task enables memory), the injection-first file-based lifecycle replaces the legacy
|
|
1439
|
+
// memoryStore path WHOLESALE for this task — no remember/recall tools, no legacy <user_memory>
|
|
1440
|
+
// block, no legacy consolidation/dynamic-recall (S2 rewires consolidation onto the entry plane).
|
|
1441
|
+
// Unset ⇒ the legacy path below is byte-identical (this leg adds; a later slice switches defaults).
|
|
1442
|
+
const useMemoryEngine = Boolean(memorySpec && memorySpec.enabled && deps.memoryBackend);
|
|
1443
|
+
let memoryEngineSession;
|
|
1444
|
+
if (useMemoryEngine && memorySpec) {
|
|
1445
|
+
try {
|
|
1446
|
+
const backend = deps.memoryBackend;
|
|
1447
|
+
// Directory resolution (§2.7): a FileBackend pins its own directory (zero-copy — the dir IS
|
|
1448
|
+
// the storage); any other backend gets the derived per-repo projection dir under the config root.
|
|
1449
|
+
const pinned = backend.directoryRoot;
|
|
1450
|
+
const engineRoot = resolveMemoryEngineRoot(deps.memoryEngineDir);
|
|
1451
|
+
const repoRoot = deps.rootPath ?? taskRootPath;
|
|
1452
|
+
// design/142 §1.5 — marker-first identity keying (v2 opt-in only): a resolved project marker
|
|
1453
|
+
// switches the derived mount key from the PATH to the projectId, so rename/move/re-clone/
|
|
1454
|
+
// container remounts stop breaking the memory link. resolveProjectId throws config.-coded on
|
|
1455
|
+
// a nested marker (rethrown below = fail-loud). A missing marker with a recorded hint is the
|
|
1456
|
+
// §1.5 recovery breadcrumb: warn (restorable), fall through to the path key — never mint.
|
|
1457
|
+
let identityKey;
|
|
1458
|
+
if (memorySpec.scopeContract === "v2" && repoRoot) {
|
|
1459
|
+
// 独立轨 F6: a nested-marker refusal is a PROJECT-IDENTITY violation — it must only fail
|
|
1460
|
+
// sessions that actually consume project identity (proj:/userproj: keys). A pure
|
|
1461
|
+
// personal/legacy session in the same checkout degrades to a warning + path key.
|
|
1462
|
+
const consumesProjectIdentity = [...memorySpec.scopes, ...(memorySpec.writeScope !== null ? [memorySpec.writeScope] : [])]
|
|
1463
|
+
.map(parseScopeKey)
|
|
1464
|
+
.some((k) => k.kind === "proj" || k.kind === "userproj");
|
|
1465
|
+
let marker = null;
|
|
1466
|
+
try {
|
|
1467
|
+
marker = resolveProjectId(repoRoot);
|
|
1468
|
+
}
|
|
1469
|
+
catch (err) {
|
|
1470
|
+
if (consumesProjectIdentity)
|
|
1471
|
+
throw err;
|
|
1472
|
+
deps.onError?.(err instanceof Error ? err : new Error(String(err)), { phase: "config", sessionId });
|
|
1473
|
+
}
|
|
1474
|
+
if (marker) {
|
|
1475
|
+
identityKey = marker.projectId;
|
|
1476
|
+
const firstSwitch = lookupProjectIdHint(engineRoot, repoRoot) !== marker.projectId;
|
|
1477
|
+
recordProjectIdHint(engineRoot, repoRoot, marker.projectId);
|
|
1478
|
+
// 独立轨 F4 — key-switch control-plane carry-over: the OLD path-keyed control dir may hold
|
|
1479
|
+
// announcements queued for the next session's disclosure; switching the mount key must not
|
|
1480
|
+
// silently orphan them (backend entries are key-independent; the control plane is not).
|
|
1481
|
+
// One-shot, best-effort: drain old → enqueue new on the FIRST session that sees this marker.
|
|
1482
|
+
if (firstSwitch) {
|
|
1483
|
+
try {
|
|
1484
|
+
const oldCtl = deriveControlPlaneDir(engineRoot, repoRoot);
|
|
1485
|
+
const newCtl = deriveProjectControlDir(engineRoot, marker.projectId);
|
|
1486
|
+
const drained = drainMemoryAnnouncements(oldCtl);
|
|
1487
|
+
for (const ann of drained.queue)
|
|
1488
|
+
enqueueMemoryAnnouncement(newCtl, ann);
|
|
1489
|
+
}
|
|
1490
|
+
catch {
|
|
1491
|
+
/* carry-over is best-effort — announcements are disclosure, never CAS-load-bearing */
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
// codex 复审 B2 (142 §1.4 一致性核): the WRITE target's projectId must match the marker —
|
|
1495
|
+
// writing proj:t/B while standing in marker-A's checkout is a mis-addressed write, fail-loud.
|
|
1496
|
+
// READ layers may reference other projects (cross-project reference is legal); only the
|
|
1497
|
+
// write face is identity-checked.
|
|
1498
|
+
if (memorySpec.writeScope !== null) {
|
|
1499
|
+
const w = parseScopeKey(memorySpec.writeScope);
|
|
1500
|
+
if ((w.kind === "proj" || w.kind === "userproj") && w.projectId !== marker.projectId) {
|
|
1501
|
+
const e = new Error(`memory scope contract v2: writeScope ${memorySpec.writeScope} targets projectId ${w.projectId}, ` +
|
|
1502
|
+
`but this checkout's marker (.sema/project) declares ${marker.projectId} — refusing a mis-addressed write.`);
|
|
1503
|
+
e.code = "config.memory_project_identity";
|
|
1504
|
+
throw e;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
else {
|
|
1509
|
+
const hint = lookupProjectIdHint(engineRoot, repoRoot);
|
|
1510
|
+
if (hint !== undefined) {
|
|
1511
|
+
deps.onError?.(new Error(`memory project marker missing but recoverable: ${repoRoot} previously carried projectId ${hint} ` +
|
|
1512
|
+
`(.sema/project deleted?). Restore the marker to keep the identity-keyed memory mount; this session ` +
|
|
1513
|
+
`falls back to the path-derived key.`), { phase: "config", sessionId });
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
// repoKey derives from the BASE repo root (deps.rootPath) when configured — a worktree-isolated
|
|
1518
|
+
// task must share the same repo-level memory dir as the base checkout (§2.7 worktree-shared).
|
|
1519
|
+
const memoryDir = typeof pinned === "string" && pinned
|
|
1520
|
+
? pinned
|
|
1521
|
+
: identityKey !== undefined
|
|
1522
|
+
? deriveProjectMemoryDir(engineRoot, identityKey)
|
|
1523
|
+
: deriveRepoMemoryDir(engineRoot, repoRoot);
|
|
1524
|
+
// design/142 S2b — dual physical roots (S1b 清账): under v2, personal scopes (`user:`/
|
|
1525
|
+
// `userproj:`) route to the PERSONAL plane (a cross-project home mount under the config
|
|
1526
|
+
// root) while proj:/org:/legacy stay on the project plane. Assembly is OUTSIDE the engine
|
|
1527
|
+
// (two instances, each keeping its single-root invariants); exactly one owns the write face.
|
|
1528
|
+
const planes = memorySpec.scopeContract === "v2" ? classifyScopePlanes(memorySpec.scopes, memorySpec.writeScope) : undefined;
|
|
1529
|
+
const dual = planes !== undefined && needsDualRoots(planes);
|
|
1530
|
+
const personalOnly = planes !== undefined && !dual && planes.project.length === 0 && planes.writePlane !== "project" && (planes.personal.length > 0 || planes.writePlane === "personal");
|
|
1531
|
+
// design/142 §7 S1 硬门 (复审 F9): the PROJECT plane must never carry a personal key into an
|
|
1532
|
+
// in-repo memory dir. With S2b routing this is structural (the project plane is personal-free
|
|
1533
|
+
// by construction) — the assert stays as the invariant's fail-loud backstop. config.-coded →
|
|
1534
|
+
// rethrown past the fail-open catch below.
|
|
1535
|
+
if (memorySpec.scopeContract === "v2" && !personalOnly) {
|
|
1536
|
+
assertScopeContractPlacement({
|
|
1537
|
+
parsedScopes: dual
|
|
1538
|
+
? parsedProjectPlane(planes, memorySpec.writeScope)
|
|
1539
|
+
: [...memorySpec.scopes, ...(memorySpec.writeScope !== null ? [memorySpec.writeScope] : [])].map(parseScopeKey),
|
|
1540
|
+
memoryDir,
|
|
1541
|
+
repoRoot,
|
|
1542
|
+
});
|
|
1543
|
+
}
|
|
1544
|
+
// Personal-plane seat (dual or personal-only): a directory-pinned File backend keeps the
|
|
1545
|
+
// project plane zero-copy, so the personal plane gets its OWN File backend pinned to the
|
|
1546
|
+
// personal mount (the home mount IS the personal authority disk); a DB backend is shared —
|
|
1547
|
+
// the scope column partitions, both instances copy-out into their own roots.
|
|
1548
|
+
const personalMemoryDir = derivePersonalMemoryDir(engineRoot);
|
|
1549
|
+
const personalControlDir = derivePersonalControlDir(engineRoot);
|
|
1550
|
+
const makePersonalEngine = () => new MemoryEngine({
|
|
1551
|
+
backend: typeof pinned === "string" && pinned
|
|
1552
|
+
? new FileMemoryEngineBackend(personalMemoryDir, { controlDir: personalControlDir })
|
|
1553
|
+
: backend,
|
|
1554
|
+
memoryDir: personalMemoryDir,
|
|
1555
|
+
controlDir: personalControlDir,
|
|
1556
|
+
});
|
|
1557
|
+
// Assemble the session faces (single write gate, merged inject, merged harvest) so the rest
|
|
1558
|
+
// of the runner keeps ONE session shape regardless of how many roots are underneath.
|
|
1559
|
+
let writeEngine;
|
|
1560
|
+
let writeHandle;
|
|
1561
|
+
let injectFn;
|
|
1562
|
+
let harvestBoth;
|
|
1563
|
+
if (dual) {
|
|
1564
|
+
const projectEngine = new MemoryEngine({
|
|
1565
|
+
backend,
|
|
1566
|
+
memoryDir,
|
|
1567
|
+
controlDir: identityKey !== undefined ? deriveProjectControlDir(engineRoot, identityKey) : deriveControlPlaneDir(engineRoot, repoRoot),
|
|
1568
|
+
});
|
|
1569
|
+
const personalEngine = makePersonalEngine();
|
|
1570
|
+
const p = planes;
|
|
1571
|
+
const projectHandle = await projectEngine.materialize(p.project, p.writePlane === "project" ? memorySpec.writeScope : null);
|
|
1572
|
+
const personalHandle = await personalEngine.materialize(p.personal, p.writePlane === "personal" ? memorySpec.writeScope : null);
|
|
1573
|
+
const writeIsPersonal = p.writePlane === "personal";
|
|
1574
|
+
writeEngine = writeIsPersonal ? personalEngine : projectEngine;
|
|
1575
|
+
writeHandle = writeIsPersonal ? personalHandle : projectHandle;
|
|
1576
|
+
injectFn = () => mergeInjections(projectEngine.inject(projectHandle), personalEngine.inject(personalHandle));
|
|
1577
|
+
// codex 复审 H12 — the WRITE plane's harvest is the hard half (the session's actual yield):
|
|
1578
|
+
// run it FIRST and let its failure propagate (harvestSafe surfaces it via onError). The
|
|
1579
|
+
// read-only plane's harvest is tamper-detection only — its failure degrades to a warning
|
|
1580
|
+
// on the merged report instead of silently discarding the write plane's completed half.
|
|
1581
|
+
harvestBoth = async () => {
|
|
1582
|
+
const writeFirst = writeIsPersonal ? [personalEngine, personalHandle] : [projectEngine, projectHandle];
|
|
1583
|
+
const readOther = writeIsPersonal ? [projectEngine, projectHandle] : [personalEngine, personalHandle];
|
|
1584
|
+
const writeReport = await writeFirst[0].harvest(writeFirst[1]);
|
|
1585
|
+
let readReport;
|
|
1586
|
+
let readFailure;
|
|
1587
|
+
try {
|
|
1588
|
+
readReport = await readOther[0].harvest(readOther[1]);
|
|
1589
|
+
}
|
|
1590
|
+
catch (err) {
|
|
1591
|
+
readFailure = err instanceof Error ? err.message : String(err);
|
|
1592
|
+
}
|
|
1593
|
+
const merged = readReport !== undefined ? mergeHarvestReports(writeReport, readReport) : writeReport;
|
|
1594
|
+
if (readFailure !== undefined)
|
|
1595
|
+
merged.warnings.push(`read-plane harvest failed (write-plane yield committed): ${readFailure}`);
|
|
1596
|
+
return merged;
|
|
1597
|
+
};
|
|
1598
|
+
}
|
|
1599
|
+
else if (personalOnly) {
|
|
1600
|
+
const personalEngine = makePersonalEngine();
|
|
1601
|
+
const handle = await personalEngine.materialize(memorySpec.scopes, memorySpec.writeScope);
|
|
1602
|
+
writeEngine = personalEngine;
|
|
1603
|
+
writeHandle = handle;
|
|
1604
|
+
injectFn = () => personalEngine.inject(handle);
|
|
1605
|
+
harvestBoth = () => personalEngine.harvest(handle);
|
|
1606
|
+
}
|
|
1607
|
+
else {
|
|
1608
|
+
const engine = new MemoryEngine({
|
|
1609
|
+
backend,
|
|
1610
|
+
memoryDir,
|
|
1611
|
+
controlDir: identityKey !== undefined ? deriveProjectControlDir(engineRoot, identityKey) : deriveControlPlaneDir(engineRoot, repoRoot),
|
|
1612
|
+
});
|
|
1613
|
+
const handle = await engine.materialize(memorySpec.scopes, memorySpec.writeScope);
|
|
1614
|
+
writeEngine = engine;
|
|
1615
|
+
writeHandle = handle;
|
|
1616
|
+
injectFn = () => engine.inject(handle);
|
|
1617
|
+
harvestBoth = () => engine.harvest(handle);
|
|
1618
|
+
}
|
|
1619
|
+
// design/138 S2-C (C-F2): install the write gate — Write/Edit/NotebookEdit now run their
|
|
1620
|
+
// FINAL text through the engine's shared scan when (and only when) the target lands inside
|
|
1621
|
+
// the memory writable root. gateWrite never throws (its sidecar bookkeeping is fail-open).
|
|
1622
|
+
// Dual roots: the write face lives on exactly ONE plane; the other plane's trees are
|
|
1623
|
+
// chmod-protected + harvest-tamper-checked (the same protection non-write scopes always had).
|
|
1624
|
+
memoryWriteGateRef.current = (w) => writeEngine.gateWrite(writeHandle, w.key, w.content);
|
|
1625
|
+
const harvestSafe = async (phase = "terminal") => {
|
|
1626
|
+
// Never throws: harvest is a boundary side-effect (task end / checkpoint mint) — a memory
|
|
1627
|
+
// failure must not fail or strand the run. Incidents/rejections surface via onError + the report.
|
|
1628
|
+
try {
|
|
1629
|
+
const report = await harvestBoth();
|
|
1630
|
+
if (!report.ok && report.incident) {
|
|
1631
|
+
deps.onError?.(new Error(`memory harvest refused (${report.incident.kind}): ${report.incident.detail}`), { phase: "memory", sessionId });
|
|
1632
|
+
}
|
|
1633
|
+
// S2-B (O-F7/C-F3): the report is returned to the Runner's owner — never dropped on the
|
|
1634
|
+
// floor. Swallow-guarded like onError: a throwing consumer must not fail the boundary.
|
|
1635
|
+
try {
|
|
1636
|
+
deps.onMemoryHarvestReport?.(report, { sessionId, phase });
|
|
1637
|
+
}
|
|
1638
|
+
catch {
|
|
1639
|
+
/* consumer errors never escape a best-effort harvest */
|
|
1640
|
+
}
|
|
1641
|
+
return report;
|
|
1642
|
+
}
|
|
1643
|
+
catch (err) {
|
|
1644
|
+
try {
|
|
1645
|
+
deps.onError?.(err instanceof Error ? err : new Error(String(err)), { phase: "memory", sessionId });
|
|
1646
|
+
}
|
|
1647
|
+
catch {
|
|
1648
|
+
/* a throwing onError must not escape a best-effort harvest */
|
|
1649
|
+
}
|
|
1650
|
+
return undefined;
|
|
1651
|
+
}
|
|
1652
|
+
};
|
|
1653
|
+
memoryEngineSession = { engine: writeEngine, handle: writeHandle, inject: injectFn, harvest: harvestSafe };
|
|
1654
|
+
}
|
|
1655
|
+
catch (err) {
|
|
1656
|
+
// design/142 S1 硬门: a `config.`-coded violation is a DELIBERATE refusal (scope-contract
|
|
1657
|
+
// placement / malformed v2 key) — rethrow, never degrade to "runs memory-less" (F9: the
|
|
1658
|
+
// leak-shaped config must not silently proceed without its memory gate).
|
|
1659
|
+
if (typeof err.code === "string" && (err.code.startsWith("config.memory_scope") || err.code.startsWith("config.memory_project"))) {
|
|
1660
|
+
throw err;
|
|
1661
|
+
}
|
|
1662
|
+
// Fail-open like loadProjectMemory: a broken memory mount must not block the task; the task
|
|
1663
|
+
// simply runs memory-less this session (surfaced via onError).
|
|
1664
|
+
deps.onError?.(err instanceof Error ? err : new Error(String(err)), { phase: "memory", sessionId });
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
// design/138 S4 — the legacy MemoryStore path is RETIRED (BREAKING; clay 拍「直接弃用不迁移」).
|
|
1668
|
+
// A task that enables memory WITHOUT a memoryBackend no longer mounts remember/recall tools,
|
|
1669
|
+
// injects no legacy <user_memory> block, and runs NO legacy consolidation/selective/dynamic
|
|
1670
|
+
// recall. Fail-loud: ONE operator-visible deprecation warning per prepare (onError, phase
|
|
1671
|
+
// "config") so a deployment still wiring the retired deps.memoryStore learns to migrate to
|
|
1672
|
+
// RunnerDeps.memoryBackend (the design/138 injection-first memory engine) instead of silently
|
|
1673
|
+
// losing memory. Model-facing: a NEW Remember/Recall call gets the TOMBSTONED_TOOLS migration
|
|
1674
|
+
// text (agent-loop roster-miss branch); a pre-retirement checkpoint whose APPROVED pending
|
|
1675
|
+
// action was remember/recall resumes down the established P-7 `resume.tool_unavailable` reopen
|
|
1676
|
+
// (the checkpoint reopens for a fresh operator decision — the suspended work is never dropped).
|
|
1677
|
+
let memoryBlock;
|
|
1678
|
+
if (memorySpec && memorySpec.enabled && !useMemoryEngine) {
|
|
1679
|
+
const detail = deps.memoryStore
|
|
1680
|
+
? "RunnerDeps.memoryStore is retired (design/138 S4): the legacy remember/recall tools and <user_memory> injection no longer run. Configure RunnerDeps.memoryBackend (memory engine) instead; this task runs memory-less."
|
|
1681
|
+
: "TaskSpec.memory is enabled but RunnerDeps.memoryBackend is not configured; memory is inactive for this task (the legacy memoryStore fallback was retired in design/138 S4).";
|
|
1682
|
+
deps.onError?.(new Error(detail), { phase: "config", sessionId });
|
|
1683
|
+
}
|
|
1684
|
+
// design/138 S1 — the memory-engine INJECTION replaces the legacy block wholesale on the new path:
|
|
1685
|
+
// ① the CC-verbatim `# Memory` instruction section (system authority — the §0.3 逐字 surface) and
|
|
1686
|
+
// ② the derived MEMORY.md index, truncated to CC parameters and FENCED untrusted (composeMemoryBlock
|
|
1687
|
+
// family — memory CONTENT never gets system-prompt authority). Mounted in the SAME stable-prefix
|
|
1688
|
+
// memory slot as the legacy memoryBlock (finishStable places it at the tail, before the project layer).
|
|
1689
|
+
if (memoryEngineSession) {
|
|
1690
|
+
const injection = memoryEngineSession.inject();
|
|
1691
|
+
if (injection.block.trim())
|
|
1692
|
+
memoryBlock = injection.block;
|
|
1693
|
+
}
|
|
1694
|
+
// === C4 (design/113) proactive project startup — load the deployment's project context (CLAUDE.md ± git
|
|
1695
|
+
// narrative) via the TRUSTED RunnerDeps seam and append it as a READ-ONLY, FENCED virtual "project" memory
|
|
1696
|
+
// layer at the TAIL. DECOUPLED from any memory feature (H5; the legacy `if (memoryEnabled…)` block this
|
|
1697
|
+
// note once referenced was retired in design/138 S4 — this layer always runs). Reuses composeMemoryBlock's
|
|
1698
|
+
// sanitize/fence (H2 — repo-controlled CLAUDE.md is UNTRUSTED). The project layer NEVER enters
|
|
1699
|
+
// memorySpec.scopes/writeScope (S4: the memory ENGINE owns the real scopes). Seam unwired OR null/blank
|
|
1700
|
+
// content ⇒ memoryBlock UNCHANGED = byte-identical (H3). Re-read every prepare incl. durable resume
|
|
1701
|
+
// (H8 — fresher than CC's conversation-frozen snapshot; documented in design/113 §6).
|
|
1702
|
+
if (deps.loadProjectMemory) {
|
|
1703
|
+
let loaded = null;
|
|
1704
|
+
try {
|
|
1705
|
+
loaded = await Promise.resolve(deps.loadProjectMemory({
|
|
1706
|
+
cwd: taskRootPath,
|
|
1707
|
+
handsEnabled,
|
|
1708
|
+
isSubagent: internals?.parentTaskId !== undefined,
|
|
1709
|
+
...(internals?.agentName ? { agentName: internals.agentName } : {}),
|
|
1710
|
+
}));
|
|
1711
|
+
}
|
|
1712
|
+
catch (err) {
|
|
1713
|
+
deps.onError?.(err instanceof Error ? err : new Error(String(err)), { phase: "config", sessionId }); // best-effort, never block the task
|
|
1714
|
+
}
|
|
1715
|
+
// parity-204: the structured return declares startup-seeded context files — pre-seed the hands'
|
|
1716
|
+
// read state (seedContextFiles above; undefined on a hands-less task = nothing to seed). Best-effort
|
|
1717
|
+
// like the load itself: a failing canonicalization must never block the task.
|
|
1718
|
+
const projectMem = loaded !== null && typeof loaded === "object" ? loaded.content : loaded;
|
|
1719
|
+
const seededFiles = loaded !== null && typeof loaded === "object" ? loaded.seededFiles : undefined;
|
|
1720
|
+
if (seededFiles?.length && seedContextFiles) {
|
|
1721
|
+
try {
|
|
1722
|
+
await seedContextFiles(seededFiles);
|
|
1723
|
+
}
|
|
1724
|
+
catch (err) {
|
|
1725
|
+
deps.onError?.(err instanceof Error ? err : new Error(String(err)), { phase: "config", sessionId });
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
if (projectMem != null && projectMem.trim()) {
|
|
1729
|
+
// design/113 §4.5: a TRUSTED framing PRECEDES the FENCED content so the model ORIENTS to the project (the
|
|
1730
|
+
// live test showed content-injection alone left a small model project-aware but PASSIVE on a bare greeting).
|
|
1731
|
+
const projectBlock = `${PROJECT_CONTEXT_FRAMING}\n\n${composeMemoryBlock(projectMem, "project")}`; // sanitized + fenced (H2)
|
|
1732
|
+
memoryBlock = memoryBlock ? `${memoryBlock}\n\n${projectBlock}` : projectBlock; // project at the TAIL (closest to the model); appended (NOT re-composed) so selective recall above is preserved
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
// Skills (design/71 黑盒#5 fix): first-party disclosure + invocation. One normalized list feeds
|
|
1736
|
+
// BOTH halves (the `<skills>` system-prompt block below and the `skill` tool here), so the prompt
|
|
1737
|
+
// promises exactly what the tool serves. On the legacy free-form `provider.system` path the block
|
|
1738
|
+
// is the provider's to place; the tool's own description still names every skill, so disclosure
|
|
1739
|
+
// never silently vanishes there.
|
|
1740
|
+
const skillSpecs = normalizeSkills(spec.skills);
|
|
1741
|
+
// design/77 §3 Gate-3: the `skillScope` LIFO stack (declared above, before the tool wrap) is populated
|
|
1742
|
+
// here. The `skill` tool pushes a loaded skill's manifest onto it; the scope policy (composed into the
|
|
1743
|
+
// adjudicate chain below) deny-narrows tool calls to the intersection of active manifests. The stack
|
|
1744
|
+
// stays empty (a no-op policy) for a task with no manifested skills AND no inherited parent scope — so
|
|
1745
|
+
// such a task behaves exactly as before.
|
|
1746
|
+
// 🔴 design/77 §3 / §7: skill→subagent manifest-scope PROPAGATION. When this task is a SUBAGENT spawned
|
|
1747
|
+
// while a skill manifest was active on the PARENT, the parent's active frames arrive via the trusted
|
|
1748
|
+
// `internals.inheritedManifestScope` channel (never TaskSpec — design/44 §7 Q4). Seed them onto this
|
|
1749
|
+
// child's scope stack FIRST (as the base of the LIFO), so the child's tool gate inherits the parent
|
|
1750
|
+
// skill's deny-narrowing. The child's OWN skill loads then push ON TOP → the scope policy intersects
|
|
1751
|
+
// ALL live frames (parent + child), so the child can only narrow FURTHER, never re-grant. A DENY-ALL
|
|
1752
|
+
// `unresolved` frame the subagent tool threaded as a fail-closed fallback applies the same way. Empty/
|
|
1753
|
+
// absent → no inheritance (backward-compatible).
|
|
1754
|
+
const inheritedFrames = internals?.inheritedManifestScope ?? [];
|
|
1755
|
+
for (const frame of inheritedFrames) {
|
|
1756
|
+
skillScope.push(frame);
|
|
1757
|
+
}
|
|
1758
|
+
// Apply manifest scoping when THIS task carries a manifested skill OR inherits a parent manifest frame.
|
|
1759
|
+
const hasSkillManifest = skillSpecs.some((s) => s.manifest !== undefined) || inheritedFrames.length > 0;
|
|
1760
|
+
if (skillSpecs.length > 0) {
|
|
1761
|
+
// Same reservation rule as `tool_search`/`submit_output`: the harness tool Map is last-write-wins,
|
|
1762
|
+
// so a caller/MCP tool of the same name would silently shadow (or be shadowed by) ours.
|
|
1763
|
+
if (tools.some((t) => t.name === SKILL_TOOL_NAME)) {
|
|
1764
|
+
const e = new Error(`Tool name "${SKILL_TOOL_NAME}" is reserved when spec.skills is present.`);
|
|
1765
|
+
e.code = "config.reserved_tool_name";
|
|
1766
|
+
throw e;
|
|
1767
|
+
}
|
|
1768
|
+
tools.push(makeSkillTool(skillSpecs, skillScope));
|
|
1769
|
+
}
|
|
1770
|
+
const skillsBlock = buildSkillsBlock(skillSpecs);
|
|
1771
|
+
// design/138 S4: legacy consolidation retired with the memoryStore path — never active anymore
|
|
1772
|
+
// (a resumed pre-retirement checkpoint's `seed.consolidationNotes` are ignored: the task-end
|
|
1773
|
+
// reconcile pass they fed no longer runs). The prompt flags below stay wired (always-off) so the
|
|
1774
|
+
// constitution seam is unchanged.
|
|
1775
|
+
const provider = spec.promptProvider ?? deps.promptProvider ?? defaultPromptProvider;
|
|
1776
|
+
// design/64 §6.3: tell the harness-context block which runtime features are actually active for this
|
|
1777
|
+
// task so it never claims a mechanism the task lacks. Cheap derivations of spec/deps (the real policy/
|
|
1778
|
+
// hooks are resolved the same way for the tool gate below); evaluating them here has no side effects.
|
|
1779
|
+
const promptPolicyEnabled = Boolean(spec.toolPolicy ?? deps.toolPolicy);
|
|
1780
|
+
const promptHooks = spec.hooks ?? deps.hooks;
|
|
1781
|
+
const promptHooksEnabled = Boolean(promptHooks?.preToolUse || promptHooks?.postToolUse);
|
|
1782
|
+
// design/98 §1.2 (S8a): the orchestration prompt is injected iff self-orchestration is ACTIVE — the SAME
|
|
1783
|
+
// gate that mounted the run_workflow tool above (`selfOrchestrationActive`, §6.3 lockstep). When the task
|
|
1784
|
+
// opted in but the bar is unmet, emit ONE operator-visible warning (fail-closed; never run an LLM script
|
|
1785
|
+
// in the dev vm).
|
|
1786
|
+
const failClosedReason = selfOrchestrationFailClosedReason(spec, deps);
|
|
1787
|
+
if (failClosedReason) {
|
|
1788
|
+
deps.onError?.(new Error(failClosedReason), { phase: "config", sessionId });
|
|
1789
|
+
}
|
|
1790
|
+
// Isolation (1.257.x capability-split follow-up, mirrors [500]② suspendable): drive the prompt's
|
|
1791
|
+
// isolation claim off the DECLARED capability (`isIsolated` = capabilities.isolation), NOT the
|
|
1792
|
+
// structural remote check — an SSH host / ADB device is remote but `capabilities.isolation:false`,
|
|
1793
|
+
// and the old `isRemoteExecutionEnv` gate OVER-CLAIMED an isolated sandbox to the model on exactly
|
|
1794
|
+
// the un-isolated targets where design/53 zero-trust wants the tighter posture (§16.2: never claim
|
|
1795
|
+
// isolation that isn't there). Behavior change: un-isolated remote envs without a policy gate no
|
|
1796
|
+
// longer get the # Execution environment contract block (hooks-only tasks regain the standalone
|
|
1797
|
+
// deny-don't-retry line instead, per the harnessContext gap-fill gate).
|
|
1798
|
+
const featureFlags = {
|
|
1799
|
+
policyEnabled: promptPolicyEnabled,
|
|
1800
|
+
hooksEnabled: promptHooksEnabled,
|
|
1801
|
+
isolationEnabled: isIsolated(executionEnv),
|
|
1802
|
+
// design/98 §1.2 (S8a): inject ORCHESTRATION_GUIDANCE iff self-orchestration is really active (same gate
|
|
1803
|
+
// as the run_workflow tool mount).
|
|
1804
|
+
orchestrationEnabled: selfOrchestrationActive,
|
|
1805
|
+
// design/96 §C (S2): inject GOAL_COMPLETION_GUIDANCE iff this run is a `runGoal` iteration — the trusted
|
|
1806
|
+
// internal flag is set ONLY by runGoal (never a TaskSpec field), so the prompt's "declaring done stops
|
|
1807
|
+
// iteration" promise is always backed by a real loop (§6.3).
|
|
1808
|
+
goalEnabled: internals?.goalMode === true,
|
|
1809
|
+
// design/96 §D (S4, P0): inject ORCHESTRATION_AWARENESS iff the task runs at an ULTRA reasoning tier. The
|
|
1810
|
+
// honest reasoning-tier analogue of CC's ultracode nudge — driven by the CALLER-DECLARED, role-default-
|
|
1811
|
+
// folded `thinking` (resolved above at L514: `spec.thinking ?? resolvedRole.thinking`), NOT a keyword scan
|
|
1812
|
+
// of untrusted input (design/53). Independent of orchestrationEnabled (the run_workflow gate): a task can
|
|
1813
|
+
// be ultra AND self-orchestrating → both blocks compose.
|
|
1814
|
+
awarenessEnabled: thinking !== undefined && ULTRA_REASONING_TIERS.has(thinking),
|
|
1815
|
+
// design/97 CORE-6 (P1b): inject WORKTREE_NOTICE iff this task ACTUALLY runs in an isolated git worktree.
|
|
1816
|
+
// Driven by the TRUSTED internal channel (set by the workflow `{ isolation: "worktree" }` option), never a
|
|
1817
|
+
// TaskSpec field. AND-gated on `ownedEnv` (a factory actually minted the isolated env): a worktree request
|
|
1818
|
+
// with NO worktree-capable `executionEnvFactory` falls back to the static/stub env (no real worktree) — in
|
|
1819
|
+
// that case claiming an isolated worktree would be a §6.3 over-claim, so fail closed (codex review).
|
|
1820
|
+
worktreeIsolated: internals?.isolation === "worktree" && ownedEnv !== undefined,
|
|
1821
|
+
// design/64 §25 (A): same derivation as the runtask turn-boundary handler — the prompt's
|
|
1822
|
+
// context-management line must claim mid-task summarization ONLY when it will actually run (§6.3).
|
|
1823
|
+
withinTaskCompactionEnabled: (spec.compaction?.enabled ?? true) && (spec.compaction?.withinTask ?? true),
|
|
1824
|
+
};
|
|
1825
|
+
// Explicit task prompt wins; else the requested role's preset (e.g. a dev role's CODE_AGENT_PROMPT);
|
|
1826
|
+
// else a core caller's trusted default persona (合车复审修②: the delegation tool's lean
|
|
1827
|
+
// SUBAGENT_PROMPT rides here so a roles.* preset is never shadowed by the built-in default);
|
|
1828
|
+
// else the provider's own default (domain-neutral). Keeps the global default neutral while letting
|
|
1829
|
+
// coding roles opt into a coding prompt.
|
|
1830
|
+
const userSystemPrompt = spec.systemPrompt ?? resolvedRole.systemPrompt ?? internals?.defaultSystemPrompt;
|
|
1831
|
+
// TaskSpec.appendSystemPrompt (caller-trusted): threaded into the stable prompt context, composed as the
|
|
1832
|
+
// LAST stable block (keeps the base + harness/safety; before the volatile memory tail — prefix-cache safe).
|
|
1833
|
+
const userAppendSystemPrompt = spec.appendSystemPrompt;
|
|
1834
|
+
// MCP server instructions (design/64 §17.3): STABLE per-task → woven between the base and the volatile
|
|
1835
|
+
// memory tail (composeSystemPrompt twice) so the cacheable prefix is preserved.
|
|
1836
|
+
const mcpInstructionsBlock = mcp.serverInstructions.length
|
|
1837
|
+
? `# MCP Server Instructions\n${mcp.serverInstructions.map((s) => `## ${s.server}\n${s.text}`).join("\n\n")}`
|
|
1838
|
+
: undefined;
|
|
1839
|
+
// Environment context (design/64 §20): situational facts in the VARIABLE tail (date changes daily). cwd/
|
|
1840
|
+
// git/platform/OS come from the ExecutionEnv (remote = the container's values) — best-effort, one probe.
|
|
1841
|
+
// design/112 C1/C5: USER facts from the client-supplied clientContext (NOT execution-env probes). Localize
|
|
1842
|
+
// "today" to the user's zone (TOB: the container runs UTC, the user is elsewhere); absent ⇒ UTC (annotated).
|
|
1843
|
+
const userTz = spec.clientContext?.timeZone;
|
|
1844
|
+
// Hardening (cross-feature review): validate the zone ONCE so the date basis and the `(zone)` annotation can
|
|
1845
|
+
// NEVER disagree. An INVALID client zone must take the UTC path (date) AND stay unannotated → the env block
|
|
1846
|
+
// shows `(UTC)`, never `(Asia/Faketown)` over a UTC date (§25 honesty: don't claim a localization that didn't
|
|
1847
|
+
// happen). `formatLocalDate` alone silently falls back to UTC but `timeZone` being merely PRESENT would still
|
|
1848
|
+
// mislabel it; gating on validity keeps both the initial render and the per-turn re-render (below) consistent.
|
|
1849
|
+
const tzValid = userTz !== undefined && isValidTimeZone(userTz);
|
|
1850
|
+
const envFacts = { date: formatLocalDate(new Date(), tzValid ? userTz : undefined), modelId: model.id };
|
|
1851
|
+
if (tzValid)
|
|
1852
|
+
envFacts.timeZone = userTz;
|
|
1853
|
+
if (spec.clientContext?.userEmail)
|
|
1854
|
+
envFacts.userEmail = spec.clientContext.userEmail;
|
|
1855
|
+
// P1a (knowledge cutoff): deployment-declared per model — core hardcodes no model→date map (a stale cutoff
|
|
1856
|
+
// would be a §25 honesty breach). Looked up by the resolved model id; omitted when the deployment didn't declare it.
|
|
1857
|
+
const declaredCutoff = deps.modelKnowledgeCutoffs?.[model.id];
|
|
1858
|
+
if (declaredCutoff)
|
|
1859
|
+
envFacts.knowledgeCutoff = declaredCutoff;
|
|
1860
|
+
// 黑板 [442] A1 — DEPLOYMENT-supplied sandbox facts (TaskSpec.envFacts): rendered by the same
|
|
1861
|
+
// buildEnvironmentContext (sanitize + bound happen at render). Copied field-by-field (whitelist shape —
|
|
1862
|
+
// unknown keys never ride into the block).
|
|
1863
|
+
if (spec.envFacts?.profile)
|
|
1864
|
+
envFacts.sandboxProfile = spec.envFacts.profile;
|
|
1865
|
+
if (spec.envFacts?.capabilities && spec.envFacts.capabilities.length > 0)
|
|
1866
|
+
envFacts.sandboxCapabilities = [...spec.envFacts.capabilities];
|
|
1867
|
+
if (spec.envFacts?.pkgSource)
|
|
1868
|
+
envFacts.sandboxPkgSource = spec.envFacts.pkgSource;
|
|
1869
|
+
// codex 1.240 ④: validate at the JS boundary — an out-of-union egress from an untyped caller must not
|
|
1870
|
+
// fall into the "full" render branch (claiming open egress that isn't).
|
|
1871
|
+
if (spec.envFacts?.egress === "none" || spec.envFacts?.egress === "allowlist" || spec.envFacts?.egress === "full")
|
|
1872
|
+
envFacts.sandboxEgress = spec.envFacts.egress;
|
|
1873
|
+
// codex 1.257.3 review (MED): standard-Runner path for the Scratchpad section (CC parity) — without
|
|
1874
|
+
// this copy `buildScratchpadSection` was reachable only via a manual buildEnvironmentContext call.
|
|
1875
|
+
if (spec.envFacts?.scratchpadDir)
|
|
1876
|
+
envFacts.scratchpadDir = spec.envFacts.scratchpadDir;
|
|
1877
|
+
if (handsEnabled) {
|
|
1878
|
+
envFacts.cwd = taskRootPath;
|
|
1879
|
+
// design/119 (CC --add-dir parity): announce the extra allowed roots in # Environment.
|
|
1880
|
+
if (additionalRootsCanonical.length > 0) {
|
|
1881
|
+
envFacts.additionalDirectories = [...additionalRootsCanonical];
|
|
1882
|
+
}
|
|
1883
|
+
try {
|
|
1884
|
+
// design/99 §E14: one batched probe (one round-trip). Each sub-command MUST emit EXACTLY ONE line so the
|
|
1885
|
+
// position-based parse stays in sync: osName, osVer, isInsideWorkTree, branch, worktreeRoot, dirty|clean.
|
|
1886
|
+
// Branch uses `symbolic-ref` (NOT `rev-parse --abbrev-ref HEAD`): on an UNBORN branch (`git init`, no commit)
|
|
1887
|
+
// abbrev-ref prints "HEAD" to stdout AND exits non-zero, so `|| echo` emits a SECOND line and every later
|
|
1888
|
+
// field shifts. `symbolic-ref --short -q` prints the branch (even unborn) on success / nothing on detached,
|
|
1889
|
+
// so `|| echo "HEAD (detached)"` yields exactly one line and never mislabels an unborn branch as detached.
|
|
1890
|
+
// P1a (shell): a 7th sub-command appended LAST (one line, always) — the basename of the shell ACTUALLY
|
|
1891
|
+
// RUNNING this probe (the env's exec interpreter), so the agent picks shell-correct syntax. Uses
|
|
1892
|
+
// `ps -p $$ -o comm=` (the current shell process), NOT `$SHELL` — `$SHELL` is the login shell, which can
|
|
1893
|
+
// differ from the interpreter the env spawns (e.g. NodeExecutionEnv runs commands under bash even when
|
|
1894
|
+
// `$SHELL` is zsh; codex review). Strips dir + leading `-` (login marker); empty (e.g. no `ps`) ⇒ omitted.
|
|
1895
|
+
// Appended at the END so the existing 6-field position parse is unaffected; read independently below.
|
|
1896
|
+
const probe = await executionEnv.exec('uname -s; uname -r; (git rev-parse --is-inside-work-tree 2>/dev/null || echo false); (git symbolic-ref --short -q HEAD 2>/dev/null || echo "HEAD (detached)"); (git rev-parse --show-toplevel 2>/dev/null || echo); (test -n "$(git status --porcelain 2>/dev/null | head -1)" && echo dirty || echo clean); (s=$(ps -p $$ -o comm= 2>/dev/null); s=${s##*/}; echo "${s#-}"); (test "$(git rev-parse --git-dir 2>/dev/null)" != "$(git rev-parse --git-common-dir 2>/dev/null)" && echo linked || echo main)', { cwd: envFacts.cwd, timeout: 10 });
|
|
1897
|
+
if (probe.ok && probe.value.exitCode === 0) {
|
|
1898
|
+
const lines = probe.value.stdout.split("\n");
|
|
1899
|
+
// Self-checking: the 6 sub-commands each emit one line, plus a trailing newline → ≥ 6 elements. Fewer
|
|
1900
|
+
// means a sub-command was swallowed (de-sync) — omit the shell-derived facts rather than mis-parse.
|
|
1901
|
+
if (lines.length >= 6) {
|
|
1902
|
+
const [osName, osVer, git, branch, worktreeRoot, dirty] = lines;
|
|
1903
|
+
if (osName?.trim())
|
|
1904
|
+
envFacts.platform = osName.trim();
|
|
1905
|
+
if (osVer?.trim())
|
|
1906
|
+
envFacts.osVersion = osVer.trim();
|
|
1907
|
+
envFacts.isGitRepo = (git ?? "").trim() === "true";
|
|
1908
|
+
if (envFacts.isGitRepo) {
|
|
1909
|
+
// §E14: branch/worktree/dirty only when actually a repo (else the empty/`clean` lines are noise).
|
|
1910
|
+
const b = (branch ?? "").trim();
|
|
1911
|
+
if (b)
|
|
1912
|
+
envFacts.gitBranch = b; // probe already emits "HEAD (detached)" for a detached HEAD
|
|
1913
|
+
const root = (worktreeRoot ?? "").trim();
|
|
1914
|
+
if (root)
|
|
1915
|
+
envFacts.gitWorktreeRoot = root;
|
|
1916
|
+
const d = (dirty ?? "").trim();
|
|
1917
|
+
if (d === "dirty" || d === "clean")
|
|
1918
|
+
envFacts.gitDirty = d === "dirty";
|
|
1919
|
+
}
|
|
1920
|
+
// P1a: shell is the 7th line (independent of the >=6 git/OS guard — a swallowed shell line just omits it).
|
|
1921
|
+
const sh = (lines[6] ?? "").trim();
|
|
1922
|
+
if (sh)
|
|
1923
|
+
envFacts.shell = sh;
|
|
1924
|
+
// design/119 (CC 198 parity): 8th line — linked-worktree detection (git-dir ≠ git-common-dir).
|
|
1925
|
+
// CC's # Environment carries an isolated-copy directive for a linked worktree; feed the same
|
|
1926
|
+
// fact so buildEnvironmentContext can emit it even when the caller didn't set isolation:"worktree".
|
|
1927
|
+
if (envFacts.isGitRepo && (lines[7] ?? "").trim() === "linked")
|
|
1928
|
+
envFacts.isLinkedWorktree = true;
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
catch {
|
|
1933
|
+
/* best-effort: a stub/odd env just omits the shell-derived facts */
|
|
1934
|
+
}
|
|
1935
|
+
// H4 (CC 2.1.198 ENV git snapshot, 考据档 B1/B2) — one more batched round-trip through the SAME
|
|
1936
|
+
// ExecutionEnv seam (TOB e2b = the container's repo), only when the §E14 probe confirmed a repo.
|
|
1937
|
+
// Four sections split by a sentinel line: main-branch inference (CC qP: origin/HEAD symref →
|
|
1938
|
+
// [inferred, main, master] each `show-ref --verify --quiet` → renderer falls back "main"),
|
|
1939
|
+
// `git config user.name`, `git --no-optional-locks status --short`, and
|
|
1940
|
+
// `git --no-optional-locks log --oneline -n 5` (CC-exact commands). 1.256 复审 MED-4: the two
|
|
1941
|
+
// REQUIRED sections (status, log) are captured explicitly and fail the WHOLE script with a
|
|
1942
|
+
// distinct exit code (41 = status, 42 = log) — previously the script's exit code was only the
|
|
1943
|
+
// LAST command's, so a failing status with a succeeding log rendered an EMPTY status section as
|
|
1944
|
+
// "(clean)" (a half-rendered snapshot lying about a dirty tree). Either required failure now
|
|
1945
|
+
// skips the whole section + degraded telemetry (CC's Promise.all catch → null is the same
|
|
1946
|
+
// all-or-nothing shape; an unborn repo still skips via exit 42). The main-branch/user sections
|
|
1947
|
+
// stay best-effort degradable. Failure/timeout = silent skip + onError telemetry with reason.
|
|
1948
|
+
if (envFacts.isGitRepo === true) {
|
|
1949
|
+
const SEP = "@@SEMA_ENV_GIT_SPLIT@@";
|
|
1950
|
+
try {
|
|
1951
|
+
const snap = await executionEnv.exec(`(m=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null); m=\${m#origin/}; for s in "$m" main master; do if [ -n "$s" ] && git show-ref --verify --quiet "refs/remotes/origin/$s"; then echo "$s"; break; fi; done) || true; echo "${SEP}"; (git config user.name 2>/dev/null || true); echo "${SEP}"; gs=$(git --no-optional-locks status --short) || exit 41; printf '%s\\n' "$gs"; echo "${SEP}"; gl=$(git --no-optional-locks log --oneline -n 5) || exit 42; printf '%s\\n' "$gl"`, { cwd: envFacts.cwd, timeout: 10 });
|
|
1952
|
+
if (snap.ok && snap.value.exitCode === 0) {
|
|
1953
|
+
const parts = snap.value.stdout.split(`${SEP}\n`);
|
|
1954
|
+
if (parts.length === 4) {
|
|
1955
|
+
envFacts.gitSnapshot = buildGitSnapshot({
|
|
1956
|
+
// Reuse the §E14 probe's branch ("HEAD (detached)" on detached — richer than CC's bare
|
|
1957
|
+
// "HEAD"; recorded deviation, keeps this block consistent with the `Git branch:` line).
|
|
1958
|
+
branch: envFacts.gitBranch ?? "HEAD",
|
|
1959
|
+
mainBranch: parts[0].trim() || "main",
|
|
1960
|
+
...(parts[1].trim() ? { userName: parts[1].trim() } : {}),
|
|
1961
|
+
status: parts[2],
|
|
1962
|
+
log: parts[3],
|
|
1963
|
+
});
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
else {
|
|
1967
|
+
// Silent-fallback telemetry (C1-C11 discipline: a degrade always carries its reason).
|
|
1968
|
+
// MED-4: the explicit required-section exit codes get a named reason so the degrade is diagnosable.
|
|
1969
|
+
const reason = snap.ok
|
|
1970
|
+
? snap.value.exitCode === 41
|
|
1971
|
+
? "git status failed (exit 41)"
|
|
1972
|
+
: snap.value.exitCode === 42
|
|
1973
|
+
? "git log failed (exit 42)"
|
|
1974
|
+
: `git exited ${snap.value.exitCode}`
|
|
1975
|
+
: `exec failed: ${snap.error.message}`;
|
|
1976
|
+
deps.onError?.(new Error(`env git snapshot skipped — ${reason}`), { phase: "degraded", sessionId, classification: "env-git-snapshot" });
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
catch (err) {
|
|
1980
|
+
deps.onError?.(new Error(`env git snapshot skipped — ${err instanceof Error ? err.message : String(err)}`), { phase: "degraded", sessionId, classification: "env-git-snapshot" });
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
// Definite assignment: every branch below assigns via finishStable() or the legacy path (TS can't
|
|
1985
|
+
// see through the closure).
|
|
1986
|
+
let systemPrompt;
|
|
1987
|
+
// design/66 date_change: a multi-day task must see the CURRENT date. Everything except the date is
|
|
1988
|
+
// frozen at prepare time (stable base, MCP block, probed env facts, memory block), so re-rendering
|
|
1989
|
+
// with a new date changes ONLY the `Today's date:` line — the rest stays byte-identical (prefix-cache
|
|
1990
|
+
// loses just the variable tail, by §20 design). Set on the stableSystem/default paths; the legacy
|
|
1991
|
+
// free-form `provider.system` owns its own ordering and stays a static string.
|
|
1992
|
+
let renderWithDate;
|
|
1993
|
+
// Prompt-constitution assembly (sema 提示词主权批): the constitution layer (harness context + safety
|
|
1994
|
+
// blocks + mode blocks + memory guidance) is composed HERE, OUTSIDE the provider — same tier as the
|
|
1995
|
+
// skills/MCP/env/memory tails — so a custom role provider physically cannot drop it (the class of bug
|
|
1996
|
+
// where a deployment's `stablePrompt(base)` replacement silently ran business scenarios with no
|
|
1997
|
+
// truth/safety blocks). `replaceAll: true` is the explicit personalization escape hatch. Every path
|
|
1998
|
+
// records a labelled block manifest → the `prompt.assembled` trace event (black-box killer: "which
|
|
1999
|
+
// blocks were in the prompt" becomes a runtime fact, not archaeology).
|
|
2000
|
+
// design/138 S4: memoryEnabled/consolidationEnabled are the LEGACY-path prompt flags (MEMORY_SAFETY/
|
|
2001
|
+
// MEMORY_HYGIENE constitution blocks) — permanently false now; the engine path injects the CC-verbatim
|
|
2002
|
+
// `# Memory` instruction via injection.block instead (same as pre-S4, which also passed false here).
|
|
2003
|
+
const promptCtx = { userSystemPrompt, userAppendSystemPrompt, tools, memoryEnabled: false, consolidationEnabled: false, ...featureFlags };
|
|
2004
|
+
let promptConstitution = "core";
|
|
2005
|
+
let promptBlocks = [];
|
|
2006
|
+
const finishStable = (stableCore) => {
|
|
2007
|
+
const stable = composeSystemPrompt(composeSystemPrompt(stableCore, skillsBlock), mcpInstructionsBlock);
|
|
2008
|
+
if (skillsBlock?.trim())
|
|
2009
|
+
promptBlocks.push({ id: "skills.block", text: skillsBlock });
|
|
2010
|
+
if (mcpInstructionsBlock?.trim())
|
|
2011
|
+
promptBlocks.push({ id: "mcp.instructions", text: mcpInstructionsBlock });
|
|
2012
|
+
renderWithDate = (date) => composeSystemPrompt(composeSystemPrompt(stable, buildEnvironmentContext({ ...envFacts, date }) || undefined), memoryBlock);
|
|
2013
|
+
systemPrompt = renderWithDate(envFacts.date);
|
|
2014
|
+
const envBlock = buildEnvironmentContext({ ...envFacts, date: envFacts.date });
|
|
2015
|
+
if (envBlock?.trim())
|
|
2016
|
+
promptBlocks.push({ id: "env.context", text: envBlock });
|
|
2017
|
+
if (memoryBlock?.trim())
|
|
2018
|
+
promptBlocks.push({ id: "memory.tail", text: memoryBlock });
|
|
2019
|
+
};
|
|
2020
|
+
if (provider === defaultPromptProvider) {
|
|
2021
|
+
// No custom provider (the ?? fallback): default role base + constitution. Checked FIRST —
|
|
2022
|
+
// defaultPromptProvider implements stableSystem whose output contains the constitution anchor,
|
|
2023
|
+
// so falling into the generic branch below would misroute every plain task through the
|
|
2024
|
+
// migration guard (caught by the prompt-constitution test suite on first run).
|
|
2025
|
+
const c = composeConstitution(userSystemPrompt ?? DEFAULT_SYSTEM_PROMPT, promptCtx);
|
|
2026
|
+
promptBlocks = [...c.blocks];
|
|
2027
|
+
finishStable(c.text);
|
|
2028
|
+
}
|
|
2029
|
+
else if (provider.stableSystem) {
|
|
2030
|
+
const roleOut = provider.stableSystem(promptCtx);
|
|
2031
|
+
if (provider.replaceAll) {
|
|
2032
|
+
// Explicit full replacement: the provider owns the whole stable base (constitution deliberately
|
|
2033
|
+
// omitted — the manifest marks it so the omission is observable, never accidental).
|
|
2034
|
+
promptConstitution = "replaced";
|
|
2035
|
+
promptBlocks = [{ id: "provider.replace_all", text: roleOut }];
|
|
2036
|
+
finishStable(roleOut);
|
|
2037
|
+
}
|
|
2038
|
+
else if (roleOut.includes(CYBER_RISK) && roleOut.includes(URL_SAFETY) && roleOut.includes("# Harness")) {
|
|
2039
|
+
// Migration guard: a historic provider that returns an ALREADY-assembled prompt (e.g. it wraps
|
|
2040
|
+
// `defaultPromptProvider.stableSystem`) — appending the constitution again would double every
|
|
2041
|
+
// safety block. Pass through un-doubled, but surface the upgrade path loudly.
|
|
2042
|
+
// Detection = ALL THREE anchors (codex [B]): a role base merely QUOTING one safety line no longer
|
|
2043
|
+
// trips it (that path gets the constitution appended; a duplicated quote is harmless). Adversarial
|
|
2044
|
+
// bound: a provider is CODE (function-valued, in-process only — a wire-level TaskSpec cannot carry
|
|
2045
|
+
// one), so whoever can spoof this already runs deployment code; and the manifest records the mode,
|
|
2046
|
+
// so a pass-through is observable, never silent.
|
|
2047
|
+
promptConstitution = "provider-assembled";
|
|
2048
|
+
promptBlocks = [{ id: "provider.assembled", text: roleOut }];
|
|
2049
|
+
deps.onError?.(new Error("prompt-constitution: this PromptProvider returns an already-assembled prompt (constitution anchor found). Core now appends the constitution structurally — return ONLY the role base from stableSystem (or set replaceAll:true to own the whole base). Passed through un-doubled."), { phase: "prompt-constitution", sessionId });
|
|
2050
|
+
finishStable(roleOut);
|
|
2051
|
+
}
|
|
2052
|
+
else {
|
|
2053
|
+
// Role-layer semantics (the new contract): provider supplies the role base; core appends the
|
|
2054
|
+
// constitution structurally. The easy path is now the correct path.
|
|
2055
|
+
const c = composeConstitution(roleOut, promptCtx);
|
|
2056
|
+
promptBlocks = [...c.blocks];
|
|
2057
|
+
finishStable(c.text);
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
else if (provider.system) {
|
|
2061
|
+
// Legacy free-form path: the provider owns ordering. Lint it — memory present but NOT at the tail
|
|
2062
|
+
// means the cacheable prefix shifts every turn (vLLM/Anthropic prefix cache misses). Surface loudly.
|
|
2063
|
+
systemPrompt = provider.system({ ...promptCtx, memoryBlock });
|
|
2064
|
+
promptConstitution = "legacy";
|
|
2065
|
+
promptBlocks = [{ id: "provider.legacy", text: systemPrompt }];
|
|
2066
|
+
const memTrim = memoryBlock?.trim();
|
|
2067
|
+
if (memTrim && systemPrompt.includes(memTrim) && !systemPrompt.trimEnd().endsWith(memTrim)) {
|
|
2068
|
+
deps.onError?.(new Error("prompt-cache: the <user_memory> block is not at the END of the system prompt — the cacheable prefix changes every turn, so vLLM/Anthropic prefix caching will miss. Put memoryBlock LAST (STABLE→VARIABLE), or implement PromptProvider.stableSystem and let core place memory."), { phase: "prompt-cache", sessionId });
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
else {
|
|
2072
|
+
// No provider: default role base (task systemPrompt or the neutral default) + constitution.
|
|
2073
|
+
const c = composeConstitution(userSystemPrompt ?? DEFAULT_SYSTEM_PROMPT, promptCtx);
|
|
2074
|
+
promptBlocks = [...c.blocks];
|
|
2075
|
+
finishStable(c.text);
|
|
2076
|
+
}
|
|
2077
|
+
// design/119 S3: model-declared prompt guidance (CC 198 per-model mitigations, declaratively).
|
|
2078
|
+
// Appended at the very end of the assembled prompt via the same renderWithDate wrapper the
|
|
2079
|
+
// date_change re-render uses, so a mid-task date rollover keeps the guidance.
|
|
2080
|
+
if (model.promptGuidance && model.promptGuidance.length > 0) {
|
|
2081
|
+
const guidance = model.promptGuidance.join("\n\n");
|
|
2082
|
+
const baseRender = renderWithDate;
|
|
2083
|
+
if (baseRender) {
|
|
2084
|
+
renderWithDate = (date) => composeSystemPrompt(baseRender(date), guidance);
|
|
2085
|
+
systemPrompt = renderWithDate(envFacts.date);
|
|
2086
|
+
}
|
|
2087
|
+
else {
|
|
2088
|
+
// Legacy PromptProvider.system path has no date re-render — append once (workflow review:
|
|
2089
|
+
// the previous non-null assertion crashed this branch at runtime).
|
|
2090
|
+
systemPrompt = composeSystemPrompt(systemPrompt, guidance) ?? systemPrompt;
|
|
2091
|
+
}
|
|
2092
|
+
promptBlocks.push({ id: "model.guidance", text: guidance });
|
|
2093
|
+
}
|
|
2094
|
+
// 提示词主权批: the labelled composition manifest (id + size + short content hash per block) —
|
|
2095
|
+
// emitted by the run loop as `prompt.assembled` right after `task.start`. Hashes let a deployment
|
|
2096
|
+
// spot "same block id, different content" drift without shipping prompt text into traces. SALTED
|
|
2097
|
+
// with a per-process random value (codex [E]): an unsalted sha256 of a short, guessable block
|
|
2098
|
+
// (env facts, a one-line memory note) is a dictionary-preimage surface for whoever can read traces
|
|
2099
|
+
// but not prompts. Same-process comparability (drift detection) survives; cross-process hash
|
|
2100
|
+
// equality is deliberately not a feature.
|
|
2101
|
+
const promptManifest = {
|
|
2102
|
+
constitution: promptConstitution,
|
|
2103
|
+
blocks: promptBlocks.map((b) => ({ id: b.id, chars: b.text.length, hash: createHash("sha256").update(PROMPT_HASH_SALT).update(b.text).digest("hex").slice(0, 12) })),
|
|
2104
|
+
};
|
|
2105
|
+
// Deferred-tool disclosure (design/36): when any caller/MCP tool is deferred, it ships as a name-only
|
|
2106
|
+
// placeholder and an injected `tool_search` materializes full schemas on demand. Materialization runs
|
|
2107
|
+
// as a side-effect of `tool_search.execute`, which completes BEFORE the harness snapshots tools for the
|
|
2108
|
+
// next turn — so the run loop is untouched and the new schemas land in the next request.
|
|
2109
|
+
const userToolNames = (spec.tools ?? []).map((t) => t.name);
|
|
2110
|
+
const mcpToolNames = mcp.tools.map((t) => t.name);
|
|
2111
|
+
const deferred = classifyDeferred({
|
|
2112
|
+
specs: spec.tools ?? [],
|
|
2113
|
+
mcpToolNames,
|
|
2114
|
+
fullTools: tools.filter((t) => userToolNames.includes(t.name) || mcpToolNames.includes(t.name)),
|
|
2115
|
+
deferMode: deps.deferMode,
|
|
2116
|
+
model,
|
|
2117
|
+
});
|
|
2118
|
+
const activeTools = new Set();
|
|
2119
|
+
// `harnessRef` declared above (tool ctx.model + harness↔tool_search cycle). `fpRef` lets `rematerialize`
|
|
2120
|
+
// reach the fingerprint object built after the harness; both are populated before any tool_search runs.
|
|
2121
|
+
const fpRef = {}; // fp = cache fingerprint
|
|
2122
|
+
let harnessTools = tools;
|
|
2123
|
+
// G1 [482]: created inside the deferred branch below (a task without deferred tools has no delta source).
|
|
2124
|
+
let toolsDeltaRef;
|
|
2125
|
+
if (deferred.size > 0) {
|
|
2126
|
+
// Our injected tool_search must not collide with a caller/MCP tool (the harness Map is last-write-wins).
|
|
2127
|
+
if (deferred.has(TOOL_SEARCH_NAME) || tools.some((t) => t.name === TOOL_SEARCH_NAME)) {
|
|
2128
|
+
const e = new Error(`Tool name "${TOOL_SEARCH_NAME}" is reserved when deferred tools are present.`);
|
|
2129
|
+
e.code = "config.reserved_tool_name";
|
|
2130
|
+
throw e;
|
|
2131
|
+
}
|
|
2132
|
+
const registry = buildDeferredRegistry(deferred, tools);
|
|
2133
|
+
const placeholders = new Map([...registry.values()].map((i) => [i.name, makePlaceholderTool(i)]));
|
|
2134
|
+
// Seed the active set from the log so a RESUMED session keeps its previously-activated tools (else
|
|
2135
|
+
// they'd silently revert to placeholders). `buildContext` returns [] for a fresh session.
|
|
2136
|
+
const { messages } = await session.buildContext();
|
|
2137
|
+
for (const n of extractDiscoveredToolNames(messages, registry)) {
|
|
2138
|
+
if (deferred.has(n))
|
|
2139
|
+
activeTools.add(n);
|
|
2140
|
+
}
|
|
2141
|
+
if (resume) {
|
|
2142
|
+
// design/45 §4.bis: re-seed the activated set from the checkpoint (authoritative), on top of what
|
|
2143
|
+
// the rewound transcript implies, so a resumed task never reverts an activated tool to a placeholder.
|
|
2144
|
+
for (const n of resume.seed.activeTools)
|
|
2145
|
+
if (deferred.has(n))
|
|
2146
|
+
activeTools.add(n);
|
|
2147
|
+
}
|
|
2148
|
+
let toolSearch;
|
|
2149
|
+
const buildToolList = (active) => {
|
|
2150
|
+
const list = tools.map((t) => (deferred.has(t.name) && !active.has(t.name) ? placeholders.get(t.name) : t));
|
|
2151
|
+
list.push(toolSearch);
|
|
2152
|
+
return list;
|
|
2153
|
+
};
|
|
2154
|
+
// G1 通告层 [482] (CC deferred_tools_delta parity): track which activations the model has been
|
|
2155
|
+
// TOLD about at a boundary. Seeded with the prepare-time actives (transcript-derived + resume
|
|
2156
|
+
// checkpoint reseed above) — those were active before this leg's first turn, so a resume never
|
|
2157
|
+
// replays old announcements (TD3). The diff below is append-only and dedup'd by this set.
|
|
2158
|
+
const announcedTools = new Set(activeTools);
|
|
2159
|
+
toolsDeltaRef = { pending: [] };
|
|
2160
|
+
const deltaRef = toolsDeltaRef;
|
|
2161
|
+
const rematerialize = async (active) => {
|
|
2162
|
+
const list = buildToolList(active);
|
|
2163
|
+
await harnessRef.current.setTools(list);
|
|
2164
|
+
if (fpRef.current)
|
|
2165
|
+
fpRef.current.tools = toolsToFingerprintInputs(list);
|
|
2166
|
+
// Diff AFTER the setTools landed (announce only what is truly live on the harness).
|
|
2167
|
+
for (const n of active) {
|
|
2168
|
+
if (!announcedTools.has(n)) {
|
|
2169
|
+
announcedTools.add(n);
|
|
2170
|
+
deltaRef.pending.push(n);
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
};
|
|
2174
|
+
toolSearch = makeToolSearchTool({ registry, active: activeTools, rematerialize });
|
|
2175
|
+
harnessTools = buildToolList(activeTools);
|
|
2176
|
+
}
|
|
2177
|
+
// G1 通告层续批 (CC agent_listing_delta) — the announce face: read the roster off the mounted
|
|
2178
|
+
// delegation tool (ToolSpec.agentListing, filled by createSubagentTool; deployment tools normally
|
|
2179
|
+
// leave it unset). Static per-leg; on a durable resume leg the run loop seeds the producer as
|
|
2180
|
+
// already-announced ONLY after confirming a listing reminder in the transcript (MED-3② — TD3
|
|
2181
|
+
// discipline, made delivery-confirmed instead of leg-assumed).
|
|
2182
|
+
const agentListingSpec = (spec.tools ?? []).find((t) => t.agentListing !== undefined && t.agentListing.length > 0);
|
|
2183
|
+
const agentListing = agentListingSpec
|
|
2184
|
+
? { entries: agentListingSpec.agentListing, toolName: agentListingSpec.name, seedAnnounced: resume !== undefined }
|
|
2185
|
+
: undefined;
|
|
2186
|
+
// design/66 date_change: the vendored harness re-evaluates a systemPrompt FUNCTION at every turn
|
|
2187
|
+
// boundary (createTurnState), so a multi-day task picks up a date flip on the next request. Memoized:
|
|
2188
|
+
// same date ⇒ the SAME string instance is returned (byte-stability is trivially guaranteed). On a
|
|
2189
|
+
// flip, the design/31 fingerprint is refreshed in lockstep so the resulting one-off cache miss is
|
|
2190
|
+
// truthfully attributed to "system-prefix" instead of surfacing as a mystery break (frozen
|
|
2191
|
+
// fingerprint = false-negative direction). Legacy `provider.system` path keeps the static string.
|
|
2192
|
+
let promptDate = envFacts.date;
|
|
2193
|
+
let renderedPrompt = systemPrompt;
|
|
2194
|
+
const liveSystemPrompt = renderWithDate
|
|
2195
|
+
? () => {
|
|
2196
|
+
// C1: re-derive in the user's zone so the date flips at the USER's midnight, not the container's UTC midnight.
|
|
2197
|
+
// Use the VALIDATED zone (tzValid) — an invalid zone re-renders as UTC, consistent with the `(UTC)` annotation.
|
|
2198
|
+
const today = formatLocalDate(new Date(), tzValid ? userTz : undefined);
|
|
2199
|
+
if (today !== promptDate) {
|
|
2200
|
+
promptDate = today;
|
|
2201
|
+
// Non-null: this closure only exists on the renderWithDate branch of the ternary below;
|
|
2202
|
+
// assignment now happens inside finishStable, which TS's flow analysis can't see through.
|
|
2203
|
+
renderedPrompt = renderWithDate(today);
|
|
2204
|
+
if (fpRef.current)
|
|
2205
|
+
fpRef.current.systemPrompt = renderedPrompt;
|
|
2206
|
+
}
|
|
2207
|
+
return renderedPrompt;
|
|
2208
|
+
}
|
|
2209
|
+
: undefined;
|
|
2210
|
+
const harness = new AgentHarness({
|
|
2211
|
+
// design/119 #2: per-request output cap (spec.limits.maxOutputTokens → provider max_tokens).
|
|
2212
|
+
// Guard: a non-positive cap would ride to the wire as max_tokens=0 → provider 400; ignore it
|
|
2213
|
+
// loudly instead (design/120 review).
|
|
2214
|
+
...(spec.limits?.maxOutputTokens !== undefined && spec.limits.maxOutputTokens > 0
|
|
2215
|
+
? { maxOutputTokens: spec.limits.maxOutputTokens }
|
|
2216
|
+
: {}),
|
|
2217
|
+
...(callCapRef !== undefined && callCapOn
|
|
2218
|
+
? {
|
|
2219
|
+
maxOutputTokensPerCall: () => {
|
|
2220
|
+
// service [405] P0: a model OBSERVED emitting self-directed reasoning is never shrunk
|
|
2221
|
+
// again — reasoning tokens bill against max_tokens with no visible output, so the
|
|
2222
|
+
// wall-clock cap math decapitates the thought mid-stream. Observation (a thinking
|
|
2223
|
+
// block, or a shrunk call ending `length` with zero output — both set in runtask) is
|
|
2224
|
+
// the ONLY stand-down source: a `Model.reasoning` metadata gate would ALSO switch off
|
|
2225
|
+
// Anthropic budget-thinking's live [1024,2047] computeCallCap contract (镜头A MED #5)
|
|
2226
|
+
// and a wrong `false` was exactly the TB hole (镜头B Q4). Worst case = ONE capped
|
|
2227
|
+
// first call, which the dynamicCut length-recovery turns into a continue, not a death.
|
|
2228
|
+
if (callCapRef.reasoningObserved === true)
|
|
2229
|
+
return undefined;
|
|
2230
|
+
const cap = computeCallCap(callCapRef, Date.now(), (thinking ?? "off") !== "off");
|
|
2231
|
+
if (cap !== undefined)
|
|
2232
|
+
callCapRef.shrinks = (callCapRef.shrinks ?? 0) + 1; // B2: mechanisms.capShrinks
|
|
2233
|
+
return cap;
|
|
2234
|
+
},
|
|
2235
|
+
}
|
|
2236
|
+
: {}),
|
|
2237
|
+
// design/130 P2b: soft per-call wall-clock deadline — the brain cuts a stream that crosses it
|
|
2238
|
+
// (salvaging the prefix) instead of riding into the hard abort. Inert until runtask arms
|
|
2239
|
+
// deadlineMs + cushionMs on the ref (gracefulFinalize gates the cushion).
|
|
2240
|
+
...(callCapRef !== undefined
|
|
2241
|
+
? { callDeadlineMsPerCall: () => softExecDeadlineMs(callCapRef) }
|
|
2242
|
+
: {}),
|
|
2243
|
+
// design/131: per-task resilience intent → every provider call (decorators stand aside).
|
|
2244
|
+
...(spec.resilience !== undefined ? { resilience: spec.resilience } : {}),
|
|
2245
|
+
// service [398] C2: bridge the loop's self-heal recoveries to the trace stream (`loop.recovery`).
|
|
2246
|
+
// Only RECOVERY continues are bridged — the three normal drive reasons (next_turn / steer /
|
|
2247
|
+
// follow_up) are the loop's ordinary heartbeat, not fallback events.
|
|
2248
|
+
loopTrace: (step) => {
|
|
2249
|
+
if (step.kind !== "continue")
|
|
2250
|
+
return;
|
|
2251
|
+
if (step.reason === "next_turn" || step.reason === "steer_injected" || step.reason === "follow_up_injected")
|
|
2252
|
+
return;
|
|
2253
|
+
emitTrace(deps.tracer, () => ({
|
|
2254
|
+
kind: "loop.recovery",
|
|
2255
|
+
version: 1,
|
|
2256
|
+
taskId: spec.taskId ?? sessionId,
|
|
2257
|
+
reason: step.reason,
|
|
2258
|
+
ts: Date.now(),
|
|
2259
|
+
}));
|
|
2260
|
+
},
|
|
2261
|
+
// design/120 P2: in-stream tool execution opt-in. The harness's own run-start snapshot
|
|
2262
|
+
// (no `tool_call` handler registered) is the real gate keeper — on a run where the policy/
|
|
2263
|
+
// approval gate below registers its handler, the flag never reaches the loop.
|
|
2264
|
+
...(spec.streamingToolExecution === true ? { streamingToolExecution: true } : {}),
|
|
2265
|
+
// Blackboard 2026-07-03 (clay transcript 40e3f65f): the followUp queue's only producer in a
|
|
2266
|
+
// runtask deployment is the task-notification lane ("later" priority — workflow/agent
|
|
2267
|
+
// completions). The default one-at-a-time drain turned 8 buffered completions into 8 chained
|
|
2268
|
+
// turns, each interrupting the final answer. Drain them ALL into one boundary turn instead —
|
|
2269
|
+
// one turn digests every pending notification. (User steering keeps its own queue/mode.)
|
|
2270
|
+
followUpMode: "all",
|
|
2271
|
+
env: executionEnv,
|
|
2272
|
+
session,
|
|
2273
|
+
tools: harnessTools,
|
|
2274
|
+
model,
|
|
2275
|
+
thinkingLevel: thinking ?? "off",
|
|
2276
|
+
systemPrompt: liveSystemPrompt ?? systemPrompt,
|
|
2277
|
+
getApiKeyAndHeaders: spec.getApiKeyAndHeaders,
|
|
2278
|
+
runtime: brainToRuntime(deps.brain),
|
|
2279
|
+
// No `resources`: harness resources (skills/promptTemplates) were a write-only sink here — the
|
|
2280
|
+
// vendored readers were deleted in design/71 P0/P1. Skills are first-party now (see above).
|
|
2281
|
+
});
|
|
2282
|
+
// Now that the harness exists, let `tool_search`'s `rematerialize` reach it (design/36).
|
|
2283
|
+
harnessRef.current = harness;
|
|
2284
|
+
// `abortController` is created early (above, before any env op) for the resume VM-restore. It releases a
|
|
2285
|
+
// pending human-approval gate on abort so the worker isn't hung past its deadline (F4). Here — now that
|
|
2286
|
+
// the harness exists — wire the LIVE external-cancellation listener.
|
|
2287
|
+
// External cancellation: a caller (or a parent task's tool, via ToolExecuteContext.signal) can pass
|
|
2288
|
+
// `spec.signal` to abort this run. Mirror the timeout path: release the policy gate first
|
|
2289
|
+
// (abortController) then stop the loop (harness.abort) — the controller alone does not terminate it.
|
|
2290
|
+
let releaseSignal = () => undefined;
|
|
2291
|
+
if (spec.signal) {
|
|
2292
|
+
if (spec.signal.aborted) {
|
|
2293
|
+
// Already aborted before we started: mark the controller; run() short-circuits before prompt.
|
|
2294
|
+
abortController.abort();
|
|
2295
|
+
}
|
|
2296
|
+
else {
|
|
2297
|
+
const sig = spec.signal;
|
|
2298
|
+
const onExternalAbort = () => {
|
|
2299
|
+
abortController.abort();
|
|
2300
|
+
void harness.abort();
|
|
2301
|
+
};
|
|
2302
|
+
sig.addEventListener("abort", onExternalAbort, { once: true });
|
|
2303
|
+
// Must be removed on task end: a never-fired {once} listener otherwise lingers on a caller's
|
|
2304
|
+
// long-lived/shared signal and pins this run's controller + harness (leak across many tasks).
|
|
2305
|
+
releaseSignal = () => sig.removeEventListener("abort", onExternalAbort);
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
// Two-phase tool gate (design/37): a SINGLE `tool_call` handler runs PreToolUse hooks (collect arg
|
|
2309
|
+
// rewrites + context) then the tool policy (adjudicate on the final args). Funneling both phases
|
|
2310
|
+
// through one handler — instead of two `tool_call` handlers raced by the harness's last-write-wins
|
|
2311
|
+
// `emitHook` — makes the load-bearing invariant ("a hook's allow cannot bypass a policy deny/ask")
|
|
2312
|
+
// structural: the policy always runs last, in this call stack. A task's own policy/hooks/onAsk
|
|
2313
|
+
// override the Runner defaults.
|
|
2314
|
+
const policy = spec.toolPolicy ?? deps.toolPolicy;
|
|
2315
|
+
// design/99 §E6: per-session permission rules (deny-narrowing), composed BEFORE the caller policy like the
|
|
2316
|
+
// skill scope below. Read ONLY when a `sessionPolicyStore` is wired AND this task carries its own
|
|
2317
|
+
// `sessionId` — a delegated subagent has no `spec.sessionId`, so it inherits no session rules (symmetric
|
|
2318
|
+
// with the rules being keyed on the owner's session). v1 reads the LATEST rules at prepare time (an operator
|
|
2319
|
+
// change takes effect on resume), unlike the leg-frozen durable budget. Unset store / no sessionId / no
|
|
2320
|
+
// rules ⇒ undefined ⇒ zero behavior change (fully backward-compatible, opt-in).
|
|
2321
|
+
let sessionRulePolicy;
|
|
2322
|
+
if (deps.sessionPolicyStore && spec.sessionId) {
|
|
2323
|
+
const rules = await deps.sessionPolicyStore.getRules(spec.sessionId, spec.principal);
|
|
2324
|
+
if (rules) {
|
|
2325
|
+
sessionRulePolicy = createSessionRulePolicy(rules, { env: executionEnv, rootPath: taskRootPath, toolEffects });
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
// design/77 §3 Gate-3: the manifest deny-narrowing policy. Active when ≥1 skill carries a manifest OR this
|
|
2329
|
+
// task INHERITED a parent manifest frame across the subagent boundary (design/77 §7 propagation).
|
|
2330
|
+
const skillScopePolicy = hasSkillManifest
|
|
2331
|
+
? createActiveSkillScopePolicy({
|
|
2332
|
+
scope: skillScope,
|
|
2333
|
+
env: executionEnv,
|
|
2334
|
+
rootPath: taskRootPath,
|
|
2335
|
+
// MAJOR-2 fail-closed: the same effects map (spec.tools + hand band + folded MCP) so the scope can
|
|
2336
|
+
// DENY an allowlisted mutating tool that `allowPaths` cannot path-confine (e.g. bash).
|
|
2337
|
+
toolEffects,
|
|
2338
|
+
})
|
|
2339
|
+
: undefined;
|
|
2340
|
+
// The DENY-NARROWING layers (session rules, then skill scope — deny/allow-only, no ask, no rewrite). Each
|
|
2341
|
+
// layer's `deny` short-circuits the `deny > ask > allow` fold (tool-policy.ts:196): they can only SUBTRACT
|
|
2342
|
+
// capability, and a caller `allow` can never widen them back. Core OWNS this composition (the caller's
|
|
2343
|
+
// `spec.toolPolicy` cannot remove a narrowing layer). Re-checked on RESUME before an approved pending tool
|
|
2344
|
+
// (runtask.ts) so a rule TIGHTENED during suspend still applies.
|
|
2345
|
+
const narrowingLayers = [sessionRulePolicy, skillScopePolicy].filter((p) => p !== undefined);
|
|
2346
|
+
const denyNarrowingPolicy = narrowingLayers.length === 0 ? undefined : narrowingLayers.length === 1 ? narrowingLayers[0] : combinePolicies(...narrowingLayers);
|
|
2347
|
+
// SECURITY (bug-hunt MAJOR): the caller `policy` may return `allow{updatedInput}` — a REWRITE the LEADING
|
|
2348
|
+
// narrowing already passed (it checked the ORIGINAL args). combinePolicies cascades the rewrite forward, so
|
|
2349
|
+
// RE-APPEND the narrowing layers AFTER the caller to re-check the REWRITTEN args → a rewrite that escapes
|
|
2350
|
+
// allowDirs/skill-scope is DENIED (the live-path twin of the resume-path re-check). The narrowing layers are
|
|
2351
|
+
// deny/allow-only + idempotent, so the trailing re-check never perturbs ask-precedence or rewrites.
|
|
2352
|
+
const policyLayers = policy !== undefined ? [...narrowingLayers, policy, ...narrowingLayers] : narrowingLayers;
|
|
2353
|
+
// feat(parity-206) — CC 2.1.205/206 auto-mode safety rules, ALWAYS-ON and core-owned (a caller's
|
|
2354
|
+
// `spec.toolPolicy` cannot remove them, same doctrine as the narrowing layers): ① session-transcript
|
|
2355
|
+
// tamper deny (keyed on SEMA's real transcript dir, `($AGENT_DATA_DIR ?? ~/.ai-agent)/sessions`) and
|
|
2356
|
+
// ② unverifiable recursive-delete deny (`rm -rf $VAR` with no in-window assignment — «Fail closed»).
|
|
2357
|
+
// Both are DENY-only (deny short-circuits the deny>ask>allow fold → not auto-allowable by any permission
|
|
2358
|
+
// rule or `onAsk:"allow"`, the CC rule's exact clause) and fire only on two ultra-narrow signatures, so
|
|
2359
|
+
// the design/77 §4 stance (no blanket bash auto-tighten; unattended operation stays cheap) is preserved.
|
|
2360
|
+
// LEADING so they adjudicate original args; RE-APPENDED after a caller policy for the same rewrite-escape
|
|
2361
|
+
// reason as the narrowing layers above.
|
|
2362
|
+
const integrityGuardLayers = [createTranscriptIntegrityPolicy(), createUnverifiableDeletePolicy()];
|
|
2363
|
+
const guardedPolicyLayers = policy !== undefined ? [...integrityGuardLayers, ...policyLayers, ...integrityGuardLayers] : [...integrityGuardLayers, ...policyLayers];
|
|
2364
|
+
const effectivePolicy = guardedPolicyLayers.length === 1 ? guardedPolicyLayers[0] : combinePolicies(...guardedPolicyLayers);
|
|
2365
|
+
const hooks = spec.hooks ?? deps.hooks;
|
|
2366
|
+
const onAsk = spec.onAsk ?? deps.onAsk;
|
|
2367
|
+
// ── MAJOR-1 (design/77 §4 / design/70): write-capable hand band + destructive MCP visibility. ─────────────
|
|
2368
|
+
// DESIGN STANCE (chief designer's call): bash's irreversible-action protection is the DEPLOYMENT SANDBOX —
|
|
2369
|
+
// the egress/microVM boundary (design/53 §2.H, design/76 §4.2) — NOT a built-in per-bash suspend. The
|
|
2370
|
+
// per-tool irreversibility axis reliably covers TYPED tools (spec.tools AND the now-folded MCP tools).
|
|
2371
|
+
// Making every bash 'always' would force every shell command to suspend and break unattended operation, so
|
|
2372
|
+
// we DO NOT auto-tighten bash here. A finance/high-stakes profile may opt the hand band into 'always' via
|
|
2373
|
+
// its tool policy (an `ask`/deny on bash) — that knob is PROFILE-side, not built here.
|
|
2374
|
+
//
|
|
2375
|
+
// (b) The effect:write/idempotent hand-band tools (bash/edit_file/write_file, from toolEffects when a hand
|
|
2376
|
+
// env is injected) are CONSIDERED in the gate-registration predicate below: when the deployment HAS an
|
|
2377
|
+
// effect-aware policy/hook, the gate registers and the policy can adjudicate bash. (No-op when a policy/hook
|
|
2378
|
+
// already forces registration — but explicit, so the link is documented and survives a refactor.)
|
|
2379
|
+
const handWriteTools = handsEnabled
|
|
2380
|
+
? Object.keys(HAND_TOOL_EFFECTS).filter((name) => {
|
|
2381
|
+
const eff = HAND_TOOL_EFFECTS[name];
|
|
2382
|
+
return eff === "write" || eff === "idempotent";
|
|
2383
|
+
})
|
|
2384
|
+
: [];
|
|
2385
|
+
// feat(parity-206): keyed on the CALLER-facing layers (`policyLayers`), NOT `effectivePolicy` — the
|
|
2386
|
+
// always-on integrity guard makes `effectivePolicy` unconditionally defined, and counting it here would
|
|
2387
|
+
// permanently silence the loud UNGATED warning below (the guard fires on two narrow tamper/delete
|
|
2388
|
+
// signatures only; it is not an effect-aware adjudicator for the write band).
|
|
2389
|
+
const hasEffectAwareGate = Boolean(policyLayers.length > 0 || hooks?.preToolUse);
|
|
2390
|
+
// (c) 🔴 LOUD fail-safe: a write-capable hand band OR a destructive MCP tool is present, but NO effect-aware
|
|
2391
|
+
// gate covers it (no policy, no PreToolUse hook) AND it is not otherwise marked egress/irreversible → its
|
|
2392
|
+
// irreversible actions are UNADJUDICATED. Emit a one-time loud warning so the misconfiguration is OBSERVABLE
|
|
2393
|
+
// instead of a silent auto-allow. (The MCP fold above already marks `destructiveHint` tools irreversible, so
|
|
2394
|
+
// a destructive MCP tool only reaches here when its server advertised NO destructiveHint annotation.)
|
|
2395
|
+
const destructiveMcpUngated = mcp.tools.some((t) => !irreversibleTools.has(t.name) && !egressTools.has(t.name) && (toolEffects.get(t.name) ?? "write") !== "read");
|
|
2396
|
+
// design/53 GAP3 (defense-in-depth): a FIRST-PARTY effect:"write" tool that FORGOT to declare egress:true /
|
|
2397
|
+
// irreversibility (e.g. a future external-write tool like open_gitea_issue) is also unadjudicated under no
|
|
2398
|
+
// policy/hook. Scan spec.tools the same way (egress/irreversible-marked tools are already covered by the
|
|
2399
|
+
// egressTools.size / irreversibleTools.size gate-registration above, so exclude them here) so the omission
|
|
2400
|
+
// is at least OBSERVABLE, not silent. Effect comes from toolEffects (filled on the spec.tools pass).
|
|
2401
|
+
const firstPartyWriteUngated = (spec.tools ?? [])
|
|
2402
|
+
.map((t) => t.name)
|
|
2403
|
+
.filter((name) => !irreversibleTools.has(name) && !egressTools.has(name) && toolEffects.get(name) === "write");
|
|
2404
|
+
if (!hasEffectAwareGate && (handWriteTools.length > 0 || destructiveMcpUngated || firstPartyWriteUngated.length > 0)) {
|
|
2405
|
+
// Compose the "what" label ADDITIVELY: when MULTIPLE surfaces are ungated the warning must name each, not
|
|
2406
|
+
// just the hand band (a destructive MCP / first-party write tool would otherwise be silently elided).
|
|
2407
|
+
const what = [
|
|
2408
|
+
handWriteTools.length > 0 ? `hand tools [${handWriteTools.join(", ")}]` : undefined,
|
|
2409
|
+
destructiveMcpUngated ? "MCP write tools" : undefined,
|
|
2410
|
+
firstPartyWriteUngated.length > 0 ? `write tools [${firstPartyWriteUngated.join(", ")}]` : undefined,
|
|
2411
|
+
]
|
|
2412
|
+
.filter(Boolean)
|
|
2413
|
+
.join(" + ");
|
|
2414
|
+
// 飞轮 [520]③ (hookAgent noise): a deployment whose hook-runner spins a child run per hook firing
|
|
2415
|
+
// re-triggered this on EVERY firing — same deps, same shape, an error-level line each time. The warning
|
|
2416
|
+
// is deployment-configuration observability, so it dedupes per (deps, shape): the FIRST task with a
|
|
2417
|
+
// given ungated shape still fires loud (nothing goes silent — C-telemetry discipline), repeats of the
|
|
2418
|
+
// exact same shape on the same deployment are spam and are dropped. A DIFFERENT shape (new tool set)
|
|
2419
|
+
// fires again. Proper fix for the hook-runner remains spec-side (inherit a baseline policy).
|
|
2420
|
+
if (!ungatedWarnedShapes.get(deps)?.has(what)) {
|
|
2421
|
+
let shapes = ungatedWarnedShapes.get(deps);
|
|
2422
|
+
if (!shapes) {
|
|
2423
|
+
shapes = new Set();
|
|
2424
|
+
ungatedWarnedShapes.set(deps, shapes);
|
|
2425
|
+
}
|
|
2426
|
+
shapes.add(what);
|
|
2427
|
+
deps.onError?.(new Error(`write-capable ${what} are present but UNGATED — wire an effect-aware tool policy (or a PreToolUse hook). ` +
|
|
2428
|
+
`bash/MCP irreversible actions are otherwise unadjudicated; the deployment sandbox/egress boundary is the only protection (design/77 §4, design/53 §2.H). ` +
|
|
2429
|
+
`(This warning fires once per deployment per tool-set shape.)`), { phase: "config", sessionId });
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
// PreToolUse `additionalContext`, stashed per tool-call id in phase 1 and drained onto the result in
|
|
2433
|
+
// the PostToolUse handler below (the `tool_call` return shape has no context channel of its own).
|
|
2434
|
+
const preToolContexts = new Map();
|
|
2435
|
+
// design/134 R3: tool-call ids the gate BLOCKED (policy/hook deny, plan-mode deny) or SUSPENDED.
|
|
2436
|
+
// Their error results are not tool EXECUTION failures — CC scopes them to the PermissionDenied
|
|
2437
|
+
// event domain — so the tool_result handler below fires NEITHER postToolUse NOR postToolUseFailure
|
|
2438
|
+
// for them, and the runner's postToolBatch collector excludes them from the turn batch. Entries
|
|
2439
|
+
// are one-shot (deleted on consumption), same discipline as preToolContexts.
|
|
2440
|
+
const blockedToolCalls = new Set();
|
|
2441
|
+
// design/134 复审 (codex HIGH): populate ONLY when a consumer exists. A blocked call's "immediate"
|
|
2442
|
+
// result skips the loop's finalize, so the tool_result handler never drains its entry — the real
|
|
2443
|
+
// consumers are (a) that handler's defensive delete (any per-tool post/pre hook wired) and (b) the
|
|
2444
|
+
// runner's batch-collector delete (postToolBatch wired). With neither, adds would only accumulate.
|
|
2445
|
+
const blockedTracked = Boolean(hooks?.postToolUse || hooks?.preToolUse || hooks?.postToolUseFailure || hooks?.postToolBatch);
|
|
2446
|
+
// design/70: egress tools force the gate even with no policy/hooks wired — otherwise an
|
|
2447
|
+
// unconfigured deployment would auto-allow an external write (the exact thing the marker forbids).
|
|
2448
|
+
// design/74 Slice 3c: ALSO enter this block when resource-suspend is eligible — the suspend helpers
|
|
2449
|
+
// (serializeCheckpointState / commitSuspendSaga / suspendForResource) live here and must be assigned even
|
|
2450
|
+
// for a task with no policy/hooks/egress (resourceSuspend is independent of durableApproval). The gate
|
|
2451
|
+
// *handler* itself stays guarded below, so a pure resource-suspend task keeps the default tool path.
|
|
2452
|
+
const resourceSuspendEligible = spec.resourceSuspend !== undefined &&
|
|
2453
|
+
(spec.checkpointStore ?? deps.checkpointStore) !== undefined &&
|
|
2454
|
+
!(offloadStore !== undefined && isVolatileOffloadStore(offloadStore)) &&
|
|
2455
|
+
// Structural remote check is CORRECT here (≠ the [500]② suspendVM-target capability split below): ANY
|
|
2456
|
+
// remote per-task env is eligible — a suspendable one snapshots via suspendVM, a non-suspendable one
|
|
2457
|
+
// (SSH/ADB host) takes the durable-park-only degrade (workspace persists on the target). Only a
|
|
2458
|
+
// NON-remote per-task env is ineligible (destroyed on suspend → corrupt resume).
|
|
2459
|
+
(ownedEnv === undefined || isRemoteExecutionEnv(ownedEnv));
|
|
2460
|
+
if (spec.resourceSuspend !== undefined && !resourceSuspendEligible) {
|
|
2461
|
+
// design/74 (council #5): opted into resource-slice suspend but NOT eligible — say WHY, else a resource
|
|
2462
|
+
// limit silently hard-fails (failed/limit.max_turns) where the operator expects a resumable `suspended`.
|
|
2463
|
+
const why = (spec.checkpointStore ?? deps.checkpointStore) === undefined
|
|
2464
|
+
? "no CheckpointStore is wired"
|
|
2465
|
+
: offloadStore !== undefined && isVolatileOffloadStore(offloadStore)
|
|
2466
|
+
? "tool-result offload uses the in-memory store (a resume needs durable results)"
|
|
2467
|
+
: "the per-task execution env is not a RemoteExecutionEnv (it would be destroyed on suspend)";
|
|
2468
|
+
deps.onError?.(new Error(`resourceSuspend is set but INACTIVE: ${why}; resource limits will hard-fail, not suspend`), {
|
|
2469
|
+
phase: "config",
|
|
2470
|
+
sessionId,
|
|
2471
|
+
});
|
|
2472
|
+
}
|
|
2473
|
+
// `effectivePolicy` folds the Gate-3 manifest scope (design/77 §3) over the caller policy; `irreversibleTools`
|
|
2474
|
+
// gates registration too (design/77 §4) so an irreversible tool in a no-policy deployment still registers.
|
|
2475
|
+
// MAJOR-1 (b): the write-capable hand band (`handWriteTools`) is adjudicated via this same predicate — when a
|
|
2476
|
+
// deployment wires an effect-aware policy/hook (`hasEffectAwareGate`), `effectivePolicy || hooks?.preToolUse`
|
|
2477
|
+
// is already true here, so the gate registers and the policy can adjudicate bash/edit_file/write_file. We do
|
|
2478
|
+
// NOT add hand tools as an independent activation term (that would force the gate — and a default-allow no-op
|
|
2479
|
+
// — on a no-policy deployment, breaking the unattended default path); the LOUD warning above makes a
|
|
2480
|
+
// no-policy + write-hands deployment OBSERVABLE instead.
|
|
2481
|
+
// feat(parity-206): `effectivePolicy` is now ALWAYS defined (the always-on integrity guard), so this
|
|
2482
|
+
// predicate — and the handler-registration twin below — always registers the gate. Deliberate: the CC
|
|
2483
|
+
// 2.1.205/206 auto-mode rules must hold precisely in the no-policy unattended deployment ("auto mode");
|
|
2484
|
+
// for every call outside the two guard signatures the adjudication is a cheap string-scan allow.
|
|
2485
|
+
if (effectivePolicy || hooks?.preToolUse || egressTools.size > 0 || irreversibleTools.size > 0 || resourceSuspendEligible || spec.enablePlanMode === true) {
|
|
2486
|
+
// Bound the policy check by the task's abort signal: even a policy that ignores `signal` (or an
|
|
2487
|
+
// operator who never answers) cannot hang the loop past timeout/cancel — on abort it resolves to a
|
|
2488
|
+
// deny so the harness can proceed to its own abort handling. `effectivePolicy` includes the Gate-3
|
|
2489
|
+
// manifest scope (design/77 §3) when a manifested skill is present, so the narrowing rides this same
|
|
2490
|
+
// abort-bound adjudicate path.
|
|
2491
|
+
const adjudicate = effectivePolicy
|
|
2492
|
+
? (req) => raceAbort(
|
|
2493
|
+
// design/80 D-E-core (Part A2): inject the read-only durable budget snapshot onto EVERY request
|
|
2494
|
+
// the gate adjudicates (per-leg-immutable; the same wrapper D-2's safety axis flows through). A
|
|
2495
|
+
// stateless escalation policy reads `req.budget` to self-limit across resume legs; it cannot
|
|
2496
|
+
// widen it (frozen here, no write-back). Same object every call — read-only by contract.
|
|
2497
|
+
Promise.resolve(effectivePolicy.check({ ...req, budget: budgetSnapshot }, abortController.signal)), abortController.signal, () => ({
|
|
2498
|
+
action: "deny",
|
|
2499
|
+
reason: "policy check aborted (task timed out or cancelled)",
|
|
2500
|
+
}))
|
|
2501
|
+
: undefined;
|
|
2502
|
+
const resolveAskBound = async (decision, req) => {
|
|
2503
|
+
// design/91: time the SYNCHRONOUS human-review wait (the in-process `onAsk` await). `t0`/`t1` come from
|
|
2504
|
+
// the injectable `now` (NOT bare Date.now) so a test clock pins `waitMs`. Recorded for EVERY resolveAsk
|
|
2505
|
+
// — including the headless auto-deny / `"allow"` fast paths, which return ~0ms (a real human wait shows
|
|
2506
|
+
// a real delay). The wait is bucketed under `kind:"human"` (a synchronous policy ask has no durable
|
|
2507
|
+
// gate kind; it is the in-process analog of the `human` durable gate). **Budget-EXCLUDED** observable.
|
|
2508
|
+
const t0 = now();
|
|
2509
|
+
const resolved = await resolveAsk({
|
|
2510
|
+
toolName: req.toolName,
|
|
2511
|
+
toolCallId: req.toolCallId,
|
|
2512
|
+
args: req.args,
|
|
2513
|
+
message: decision.message ?? decision.reason ?? `approval required for "${req.toolName}"`,
|
|
2514
|
+
// design S1d §1.2: Runner-filled source identity so an AGGREGATING onAsk (one approver fanning
|
|
2515
|
+
// over N delegated workers) can attribute each ask per worker (sourceTaskId) / user (principal).
|
|
2516
|
+
// Non-aggregating approvers ignore these (additive, no-op).
|
|
2517
|
+
principal: spec.principal,
|
|
2518
|
+
sourceTaskId: sessionId,
|
|
2519
|
+
}, onAsk, abortController.signal);
|
|
2520
|
+
const waitMs = Math.max(0, now() - t0); // clamp: a non-monotonic injected clock must never refund time
|
|
2521
|
+
humanReviewRef.count += 1;
|
|
2522
|
+
humanReviewRef.totalWaitMs += waitMs;
|
|
2523
|
+
// design/99 MF-24 (Service AI [297]): record the gated tool + a SHORT, SECRET-SCRUBBED input summary for
|
|
2524
|
+
// the permission-denial ledger ("denied: Bash(rm …)"). The input is in scope on this synchronous path.
|
|
2525
|
+
const toolArg = primaryActivityArg(req.args);
|
|
2526
|
+
humanReviewRef.gates.push({
|
|
2527
|
+
kind: "human",
|
|
2528
|
+
waitMs,
|
|
2529
|
+
// `resolveAsk` only ever returns a terminal allow/deny (never `ask`) — record which the operator chose.
|
|
2530
|
+
decision: resolved.action === "allow" ? "allow" : "deny",
|
|
2531
|
+
toolName: req.toolName,
|
|
2532
|
+
...(toolArg !== undefined ? { toolArg } : {}),
|
|
2533
|
+
});
|
|
2534
|
+
return resolved;
|
|
2535
|
+
};
|
|
2536
|
+
// design/134 R5 (CC PermissionDenied parity, service [418]③): the pre-wrapped deny observer — the
|
|
2537
|
+
// SAME three-way swallow posture as every 1.252 lifecycle callback (hook throw → onError(phase:
|
|
2538
|
+
// "hook") → a throwing onError sink is itself swallowed). Built ONCE here and consumed at exactly
|
|
2539
|
+
// TWO deny short-circuits: the plan-mode write-deny below and runToolGate's single deny exit (which
|
|
2540
|
+
// funnels policy/hook-ask/safety/shellGate denies). A durable suspend and a PreToolUse hook deny
|
|
2541
|
+
// never reach either site (R5 exclusions, enforced structurally in runToolGate).
|
|
2542
|
+
const hooksWithPermissionDenied = hooks?.permissionDenied ? hooks : undefined;
|
|
2543
|
+
const notifyPermissionDenied = hooksWithPermissionDenied
|
|
2544
|
+
? async (payload) => {
|
|
2545
|
+
try {
|
|
2546
|
+
// Called THROUGH the hooks object so a class-instance Hooks keeps its `this` binding.
|
|
2547
|
+
await hooksWithPermissionDenied.permissionDenied(payload);
|
|
2548
|
+
}
|
|
2549
|
+
catch (err) {
|
|
2550
|
+
try {
|
|
2551
|
+
deps.onError?.(err instanceof Error ? err : new Error(String(err)), { phase: "hook", sessionId });
|
|
2552
|
+
}
|
|
2553
|
+
catch {
|
|
2554
|
+
/* throwing onError sink — swallowed */
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
: undefined;
|
|
2559
|
+
// design/45 durable suspend-on-approval (F4) + design/49 v1.5 remote suspend. Active only when BOTH a
|
|
2560
|
+
// CheckpointStore is wired and the task opted into `durableApproval` — else `undefined`, so the gate keeps
|
|
2561
|
+
// the synchronous onAsk path (opt-in, default-off). On a policy `ask` it persists a checkpoint with enough
|
|
2562
|
+
// per-task state to resume on any replica (§4.bis whitelist + the hand's readFileState + the remote
|
|
2563
|
+
// workspaceHandle), then aborts the run so it assembles as `status:"suspended"` (M2 blocked-pattern —
|
|
2564
|
+
// capture + abort + suspendRef, no vendored change).
|
|
2565
|
+
//
|
|
2566
|
+
// The closure is ordered as a commit protocol (design/49 §1/§4①, code-ready council BUG#1): a remote
|
|
2567
|
+
// `suspendVM()` (E2B pause) is the FIRST, atomic side effect; `checkpointStore.put()` is the commit point,
|
|
2568
|
+
// after which `suspendRef` is set with NO intervening await and the run NEVER falls back to onAsk (a durable
|
|
2569
|
+
// checkpoint plus an onAsk both resolving the same approval would double-resolve it). A PRE-commit failure
|
|
2570
|
+
// (including a `{ok:false}` suspendVM) returns `undefined` → safe onAsk fallback (VM untouched). The one
|
|
2571
|
+
// post-pause/pre-commit window — `put` fails after the VM is already paused — is handled explicitly by
|
|
2572
|
+
// unpausing (resumeVM) before any onAsk, or aborting if it can't be unpaused (§4①.split).
|
|
2573
|
+
// `pin` is best-effort and runs only AFTER the commit (its failure must not undo a durable suspend).
|
|
2574
|
+
const checkpointStore = spec.checkpointStore ?? deps.checkpointStore;
|
|
2575
|
+
// design/99 §K (center [318] forceDurableGate): a per-principal entitlement can FORCE this run onto the
|
|
2576
|
+
// durable-approval path — so a plain policy `ask` suspends to the wire (per-tool CC-faithful gate) even
|
|
2577
|
+
// when the caller did not opt into `durableApproval`. center's fleet-wide "every interactive run gates"
|
|
2578
|
+
// mandate. We synthesize the same `{scope}` the caller would supply (principal-isolated, no TTL → the
|
|
2579
|
+
// operator decides), letting the existing suspendAsk path below take over unchanged. Caller-supplied
|
|
2580
|
+
// `durableApproval` always wins (it may carry a tighter scope/ttl).
|
|
2581
|
+
// scope = `spec.principal || DEFAULT` (NOT `??`, DELIBERATELY — same council-MINOR pattern as the scope
|
|
2582
|
+
// read at ~L2223): an EMPTY-string principal must fall to the default ISOLATION scope, never be kept as a
|
|
2583
|
+
// shared `""` bucket that would collapse every empty-principal task into one cross-tenant scope. This
|
|
2584
|
+
// synthesized scope is exactly what L2223 would compute for a `forceDurableGate` ask, so the two agree.
|
|
2585
|
+
const durableApproval = spec.durableApproval ??
|
|
2586
|
+
(runtimeCaps?.forceDurableGate ? { scope: spec.principal || DEFAULT_IRREVERSIBLE_SCOPE } : undefined);
|
|
2587
|
+
// design/74 Slice 3a: the §4.bis correctness-state whitelist, extracted so BOTH the human-gate suspend
|
|
2588
|
+
// (below) and the design/74 resource-slice suspend (the turn-boundary trigger) serialize the SAME live
|
|
2589
|
+
// state. Typed `SerializedCheckpointState` (P3): a new CheckpointState field is a COMPILE error here
|
|
2590
|
+
// until handled — never a silent resume drop. `workspaceHandle` is the suspend-specific snapshot identity
|
|
2591
|
+
// (or undefined for a process-local suspend).
|
|
2592
|
+
const serializeCheckpointState = (workspaceHandle) => ({
|
|
2593
|
+
activeTools: [...activeTools],
|
|
2594
|
+
outputRef: { value: outputRef.value, set: outputRef.set },
|
|
2595
|
+
nestedStats: { ...nestedStats },
|
|
2596
|
+
consolidationNotes: undefined, // design/138 S4: legacy consolidation retired — nothing to carry
|
|
2597
|
+
readFileState: readFileStateForCheckpoint ? [...readFileStateForCheckpoint.entries()] : undefined,
|
|
2598
|
+
// design/78 Slice-1 (MAJOR-3 wiring): the SAFE-tier repair bundle is held in-memory by `runRepairLoop`
|
|
2599
|
+
// OUTSIDE the Runner (a thin composition over runTask). Two sources, in order:
|
|
2600
|
+
// 1. the LIVE bundle threaded by `runRepairLoop` for an in-flight repair attempt
|
|
2601
|
+
// (`internals.repairBundle`) — so a checkpoint minted MID-attempt (attemptCount>0) carries the
|
|
2602
|
+
// live Reflexion state + attemptCount, not `undefined` (the unwired-producer bug);
|
|
2603
|
+
// 2. else the RESUME seed (`resume.seed.repairBundle`) — the pass-through for a re-suspend of a run
|
|
2604
|
+
// that was itself resumed WITH a bundle (multi-suspend chain preservation, like consolidationNotes).
|
|
2605
|
+
// A worker that was never repair-driven serializes `undefined`. The bundle is `structuredClone`d (no
|
|
2606
|
+
// fn/Date) so the persisted copy is independent of the loop's mutable closure object.
|
|
2607
|
+
repairBundle: internals?.repairBundle !== undefined
|
|
2608
|
+
? structuredClone(internals.repairBundle)
|
|
2609
|
+
: resume?.seed.repairBundle !== undefined
|
|
2610
|
+
? structuredClone(resume.seed.repairBundle)
|
|
2611
|
+
: undefined,
|
|
2612
|
+
workspaceHandle,
|
|
2613
|
+
// codex 终审 1.255 F2: the live LOGICAL cwd + active EnterWorktree session at suspend — without
|
|
2614
|
+
// these a resume reset the cwd to the task root and orphaned the worktree session (Exit no-op,
|
|
2615
|
+
// double-Enter accepted). `undefined` when no tracked cwd / no active session (both optional).
|
|
2616
|
+
handsCwd: handsCwdRef?.current,
|
|
2617
|
+
activeWorktree: worktreeSessionRef?.current ? { ...worktreeSessionRef.current } : undefined,
|
|
2618
|
+
// SR-7 (CC F6c :698391-698398 + resume leg :707384-707399): snapshot this run's still-live
|
|
2619
|
+
// background tasks (SAME owner triple as listBackgroundTasks / the TaskOutput mounts) so the
|
|
2620
|
+
// resume leg can diff them against its own registry and announce the orphans in ONE CC-shaped
|
|
2621
|
+
// notice. Serialized at suspend COMMIT time (the saga runs mid-task, BEFORE the run-exit
|
|
2622
|
+
// teardown settles/evicts these rows) — the pre-kill truth lands on the checkpoint.
|
|
2623
|
+
runningBackgroundTasks: (() => {
|
|
2624
|
+
const live = defaultTaskRegistry
|
|
2625
|
+
.list({ owner: hostTaskId, scope: taskScope, sessionId })
|
|
2626
|
+
.filter((t) => t.status === "pending" || t.status === "running")
|
|
2627
|
+
.map((t) => ({ id: t.task_id, ...(t.description !== undefined ? { description: t.description } : {}) }));
|
|
2628
|
+
return live.length > 0 ? live : undefined;
|
|
2629
|
+
})(),
|
|
2630
|
+
// design/80 D-A: a `pendingSteer` is CONSUMED by the resume that injects it (runtask.ts, right after the
|
|
2631
|
+
// resume-continuation prompt) — it is NOT carried onto the next checkpoint. Re-seeding it would re-inject
|
|
2632
|
+
// the SAME guidance on every subsequent resume (a duplicate delivery). So a re-suspend mints
|
|
2633
|
+
// `pendingSteer: undefined`; a supervisor steers the NEW checkpoint with a fresh `setPendingSteer`. (This
|
|
2634
|
+
// is deliberately UNLIKE the `consolidationNotes`/`repairBundle` pass-through — those are cumulative
|
|
2635
|
+
// correctness state; a steer is a one-shot message already delivered to the model.)
|
|
2636
|
+
pendingSteer: undefined,
|
|
2637
|
+
});
|
|
2638
|
+
// design/74 Slice 3b: the put-commit + put-failure split (design/49 §4①), extracted so BOTH the
|
|
2639
|
+
// human-gate suspend (below) and the design/74 resource-slice suspend reuse the SAME commit semantics.
|
|
2640
|
+
// `put` is the point of no return; on put-failure a paused remote VM is unpaused (or the run aborted if
|
|
2641
|
+
// it can't be) so a paused VM is NEVER left without a committed checkpoint. Returns true iff committed —
|
|
2642
|
+
// the caller then sets suspendRef + stops the run ITS way (abort for the human gate, requestStopAfterTurn
|
|
2643
|
+
// for resource). `checkpointStore` is captured; callers gate on it being defined (the guard is defensive).
|
|
2644
|
+
const commitSuspendSaga = async (token, cp, remoteEnv, remoteHandle) => {
|
|
2645
|
+
if (!checkpointStore)
|
|
2646
|
+
return false;
|
|
2647
|
+
// design/138 §2.2 — harvest at the CHECKPOINT MINT point: memory written this slice must be
|
|
2648
|
+
// durable in the backend BEFORE the run suspends (the resume may land on another replica /
|
|
2649
|
+
// after the workspace is gone). Swallow-guarded — a memory failure never blocks the suspend.
|
|
2650
|
+
if (memoryEngineSession)
|
|
2651
|
+
await memoryEngineSession.harvest("checkpoint");
|
|
2652
|
+
try {
|
|
2653
|
+
await checkpointStore.put(token, cp);
|
|
2654
|
+
return true;
|
|
2655
|
+
}
|
|
2656
|
+
catch (putErr) {
|
|
2657
|
+
deps.onError?.(putErr, { phase: "config", sessionId });
|
|
2658
|
+
if (remoteEnv !== undefined && remoteHandle?.snapshotId !== undefined) {
|
|
2659
|
+
const back = await remoteEnv.resumeVM(remoteHandle.snapshotId, { abortSignal: abortController.signal });
|
|
2660
|
+
if (back.ok) {
|
|
2661
|
+
const init = await remoteEnv.postResumeInit();
|
|
2662
|
+
if (init.ok)
|
|
2663
|
+
return false; // VM restored to running → safe fallback (caller: NOT suspended)
|
|
2664
|
+
deps.onError?.(init.error, { phase: "config", sessionId });
|
|
2665
|
+
}
|
|
2666
|
+
else {
|
|
2667
|
+
deps.onError?.(back.error, { phase: "config", sessionId });
|
|
2668
|
+
}
|
|
2669
|
+
// Could not restore the paused VM → fatal: abort the run, never continue on a paused VM.
|
|
2670
|
+
abortController.abort();
|
|
2671
|
+
void harness.abort();
|
|
2672
|
+
return false;
|
|
2673
|
+
}
|
|
2674
|
+
// No remote VM was paused (process-local suspend) → put failure is safe to fall back.
|
|
2675
|
+
return false;
|
|
2676
|
+
}
|
|
2677
|
+
};
|
|
2678
|
+
// design/76 §2.5 — THE COMMIT-SIDE DISCRIMINANT (v4 MAJOR-A, the load-bearing dry-run fix). Called
|
|
2679
|
+
// AFTER `commitSuspendSaga` returns true (the checkpoint is durably committed) to publish which holder
|
|
2680
|
+
// the run loop reads for the terminal status. Branch on the COMMITTED `gate.kind`: a REVIEW-PAUSE gate
|
|
2681
|
+
// writes `reviewRef` (→ assemble-result slot 8.6 → `status:"needs_review"` / `review.pending`); every
|
|
2682
|
+
// approval/resource gate (human / irreversible_ask / resource_limit / task_done) writes `suspendRef` as
|
|
2683
|
+
// before (→ slot 8.5 → `status:"suspended"`). NEVER both — a review pause that also set `suspendRef`
|
|
2684
|
+
// would assemble as `"suspended"` and make slot 8.6 dead code. The review-pause family is BOTH the
|
|
2685
|
+
// dry-run `{kind:"needs_review"}` gate (design/76 §2.5) AND the design/80 D-B `{kind:"plan_review"}`
|
|
2686
|
+
// gate — both are human-review pauses that share the `needs_review` terminal (a `plan_review` resumes
|
|
2687
|
+
// with a `plan_review` outcome, a `needs_review` with `dry_run_review`; the distinct gate.kind keeps the
|
|
2688
|
+
// resume discriminant unambiguous). (The review checkpoints themselves are minted by a PROFILE, out of
|
|
2689
|
+
// core scope; core only owns this routing seam + the gate/status/resume discriminants.)
|
|
2690
|
+
const publishCommittedSuspend = (token, gate, scope) => {
|
|
2691
|
+
const ref = gate.kind === "needs_review" || gate.kind === "plan_review" ? reviewRef : suspendRef;
|
|
2692
|
+
ref.token = token;
|
|
2693
|
+
ref.gate = gate;
|
|
2694
|
+
// design/80 D-2 (r3 BLOCKER): stash the COMMITTED scope so the reap/cancel path reads it verbatim
|
|
2695
|
+
// instead of re-deriving from `spec` (which, after the cross-scope re-scope above, would mismatch the
|
|
2696
|
+
// row's scope → expire()'s WHERE never fires → stranded paused VM + orphan checkpoint).
|
|
2697
|
+
ref.scope = scope;
|
|
2698
|
+
};
|
|
2699
|
+
// design/72 §2.2 (B) / council design/74 #4: the shared suspend-loop-cap guard for BOTH the human gate
|
|
2700
|
+
// (`suspendAsk`) and the resource suspend (`suspendForResource`) — extracted so the cap logic can't drift
|
|
2701
|
+
// between them. Returns true when the cap is hit (the caller then bails with its own no-suspend value).
|
|
2702
|
+
// SAFETY STATE FIRST, diagnostic SECOND (council 2026-06-13 BUG): a deployment-owned `onError` that throws
|
|
2703
|
+
// must NOT skip the safety mutations and leave the run looping — commit hit + abort, THEN report.
|
|
2704
|
+
// design/74 (search [136] HIGH-1): generalized over (count, cap) so the restart-loop path and the
|
|
2705
|
+
// resource-slice path use SEPARATE counters/caps (suspendCount/maxSuspends vs ledger.sliceCount/maxSlices)
|
|
2706
|
+
// while sharing the safety-state-first commit (hit + abort, THEN best-effort diagnostic).
|
|
2707
|
+
const suspendLoopCapHit = (count, cap, detail) => {
|
|
2708
|
+
if (count + 1 <= cap)
|
|
2709
|
+
return false;
|
|
2710
|
+
suspendLoopRef.hit = true;
|
|
2711
|
+
abortController.abort();
|
|
2712
|
+
void harness.abort();
|
|
2713
|
+
// design/74 (codex Vector 5): a deployment-owned `onError` that THROWS must not unwind past the safety
|
|
2714
|
+
// state already committed above (which would reject `suspendForResource` instead of returning, breaking
|
|
2715
|
+
// the turn_boundary hook). The diagnostic is best-effort; the loop-fail state stands regardless.
|
|
2716
|
+
try {
|
|
2717
|
+
deps.onError?.(new Error(`suspend loop: task already suspended ${count} time(s) (max ${cap}); refusing to suspend again${detail}`), { phase: "config", sessionId });
|
|
2718
|
+
}
|
|
2719
|
+
catch {
|
|
2720
|
+
/* ignore a throwing onError — safety state is already set */
|
|
2721
|
+
}
|
|
2722
|
+
return true;
|
|
2723
|
+
};
|
|
2724
|
+
// design/74 Slice 3c: opt-in resource-slice suspend. A resource limit (turns/budget/walltime) hit at a
|
|
2725
|
+
// CLEAN turn boundary (no pending tool) mints a `resource_limit` checkpoint + pauses the workspace, then
|
|
2726
|
+
// stops the loop CLEANLY (requestStopAfterTurn — NOT abort, so NO orphan-[INTERRUPTED] reconcile), so the
|
|
2727
|
+
// run is RESUMABLE instead of failed. Reuses `serializeCheckpointState` (3a) + `commitSuspendSaga` (3b).
|
|
2728
|
+
// Exposed (≠ undefined) ONLY when ELIGIBLE: opted in, a durable store, durable tool-results, and a remote
|
|
2729
|
+
// per-task env — or a static caller-owned env (process-local, no workspace pause). A non-remote PER-TASK
|
|
2730
|
+
// env would be destroyed on suspend → corrupt resume, so it is refused (= human gate).
|
|
2731
|
+
//
|
|
2732
|
+
// service [500]② capability-split: pick the `suspendVM` target by CAPABILITY (`isSuspendable`), NOT the
|
|
2733
|
+
// structural `isRemoteExecutionEnv` — a non-suspendable remote env (SSH host / ADB:
|
|
2734
|
+
// `capabilities.suspendable:false`) has the method structurally but it returns `"unsupported"`, which used
|
|
2735
|
+
// to fail the whole suspend (plan_review park never landed → approval flow broke). Such an env instead
|
|
2736
|
+
// takes the DURABLE-PARK-ONLY degrade shared by all three suspend paths (resource / plan_review / ask):
|
|
2737
|
+
// no `suspendVM`, the checkpoint is minted as usual, and the workspaceHandle is captured WITHOUT a
|
|
2738
|
+
// `snapshotId` — resume then skips `resumeVM` (the host/device workspace persists on the target itself;
|
|
2739
|
+
// the factory reconnects to it). Both consts are task-constant (ownedEnv never changes after prepare).
|
|
2740
|
+
//
|
|
2741
|
+
// ⚠️ INVARIANT this selection rests on (opus review 1.257.2): `suspendable:false` remote ⇒ the
|
|
2742
|
+
// workspace is EXTERNALLY DURABLE (see the capabilities.suspendable doc in remote-env.ts). A
|
|
2743
|
+
// non-suspendable-but-EPHEMERAL adapter must not exist under this contract — it would take this
|
|
2744
|
+
// branch and silently resume onto a fresh workspace. If such a backend class appears, add an
|
|
2745
|
+
// explicit workspace-durability capability and gate `parkOnlyRemoteEnv` on it (fall back to the
|
|
2746
|
+
// human gate instead of parking).
|
|
2747
|
+
const suspendableEnv = ownedEnv !== undefined && isSuspendable(ownedEnv) ? ownedEnv : undefined;
|
|
2748
|
+
const parkOnlyRemoteEnv = suspendableEnv === undefined && ownedEnv !== undefined && isRemoteExecutionEnv(ownedEnv) ? ownedEnv : undefined;
|
|
2749
|
+
// Park-only identity capture: STRIP any snapshotId the env's handle may carry (`WorkspaceHandle.snapshotId`
|
|
2750
|
+
// = "this env was produced by resuming a snapshot", i.e. lineage — the park itself minted NO snapshot).
|
|
2751
|
+
// Persisting a stale lineage id would make the resume path call `resumeVM` on the non-suspendable env
|
|
2752
|
+
// ("unsupported" → failed resume); the ABSENT id is exactly what routes resume onto the skip-restore branch.
|
|
2753
|
+
const parkOnlyHandle = (env) => {
|
|
2754
|
+
const { snapshotId: _lineage, ...identity } = env.workspaceHandle();
|
|
2755
|
+
// `restoreMode` makes the park EXPLICIT in the durable record: resume distinguishes "parked on
|
|
2756
|
+
// purpose" from "snapshot handle that LOST its snapshotId" (corruption → fail-closed there).
|
|
2757
|
+
return { ...identity, restoreMode: "park_only" };
|
|
2758
|
+
};
|
|
2759
|
+
// Assign the task-scoped `suspendForResource` (forward-declared above). `resourceSuspendEligible` (computed
|
|
2760
|
+
// before this block, for the block guard) is the SAME eligibility predicate — reuse it so the two can't
|
|
2761
|
+
// drift; `suspendableEnv`/`parkOnlyRemoteEnv` re-derive the capability split the closure needs.
|
|
2762
|
+
suspendForResource = resourceSuspendEligible
|
|
2763
|
+
? async (reason, sliceSpend) => {
|
|
2764
|
+
const rs = spec.resourceSuspend;
|
|
2765
|
+
if (!checkpointStore || !rs)
|
|
2766
|
+
return false;
|
|
2767
|
+
// A cancelling run must not mint an irreversible checkpoint (design/51 §5, same as the human gate).
|
|
2768
|
+
if (abortController.signal.aborted)
|
|
2769
|
+
return false;
|
|
2770
|
+
// design/72 §2.2 (B): refuse a re-suspend past the cap — fail the run instead of looping forever.
|
|
2771
|
+
// design/74 (search [136] HIGH-1): bound on the RESOURCE-slice counter (ledger.sliceCount) vs the
|
|
2772
|
+
// independent `maxSlices` — NOT the restart-loop suspendCount/maxSuspends. A long resource run is
|
|
2773
|
+
// legitimate; only a $0-progress slice loop trips this backstop (the $ ledger is the real bound).
|
|
2774
|
+
if (suspendLoopCapHit(priorLedger?.sliceCount ?? 0, maxSlices, " (resource slice limit)"))
|
|
2775
|
+
return false;
|
|
2776
|
+
const leafId = await session.getLeafId();
|
|
2777
|
+
if (!leafId)
|
|
2778
|
+
return false; // no committed leaf → no consistent resume point
|
|
2779
|
+
// LOW-10 (search [136]): re-check abort AFTER the async getLeafId — a cancel that landed DURING that
|
|
2780
|
+
// await must not proceed to mint an irreversible checkpoint + pause the VM (the human-gate path does
|
|
2781
|
+
// the same second check before its first durable side effect). Cheap insurance against a TOCTOU race.
|
|
2782
|
+
if (abortController.signal.aborted)
|
|
2783
|
+
return false;
|
|
2784
|
+
// suspendVM = the FIRST durable side effect (atomic seam: {ok:false} ⇒ VM untouched ⇒ safe to skip).
|
|
2785
|
+
let remoteHandle;
|
|
2786
|
+
if (suspendableEnv !== undefined) {
|
|
2787
|
+
// design/103 §3.7: kill background processes BEFORE suspendVM — a suspendable TOB env would otherwise
|
|
2788
|
+
// snapshot them and resume revive untracked zombies (breaking 不跨 suspend). Detached jobs are OUTSIDE
|
|
2789
|
+
// suspendVM's in-flight contract, so the runner MUST dispose explicitly here, not rely on the adapter.
|
|
2790
|
+
if (hasBackgroundShell(suspendableEnv))
|
|
2791
|
+
await suspendableEnv.disposeBackgroundShells();
|
|
2792
|
+
const snap = await suspendableEnv.suspendVM({ abortSignal: abortController.signal });
|
|
2793
|
+
if (!snap.ok) {
|
|
2794
|
+
deps.onError?.(new Error(`resource suspendVM failed (${snap.error.code}): ${snap.error.message}`, { cause: snap.error }), { phase: "config", sessionId });
|
|
2795
|
+
return false;
|
|
2796
|
+
}
|
|
2797
|
+
remoteHandle = { ...suspendableEnv.workspaceHandle(), snapshotId: snap.value };
|
|
2798
|
+
}
|
|
2799
|
+
else if (parkOnlyRemoteEnv !== undefined) {
|
|
2800
|
+
// service [500]② durable-park-only: a non-suspendable remote env (SSH/ADB host) is NOT paused —
|
|
2801
|
+
// its workspace persists on the target itself. Capture identity WITHOUT a snapshotId so the
|
|
2802
|
+
// resume skips resumeVM and simply reconnects via the factory. Background shells are still
|
|
2803
|
+
// disposed (the run is stopping; a detached job must not outlive the leg untracked).
|
|
2804
|
+
if (hasBackgroundShell(parkOnlyRemoteEnv))
|
|
2805
|
+
await parkOnlyRemoteEnv.disposeBackgroundShells();
|
|
2806
|
+
remoteHandle = parkOnlyHandle(parkOnlyRemoteEnv);
|
|
2807
|
+
}
|
|
2808
|
+
const token = mintCheckpointToken();
|
|
2809
|
+
const gate = { kind: "resource_limit", reason };
|
|
2810
|
+
const cp = {
|
|
2811
|
+
token,
|
|
2812
|
+
version: RESOURCE_CHECKPOINT_VERSION, // v2 — an old worker rejects it pre-CAS
|
|
2813
|
+
scope: rs.scope,
|
|
2814
|
+
sessionId,
|
|
2815
|
+
leafId,
|
|
2816
|
+
gate,
|
|
2817
|
+
pendingAction: { kind: "resource_limit", reason },
|
|
2818
|
+
state: serializeCheckpointState(remoteHandle),
|
|
2819
|
+
status: "pending",
|
|
2820
|
+
createdAt: Date.now(),
|
|
2821
|
+
// design/74 (search [136] HIGH-2): stamp a `deadline` (createdAt + ttl, default 30 days) so the
|
|
2822
|
+
// durable reaper — which filters on `deadline` — can GC an ORPHANED (never-resumed) resource
|
|
2823
|
+
// suspend. Without it the `pending` row + in-memory pin leak forever. A legit auto-resume fires
|
|
2824
|
+
// long before this; the deadline only bounds abandonment.
|
|
2825
|
+
deadline: Date.now() + (rs.ttlMs ?? DEFAULT_RESOURCE_TTL_MS),
|
|
2826
|
+
// design/74 (search [136] HIGH-1): the resource slice does NOT advance the restart-loop
|
|
2827
|
+
// `suspendCount` — its place in the chain is the ledger's `sliceCount`. Carrying suspendCount
|
|
2828
|
+
// UNCHANGED keeps the restart-loop safety (maxSuspends) counting only restart suspends, and stops
|
|
2829
|
+
// resource slices from tripping that (low) cap. (The cap for slices is `maxSlices`, checked above.)
|
|
2830
|
+
suspendCount: priorSuspendCount,
|
|
2831
|
+
// design/91: a resource_limit suspend is NOT a human-review wait (no `suspendedAt`), but it must
|
|
2832
|
+
// CARRY FORWARD any human-review burden already accumulated this chain (a prior approval leg) so it
|
|
2833
|
+
// isn't lost across a resource leg. Snapshot the accumulator (omitted when empty). The resume reads
|
|
2834
|
+
// it back into the accumulator; no new latency is added for a resource resume (no human waited).
|
|
2835
|
+
humanReview: humanReviewRef.count > 0
|
|
2836
|
+
? { count: humanReviewRef.count, totalWaitMs: humanReviewRef.totalWaitMs, gates: [...humanReviewRef.gates] }
|
|
2837
|
+
: undefined,
|
|
2838
|
+
// design/74 Slice 4: debit THIS slice's spend onto the cross-slice ledger (prior + this slice; the
|
|
2839
|
+
// first slice seeds the human total from spec.resourceSuspend.totalBudgetUsd). The next resume
|
|
2840
|
+
// reads it to size its own budget = min(maxCostUsd, total − spent) — so a resume can't refresh it.
|
|
2841
|
+
resourceLedger: debitLedger(priorLedger, sliceSpend, resourceTotal),
|
|
2842
|
+
};
|
|
2843
|
+
if (!(await commitSuspendSaga(token, cp, suspendableEnv, remoteHandle)))
|
|
2844
|
+
return false;
|
|
2845
|
+
// ── COMMITTED. Set suspendRef + pin, then stop the loop CLEANLY (Slice 2: requestStopAfterTurn, NOT
|
|
2846
|
+
// abort): a resource suspend is at a clean turn boundary with no orphan tool calls, so the loop
|
|
2847
|
+
// exits via `agent_end` and the session is left resumable (no interrupted-reconcile corruption).
|
|
2848
|
+
// Via the commit-side discriminant (design/76 §2.5): a `resource_limit` gate routes to `suspendRef`.
|
|
2849
|
+
publishCommittedSuspend(token, gate, rs.scope); // design/80 D-2: stash the COMMITTED scope (== cp.scope)
|
|
2850
|
+
try {
|
|
2851
|
+
await sessions.pin?.(sessionId);
|
|
2852
|
+
}
|
|
2853
|
+
catch (pinErr) {
|
|
2854
|
+
deps.onError?.(pinErr, { phase: "config", sessionId });
|
|
2855
|
+
}
|
|
2856
|
+
harness.requestStopAfterTurn();
|
|
2857
|
+
return true;
|
|
2858
|
+
}
|
|
2859
|
+
: undefined;
|
|
2860
|
+
// design/80 D-B: mint a `plan_review` checkpoint at a CLEAN turn boundary when a tool requested review via
|
|
2861
|
+
// `ctx.requestReview()`. Mirrors the human/resource suspend commit saga (suspendVM → put → publish), but the
|
|
2862
|
+
// gate is a REVIEW pause: it routes to `reviewRef` (→ `status:"needs_review"`), binds NO tool call
|
|
2863
|
+
// (`pendingAction:{kind:"plan_review"}`), and ABORTS the loop (like the human gate — the run stops here and
|
|
2864
|
+
// assembles as needs_review; resume continues with a `plan_review` outcome). Present only with a
|
|
2865
|
+
// checkpointStore (else `ctx.requestReview` is a silent no-op). The plan text is in the tool's transcript
|
|
2866
|
+
// content, NOT on the gate (design/76 §9).
|
|
2867
|
+
suspendForReview = checkpointStore
|
|
2868
|
+
? async (reason) => {
|
|
2869
|
+
if (!checkpointStore)
|
|
2870
|
+
return false;
|
|
2871
|
+
// A cancelling run must not mint a checkpoint (design/51 §5, same as the human/resource gates).
|
|
2872
|
+
if (abortController.signal.aborted)
|
|
2873
|
+
return false;
|
|
2874
|
+
// Refuse a re-suspend past the restart-loop cap (a model that re-requests review every resume) —
|
|
2875
|
+
// fail the run instead of looping (design/72 §2.2 B), same backstop as the human gate.
|
|
2876
|
+
if (suspendLoopCapHit(priorSuspendCount, maxSuspends, " for a plan_review (likely a resume/restart loop)."))
|
|
2877
|
+
return false;
|
|
2878
|
+
const leafId = await session.getLeafId();
|
|
2879
|
+
if (!leafId) {
|
|
2880
|
+
// 飞轮 [506]④ diagnosability: this was the ONLY silent drop on the plan_review park path (every
|
|
2881
|
+
// other failure logs via onError). A dropped HITL plan review must never vanish without signal.
|
|
2882
|
+
// codex 1.257.3 (LOW): diagnostic sink swallow-guarded — a throwing deployment onError would
|
|
2883
|
+
// otherwise reject this async branch instead of returning false (run continues).
|
|
2884
|
+
try {
|
|
2885
|
+
deps.onError?.(new Error("plan_review park skipped: no committed session leaf (the plan-review request was dropped; the run continues without pausing)"), { phase: "config", sessionId });
|
|
2886
|
+
}
|
|
2887
|
+
catch {
|
|
2888
|
+
/* diagnostic only — never fault the park path */
|
|
2889
|
+
}
|
|
2890
|
+
return false; // no committed leaf → no consistent resume point
|
|
2891
|
+
}
|
|
2892
|
+
if (abortController.signal.aborted)
|
|
2893
|
+
return false; // TOCTOU re-check after the async getLeafId
|
|
2894
|
+
// suspendVM = the FIRST durable side effect (atomic seam: {ok:false} ⇒ VM untouched ⇒ safe to skip).
|
|
2895
|
+
// service [500]② capability-split: `suspendableEnv` (NOT the structural isRemoteExecutionEnv) picks
|
|
2896
|
+
// the suspendVM target; a non-suspendable remote env takes the park-only branch below instead of
|
|
2897
|
+
// calling suspendVM (which returned "unsupported" → the plan_review park failed → approval flow broke).
|
|
2898
|
+
let remoteHandle;
|
|
2899
|
+
if (suspendableEnv !== undefined) {
|
|
2900
|
+
// design/103 §3.7: dispose background processes before suspendVM (see resource path) — no snapshot zombies.
|
|
2901
|
+
if (hasBackgroundShell(suspendableEnv))
|
|
2902
|
+
await suspendableEnv.disposeBackgroundShells();
|
|
2903
|
+
const snap = await suspendableEnv.suspendVM({ abortSignal: abortController.signal });
|
|
2904
|
+
if (!snap.ok) {
|
|
2905
|
+
deps.onError?.(new Error(`plan_review suspendVM failed (${snap.error.code}): ${snap.error.message}`, { cause: snap.error }), { phase: "config", sessionId });
|
|
2906
|
+
return false;
|
|
2907
|
+
}
|
|
2908
|
+
remoteHandle = { ...suspendableEnv.workspaceHandle(), snapshotId: snap.value };
|
|
2909
|
+
}
|
|
2910
|
+
else if (parkOnlyRemoteEnv !== undefined) {
|
|
2911
|
+
// durable-park-only (see resource path): no pause; identity without snapshotId; resume reconnects.
|
|
2912
|
+
if (hasBackgroundShell(parkOnlyRemoteEnv))
|
|
2913
|
+
await parkOnlyRemoteEnv.disposeBackgroundShells();
|
|
2914
|
+
remoteHandle = parkOnlyHandle(parkOnlyRemoteEnv);
|
|
2915
|
+
}
|
|
2916
|
+
const token = mintCheckpointToken();
|
|
2917
|
+
// Review pause is principal-isolated (operator `/decide` arrives in the task's scope), same `|| DEFAULT`
|
|
2918
|
+
// empty-string rule as the human gate (L2253). A caller `durableApproval` scope/ttl, if any, wins.
|
|
2919
|
+
const scope = spec.durableApproval?.scope ?? (spec.principal || DEFAULT_IRREVERSIBLE_SCOPE);
|
|
2920
|
+
const gate = { kind: "plan_review", reason: reason ?? `plan review requested` };
|
|
2921
|
+
const cp = {
|
|
2922
|
+
token,
|
|
2923
|
+
version: BINDING_CHECKPOINT_VERSION, // ≤ MAX_SUPPORTED ⇒ current workers accept; plan_review resume already supported
|
|
2924
|
+
scope,
|
|
2925
|
+
sessionId,
|
|
2926
|
+
leafId,
|
|
2927
|
+
gate,
|
|
2928
|
+
pendingAction: { kind: "plan_review" },
|
|
2929
|
+
state: serializeCheckpointState(remoteHandle),
|
|
2930
|
+
status: "pending",
|
|
2931
|
+
createdAt: Date.now(),
|
|
2932
|
+
// A plan_review is a HUMAN-DECISION wait (like the approval gate, NOT a resource slice): stamp
|
|
2933
|
+
// `suspendedAt` so resume derives this leg's human-review latency = `resumeNow − suspendedAt`
|
|
2934
|
+
// (design/91), and ADVANCE `suspendCount` so the maxSuspends restart-loop cap actually bounds a
|
|
2935
|
+
// present_plan→reject→present_plan loop (a non-advancing count would never trip the cap above).
|
|
2936
|
+
suspendedAt: now(),
|
|
2937
|
+
deadline: Date.now() + (spec.durableApproval?.ttlMs ?? DEFAULT_RESOURCE_TTL_MS), // bound an abandoned review
|
|
2938
|
+
suspendCount: priorSuspendCount + 1,
|
|
2939
|
+
humanReview: humanReviewRef.count > 0
|
|
2940
|
+
? { count: humanReviewRef.count, totalWaitMs: humanReviewRef.totalWaitMs, gates: [...humanReviewRef.gates] }
|
|
2941
|
+
: undefined,
|
|
2942
|
+
resourceLedger: priorLedger, // carry forward unchanged (no slice debit for a review pause)
|
|
2943
|
+
// Source attribution (parity with the approval gate) so a service supervisor inbox can attribute a
|
|
2944
|
+
// plan_review pause to the issuing task/principal (worker-unforgeable: sessionId, not a tool arg).
|
|
2945
|
+
sourceTaskId: sessionId,
|
|
2946
|
+
...(spec.principal ? { principal: spec.principal } : {}),
|
|
2947
|
+
};
|
|
2948
|
+
if (!(await commitSuspendSaga(token, cp, suspendableEnv, remoteHandle)))
|
|
2949
|
+
return false;
|
|
2950
|
+
// ── COMMITTED. Route plan_review → reviewRef (design/76 §2.5 discriminant), pin, then ABORT the loop
|
|
2951
|
+
// (like the human gate — the run stops here and assembles as needs_review; design/45 capture+abort).
|
|
2952
|
+
publishCommittedSuspend(token, gate, scope);
|
|
2953
|
+
try {
|
|
2954
|
+
await sessions.pin?.(sessionId);
|
|
2955
|
+
}
|
|
2956
|
+
catch (pinErr) {
|
|
2957
|
+
deps.onError?.(pinErr, { phase: "config", sessionId });
|
|
2958
|
+
}
|
|
2959
|
+
abortController.abort();
|
|
2960
|
+
void harness.abort();
|
|
2961
|
+
return true;
|
|
2962
|
+
}
|
|
2963
|
+
: undefined;
|
|
2964
|
+
// design/77 §4.4 (load-bearing build-time fix): arm the durable suspend for an IRREVERSIBLE tool even
|
|
2965
|
+
// when the deployment did NOT opt into `durableApproval`. Otherwise the Gate-4 tighten (allow→ask) in an
|
|
2966
|
+
// unattended deployment degrades to a headless auto-DENY (a model-facing deny = reward-hack retry
|
|
2967
|
+
// surface) — the exact surface this gate exists to remove. The closure below is PARAMETERIZED: with
|
|
2968
|
+
// `durableApproval` it mints the normal `{kind:"human"}` checkpoint under its scope/ttl; without it
|
|
2969
|
+
// (irreversible-only case) it mints `{kind:"irreversible_ask"}` under the DEFAULT_IRREVERSIBLE_SCOPE and
|
|
2970
|
+
// no TTL. Reading `durableApproval.scope`/`.ttlMs` unconditionally here would throw TypeError →
|
|
2971
|
+
// `undefined` → the headless deny it must avoid (that is the inert-gate bug §4.4 calls out).
|
|
2972
|
+
const suspendAsk =
|
|
2973
|
+
// design/80 D-2 (egress predicate): egress-marked tools must arm the durable suspend too — a THIRD OR
|
|
2974
|
+
// inside the `checkpointStore &&` conjunction (not a sibling), so a no-store deployment still falls to
|
|
2975
|
+
// the store-gated headless path and never crash/hangs. Without this, an egress tighten with no
|
|
2976
|
+
// `durableApproval` falls to a headless auto-DENY (design/77 §4.4 reward-hack retry surface).
|
|
2977
|
+
checkpointStore && (durableApproval || irreversibleTools.size > 0 || egressTools.size > 0)
|
|
2978
|
+
? async (req, postHookArgs, safety) => {
|
|
2979
|
+
// ── PRE-COMMIT (read-only prep + the remote pause). Any failure here is SAFE: the checkpoint
|
|
2980
|
+
// is not yet persisted and (per the suspendVM atomicity contract) the remote VM is untouched,
|
|
2981
|
+
// so we fall back to the synchronous onAsk gate rather than silently dropping the approval.
|
|
2982
|
+
let token;
|
|
2983
|
+
let gate;
|
|
2984
|
+
let cp;
|
|
2985
|
+
// The remote workspace handle: with a snapshotId when a remote per-task env was actually
|
|
2986
|
+
// `suspendVM`-paused below, WITHOUT one on the [500]② park-only degrade (non-suspendable remote
|
|
2987
|
+
// env; identity only) — drives both the checkpoint field and the put-failure unpause path
|
|
2988
|
+
// (design/49 §4①.split — the unpause is keyed on `snapshotId`, so a park never "unpauses").
|
|
2989
|
+
let remoteHandle;
|
|
2990
|
+
const remoteEnv = suspendableEnv;
|
|
2991
|
+
try {
|
|
2992
|
+
// §4.bis / Minor #1: durable suspend requires durable tool-result storage. The default
|
|
2993
|
+
// in-memory offload store would deref to null after a resume on a fresh instance — refuse
|
|
2994
|
+
// to suspend (fail-fast) rather than resume into corrupt state. Offload disabled (no store)
|
|
2995
|
+
// is fine: results stay inline in the session and are durable with it.
|
|
2996
|
+
// design/80 D-2 (council MAJOR): fail-fast ONLY if the in-memory store ACTUALLY holds offloaded
|
|
2997
|
+
// results a cross-replica resume would lose. An EMPTY in-memory store has nothing to lose, so a
|
|
2998
|
+
// durable suspend is safe — this is what makes the D-2 egress/irreversible_ask suspend WORK on
|
|
2999
|
+
// the common "wire only a checkpointStore" deployment (default offload, but small args that
|
|
3000
|
+
// never offloaded) instead of silently throwing here → caught below → headless auto-DENY (the
|
|
3001
|
+
// very reward-hack surface D-2's egress predicate removes). A store with entries still fails fast.
|
|
3002
|
+
if (offloadStore && isVolatileOffloadStore(offloadStore) && !offloadStore.isEmpty()) {
|
|
3003
|
+
throw new Error("durable suspend requires a durable toolResultStore (the default InMemoryToolResultStore " +
|
|
3004
|
+
"holds offloaded results that are lost across a cross-replica resume); inject " +
|
|
3005
|
+
"RunnerDeps.toolResultStore or disable offload.");
|
|
3006
|
+
}
|
|
3007
|
+
// design/49 v1.5: a per-task `executionEnvFactory` env that is REMOTE survives the suspend via
|
|
3008
|
+
// `suspendVM()` (E2B pause) instead of being destroyed — resume rebuilds it + `resumeVM()`. A
|
|
3009
|
+
// per-task env that is NOT remote (theoretical — the factory only mints remote) cannot be
|
|
3010
|
+
// paused, so it would still be destroyed on finish() → a fresh empty env on resume → corrupt
|
|
3011
|
+
// readFileState. Refuse that one (the v1 fail-fast, now narrowed to non-remote per-task envs).
|
|
3012
|
+
// A static, caller-owned `deps.executionEnv` (ownedEnv === undefined) survives in-process and
|
|
3013
|
+
// suspends process-locally (no workspaceHandle), exactly as in v1. A NON-SUSPENDABLE remote
|
|
3014
|
+
// per-task env ([500]② park-only: `parkOnlyRemoteEnv`) is fine too — its workspace persists on
|
|
3015
|
+
// the target itself, so no pause is needed; only a non-remote per-task env is refused.
|
|
3016
|
+
if (ownedEnv !== undefined && remoteEnv === undefined && parkOnlyRemoteEnv === undefined) {
|
|
3017
|
+
throw new Error("durable suspend is not supported with a non-remote per-task executionEnvFactory env: the " +
|
|
3018
|
+
"minted env is destroyed on suspend, so a resumed file/shell tool would act on a fresh " +
|
|
3019
|
+
"(empty) env. Use a RemoteExecutionEnv factory (it is paused, not destroyed) or a static, " +
|
|
3020
|
+
"caller-owned RunnerDeps.executionEnv.");
|
|
3021
|
+
}
|
|
3022
|
+
const leafId = await session.getLeafId();
|
|
3023
|
+
if (!leafId) {
|
|
3024
|
+
// A suspend always happens after the suspending assistant message is persisted, so the
|
|
3025
|
+
// branch has a leaf. Guard defensively: no leaf → no consistent resume point → don't suspend.
|
|
3026
|
+
throw new Error("cannot suspend: session has no committed leaf to resume from");
|
|
3027
|
+
}
|
|
3028
|
+
const { messages } = await session.buildContext();
|
|
3029
|
+
const { batchToolCallIds, completedCallIds } = batchContextAt(messages, req.toolCallId);
|
|
3030
|
+
// design/51 §5: a run being CANCELLED must not mint an irreversible durable checkpoint (the
|
|
3031
|
+
// budget→cancel race: an orchestrator detects over-budget and aborts the shared signal just as
|
|
3032
|
+
// a worker is about to suspend). Check the abort HERE — after the read-only prep, immediately
|
|
3033
|
+
// before the first side effect (suspendVM on the remote path; `put` on the process-local
|
|
3034
|
+
// path, which has NO suspendVM) — so neither path commits for a cancelling run. Aborted ⇒
|
|
3035
|
+
// return undefined = safe onAsk fallback (the run is aborting anyway → the abort-bounded onAsk
|
|
3036
|
+
// gate resolves to deny → the loop ends cancelled, no durable checkpoint).
|
|
3037
|
+
//
|
|
3038
|
+
// This is a CORRECTNESS guard, not just an optimization: on the process-local suspend path
|
|
3039
|
+
// there is no suspendVM, so `put` is the first side effect and this check is the ONLY thing
|
|
3040
|
+
// gating the commit on cancel; on the remote path it is also defense-in-depth, since
|
|
3041
|
+
// suspendVM's abortSignal is best-effort (a backend may finish the pause before honoring it).
|
|
3042
|
+
// The residual micro-race (passes here, THEN abort fires, THEN put commits) is covered by
|
|
3043
|
+
// TaskStream.destroy() reaping the resulting suspension (design/51 §4) — the two are
|
|
3044
|
+
// complementary = atomic cancel.
|
|
3045
|
+
if (abortController.signal.aborted) {
|
|
3046
|
+
return undefined;
|
|
3047
|
+
}
|
|
3048
|
+
// design/72 §2.2 (B): refuse a re-suspend past the cap BEFORE any side effect (no suspendVM,
|
|
3049
|
+
// no put) — fail the run instead of looping. Checked here, after the read-only prep and the
|
|
3050
|
+
// abort check, so a cancelling run still takes the abort path above. The shared helper commits
|
|
3051
|
+
// `suspendLoopRef` + abort (safety-state-first) so assembleResult reports `failed`/`suspend.loop`.
|
|
3052
|
+
if (suspendLoopCapHit(priorSuspendCount, maxSuspends, ` for tool "${req.toolName}" — likely a resume/restart loop.`))
|
|
3053
|
+
return undefined;
|
|
3054
|
+
// suspendVM = the FIRST durable side effect, and it is ATOMIC (seam contract): {ok:false} ⇒ the
|
|
3055
|
+
// VM is left running/untouched ⇒ safe to fall back to onAsk. Done BEFORE `put` so a paused VM
|
|
3056
|
+
// is always paired with a committed checkpoint (or, on the put-failure split path, unpaused).
|
|
3057
|
+
// abortSignal: a hung pause (network partition) cannot pin the worker (council BUG#2).
|
|
3058
|
+
if (remoteEnv !== undefined) {
|
|
3059
|
+
// design/103 §3.7: dispose background processes before suspendVM (see resource path) — no snapshot zombies.
|
|
3060
|
+
if (hasBackgroundShell(remoteEnv))
|
|
3061
|
+
await remoteEnv.disposeBackgroundShells();
|
|
3062
|
+
const snap = await remoteEnv.suspendVM({ abortSignal: abortController.signal });
|
|
3063
|
+
if (!snap.ok) {
|
|
3064
|
+
throw new Error(`suspendVM failed (${snap.error.code}): ${snap.error.message}`, { cause: snap.error });
|
|
3065
|
+
}
|
|
3066
|
+
// Capture identity AFTER the pause and stamp the returned snapshotId ourselves (don't rely on
|
|
3067
|
+
// the env to mutate workspaceHandle() in place) — this is what resume's resumeVM() reads.
|
|
3068
|
+
remoteHandle = { ...remoteEnv.workspaceHandle(), snapshotId: snap.value };
|
|
3069
|
+
}
|
|
3070
|
+
else if (parkOnlyRemoteEnv !== undefined) {
|
|
3071
|
+
// durable-park-only (see resource path): no pause; identity without snapshotId; resume reconnects.
|
|
3072
|
+
if (hasBackgroundShell(parkOnlyRemoteEnv))
|
|
3073
|
+
await parkOnlyRemoteEnv.disposeBackgroundShells();
|
|
3074
|
+
remoteHandle = parkOnlyHandle(parkOnlyRemoteEnv);
|
|
3075
|
+
}
|
|
3076
|
+
token = mintCheckpointToken();
|
|
3077
|
+
// design/77 §4.4/§4.5: pick the gate kind + scope + ttl by whether `durableApproval` is wired.
|
|
3078
|
+
// WITH it → the normal `{kind:"human"}` durable-approval suspend (unchanged). WITHOUT it (the
|
|
3079
|
+
// irreversible-only case, where `durableApproval.scope`/`.ttlMs` would throw TypeError) → mint
|
|
3080
|
+
// the DISJOINT `{kind:"irreversible_ask"}` gate under the default scope, no TTL. Both are
|
|
3081
|
+
// pre-action approval suspends (suspendRef → `status:"suspended"`), resumed identically with a
|
|
3082
|
+
// `policy_ask` outcome (the new third gateMatch arm in runtask.ts).
|
|
3083
|
+
// design/80 D-2 (mint-site safety axis): a SAFETY tighten (`safety !== undefined` — the egress/
|
|
3084
|
+
// irreversibility gate forced this ask) mints the DISJOINT `irreversible_ask` kind with its
|
|
3085
|
+
// persisted `safetyAxis` — EVEN WHEN `durableApproval` is wired (which supplies scope/ttl, NOT
|
|
3086
|
+
// the kind). This is the load-bearing fix: a network budget resolver keys on `kind` to NEVER
|
|
3087
|
+
// auto-approve a safety ask. A plain (non-safety) ask keeps the normal choice: `human` under
|
|
3088
|
+
// `durableApproval`, else `irreversible_ask` (the §4.4 unattended-irreversible-only case).
|
|
3089
|
+
// design/80 D-E: a DETERMINISTIC, REDACTED risk summary attached AT MINT so a supervisor inbox
|
|
3090
|
+
// sorts/triages by TRUE severity. INERT — it is display/triage metadata ONLY: nothing below
|
|
3091
|
+
// reads it to gate/budget/suppress (structural ask still strictly wins). `shellGated` is true only
|
|
3092
|
+
// for a bash call coarsely gated by `shellGate` (grades as the `shell` axis, severity 3, not 4).
|
|
3093
|
+
// Pure (no clock/random) — same call ⇒ identical descriptor. `postHookArgs` are the SHOWN args.
|
|
3094
|
+
const riskDescriptor = buildRiskDescriptor({
|
|
3095
|
+
toolName: req.toolName,
|
|
3096
|
+
args: postHookArgs,
|
|
3097
|
+
safety,
|
|
3098
|
+
shellGated: (req.toolName === "Bash" && shellGatedBash) || (req.toolName === "Monitor" && shellGatedMonitor),
|
|
3099
|
+
});
|
|
3100
|
+
gate =
|
|
3101
|
+
safety !== undefined
|
|
3102
|
+
? {
|
|
3103
|
+
kind: "irreversible_ask",
|
|
3104
|
+
reason: `human approval required before safety-tightened tool "${req.toolName}"`,
|
|
3105
|
+
toolName: req.toolName,
|
|
3106
|
+
safetyAxis: safety,
|
|
3107
|
+
riskDescriptor,
|
|
3108
|
+
}
|
|
3109
|
+
: durableApproval
|
|
3110
|
+
? {
|
|
3111
|
+
kind: "human",
|
|
3112
|
+
reason: `human approval required for tool "${req.toolName}"`,
|
|
3113
|
+
toolName: req.toolName,
|
|
3114
|
+
riskDescriptor,
|
|
3115
|
+
}
|
|
3116
|
+
: {
|
|
3117
|
+
kind: "irreversible_ask",
|
|
3118
|
+
reason: `human approval required before irreversible tool "${req.toolName}"`,
|
|
3119
|
+
toolName: req.toolName,
|
|
3120
|
+
riskDescriptor,
|
|
3121
|
+
};
|
|
3122
|
+
// design/80 D-2 (cross-scope mint): an UNATTENDED safety/irreversible_ask (no `durableApproval`)
|
|
3123
|
+
// must be resumable by the operator whose `/decide` arrives in the task's principal scope — so
|
|
3124
|
+
// re-scope to `spec.principal` (the authenticated end-user, design/62) when no durableApproval
|
|
3125
|
+
// scope is set, else the resolve scope-WHERE never matches (or a fixed DEFAULT cross-tenant
|
|
3126
|
+
// leaks). The reap path reads the COMMITTED `cp.scope` (below), never re-derives — so this
|
|
3127
|
+
// re-scope can't strand the paused VM (r3 BLOCKER).
|
|
3128
|
+
// `|| DEFAULT` (not `??`): an EMPTY-string principal must NOT become a shared scope bucket
|
|
3129
|
+
// (council MINOR) — `?? spec.principal` would keep `""` and collapse every empty-principal task
|
|
3130
|
+
// into one cross-tenant scope. Falsy "" falls through to the default isolation scope.
|
|
3131
|
+
const scope = durableApproval?.scope ?? (spec.principal || DEFAULT_IRREVERSIBLE_SCOPE);
|
|
3132
|
+
const ttlMs = durableApproval?.ttlMs;
|
|
3133
|
+
// §4.bis whitelist of correctness state (snapshot of the live values at suspend), via the shared
|
|
3134
|
+
// serializer (Slice 3a) so the resource-slice suspend captures the SAME state. `remoteHandle`
|
|
3135
|
+
// is undefined for a process-local suspend (design/49 v1.5).
|
|
3136
|
+
const checkpointState = serializeCheckpointState(remoteHandle);
|
|
3137
|
+
cp = {
|
|
3138
|
+
token,
|
|
3139
|
+
// design/80 D-1 (version-skew fix): binding-bearing human/irreversible_ask checkpoints stamp
|
|
3140
|
+
// v3 so a pre-D-1 worker (MAX_SUPPORTED=2, no binding enforcement) REJECTS them pre-CAS
|
|
3141
|
+
// instead of resuming them with the decision-action binding silently voided (council BLOCKER).
|
|
3142
|
+
version: BINDING_CHECKPOINT_VERSION,
|
|
3143
|
+
scope,
|
|
3144
|
+
sessionId,
|
|
3145
|
+
leafId,
|
|
3146
|
+
gate,
|
|
3147
|
+
pendingAction: {
|
|
3148
|
+
kind: "tool_approval",
|
|
3149
|
+
toolCallId: req.toolCallId,
|
|
3150
|
+
toolName: req.toolName,
|
|
3151
|
+
args: postHookArgs, // design/37 post-hook (rewritten) args — resume executes these
|
|
3152
|
+
// design/80 D-1 §2 (slice 1a.2): server-minted opaque boundInputHash over the SAME
|
|
3153
|
+
// post-hook args — computed ONCE here, persisted on the row, echoed by the operator and
|
|
3154
|
+
// verified by opaque equality on resume (never re-serialized resume-side).
|
|
3155
|
+
boundInputHash: boundInputHashOf(postHookArgs),
|
|
3156
|
+
batchToolCallIds,
|
|
3157
|
+
completedCallIds,
|
|
3158
|
+
},
|
|
3159
|
+
state: checkpointState,
|
|
3160
|
+
// design/80 D-2 (BLOCKER): an UNATTENDED suspend (no durableApproval = no operator to ever
|
|
3161
|
+
// resolve it) MUST carry an abandonment deadline so the reaper can GC it; else the egress/
|
|
3162
|
+
// irreversible_ask suspend pins the session + strands the paused VM forever. A durableApproval
|
|
3163
|
+
// suspend keeps the operator's ttlMs (undefined = unbounded; D-D terminalAt is the backstop).
|
|
3164
|
+
deadline: ttlMs !== undefined
|
|
3165
|
+
? Date.now() + ttlMs
|
|
3166
|
+
: durableApproval !== undefined
|
|
3167
|
+
? undefined
|
|
3168
|
+
: Date.now() + DEFAULT_UNATTENDED_APPROVAL_TTL_MS,
|
|
3169
|
+
status: "pending",
|
|
3170
|
+
createdAt: Date.now(),
|
|
3171
|
+
// design/91: the wall-clock instant this approval gate suspended, from the injectable `now`.
|
|
3172
|
+
// Resume derives this leg's human-review latency as `resumeNow − suspendedAt`. (createdAt/
|
|
3173
|
+
// deadline stay on Date.now — they are reaper/SLA fields, not the human-time observable.)
|
|
3174
|
+
suspendedAt: now(),
|
|
3175
|
+
// design/91: snapshot the PRIOR-leg human-review burden onto the row so the resume that wakes
|
|
3176
|
+
// this checkpoint seeds the accumulator + adds THIS leg's latency = the whole chain's burden.
|
|
3177
|
+
// Snapshot the gate array (independent of the mutable ref). Omitted when nothing accumulated yet.
|
|
3178
|
+
humanReview: humanReviewRef.count > 0
|
|
3179
|
+
? { count: humanReviewRef.count, totalWaitMs: humanReviewRef.totalWaitMs, gates: [...humanReviewRef.gates] }
|
|
3180
|
+
: undefined,
|
|
3181
|
+
suspendCount: priorSuspendCount + 1, // design/72 §2.2 (B): this suspend's place in the chain
|
|
3182
|
+
// design/80 D-E-core (A3): attach the durable resource ledger to this APPROVAL suspend so a
|
|
3183
|
+
// STATELESS policy can read cumulative spend across the resume chain (a fresh in-memory counter
|
|
3184
|
+
// resets every leg). Debit THIS leg's live cumulative spend onto the prior ledger (reuse the
|
|
3185
|
+
// resource-slice `debitLedger`: prior-wins frozen total, monotonic spend, never refunded) — the
|
|
3186
|
+
// event-driven gate reads the live spend via `liveSpendRef` (the resource-slice path passes it
|
|
3187
|
+
// explicitly instead). On the FIRST suspend the first slice seeds the human total from
|
|
3188
|
+
// `resourceTotal` (undefined when no resource budget is configured → an unbounded read).
|
|
3189
|
+
// **READ-ONLY for stats**: this ledger rides the approval suspend purely for the `req.budget`
|
|
3190
|
+
// read — the final-stats aggregate fold (runtask) is GATED to the resource-slice gate kind, so
|
|
3191
|
+
// an approval resume is NEVER double-counted (design/80 A3, the single subtle correctness point).
|
|
3192
|
+
// `debitLedger` is pure + total (never throws) so it cannot leak a paused VM in this try block.
|
|
3193
|
+
resourceLedger: debitLedger(priorLedger, liveSpendRef.get?.() ?? { costMicroUsd: 0, tokens: 0, turns: 0 }, resourceTotal, { countSlice: false }),
|
|
3194
|
+
// design S1d (source-tag persistence, service [198]/[199]): stamp the SAME source identity the
|
|
3195
|
+
// synchronous ask carries (resolveAskBound above: principal = spec.principal, sourceTaskId =
|
|
3196
|
+
// sessionId) onto the durable approval checkpoint — the suspend path never invokes onAsk, so a
|
|
3197
|
+
// supervisor inbox would otherwise have no per-worker/per-user attribution for a PAUSED ask.
|
|
3198
|
+
// ECHO-ONLY triage metadata, NEVER a gate input (no gate/CAS/winner/resume-validation reads it).
|
|
3199
|
+
// `sourceTaskId = sessionId` is worker-unforgeable for a Runner-created delegated subagent (the
|
|
3200
|
+
// child never sets its own sessionId). Only the APPROVAL (human/irreversible_ask) suspend stamps
|
|
3201
|
+
// these — the resource_limit suspend has no ask source (mirrors the winnerFromOutcome/policy_ask
|
|
3202
|
+
// binding scope). Omitted via `||`/conditional spread when absent (empty principal → omit).
|
|
3203
|
+
sourceTaskId: sessionId,
|
|
3204
|
+
...(spec.principal ? { principal: spec.principal } : {}),
|
|
3205
|
+
};
|
|
3206
|
+
}
|
|
3207
|
+
catch (err) {
|
|
3208
|
+
// Reaching here means the VM is still running: suspendVM was either never called or returned
|
|
3209
|
+
// {ok:false} (atomic seam contract: VM untouched). The only post-pause statements above are the
|
|
3210
|
+
// SYNCHRONOUS `workspaceHandle()` (documented as data the env already holds, remote-env.ts),
|
|
3211
|
+
// `boundInputHashOf(postHookArgs)` (canonical-json.ts — TOTAL by construction, never throws, so a
|
|
3212
|
+
// pathological arg can't leak the paused VM here; design/80 D-1 §2 / round-2 MAJOR), and pure
|
|
3213
|
+
// object construction — by contract they don't throw, so a paused VM never falls through to here.
|
|
3214
|
+
// (A contract-violating throw from a post-pause backend would be the same class as the
|
|
3215
|
+
// put-failure split path below; it is out of scope, not silently mishandled.) → safe onAsk.
|
|
3216
|
+
deps.onError?.(err, { phase: "config", sessionId });
|
|
3217
|
+
return undefined;
|
|
3218
|
+
}
|
|
3219
|
+
// ── COMMIT (Slice 3b: shared put + put-failure split, see `commitSuspendSaga`). ──────────────
|
|
3220
|
+
if (!(await commitSuspendSaga(token, cp, remoteEnv, remoteHandle))) {
|
|
3221
|
+
return undefined; // put failed → rolled back / aborted → safe onAsk fallback (NOT suspended)
|
|
3222
|
+
}
|
|
3223
|
+
// ── COMMITTED (point of no return). No await/throw before setting suspendRef; NEVER onAsk now. ──
|
|
3224
|
+
// Via the commit-side discriminant (design/76 §2.5): a human/irreversible_ask gate routes to
|
|
3225
|
+
// `suspendRef` (the dry-run `needs_review` gate — minted by a profile — would route to `reviewRef`).
|
|
3226
|
+
publishCommittedSuspend(token, gate, cp.scope); // design/80 D-2: stash the COMMITTED scope (after the cross-scope re-scope)
|
|
3227
|
+
// pin is best-effort: a failure only risks idle eviction of an IN-MEMORY session (durable store:
|
|
3228
|
+
// pin is a no-op) — it must NOT undo a committed suspend, so swallow + log rather than throw.
|
|
3229
|
+
try {
|
|
3230
|
+
await sessions.pin?.(sessionId); // protect the session from idle eviction until resume (§5)
|
|
3231
|
+
}
|
|
3232
|
+
catch (pinErr) {
|
|
3233
|
+
deps.onError?.(pinErr, { phase: "config", sessionId });
|
|
3234
|
+
}
|
|
3235
|
+
// Abort the run (capture is durable now): release the policy gate then stop the harness —
|
|
3236
|
+
// the loop returns an aborted message and assembleResult sees suspendRef → "suspended".
|
|
3237
|
+
abortController.abort();
|
|
3238
|
+
void harness.abort();
|
|
3239
|
+
return { token, gate };
|
|
3240
|
+
}
|
|
3241
|
+
: undefined;
|
|
3242
|
+
// design/108: sync `toolEffects` from the FINAL tool set so the plan-mode write-deny sees every tool's real
|
|
3243
|
+
// effect. First-party tools pushed AFTER the toolEffects build (~590) — present_plan, enter_plan_mode,
|
|
3244
|
+
// AskUserQuestion, Skill, recall, repo_map, … — carry their `effect` on the AgentTool (defineTool preserves
|
|
3245
|
+
// it; raw factories already set it). Without this they read as unknown→"write" and plan mode over-rejects
|
|
3246
|
+
// read tools. Only fill MISSING entries (never override an explicit earlier set, e.g. handsReadOnly's Bash:read).
|
|
3247
|
+
for (const t of tools) {
|
|
3248
|
+
const eff = t.effect;
|
|
3249
|
+
if (eff !== undefined && !toolEffects.has(t.name))
|
|
3250
|
+
toolEffects.set(t.name, eff);
|
|
3251
|
+
}
|
|
3252
|
+
// design/74 Slice 3c: register the tool-gate handler ONLY for real gate work (a policy / PreToolUse
|
|
3253
|
+
// hook / egress tool / irreversible tool / design/77 §3 manifest scope). A task that entered this block
|
|
3254
|
+
// solely for resource-suspend has no gate work — runToolGate would no-op — so skipping registration keeps
|
|
3255
|
+
// its tool path provably the default. `effectivePolicy` covers the caller policy AND the Gate-3 scope;
|
|
3256
|
+
// design/77 §4: `irreversibleTools.size > 0` MUST gate registration too, else an `always`/`maybe` tool in
|
|
3257
|
+
// a no-policy/no-hook/no-egress unattended deployment would never be tightened → silently auto-allow.
|
|
3258
|
+
// design/108: `spec.enablePlanMode` MUST also register the handler — a plan-mode task with no policy/hook/egress
|
|
3259
|
+
// /irreversible would otherwise skip the tool_call handler entirely → the plan-mode write-deny below never runs.
|
|
3260
|
+
if (effectivePolicy || hooks?.preToolUse || egressTools.size > 0 || irreversibleTools.size > 0 || spec.enablePlanMode === true) {
|
|
3261
|
+
harness.on("tool_call", async (e) => {
|
|
3262
|
+
// design/134 复审 (codex HIGH): drop any stale blocked-marker for this id FIRST — a provider
|
|
3263
|
+
// reusing a toolCallId across calls must not have the new call's post hooks suppressed by an
|
|
3264
|
+
// old call's block record (the set is otherwise only drained on tool_result/batch collect).
|
|
3265
|
+
blockedToolCalls.delete(e.toolCallId);
|
|
3266
|
+
// design/108: plan-mode write-deny — a FIDELITY check kept SEPARATE from the safety gate below (plan mode
|
|
3267
|
+
// is a read-only behavior mode the model self-imposes, NOT a security boundary; the real gates run on read
|
|
3268
|
+
// tools as usual). Short-circuit BEFORE runToolGate so a write-effect call in plan mode is rejected without
|
|
3269
|
+
// running policy/onAsk/probe/durable-mint. Unknown effect ⇒ "write" (fail-closed: gated in plan mode).
|
|
3270
|
+
//
|
|
3271
|
+
// NOT a security bypass (review): this short-circuit can only ADD a denial (for write-effect tools while
|
|
3272
|
+
// the model is in plan mode) — it never ALLOWS anything runToolGate would have denied. Net effect on a
|
|
3273
|
+
// write tool in plan mode = DENY, which is ≥ as restrictive as any policy/PreToolUse verdict. The only
|
|
3274
|
+
// thing skipped is the gate's SIDE EFFECTS (PreToolUse audit/arg-rewrite, reversibility probe) for a call
|
|
3275
|
+
// that DID NOT EXECUTE; the plan-block itself is recorded as the tool's error result. Deployments that
|
|
3276
|
+
// need every blocked write attempt to traverse their PreToolUse hook should not rely on plan mode for that.
|
|
3277
|
+
if (planModeRef.active && (toolEffects.get(e.toolName) ?? "write") !== "read") {
|
|
3278
|
+
if (blockedTracked)
|
|
3279
|
+
blockedToolCalls.add(e.toolCallId); // design/134 R3: a plan-mode deny is not an execution failure
|
|
3280
|
+
const planDenyReason = `Plan mode is active — "${e.toolName}" is a write/mutating tool and is read-only-blocked. ` +
|
|
3281
|
+
`Research with read-only tools, then call ${PRESENT_PLAN_TOOL_NAME} with your plan to get it approved before acting.`;
|
|
3282
|
+
// design/134 R5: the plan-mode write-deny is a PermissionDenied-domain outcome (R3 already scopes
|
|
3283
|
+
// it OUT of the post-tool callbacks) — the second of the two deny short-circuits the observer covers.
|
|
3284
|
+
if (notifyPermissionDenied) {
|
|
3285
|
+
// 1.256 复审 MED-1: clone the live args before the observe-only payload — a hook mutating
|
|
3286
|
+
// `payload.input` must never pollute the harness event's input object.
|
|
3287
|
+
await notifyPermissionDenied({ toolName: e.toolName, input: cloneObserverInput(e.input), toolCallId: e.toolCallId, reason: planDenyReason, source: "planMode" });
|
|
3288
|
+
}
|
|
3289
|
+
return {
|
|
3290
|
+
block: true,
|
|
3291
|
+
reason: formatHookFeedback(planDenyReason),
|
|
3292
|
+
preToolContext: [],
|
|
3293
|
+
};
|
|
3294
|
+
}
|
|
3295
|
+
const result = await runToolGate({
|
|
3296
|
+
event: e,
|
|
3297
|
+
preToolUse: hooks?.preToolUse,
|
|
3298
|
+
adjudicate,
|
|
3299
|
+
resolveAsk: resolveAskBound,
|
|
3300
|
+
suspendAsk,
|
|
3301
|
+
egress: egressTools.has(e.toolName), // design/70: the gate tightens allow→ask for these
|
|
3302
|
+
// design/77 §4: per-call irreversibility tier + probe (read from the spec at prepare-time,
|
|
3303
|
+
// captured here — not a tool argument the model can monkey-patch). The probe is time-bounded by
|
|
3304
|
+
// the task abort signal (its real deadline) and fails closed to `ask`.
|
|
3305
|
+
irreversibility: irreversibilityTier.get(e.toolName),
|
|
3306
|
+
reversibilityProbe: reversibilityProbes.get(e.toolName),
|
|
3307
|
+
abortSignal: abortController.signal,
|
|
3308
|
+
// design/134 R5: the deny observer (pre-wrapped, never throws) + the coarse-shellGate marker so
|
|
3309
|
+
// a tighten-deny on a shell-gated Bash/Monitor attributes to "shellGate" (not "safety").
|
|
3310
|
+
...(notifyPermissionDenied ? { permissionDenied: notifyPermissionDenied } : {}),
|
|
3311
|
+
shellGated: (e.toolName === "Bash" && shellGatedBash) || (e.toolName === "Monitor" && shellGatedMonitor),
|
|
3312
|
+
});
|
|
3313
|
+
// Stash PreToolUse context only for calls that will EXECUTE: a blocked call skips the loop's
|
|
3314
|
+
// finalize step, so its `tool_result` hook never fires to drain the map — stashing it there would
|
|
3315
|
+
// both leak the entry and never deliver it (the block reason already carries the model-facing
|
|
3316
|
+
// text). A suspended call is likewise aborted, so don't stash it either.
|
|
3317
|
+
if (!result.block && !result.suspend && result.preToolContext.length > 0) {
|
|
3318
|
+
preToolContexts.set(e.toolCallId, result.preToolContext);
|
|
3319
|
+
}
|
|
3320
|
+
// design/134 R3: remember blocked/suspended ids so a tool_result carrying their error text
|
|
3321
|
+
// (if any path ever emits one) triggers neither post callback.
|
|
3322
|
+
if (blockedTracked && (result.block || result.suspend)) {
|
|
3323
|
+
blockedToolCalls.add(e.toolCallId);
|
|
3324
|
+
}
|
|
3325
|
+
// A suspend has already aborted the run inside `suspendAsk`; let the harness's own abort handling
|
|
3326
|
+
// produce the aborted result for this call (no block/updatedInput to return here).
|
|
3327
|
+
return result.block
|
|
3328
|
+
? { block: true, reason: result.reason }
|
|
3329
|
+
: result.updatedInput !== undefined
|
|
3330
|
+
? { updatedInput: result.updatedInput }
|
|
3331
|
+
: undefined;
|
|
3332
|
+
});
|
|
3333
|
+
}
|
|
3334
|
+
}
|
|
3335
|
+
// PostToolUse (design/37): rewrite the tool result content and/or append `<system-reminder>` context
|
|
3336
|
+
// (both the hook's own `additionalContext` and any PreToolUse context stashed for this call).
|
|
3337
|
+
// design/134 §2 (BREAKING): success/failure results are MUTUALLY EXCLUSIVE — an `isError` result
|
|
3338
|
+
// fires postToolUseFailure ONLY (additionalContext-only, no rewrite), never postToolUse.
|
|
3339
|
+
if (hooks?.postToolUse || hooks?.preToolUse || hooks?.postToolUseFailure) {
|
|
3340
|
+
harness.on("tool_result", async (e) => {
|
|
3341
|
+
// design/134 R3: a blocked/suspended call's result is a PermissionDenied-domain outcome, not a
|
|
3342
|
+
// tool execution — fire NEITHER post callback. One-shot: consume the entry so it can't leak.
|
|
3343
|
+
// (preToolContexts never stashes blocked calls, so there is nothing to drain here either.)
|
|
3344
|
+
if (blockedToolCalls.delete(e.toolCallId)) {
|
|
3345
|
+
return undefined;
|
|
3346
|
+
}
|
|
3347
|
+
let content = e.content;
|
|
3348
|
+
let changed = false;
|
|
3349
|
+
if (e.isError) {
|
|
3350
|
+
if (hooks?.postToolUseFailure) {
|
|
3351
|
+
// design/134 复审 (codex MED): isolate the failure payload from the live result the loop
|
|
3352
|
+
// is about to commit — an element-level clone of `content` (a hook mutating a block's
|
|
3353
|
+
// `text` must not rewrite the transcript) and a best-effort deep clone of `details`
|
|
3354
|
+
// (non-cloneable graphs — functions/handles — fall back to the live reference; the
|
|
3355
|
+
// HookToolFailure.details contract documents that read-only expectation).
|
|
3356
|
+
let clonedDetails = e.details;
|
|
3357
|
+
try {
|
|
3358
|
+
clonedDetails = structuredClone(e.details);
|
|
3359
|
+
}
|
|
3360
|
+
catch {
|
|
3361
|
+
/* best-effort: pass the live reference for non-structured-cloneable details */
|
|
3362
|
+
}
|
|
3363
|
+
const patch = await hooks.postToolUseFailure(e.toolName, e.input, {
|
|
3364
|
+
error: e.content
|
|
3365
|
+
.filter((c) => c.type === "text")
|
|
3366
|
+
.map((c) => c.text)
|
|
3367
|
+
.join("\n"),
|
|
3368
|
+
// Wide any-abort semantics (⊇ CC is_interrupt, which is user-interrupt only): the task
|
|
3369
|
+
// abort signal covers timeout/budget/cancel/suspend too — documented on HookToolFailure.
|
|
3370
|
+
isInterrupt: abortController.signal.aborted || spec.signal?.aborted === true,
|
|
3371
|
+
// No durationMs: the harness tool_result event carries no execution-time field today,
|
|
3372
|
+
// and the design forbids adding a timer just for it.
|
|
3373
|
+
content: e.content.map((c) => ({ ...c })),
|
|
3374
|
+
details: clonedDetails,
|
|
3375
|
+
}, { toolCallId: e.toolCallId, toolName: e.toolName });
|
|
3376
|
+
if (patch?.additionalContext) {
|
|
3377
|
+
content = [...content, { type: "text", text: formatHookFeedback(patch.additionalContext) }];
|
|
3378
|
+
changed = true;
|
|
3379
|
+
}
|
|
3380
|
+
}
|
|
3381
|
+
}
|
|
3382
|
+
else if (hooks?.postToolUse) {
|
|
3383
|
+
const patch = await hooks.postToolUse(e.toolName, e.input, { content: e.content, details: e.details, isError: e.isError }, { toolCallId: e.toolCallId, toolName: e.toolName });
|
|
3384
|
+
if (patch?.updatedOutput) {
|
|
3385
|
+
content = patch.updatedOutput;
|
|
3386
|
+
changed = true;
|
|
3387
|
+
}
|
|
3388
|
+
if (patch?.additionalContext) {
|
|
3389
|
+
content = [...content, { type: "text", text: formatHookFeedback(patch.additionalContext) }];
|
|
3390
|
+
changed = true;
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
const pre = preToolContexts.get(e.toolCallId);
|
|
3394
|
+
if (pre) {
|
|
3395
|
+
preToolContexts.delete(e.toolCallId); // one-shot: drain so it can't leak onto a later call
|
|
3396
|
+
content = [...content, ...pre.map((t) => ({ type: "text", text: formatHookFeedback(t) }))];
|
|
3397
|
+
changed = true;
|
|
3398
|
+
}
|
|
3399
|
+
return changed ? { content } : undefined;
|
|
3400
|
+
});
|
|
3401
|
+
}
|
|
3402
|
+
// In-task context management, applied before each provider request (request-only, non-destructive):
|
|
3403
|
+
// 0. per-message aggregate tool-result budget — cap a single turn's batch if N parallel results SUM
|
|
3404
|
+
// past 200K chars (design/64 §17.2; largest-first offload). Deterministic ⇒ cache-safe, resume-free.
|
|
3405
|
+
// 1. context-editing — clear old tool-result *content* (lightest; keeps turn structure)
|
|
3406
|
+
// 2. guard trim — if still over the hard budget, drop oldest messages at safe boundaries
|
|
3407
|
+
// Between-task compaction (LLM summary) handles the normal long-conversation case.
|
|
3408
|
+
const editAt = editBudget(model);
|
|
3409
|
+
const guardAt = guardBudget(model);
|
|
3410
|
+
// design/123 D2: the task model's structural coefficient — the request-layer defenses below and
|
|
3411
|
+
// the prompt-overhead term must share ONE coordinate with the compaction trigger estimate.
|
|
3412
|
+
const charsPerToken = model.charsPerToken ?? DEFAULT_CHARS_PER_TOKEN;
|
|
3413
|
+
harness.on("context", async ({ messages }) => {
|
|
3414
|
+
// audit A-1: heal pre-1.92 sessions that persisted a synthetic empty failure assistant inside an
|
|
3415
|
+
// interrupted batch (API-invalid on strict providers; append-only sessions can't be rewritten).
|
|
3416
|
+
const healed = dropEmptyFailureAssistants(messages);
|
|
3417
|
+
const capped = await capAggregateToolResults(healed, {
|
|
3418
|
+
store: offloadStore,
|
|
3419
|
+
sessionId,
|
|
3420
|
+
// service [398] C8: every capped result (offloaded or degraded to a self-contained preview)
|
|
3421
|
+
// gets a metadata-only trace frame; storeFallback flags the lost-read-back face.
|
|
3422
|
+
onCapped: (info) => emitTrace(deps.tracer, () => ({
|
|
3423
|
+
kind: "tool_result.capped",
|
|
3424
|
+
version: 1,
|
|
3425
|
+
taskId: spec.taskId ?? sessionId,
|
|
3426
|
+
...(info.tool !== undefined ? { tool: info.tool } : {}),
|
|
3427
|
+
sizeChars: info.sizeChars,
|
|
3428
|
+
storeFallback: info.storeFallback,
|
|
3429
|
+
ts: Date.now(),
|
|
3430
|
+
})),
|
|
3431
|
+
});
|
|
3432
|
+
// batch-C/§17.2: bound aggregate inline-media (image) bytes — the multimodal sibling of the text budget
|
|
3433
|
+
// above (image reads accumulate across turns; per-image cap doesn't bound the SUM). Request-only.
|
|
3434
|
+
const mediaCapped = capAggregateMediaBytes(capped, {
|
|
3435
|
+
limitBytes: deps.mediaByteCapBytes ?? AGGREGATE_MEDIA_BUDGET_BYTES,
|
|
3436
|
+
onStripped: deps.onMediaStripped,
|
|
3437
|
+
});
|
|
3438
|
+
const edited = clearStaleToolResults(mediaCapped, {
|
|
3439
|
+
budgetTokens: editAt,
|
|
3440
|
+
// design/123 D3: hand the defense the ANCHORED estimate of the processed messages (billed
|
|
3441
|
+
// truth — the same coordinate the compaction trigger uses) plus the model's coefficient, so
|
|
3442
|
+
// its gate and internal bookkeeping stop self-certifying via the chars/4 structural sum.
|
|
3443
|
+
anchoredTotalTokens: estimateContextTokens(mediaCapped, charsPerToken).tokens,
|
|
3444
|
+
charsPerToken,
|
|
3445
|
+
// roadmap #6② (CC contentReplacementState parity): offload-then-clear — the cleared marker
|
|
3446
|
+
// carries a ReadToolResult ref so the model can page a cleared result back. Deterministic
|
|
3447
|
+
// ref (sessionId+toolCallId) + write-once put ⇒ the per-request re-clear stays byte-stable
|
|
3448
|
+
// (prefix-cache safe). Only wired when offload is enabled (same store as the size cap above).
|
|
3449
|
+
...(offloadStore
|
|
3450
|
+
? {
|
|
3451
|
+
offload: {
|
|
3452
|
+
persist: (toolCallId, fullText) => {
|
|
3453
|
+
const ref = `tr_${sessionId}_${toolCallId}`;
|
|
3454
|
+
void offloadStore.put(ref, fullText); // write-once; a repeat put is a no-op
|
|
3455
|
+
return ref;
|
|
3456
|
+
},
|
|
3457
|
+
},
|
|
3458
|
+
}
|
|
3459
|
+
: {}),
|
|
3460
|
+
});
|
|
3461
|
+
// design/123 D3: same anchored coordinate for the guard trim, re-estimated on the EDITED array
|
|
3462
|
+
// (pre-anchor clears don't lower the anchored estimate — deliberately; see trimToBudget doc).
|
|
3463
|
+
const trimmed = trimToBudget(edited, guardAt, estimateContextTokens(edited, charsPerToken).tokens, charsPerToken);
|
|
3464
|
+
// design/123 D4: a REAL message drop by the trim (shorter output — content-only clears keep the
|
|
3465
|
+
// length) means the request layer was forced to discard history → arm the trim-pressure flag so
|
|
3466
|
+
// the next turn boundary force-compacts (see Prepared.trimPressureRef).
|
|
3467
|
+
if (trimmed.length < edited.length) {
|
|
3468
|
+
trimPressureRef.droppedMessages = true;
|
|
3469
|
+
// service [398] C9: the model didn't see these messages THIS turn (request-only drop) —
|
|
3470
|
+
// previously only the D4 pressure flag knew, and only indirectly via the next compaction.
|
|
3471
|
+
emitTrace(deps.tracer, () => ({
|
|
3472
|
+
kind: "context.trim",
|
|
3473
|
+
version: 1,
|
|
3474
|
+
taskId: spec.taskId ?? sessionId,
|
|
3475
|
+
dropped: edited.length - trimmed.length,
|
|
3476
|
+
ts: Date.now(),
|
|
3477
|
+
}));
|
|
3478
|
+
}
|
|
3479
|
+
// design/123 D3 belt (invariant, live-caught 16k 400s): NO pipeline combination may ship an
|
|
3480
|
+
// orphan toolResult to the provider ("role 'tool' must respond to tool_calls" is a strict-
|
|
3481
|
+
// gateway hard 400). Producers are individually turn-aligned; this is the last line. An actual
|
|
3482
|
+
// drop indicates a producer bug — surface it via onError with attribution detail.
|
|
3483
|
+
const swept = dropOrphanToolResults(trimmed);
|
|
3484
|
+
if (swept.dropped.length > 0) {
|
|
3485
|
+
try {
|
|
3486
|
+
deps.onError?.(new Error(`context belt: dropped ${swept.dropped.length} orphan toolResult(s) whose tool-call assistant was not in the request view — ` +
|
|
3487
|
+
swept.dropped
|
|
3488
|
+
.map((d) => `toolCallId=${d.toolCallId} at index ${d.index} (prev=${d.prevRole}, next=${d.nextRole})`)
|
|
3489
|
+
.join("; ")), { phase: "hook", sessionId });
|
|
3490
|
+
}
|
|
3491
|
+
catch {
|
|
3492
|
+
/* a throwing onError must never break the request build */
|
|
3493
|
+
}
|
|
3494
|
+
}
|
|
3495
|
+
return { messages: swept.messages };
|
|
3496
|
+
});
|
|
3497
|
+
// Two-phase cache-break detection (design/31): default on. Fingerprints the prefix (system + the tools
|
|
3498
|
+
// ACTUALLY sent — placeholders included) here; `tool_search` refreshes `cacheFingerprint.tools` in place
|
|
3499
|
+
// when it materializes a deferred tool (design/36 blocker #2), so the detector never goes blind to a
|
|
3500
|
+
// mid-task tool-schema change. The run loop feeds per-turn cacheRead + the served modelKey.
|
|
3501
|
+
const cacheBreakDetector = deps.cacheBreakDetection === false ? undefined : new CacheBreakDetector();
|
|
3502
|
+
const cacheFingerprint = cacheBreakDetector
|
|
3503
|
+
? { systemPrompt, tools: toolsToFingerprintInputs(harnessTools) }
|
|
3504
|
+
: undefined;
|
|
3505
|
+
fpRef.current = cacheFingerprint;
|
|
3506
|
+
// Fixed prompt overhead for the compaction trigger's anchor-less regime (design/64 §26.7): the
|
|
3507
|
+
// chars/4 fallback sees messages only; system prompt + tool schemas are billed on every request.
|
|
3508
|
+
// design/123 D2: divided by the model's charsPerToken (not a hard /4) — this term is ADDED to the
|
|
3509
|
+
// anchor-less trigger estimate, so it must share that estimate's structural coordinate.
|
|
3510
|
+
const promptOverheadTokens = Math.ceil((systemPrompt.length +
|
|
3511
|
+
harnessTools.reduce((a, t) => a + t.name.length + (t.description?.length ?? 0) + JSON.stringify(t.parameters ?? {}).length, 0)) / charsPerToken);
|
|
3512
|
+
// Working-file attachments reader (LONGRUN-2): same env as the hands, so attachments are read
|
|
3513
|
+
// from the workspace the task actually modified. Best-effort by contract — errors become null.
|
|
3514
|
+
// 编码 parity 批 (codex 镜头1 MED): SAME decode coordinate as the Read tool — a BOM'd UTF-16 file
|
|
3515
|
+
// the model read as clean text must not re-enter the compaction summary as utf8 mojibake (the
|
|
3516
|
+
// summary tells the model to TRUST attachment content); a malformed body degrades to null.
|
|
3517
|
+
const readTaskFile = handsEnabled
|
|
3518
|
+
? async (path) => {
|
|
3519
|
+
try {
|
|
3520
|
+
const r = await executionEnv.readBinaryFile(path);
|
|
3521
|
+
if (!r.ok)
|
|
3522
|
+
return null;
|
|
3523
|
+
const d = decodeTextBytes(r.value);
|
|
3524
|
+
return d.malformed ? null : d.text;
|
|
3525
|
+
}
|
|
3526
|
+
catch {
|
|
3527
|
+
return null;
|
|
3528
|
+
}
|
|
3529
|
+
}
|
|
3530
|
+
: undefined;
|
|
3531
|
+
// CC post-compact restore parity (blackboard 2026-07-03): most-recently-READ files, newest first.
|
|
3532
|
+
// Closure over the live readFileState — evaluated AT COMPACTION TIME, so it reflects reads up to
|
|
3533
|
+
// that boundary. Entries without lastReadAt (seeded from an older design/45 checkpoint) sort last.
|
|
3534
|
+
const recentlyReadFiles = handsEnabled && readFileStateForCheckpoint
|
|
3535
|
+
? () => [...readFileStateForCheckpoint.entries()]
|
|
3536
|
+
.sort((a, b) => (b[1].lastReadAt ?? 0) - (a[1].lastReadAt ?? 0))
|
|
3537
|
+
.map(([path]) => path)
|
|
3538
|
+
: undefined;
|
|
3539
|
+
// design/133 F5 (CC getChangedFiles 同构): boundary-time stat over the most-recently-read files.
|
|
3540
|
+
// Lives HERE (not the run loop) because readFileState + the hands' env are prepare-task closures;
|
|
3541
|
+
// the run loop only decides WHEN (opt-in + boundary gate). Judged against `lastReadAt` + EPS —
|
|
3542
|
+
// no new ReadEntry field, hot read/write paths do zero extra stats, and the agent's own
|
|
3543
|
+
// write-backs are immune because every fs write site already refreshes lastReadAt. Entries
|
|
3544
|
+
// without lastReadAt (old-checkpoint seeds) can't be compared ⇒ skipped, never false-fired.
|
|
3545
|
+
const detectExternalChanges = handsEnabled && readFileStateForCheckpoint
|
|
3546
|
+
? async (maxFiles) => {
|
|
3547
|
+
const candidates = [...readFileStateForCheckpoint.entries()]
|
|
3548
|
+
.filter(([, e]) => e.lastReadAt !== undefined)
|
|
3549
|
+
.sort((a, b) => (b[1].lastReadAt ?? 0) - (a[1].lastReadAt ?? 0))
|
|
3550
|
+
.slice(0, Math.max(0, maxFiles));
|
|
3551
|
+
const changed = [];
|
|
3552
|
+
const evicted = [];
|
|
3553
|
+
for (const [path, entry] of candidates) {
|
|
3554
|
+
try {
|
|
3555
|
+
const info = await executionEnv.fileInfo(path, abortController.signal);
|
|
3556
|
+
if (!info.ok) {
|
|
3557
|
+
// CC evict-only-on-ENOENT (attachments.ts:2146-2155 / PR #18525): an atomic-save race,
|
|
3558
|
+
// EACCES churn or a network-fs hiccup must NOT evict, or the next Edit fails "not read"
|
|
3559
|
+
// while the file still exists. Only a true not_found deletes the entry.
|
|
3560
|
+
if (info.error.code === "not_found") {
|
|
3561
|
+
readFileStateForCheckpoint.delete(path);
|
|
3562
|
+
evicted.push(path);
|
|
3563
|
+
}
|
|
3564
|
+
continue;
|
|
3565
|
+
}
|
|
3566
|
+
if (info.value.mtimeMs > (entry.lastReadAt ?? 0) + CHANGED_FILES_MTIME_EPS_MS) {
|
|
3567
|
+
changed.push({ path, mtimeMs: info.value.mtimeMs });
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
catch {
|
|
3571
|
+
/* best-effort scan: a throwing env must never fault the turn boundary */
|
|
3572
|
+
}
|
|
3573
|
+
}
|
|
3574
|
+
return { changed, evicted };
|
|
3575
|
+
}
|
|
3576
|
+
: undefined;
|
|
3577
|
+
// G1 通告层 [482]: post-compact background-task snapshot — the SAME identity triple the
|
|
3578
|
+
// TaskOutput/TaskStop tool mounts use (hostTaskId/taskScope/sessionId), so the announce surface
|
|
3579
|
+
// can never see a task the model's own tools couldn't reach. Bounded projection, pending/running
|
|
3580
|
+
// only (a terminal task needs no post-compact continuity note — its completion notification
|
|
3581
|
+
// already rode the task_notification lane).
|
|
3582
|
+
const listBackgroundTasks = () => defaultTaskRegistry
|
|
3583
|
+
.list({ owner: hostTaskId, scope: taskScope, sessionId })
|
|
3584
|
+
.filter((t) => t.status === "pending" || t.status === "running")
|
|
3585
|
+
.map((t) => ({ id: t.task_id, ...(t.description !== undefined ? { description: t.description } : {}), status: t.status }));
|
|
3586
|
+
return { harness, session, sessionId, taskRootPath, model, thinking, compModel, mcp, blockedRef, outputRef, abortController, conflictRef, blockedToolCalls, nestedStats, cwdRef: handsCwdRef, denyNarrowingPolicy, releaseSignal, cacheBreakDetector, cacheFingerprint, promptManifest, activeTools, ownedEnv, suspendRef, reviewRef, reviewRequestRef, suspendLoopRef, suspendForResource, ...(callCapRef ? { callCapRef } : {}), suspendForReview, resourceLedger: priorLedger, liveSpendRef, humanReviewRef, now, tools, toolEffects, promptOverheadTokens, readTaskFile, recentlyReadFiles, compactionReuseRef, trimPressureRef, ...(memoryEngineSession ? { memoryEngineSession } : {}), ...(subagentRetain ? { subagentRetain } : {}), ...(lspDiagnostics && nudgeLspOnEdit ? { lspDiagnostics: { registry: lspDiagnostics, nudge: nudgeLspOnEdit } } : {}), planModeRef, ...(detectExternalChanges ? { detectExternalChanges } : {}), ...(toolsDeltaRef ? { toolsDeltaRef } : {}), ...(agentListing ? { agentListing } : {}), listBackgroundTasks };
|
|
3587
|
+
}
|
|
3588
|
+
catch (prepareErr) {
|
|
3589
|
+
// P-8: dispose disposables in REVERSE acquisition order, each best-effort + independent so one failure neither
|
|
3590
|
+
// masks prepareErr nor blocks the others. mcp (acquired after ownedEnv) first; the `if (mcp)` guard skips a
|
|
3591
|
+
// pre-materialize throw (mcp still undefined). NEVER on the success path — there runtask owns prepared.mcp.dispose().
|
|
3592
|
+
if (mcp) {
|
|
3593
|
+
try {
|
|
3594
|
+
await mcp.dispose();
|
|
3595
|
+
}
|
|
3596
|
+
catch {
|
|
3597
|
+
/* best-effort; closing MCP clients must not mask the prepare error */
|
|
3598
|
+
}
|
|
3599
|
+
}
|
|
3600
|
+
if (ownedEnv && hasDestroy(ownedEnv)) {
|
|
3601
|
+
try {
|
|
3602
|
+
await ownedEnv.destroy();
|
|
3603
|
+
}
|
|
3604
|
+
catch {
|
|
3605
|
+
/* best-effort teardown; the service reaper is the backstop */
|
|
3606
|
+
}
|
|
3607
|
+
}
|
|
3608
|
+
await forgetOnThrow(); // 1.93.0 throw-cleanup: the caller can never release what it never received
|
|
3609
|
+
throw prepareErr;
|
|
3610
|
+
}
|
|
3611
|
+
}
|
|
3612
|
+
//# sourceMappingURL=prepare-task.js.map
|