@animalabs/agent-framework 0.22.1 → 0.22.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/src/agent.d.ts +192 -19
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +404 -65
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/mcp-server-core.d.ts +41 -0
- package/dist/src/api/mcp-server-core.d.ts.map +1 -0
- package/dist/src/api/mcp-server-core.js +593 -0
- package/dist/src/api/mcp-server-core.js.map +1 -0
- package/dist/src/api/mcp-server.d.ts +2 -36
- package/dist/src/api/mcp-server.d.ts.map +1 -1
- package/dist/src/api/mcp-server.js +15 -595
- package/dist/src/api/mcp-server.js.map +1 -1
- package/dist/src/api/server.d.ts +9 -1
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +50 -26
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/api/types.d.ts +9 -1
- package/dist/src/api/types.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/framework.d.ts +393 -113
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1748 -582
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/gate-script.d.ts.map +1 -1
- package/dist/src/gate/gate-script.js.map +1 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +26 -7
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +102 -22
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
- package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
- package/dist/src/mcpl/errors.js.map +1 -1
- package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
- package/dist/src/mcpl/feature-set-manager.js +4 -3
- package/dist/src/mcpl/feature-set-manager.js.map +1 -1
- package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
- package/dist/src/mcpl/inference-router.d.ts.map +1 -1
- package/dist/src/mcpl/log-value.d.ts +54 -0
- package/dist/src/mcpl/log-value.d.ts.map +1 -0
- package/dist/src/mcpl/log-value.js +109 -0
- package/dist/src/mcpl/log-value.js.map +1 -0
- package/dist/src/mcpl/prose-stream-router.d.ts +6 -6
- package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
- package/dist/src/mcpl/prose-stream-router.js +4 -4
- package/dist/src/mcpl/prose-stream-router.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +38 -3
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +81 -16
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts +26 -3
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +87 -23
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
- package/dist/src/mcpl/reference-fetcher.js.map +1 -1
- package/dist/src/mcpl/references.d.ts +2 -2
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/mcpl/refusal-lines.d.ts +37 -0
- package/dist/src/mcpl/refusal-lines.d.ts.map +1 -0
- package/dist/src/mcpl/refusal-lines.js +61 -0
- package/dist/src/mcpl/refusal-lines.js.map +1 -0
- package/dist/src/mcpl/scope-manager.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +18 -2
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +66 -11
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/server-registry.d.ts.map +1 -1
- package/dist/src/mcpl/tool-classes.d.ts +1 -1
- package/dist/src/mcpl/tool-classes.d.ts.map +1 -1
- package/dist/src/mcpl/tool-classes.js +3 -2
- package/dist/src/mcpl/tool-classes.js.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
- package/dist/src/mcpl/transport.d.ts +23 -5
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +103 -10
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +30 -26
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/api/index.d.ts.map +1 -1
- package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
- package/dist/src/modules/discord/discord-js-client.js.map +1 -1
- package/dist/src/modules/discord/index.d.ts.map +1 -1
- package/dist/src/modules/discord/index.js +4 -2
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/health/index.d.ts.map +1 -1
- package/dist/src/modules/health/index.js.map +1 -1
- package/dist/src/modules/history/index.d.ts.map +1 -1
- package/dist/src/modules/history/index.js.map +1 -1
- package/dist/src/modules/history/semantic.d.ts.map +1 -1
- package/dist/src/modules/history/semantic.js.map +1 -1
- package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
- package/dist/src/modules/workspace/branch-intent.js.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.d.ts +22 -4
- package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.js +63 -25
- package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
- package/dist/src/modules/workspace/effects.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +8 -4
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +39 -12
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +43 -8
- package/dist/src/modules/workspace/observe.d.ts.map +1 -1
- package/dist/src/modules/workspace/observe.js +264 -39
- package/dist/src/modules/workspace/observe.js.map +1 -1
- package/dist/src/modules/workspace/reconcile.d.ts +17 -1
- package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
- package/dist/src/modules/workspace/reconcile.js +57 -14
- package/dist/src/modules/workspace/reconcile.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +27 -2
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/operator-log.d.ts.map +1 -1
- package/dist/src/operator-log.js.map +1 -1
- package/dist/src/provider-waits.d.ts.map +1 -1
- package/dist/src/provider-waits.js.map +1 -1
- package/dist/src/queue.d.ts.map +1 -1
- package/dist/src/record-journal.d.ts.map +1 -1
- package/dist/src/record-journal.js.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
- package/dist/src/recovery/offline-branch.d.ts.map +1 -1
- package/dist/src/recovery/offline-branch.js +29 -24
- package/dist/src/recovery/offline-branch.js.map +1 -1
- package/dist/src/recovery/recover-cli.js.map +1 -1
- package/dist/src/runtime/liveness-watchdog.d.ts.map +1 -1
- package/dist/src/runtime/liveness-watchdog.js.map +1 -1
- package/dist/src/shutdown.d.ts.map +1 -1
- package/dist/src/shutdown.js.map +1 -1
- package/dist/src/stored-removal.d.ts +73 -0
- package/dist/src/stored-removal.d.ts.map +1 -0
- package/dist/src/stored-removal.js +100 -0
- package/dist/src/stored-removal.js.map +1 -0
- package/dist/src/thinking-token-stamp.js.map +1 -1
- package/dist/src/tool-image-ledger.d.ts.map +1 -1
- package/dist/src/tool-image-ledger.js.map +1 -1
- package/dist/src/tool-presentation.d.ts.map +1 -1
- package/dist/src/tool-presentation.js.map +1 -1
- package/dist/src/tool-result-guard.d.ts.map +1 -1
- package/dist/src/tool-result-guard.js.map +1 -1
- package/dist/src/tune-out/coordinator.d.ts.map +1 -1
- package/dist/src/tune-out/coordinator.js.map +1 -1
- package/dist/src/tune-out/tools.d.ts +1 -1
- package/dist/src/tune-out/tools.d.ts.map +1 -1
- package/dist/src/types/agent.d.ts +42 -6
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +10 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +8 -4
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +61 -10
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/src/usage/usage-tracker.d.ts.map +1 -1
- package/dist/test/agent-runtime-settings.test.js +1139 -3
- package/dist/test/agent-runtime-settings.test.js.map +1 -1
- package/dist/test/batched-wake-locus.test.js.map +1 -1
- package/dist/test/channel-label-history.test.js.map +1 -1
- package/dist/test/code-execution.test.js.map +1 -1
- package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -1
- package/dist/test/conversation-router.test.js.map +1 -1
- package/dist/test/conversation-routing.test.js +240 -6
- package/dist/test/conversation-routing.test.js.map +1 -1
- package/dist/test/discord-awareness-outbox.test.js +104 -0
- package/dist/test/discord-awareness-outbox.test.js.map +1 -1
- package/dist/test/discord-typing-keepalive.test.d.ts +2 -0
- package/dist/test/discord-typing-keepalive.test.d.ts.map +1 -0
- package/dist/test/discord-typing-keepalive.test.js +77 -0
- package/dist/test/discord-typing-keepalive.test.js.map +1 -0
- package/dist/test/ephemeral-settle.test.js.map +1 -1
- package/dist/test/event-gate-passthrough.test.js.map +1 -1
- package/dist/test/event-gate-policy-mutation.test.js.map +1 -1
- package/dist/test/event-gate-rate-limit.test.js.map +1 -1
- package/dist/test/event-gate-script.test.js.map +1 -1
- package/dist/test/event-gate-selfwake.test.js.map +1 -1
- package/dist/test/event-gate-tags.test.js.map +1 -1
- package/dist/test/event-gate.test.js.map +1 -1
- package/dist/test/explicit-prose-routing.test.js +363 -42
- package/dist/test/explicit-prose-routing.test.js.map +1 -1
- package/dist/test/framework.test.js.map +1 -1
- package/dist/test/helpers/coalescing-fixture.d.ts +2 -0
- package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -1
- package/dist/test/helpers/coalescing-fixture.js +8 -2
- package/dist/test/helpers/coalescing-fixture.js.map +1 -1
- package/dist/test/helpers/mock-membrane.d.ts.map +1 -1
- package/dist/test/helpers/mock-membrane.js.map +1 -1
- package/dist/test/hide-then-turn.test.d.ts +2 -0
- package/dist/test/hide-then-turn.test.d.ts.map +1 -0
- package/dist/test/hide-then-turn.test.js +101 -0
- package/dist/test/hide-then-turn.test.js.map +1 -0
- package/dist/test/history-module-overview.test.js.map +1 -1
- package/dist/test/history-module-semantic.test.js.map +1 -1
- package/dist/test/history-module.test.js.map +1 -1
- package/dist/test/history-semantic-review-findings.test.js.map +1 -1
- package/dist/test/host-quiesce.test.js +125 -0
- package/dist/test/host-quiesce.test.js.map +1 -1
- package/dist/test/inference-exhaustion-rewind.test.js +81 -3
- package/dist/test/inference-exhaustion-rewind.test.js.map +1 -1
- package/dist/test/inference-failure-bounds.test.js +1 -0
- package/dist/test/inference-failure-bounds.test.js.map +1 -1
- package/dist/test/kv-unified-open-flight-retry.test.js.map +1 -1
- package/dist/test/liveness-watchdog.test.js.map +1 -1
- package/dist/test/maintenance-timer.test.js +1564 -2
- package/dist/test/maintenance-timer.test.js.map +1 -1
- package/dist/test/mcp-server-bin.test.d.ts +2 -0
- package/dist/test/mcp-server-bin.test.d.ts.map +1 -0
- package/dist/test/mcp-server-bin.test.js +142 -0
- package/dist/test/mcp-server-bin.test.js.map +1 -0
- package/dist/test/mcpl-awareness-barrier.test.js.map +1 -1
- package/dist/test/mcpl-child-env.test.js +1 -1
- package/dist/test/mcpl-child-env.test.js.map +1 -1
- package/dist/test/mcpl-coalesced-content-parity.test.d.ts +2 -0
- package/dist/test/mcpl-coalesced-content-parity.test.d.ts.map +1 -0
- package/dist/test/mcpl-coalesced-content-parity.test.js +532 -0
- package/dist/test/mcpl-coalesced-content-parity.test.js.map +1 -0
- package/dist/test/mcpl-coalescing.test.js.map +1 -1
- package/dist/test/mcpl-config-diagnostics.test.js.map +1 -1
- package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -1
- package/dist/test/mcpl-manifest-changed.test.js.map +1 -1
- package/dist/test/mcpl-reconnect-events.test.js.map +1 -1
- package/dist/test/mcpl-reconnect-reregistration.test.js +2 -0
- package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
- package/dist/test/mcpl-reference-fetcher.test.js.map +1 -1
- package/dist/test/mcpl-reference-fields.test.js.map +1 -1
- package/dist/test/mcpl-rejection-log-lines.test.d.ts +2 -0
- package/dist/test/mcpl-rejection-log-lines.test.d.ts.map +1 -0
- package/dist/test/mcpl-rejection-log-lines.test.js +349 -0
- package/dist/test/mcpl-rejection-log-lines.test.js.map +1 -0
- package/dist/test/mcpl-stderr-tail.test.d.ts +2 -0
- package/dist/test/mcpl-stderr-tail.test.d.ts.map +1 -0
- package/dist/test/mcpl-stderr-tail.test.js +210 -0
- package/dist/test/mcpl-stderr-tail.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.js.map +1 -1
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -1
- package/dist/test/mcpl-tool-lifecycle-script.test.js.map +1 -1
- package/dist/test/mcpl-tool-lifecycle.test.js.map +1 -1
- package/dist/test/mcpl-transport-close.test.d.ts +2 -0
- package/dist/test/mcpl-transport-close.test.d.ts.map +1 -0
- package/dist/test/mcpl-transport-close.test.js +373 -0
- package/dist/test/mcpl-transport-close.test.js.map +1 -0
- package/dist/test/mcpl-transport-spawn.test.d.ts +2 -0
- package/dist/test/mcpl-transport-spawn.test.d.ts.map +1 -0
- package/dist/test/mcpl-transport-spawn.test.js +58 -0
- package/dist/test/mcpl-transport-spawn.test.js.map +1 -0
- package/dist/test/mcpl-ws-transport.test.js +1 -0
- package/dist/test/mcpl-ws-transport.test.js.map +1 -1
- package/dist/test/offline-recovery-branch.test.js +344 -0
- package/dist/test/offline-recovery-branch.test.js.map +1 -1
- package/dist/test/operator-surgery.test.js +197 -0
- package/dist/test/operator-surgery.test.js.map +1 -1
- package/dist/test/overbudget-drain-breaker.test.js +1 -0
- package/dist/test/overbudget-drain-breaker.test.js.map +1 -1
- package/dist/test/pack-installs-alone.test.d.ts +2 -0
- package/dist/test/pack-installs-alone.test.d.ts.map +1 -0
- package/dist/test/pack-installs-alone.test.js +284 -0
- package/dist/test/pack-installs-alone.test.js.map +1 -0
- package/dist/test/physical-window-restart.test.d.ts +6 -4
- package/dist/test/physical-window-restart.test.d.ts.map +1 -1
- package/dist/test/physical-window-restart.test.js +275 -10
- package/dist/test/physical-window-restart.test.js.map +1 -1
- package/dist/test/placed-reaction-baseline.test.js.map +1 -1
- package/dist/test/present-while-acting.test.js +949 -6
- package/dist/test/present-while-acting.test.js.map +1 -1
- package/dist/test/provider-acceleration-cooldown.test.js.map +1 -1
- package/dist/test/provider-waits.test.js +67 -4
- package/dist/test/provider-waits.test.js.map +1 -1
- package/dist/test/puppet-tool-call.test.js +2 -0
- package/dist/test/puppet-tool-call.test.js.map +1 -1
- package/dist/test/reaction-baseline-platform.test.js.map +1 -1
- package/dist/test/record-journal.test.js.map +1 -1
- package/dist/test/refusal-rewind.test.js +9 -0
- package/dist/test/refusal-rewind.test.js.map +1 -1
- package/dist/test/refusal-single-call.test.js.map +1 -1
- package/dist/test/release-bump.test.d.ts +2 -0
- package/dist/test/release-bump.test.d.ts.map +1 -0
- package/dist/test/release-bump.test.js +248 -0
- package/dist/test/release-bump.test.js.map +1 -0
- package/dist/test/release-changelog.test.js.map +1 -1
- package/dist/test/release-holds.test.d.ts +2 -0
- package/dist/test/release-holds.test.d.ts.map +1 -0
- package/dist/test/release-holds.test.js +456 -0
- package/dist/test/release-holds.test.js.map +1 -0
- package/dist/test/same-round-think-policy.test.js.map +1 -1
- package/dist/test/shutdown-errors.test.js.map +1 -1
- package/dist/test/silent-heartbeat-rows.test.js.map +1 -1
- package/dist/test/stop-with-host-operations.test.d.ts +20 -0
- package/dist/test/stop-with-host-operations.test.d.ts.map +1 -0
- package/dist/test/stop-with-host-operations.test.js +1174 -0
- package/dist/test/stop-with-host-operations.test.js.map +1 -0
- package/dist/test/stop-with-work-in-flight.test.js +162 -50
- package/dist/test/stop-with-work-in-flight.test.js.map +1 -1
- package/dist/test/surgery-marks-receipt.test.js.map +1 -1
- package/dist/test/tool-batch-hook.test.js.map +1 -1
- package/dist/test/tool-result-guard.test.js +131 -9
- package/dist/test/tool-result-guard.test.js.map +1 -1
- package/dist/test/tool-result-spill.test.js.map +1 -1
- package/dist/test/tool-wrapper-prose-guard-integration.test.js.map +1 -1
- package/dist/test/trunk-channel-routing.test.js +330 -7
- package/dist/test/trunk-channel-routing.test.js.map +1 -1
- package/dist/test/tune-out-e2e.test.js.map +1 -1
- package/dist/test/unconvertible-tool-result.test.js.map +1 -1
- package/dist/test/unsent-compiles.test.d.ts +2 -0
- package/dist/test/unsent-compiles.test.d.ts.map +1 -0
- package/dist/test/unsent-compiles.test.js +139 -0
- package/dist/test/unsent-compiles.test.js.map +1 -0
- package/dist/test/workspace-branch-continuation.test.js.map +1 -1
- package/dist/test/workspace-materialize-freshness.test.js.map +1 -1
- package/dist/test/workspace-read-text.test.js.map +1 -1
- package/dist/test/workspace-three-way.test.js +907 -7
- package/dist/test/workspace-three-way.test.js.map +1 -1
- package/dist/test/workspace-watcher.test.js.map +1 -1
- package/dist/test/xml-round-persist.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -6
- package/src/agent.ts +411 -66
- package/src/api/mcp-server-core.ts +681 -0
- package/src/api/mcp-server.ts +16 -685
- package/src/api/server.ts +51 -29
- package/src/api/types.ts +9 -1
- package/src/framework.ts +1899 -641
- package/src/index.ts +2 -2
- package/src/mcpl/channel-registry.ts +126 -31
- package/src/mcpl/feature-set-manager.ts +4 -3
- package/src/mcpl/log-value.ts +116 -0
- package/src/mcpl/prose-stream-router.ts +6 -6
- package/src/mcpl/push-coalescer.ts +76 -12
- package/src/mcpl/push-handler.ts +95 -23
- package/src/mcpl/refusal-lines.ts +59 -0
- package/src/mcpl/server-connection.ts +70 -11
- package/src/mcpl/tool-classes.ts +3 -2
- package/src/mcpl/transport.ts +105 -14
- package/src/mcpl/types.ts +5 -1
- package/src/modules/discord/index.ts +4 -2
- package/src/modules/workspace/disk-agreement.ts +74 -18
- package/src/modules/workspace/index.ts +41 -11
- package/src/modules/workspace/observe.ts +265 -39
- package/src/modules/workspace/reconcile.ts +71 -14
- package/src/modules/workspace/types.ts +26 -1
- package/src/recovery/offline-branch.ts +32 -28
- package/src/stored-removal.ts +126 -0
- package/src/types/agent.ts +42 -6
- package/src/types/framework.ts +10 -0
- package/src/types/module.ts +8 -4
- package/src/types/trace.ts +62 -11
package/src/framework.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeF
|
|
|
6
6
|
import { JsStore } from '@animalabs/chronicle';
|
|
7
7
|
import type { Membrane, ContentBlock, NormalizedRequest, YieldingStream, ToolResult as MembraneToolResult, ToolResultContentBlock } from '@animalabs/membrane';
|
|
8
8
|
import { MembraneError } from '@animalabs/membrane';
|
|
9
|
-
import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, OverBudgetError, UncoveredDropError } from '@animalabs/context-manager';
|
|
9
|
+
import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, OverBudgetError, UncoveredDropError, stripUnpairedToolBlocks } from '@animalabs/context-manager';
|
|
10
10
|
import type { CacheWireReceipt } from './kv-unified-wire.js';
|
|
11
11
|
import type { SilentHeartbeatStamp } from './silent-heartbeat.js';
|
|
12
12
|
import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES, type SubconsciousConfig } from './tune-out/tools.js';
|
|
@@ -19,6 +19,14 @@ import type {
|
|
|
19
19
|
StoredMessage,
|
|
20
20
|
TokenBudget,
|
|
21
21
|
} from '@animalabs/context-manager';
|
|
22
|
+
import {
|
|
23
|
+
bodyGroupRun,
|
|
24
|
+
bodyRangeIn,
|
|
25
|
+
locateMessageIndex,
|
|
26
|
+
locateMessageIndices,
|
|
27
|
+
removeStoredMessage,
|
|
28
|
+
removeStoredMessages,
|
|
29
|
+
} from './stored-removal.js';
|
|
22
30
|
import type {
|
|
23
31
|
FrameworkConfig,
|
|
24
32
|
InferencePolicy,
|
|
@@ -61,11 +69,12 @@ import type {
|
|
|
61
69
|
SameRoundThinkTextPolicy,
|
|
62
70
|
} from './types/index.js';
|
|
63
71
|
import { ProcessQueueImpl } from './queue.js';
|
|
64
|
-
import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, shutdownErrorMessage } from './shutdown.js';
|
|
72
|
+
import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, shutdownErrorMessage, waitForShutdown, ShutdownTimeoutError } from './shutdown.js';
|
|
65
73
|
import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK, REFUSAL_REACTION_BASELINE } from './refusal-reactions.js';
|
|
66
74
|
import { Agent } from './agent.js';
|
|
67
75
|
import { ModuleRegistry, checkedToolResult, isStateExistsError, storableToolResult, thrownResult } from './module-registry.js';
|
|
68
76
|
import { McplServerRegistry } from './mcpl/server-registry.js';
|
|
77
|
+
import { logValue } from './mcpl/log-value.js';
|
|
69
78
|
import { FeatureSetManager } from './mcpl/feature-set-manager.js';
|
|
70
79
|
import { computeGrant, CapabilityGrant, expandAdvertisementShorthand } from './mcpl/capability-grant.js';
|
|
71
80
|
import { maskNegotiatedCapabilities } from './mcpl/capability-mask.js';
|
|
@@ -394,7 +403,7 @@ import { isToolAllowed } from './mcpl/tool-policy.js';
|
|
|
394
403
|
import { EventGate, formatShadowWarning } from './gate/event-gate.js';
|
|
395
404
|
import { UsageTracker, type PersistedUsageState } from './usage/usage-tracker.js';
|
|
396
405
|
import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js';
|
|
397
|
-
import type
|
|
406
|
+
import { stderrTailOf, withStderr, type McplConnectionCloseInfo, type McplServerConnection } from './mcpl/server-connection.js';
|
|
398
407
|
import { isWebSocketTransport } from './mcpl/transport.js';
|
|
399
408
|
import type {
|
|
400
409
|
McplServerConfig,
|
|
@@ -522,53 +531,110 @@ interface AdmittedEnvelope {
|
|
|
522
531
|
}
|
|
523
532
|
|
|
524
533
|
/**
|
|
525
|
-
* Whether a stream resumes its
|
|
526
|
-
* restart, or a tool-result-guard retry. Such a
|
|
527
|
-
*
|
|
528
|
-
*
|
|
534
|
+
* Whether a stream resumes its turn's request rather than starting one: a
|
|
535
|
+
* budget restart, or a tool-result-guard retry. Such a start takes in no new
|
|
536
|
+
* input, and keeps the turn's locus, trigger channel and tool-call count. Its
|
|
537
|
+
* routing state is the logical turn's, as for every start that continues the
|
|
538
|
+
* turn (TurnRouting).
|
|
529
539
|
*/
|
|
530
540
|
function resumesLogicalTurn(reason: string | undefined): boolean {
|
|
531
541
|
return reason === 'context_budget_restart' || reason === 'tool_result_guard_retry';
|
|
532
542
|
}
|
|
533
543
|
|
|
534
544
|
/**
|
|
535
|
-
* One turn's routing and
|
|
536
|
-
*
|
|
545
|
+
* One logical turn's routing state and receipt tallies
|
|
546
|
+
* (AgentFramework.turnRouting): everything the turn has decided about where
|
|
547
|
+
* its speech goes. Every stream of the turn shares it: the start that opens
|
|
548
|
+
* the turn, a resume (resumesLogicalTurn), and each start while the turn is
|
|
549
|
+
* open (AgentFramework.openLogical): a framework retry, the re-inference that
|
|
550
|
+
* consumes unstreamed tool results, a capacity-held turn's later compile. So
|
|
551
|
+
* none of them re-decides the locus, drops a sticky destination or a hold, or
|
|
552
|
+
* forgets a ` !`, and the receipt at the turn's end reports every stream's
|
|
553
|
+
* deliveries. A silent start has one of its own, so it neither takes nor
|
|
554
|
+
* disturbs an open turn's. A queued delivery keeps the object of the turn
|
|
555
|
+
* that queued it.
|
|
537
556
|
*/
|
|
538
557
|
interface TurnRouting {
|
|
558
|
+
/**
|
|
559
|
+
* The channel whose message triggered the turn (item-3 redux), which the
|
|
560
|
+
* locus is resolved from when the turn opens, and which the channel
|
|
561
|
+
* registry reports as the turn's default outgoing channel. A turn that
|
|
562
|
+
* channel_opens a channel moves it there. Kept across the turn's streams:
|
|
563
|
+
* a budget restart's trigger carries no channel, and dropping it there
|
|
564
|
+
* sent Sol's DM reply to a stale guild channel (2026-07-22).
|
|
565
|
+
*/
|
|
566
|
+
triggerChannel?: string;
|
|
567
|
+
/**
|
|
568
|
+
* Locus and hybrid mode: where the turn's unpinned plain prose lands.
|
|
569
|
+
* Frozen when the turn opens (home channel for a fork, else the trigger
|
|
570
|
+
* channel, else the global default), so ambient chatter, reactions and
|
|
571
|
+
* system markers can't move it (2026-07-21 Cairn lounge misroute). Since
|
|
572
|
+
* 2026-07-31 a re-pin moves it (repinTurnLocus): an addressed message, or a
|
|
573
|
+
* follow-up in a channel the turn engaged. The agent's own channel_open
|
|
574
|
+
* moves it too. Unset in explicit and disabled mode, and for a turn with
|
|
575
|
+
* nowhere to speak.
|
|
576
|
+
*/
|
|
577
|
+
locus?: string;
|
|
578
|
+
/**
|
|
579
|
+
* Channels the turn sent into with a send tool (SEND_ENGAGEMENT_TOOLS): a
|
|
580
|
+
* conversational follow-up in one of them is addressed by context, so it
|
|
581
|
+
* re-pins the locus (2026-07-31 n=7: q's #portables reply, with no
|
|
582
|
+
* mention, followed the stale pin into repligate's DM).
|
|
583
|
+
*/
|
|
584
|
+
engaged: Set<string>;
|
|
539
585
|
/** The sticky destination, set by each `>>` (hybrid: `>>>`) prefix that resolves. */
|
|
540
586
|
pin?: string;
|
|
541
587
|
/**
|
|
542
588
|
* Explicit mode: the destination the turn's delivered rounds have set,
|
|
543
|
-
* decided at each round boundary (roundDestination), where
|
|
544
|
-
*
|
|
589
|
+
* decided at each round boundary (roundDestination), where each of the
|
|
590
|
+
* turn's streams starts.
|
|
545
591
|
*/
|
|
546
592
|
destination: string | null;
|
|
547
|
-
/**
|
|
593
|
+
/**
|
|
594
|
+
* Hybrid: held back after `>>>skip_reply`, or an envelope that is malformed,
|
|
595
|
+
* doesn't resolve or isn't delivered, until a later `>>>` names a target.
|
|
596
|
+
*/
|
|
548
597
|
hybridHold: boolean;
|
|
549
|
-
/** What the logical turn keeps across a new stream (LogicalTurn). */
|
|
550
|
-
logical: LogicalTurn;
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
/**
|
|
554
|
-
* What every stream of one logical turn shares, from the fresh start that
|
|
555
|
-
* opens it until it settles (AgentFramework.openLogical): a framework retry,
|
|
556
|
-
* a re-inference after unstreamed tool results, a capacity-held turn's later
|
|
557
|
-
* compile. beginAgentTurn starts each with fresh routing, but a ` !` asked
|
|
558
|
-
* for in any of them stands, and the receipt at the turn's end reports every
|
|
559
|
-
* stream's deliveries, even ones that land after a new stream began.
|
|
560
|
-
*/
|
|
561
|
-
interface LogicalTurn {
|
|
562
598
|
/** The turn asked for a ` !` continuation; taken once. */
|
|
563
599
|
continuation: boolean;
|
|
564
600
|
/** Successful plain-prose destinations in delivery order; deduped by the receipt. */
|
|
565
601
|
deliveries: string[];
|
|
566
602
|
/** Plain-prose segments the turn suppressed, reported by its receipt. */
|
|
567
603
|
suppressed: number;
|
|
604
|
+
/**
|
|
605
|
+
* The messages that would re-pin the locus (repinChannel) that no tool
|
|
606
|
+
* boundary of the turn weighed, in the order they were stored
|
|
607
|
+
* (noteArrival). A message stored for this agent is one when none of the
|
|
608
|
+
* turn's streams was running: stored during a capacity hold or the wait
|
|
609
|
+
* for a re-inference, flushed at the end of a stream that didn't settle
|
|
610
|
+
* the turn, or stored by the start that continues it. So is one a boundary
|
|
611
|
+
* drained but couldn't re-pin for, being over budget or without a live
|
|
612
|
+
* stream. A message stored for another ordinary resident is one whenever
|
|
613
|
+
* it lands, since residents read one message slot and a boundary weighs
|
|
614
|
+
* only the messages drained for its own agent (noteForOtherResidents). A
|
|
615
|
+
* message tune-out diverted isn't one, since a resident's compiled view
|
|
616
|
+
* leaves it out. A boundary that re-pins clears them, since what it injects
|
|
617
|
+
* is newer. The turn's next start clears them all, and in locus or hybrid
|
|
618
|
+
* mode first re-pins to the newest one still in the agent's store, as a
|
|
619
|
+
* boundary would have weighed its injections (remainingArrival). One
|
|
620
|
+
* removed meanwhile, such as an unread coalesced message its server
|
|
621
|
+
* retracted, isn't there for the agent to answer.
|
|
622
|
+
*/
|
|
623
|
+
arrivals: Arrival[];
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** A message that would re-pin its turn's locus (TurnRouting.arrivals). */
|
|
627
|
+
interface Arrival {
|
|
628
|
+
messageId: MessageId;
|
|
629
|
+
/** The channel it re-pins the locus to. */
|
|
630
|
+
channelId: string;
|
|
568
631
|
}
|
|
569
632
|
|
|
570
633
|
function freshTurnRouting(): TurnRouting {
|
|
571
|
-
return {
|
|
634
|
+
return {
|
|
635
|
+
engaged: new Set(), destination: null, hybridHold: false,
|
|
636
|
+
continuation: false, deliveries: [], suppressed: 0, arrivals: [],
|
|
637
|
+
};
|
|
572
638
|
}
|
|
573
639
|
|
|
574
640
|
/** How a round's prose is delivered: kept private, and under which turn's routing. */
|
|
@@ -637,6 +703,119 @@ function normalizeDiscordAwarenessDeadline(value: number | undefined): number {
|
|
|
637
703
|
);
|
|
638
704
|
}
|
|
639
705
|
|
|
706
|
+
/** Messages copied into another conversation (a fork's seed) without their
|
|
707
|
+
* `thinking` and `redacted_thinking` blocks: a fork inherits the template's
|
|
708
|
+
* conversation, not its reasoning. A signed block was minted under the
|
|
709
|
+
* template's prefix, which the fork's doesn't promise to match; unsigned
|
|
710
|
+
* reasoning (text-only providers, XML mode) goes by the same rule. On the
|
|
711
|
+
* OpenAI Responses path a removed block also carried the reasoning item
|
|
712
|
+
* (`rawItem`) the message's other items were paired with by id, so in a
|
|
713
|
+
* message that lost its thinking those items lose their `rawItem` and render
|
|
714
|
+
* from their fields, without ids; an opaque carrier (a text block whose raw
|
|
715
|
+
* item isn't a `message`, such as a compaction) keeps it, since the raw item
|
|
716
|
+
* is its whole content. An Anthropic or XML-mode message's thinking carries
|
|
717
|
+
* no raw item, and its only raw items are such carriers (membrane keeps a
|
|
718
|
+
* block it has no type for, such as a `server_tool_use`, that way), so it
|
|
719
|
+
* loses only its thinking. A message with no content left, whether
|
|
720
|
+
* the filter emptied it or it was already empty, is left out, since a
|
|
721
|
+
* request can't carry an empty message; any other message without thinking
|
|
722
|
+
* is kept as it was. */
|
|
723
|
+
function withoutThinking<M extends { content: ContentBlock[] }>(messages: readonly M[]): M[] {
|
|
724
|
+
const out: M[] = [];
|
|
725
|
+
for (const message of messages) {
|
|
726
|
+
const content = message.content.filter((b) => !isThinkingBlock(b));
|
|
727
|
+
if (content.length === 0) continue;
|
|
728
|
+
out.push(content.length === message.content.length
|
|
729
|
+
? message
|
|
730
|
+
: { ...message, content: content.map(releasePairedRawItem) });
|
|
731
|
+
}
|
|
732
|
+
return out;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function isThinkingBlock(block: ContentBlock): boolean {
|
|
736
|
+
return block.type === 'thinking' || block.type === 'redacted_thinking';
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function rawItemOf(block: ContentBlock): unknown {
|
|
740
|
+
return (block as { rawItem?: unknown }).rawItem;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** A block without the provider-native item it was parsed from, unless that
|
|
744
|
+
* item is the block's whole content: an opaque carrier, a text block whose
|
|
745
|
+
* raw item isn't a `message` item (membrane keeps compaction and other items
|
|
746
|
+
* it has no block for that way). */
|
|
747
|
+
function releasePairedRawItem(block: ContentBlock): ContentBlock {
|
|
748
|
+
const raw = rawItemOf(block);
|
|
749
|
+
if (raw === undefined) return block;
|
|
750
|
+
const rawType = typeof raw === 'object' && raw !== null ? (raw as { type?: unknown }).type : undefined;
|
|
751
|
+
if (block.type === 'text' && rawType !== 'message') return block;
|
|
752
|
+
const { rawItem: _released, ...rest } = block as ContentBlock & { rawItem?: unknown };
|
|
753
|
+
return rest as ContentBlock;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** One log line for a fork's seed that left something out, so an operator who
|
|
757
|
+
* sees a fork answer differently from its template has something to check:
|
|
758
|
+
* the template's thinking blocks, and the tool blocks the pairing pass
|
|
759
|
+
* dropped (calls with no result in the seed, results whose call isn't in
|
|
760
|
+
* it). `withoutReasoning` is the seed before that pass, `seed` after it. */
|
|
761
|
+
function logSeedOmissions(
|
|
762
|
+
fork: string,
|
|
763
|
+
template: string,
|
|
764
|
+
compiled: readonly { content: ContentBlock[] }[],
|
|
765
|
+
withoutReasoning: readonly { content: ContentBlock[] }[],
|
|
766
|
+
seed: readonly { content: ContentBlock[] }[],
|
|
767
|
+
): void {
|
|
768
|
+
const count = (messages: readonly { content: ContentBlock[] }[], test: (b: ContentBlock) => boolean) =>
|
|
769
|
+
messages.reduce((n, m) => n + m.content.filter(test).length, 0);
|
|
770
|
+
const thinking = count(compiled, isThinkingBlock);
|
|
771
|
+
const calls = count(withoutReasoning, (b) => b.type === 'tool_use') - count(seed, (b) => b.type === 'tool_use');
|
|
772
|
+
const results = count(withoutReasoning, (b) => b.type === 'tool_result') - count(seed, (b) => b.type === 'tool_result');
|
|
773
|
+
if (thinking + calls + results === 0) return;
|
|
774
|
+
console.log(
|
|
775
|
+
`[conversation] ${fork}: seeded from ${template} without ${thinking} thinking block(s), ` +
|
|
776
|
+
`${calls} unanswered tool call(s) and ${results} orphaned tool result(s)`,
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* A maintenance provider call withdrawn because a surgery holds the store, or
|
|
782
|
+
* because stop() has closed provider admission: either is the framework's own
|
|
783
|
+
* doing, and no failure. It sent nothing: it was refused at the provider
|
|
784
|
+
* gate, or woken there from its wait. For a call outside context-manager
|
|
785
|
+
* 0.14's compression fallback ladder, a merge's or a chunk's canonical call
|
|
786
|
+
* with its retries after a tool_use stop, context-manager counts no attempt.
|
|
787
|
+
* A merge keeps its queue entry, so the next tick after the hold, or after
|
|
788
|
+
* the next start, does it again. A chunk's compression has already left the
|
|
789
|
+
* queue: it logs "Failed to compress chunk (hierarchical)" with this error
|
|
790
|
+
* and records the call, and the chunk, still uncompressed, is queued again at
|
|
791
|
+
* that manager's next rebuild (its next initialization, message, sent compile
|
|
792
|
+
* or released hold). A call inside the ladder, after a chunk's canonical call
|
|
793
|
+
* was refused or cut short, is caught by the rung that made it and recorded
|
|
794
|
+
* as a provider error, since 0.14 rethrows only its own
|
|
795
|
+
* CompressionBranchDiscard there; once every rung has failed, the chunk's
|
|
796
|
+
* request family is quarantined, with its alert, and the tick returns.
|
|
797
|
+
*/
|
|
798
|
+
class MaintenanceWithdrawnError extends Error {
|
|
799
|
+
constructor(reason: string) {
|
|
800
|
+
super(`maintenance call withdrawn: ${reason}`);
|
|
801
|
+
this.name = 'MaintenanceWithdrawnError';
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* A runtime-settings preview's reason, from what stopped it: the strategy's
|
|
807
|
+
* known throws as the codes `RuntimeSettingsPreview.reason` documents, and
|
|
808
|
+
* anything else verbatim. Message matching is fragile by nature, so an
|
|
809
|
+
* unknown message surfaces as the reason rather than being swallowed.
|
|
810
|
+
*/
|
|
811
|
+
function previewStoppedReason(error: unknown): string {
|
|
812
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
813
|
+
if (message.includes('requires reinitialization for the current branch generation')) return 'branch_generation_changed';
|
|
814
|
+
if (message.includes('already running; previews must not overlap')) return 'preview_in_flight';
|
|
815
|
+
if (message.includes('requires adaptiveResolution')) return 'no_adaptive_resolution';
|
|
816
|
+
return message;
|
|
817
|
+
}
|
|
818
|
+
|
|
640
819
|
class DiscordAwarenessAccountingError extends Error {
|
|
641
820
|
constructor(operation: string, cause: unknown) {
|
|
642
821
|
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
@@ -745,6 +924,28 @@ export class ResumeBlockedError extends Error {
|
|
|
745
924
|
}
|
|
746
925
|
}
|
|
747
926
|
|
|
927
|
+
/**
|
|
928
|
+
* How `runEphemeralToCompletion` rejects when a retryable provider failure
|
|
929
|
+
* states a wait the run won't sleep: one longer than an ephemeral run sleeps
|
|
930
|
+
* in its turn (600 s), an infinite one, or one that is `NaN`. The run ends at
|
|
931
|
+
* once, without retrying inside the wait, and every other ending of the run
|
|
932
|
+
* rejects with a plain `Error`. `retryAfterMs` is the wait as the provider
|
|
933
|
+
* stated it, in milliseconds, so `Infinity` or `NaN` in those two cases: a
|
|
934
|
+
* host that schedules a rerun from it handles a value that isn't finite, and
|
|
935
|
+
* one past the 2,147,483,647 ms a timer can hold. The message says the same
|
|
936
|
+
* in words. A host can test `instanceof` or the `name`, which also holds for
|
|
937
|
+
* an error from another copy of this package.
|
|
938
|
+
*/
|
|
939
|
+
export class ProviderWaitNotSleptError extends Error {
|
|
940
|
+
constructor(
|
|
941
|
+
message: string,
|
|
942
|
+
readonly retryAfterMs: number,
|
|
943
|
+
) {
|
|
944
|
+
super(message);
|
|
945
|
+
this.name = 'ProviderWaitNotSleptError';
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
748
949
|
interface RedoEntry {
|
|
749
950
|
branchName: string;
|
|
750
951
|
checkpoint: TurnCheckpoint;
|
|
@@ -767,72 +968,6 @@ class DefaultInferencePolicy implements InferencePolicy {
|
|
|
767
968
|
}
|
|
768
969
|
}
|
|
769
970
|
|
|
770
|
-
/** Minimal read surface the live-surgery helpers need from a ContextManager. */
|
|
771
|
-
interface SurgeryReadableCm {
|
|
772
|
-
getMessageCount(): number;
|
|
773
|
-
getMessageWindow(
|
|
774
|
-
offset: number,
|
|
775
|
-
limit: number,
|
|
776
|
-
opts?: { resolveBlobs?: boolean; alignToBodyGroups?: boolean },
|
|
777
|
-
): { messages: Array<{ id: unknown; bodyGroupId?: string }>; startIndex: number };
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
const SURGERY_SCAN_WINDOW = 500;
|
|
781
|
-
|
|
782
|
-
/**
|
|
783
|
-
* Slot index of `messageId` on the active branch, or -1. Scans from the tail
|
|
784
|
-
* in blob-free windows: operator targets are almost always recent, and a
|
|
785
|
-
* full `getAllMessages()` re-inflates every attachment on the branch.
|
|
786
|
-
*/
|
|
787
|
-
function locateMessageIndex(cm: SurgeryReadableCm, messageId: string): number {
|
|
788
|
-
const hit = locateMessageIndices(cm, new Set([messageId])).get(messageId);
|
|
789
|
-
return hit ? hit.index : -1;
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
/** Tail-first scan resolving many ids at once; stops as soon as all are found. */
|
|
793
|
-
function locateMessageIndices(
|
|
794
|
-
cm: SurgeryReadableCm,
|
|
795
|
-
ids: Set<string>,
|
|
796
|
-
): Map<string, { index: number; bodyGroupId?: string }> {
|
|
797
|
-
const found = new Map<string, { index: number; bodyGroupId?: string }>();
|
|
798
|
-
const total = cm.getMessageCount();
|
|
799
|
-
for (let end = total; end > 0 && found.size < ids.size; end -= SURGERY_SCAN_WINDOW) {
|
|
800
|
-
const start = Math.max(0, end - SURGERY_SCAN_WINDOW);
|
|
801
|
-
const { messages, startIndex } = cm.getMessageWindow(start, end - start, { resolveBlobs: false });
|
|
802
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
803
|
-
const id = String(messages[i].id);
|
|
804
|
-
if (ids.has(id) && !found.has(id)) {
|
|
805
|
-
found.set(id, {
|
|
806
|
-
index: startIndex + i,
|
|
807
|
-
...(messages[i].bodyGroupId ? { bodyGroupId: messages[i].bodyGroupId } : {}),
|
|
808
|
-
});
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
return found;
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
/**
|
|
816
|
-
* Inclusive slot range of the body-group run containing `index` (the slot
|
|
817
|
-
* itself when it is not sharded). Delegates to the store's own
|
|
818
|
-
* `alignToBodyGroups` edge walk — one contiguity assumption, not two.
|
|
819
|
-
*/
|
|
820
|
-
function bodyGroupRun(
|
|
821
|
-
cm: SurgeryReadableCm,
|
|
822
|
-
index: number,
|
|
823
|
-
): { from: number; to: number; fromId: string; toId: string; group: boolean } {
|
|
824
|
-
const win = cm.getMessageWindow(index, 1, { alignToBodyGroups: true, resolveBlobs: false });
|
|
825
|
-
const from = win.startIndex;
|
|
826
|
-
const to = win.startIndex + win.messages.length - 1;
|
|
827
|
-
return {
|
|
828
|
-
from,
|
|
829
|
-
to,
|
|
830
|
-
fromId: String(win.messages[0].id),
|
|
831
|
-
toId: String(win.messages[win.messages.length - 1].id),
|
|
832
|
-
group: win.messages[0].bodyGroupId !== undefined,
|
|
833
|
-
};
|
|
834
|
-
}
|
|
835
|
-
|
|
836
971
|
function describeMarkers(m: SurgeryMarkerReceipt): string {
|
|
837
972
|
if (m.status === 'queued') return `queued:${m.queued}`;
|
|
838
973
|
if (m.status === 'not-scheduled') return 'not-scheduled(batch retired)';
|
|
@@ -1341,18 +1476,19 @@ function boundFailureFields(record: Record<string, unknown>): Record<string, unk
|
|
|
1341
1476
|
*
|
|
1342
1477
|
* 400 and 422 are how a provider answers a request that fails its
|
|
1343
1478
|
* validation, as a history it can't accept does, but other refusals come
|
|
1344
|
-
* back the same way, such as
|
|
1345
|
-
*
|
|
1346
|
-
* large, as an oversized attachment does, shedding that
|
|
1347
|
-
* the request back under the limit, and nothing else in
|
|
1348
|
-
* recovers a 413; when the history as a whole is too large,
|
|
1349
|
-
* help, and the breaker stops at its rewind cap. The 413 arm
|
|
1350
|
-
* labels a 413
|
|
1351
|
-
*
|
|
1352
|
-
*
|
|
1353
|
-
*
|
|
1354
|
-
*
|
|
1355
|
-
* resource, as a rule,
|
|
1479
|
+
* back the same way, such as a refusal of the account that membrane doesn't
|
|
1480
|
+
* recognize by its text. A 413 is the request's size. When the newest
|
|
1481
|
+
* exchange made it too large, as an oversized attachment does, shedding that
|
|
1482
|
+
* exchange may bring the request back under the limit, and nothing else in
|
|
1483
|
+
* the framework recovers a 413; when the history as a whole is too large,
|
|
1484
|
+
* the shed doesn't help, and the breaker stops at its rewind cap. The 413 arm
|
|
1485
|
+
* takes both labels membranes have given a 413, invalid_request and
|
|
1486
|
+
* context_length (see the notes below). Other statuses keep the history,
|
|
1487
|
+
* since a shed takes the exchange out of the agent's context, which only an
|
|
1488
|
+
* operator's undo brings back, and the status gives no sign that a shed
|
|
1489
|
+
* would help: a 404 is a model or an endpoint that doesn't exist, and 405,
|
|
1490
|
+
* 409, 410 and 415 are about the HTTP request or the resource, as a rule,
|
|
1491
|
+
* rather than the conversation.
|
|
1356
1492
|
*
|
|
1357
1493
|
* The table test in test/inference-exhaustion-rewind.test.ts is this
|
|
1358
1494
|
* predicate's contract. The notes below on which membrane version gives which
|
|
@@ -1367,13 +1503,24 @@ function boundFailureFields(record: Record<string, unknown>): Record<string, unk
|
|
|
1367
1503
|
* adapter files every status it doesn't list (404, 405, 409, 410, 413,
|
|
1368
1504
|
* 415, 422 …) as invalid_request, so it alone produces an invalid_request
|
|
1369
1505
|
* at 413 or 422, and no 0.5.86 adapter produces a context_length at 413.
|
|
1370
|
-
* - From membrane
|
|
1371
|
-
* adapter
|
|
1372
|
-
*
|
|
1373
|
-
*
|
|
1374
|
-
*
|
|
1375
|
-
*
|
|
1376
|
-
*
|
|
1506
|
+
* - From membrane 0.5.87 (membrane #56 and #107), this package's floor, every
|
|
1507
|
+
* adapter sends an HTTP failure through one status table
|
|
1508
|
+
* (errorFromProviderStatus). On a 400 or 422, auth codes and reasons and
|
|
1509
|
+
* the two account refusals it recognizes by their text (a balance too low,
|
|
1510
|
+
* usage limits reached) are auth, and a context overflow, by its code or
|
|
1511
|
+
* text, is context_length. A 401, 402 or 403 is auth, a 408 timeout, a 429
|
|
1512
|
+
* rate_limit, a 404 invalid_request and a 413 context_length; other 4xx
|
|
1513
|
+
* are invalid_request. So a 413 the table types sheds, an Anthropic one
|
|
1514
|
+
* included, while a context_length at 400 or 422, a token overflow, keeps
|
|
1515
|
+
* the history, and so does an account refusal, which is auth. An adapter
|
|
1516
|
+
* can type a failure before the table does, as Anthropic's does for a
|
|
1517
|
+
* gateway's aggregate error (server). A failure with no HTTP status takes
|
|
1518
|
+
* the status its provider code implies when the table knows the code, as
|
|
1519
|
+
* for Anthropic's mid-stream errors, and is otherwise typed from its
|
|
1520
|
+
* message, with no status. The 422 arm also covers an
|
|
1521
|
+
* OpenAI-compatible server, such as a FastAPI one, that answers a
|
|
1522
|
+
* validation failure with 422. The test of provider answers through the
|
|
1523
|
+
* table, beside the table test, pins how the two meet at the floor.
|
|
1377
1524
|
*
|
|
1378
1525
|
* An error with no status, from a membrane that reports none, keeps the rule
|
|
1379
1526
|
* the breaker had before: the type alone.
|
|
@@ -1477,6 +1624,10 @@ export class AgentFramework {
|
|
|
1477
1624
|
private stopPromise: Promise<void> | null = null;
|
|
1478
1625
|
private stopCompleted = false;
|
|
1479
1626
|
private ownedStoreClosed = false;
|
|
1627
|
+
/** An operator's operations in flight, and the config materializations they
|
|
1628
|
+
* start. stop() waits until this set is empty before it tears down, unless
|
|
1629
|
+
* its wait budget runs out. */
|
|
1630
|
+
private readonly operatorOperations = new Set<{ label: string; promise: Promise<void> }>();
|
|
1480
1631
|
private membrane: Membrane;
|
|
1481
1632
|
private queue: ProcessQueueImpl;
|
|
1482
1633
|
private agents: Map<string, Agent> = new Map();
|
|
@@ -1484,16 +1635,6 @@ export class AgentFramework {
|
|
|
1484
1635
|
private inferencePolicy: InferencePolicy;
|
|
1485
1636
|
private errorPolicy: ErrorPolicy;
|
|
1486
1637
|
private pendingRequests: InferenceRequest[] = [];
|
|
1487
|
-
/**
|
|
1488
|
-
* Per-agent channel that triggered the agent's CURRENT inference turn, if any
|
|
1489
|
-
* (item-3 redux). Read by the ChannelRegistry's `activeChannelResolver` to
|
|
1490
|
-
* route a single-trunk agent's plain-text speech back to the channel it is
|
|
1491
|
-
* answering, instead of the process-global most-recent-inbound locus that a
|
|
1492
|
-
* concurrent message elsewhere can hijack. Set at turn start (or cleared for a
|
|
1493
|
-
* heartbeat / no-trigger turn) in startAgentStream; overwritten by the next
|
|
1494
|
-
* turn. Never read between turns (a given agent runs one turn at a time).
|
|
1495
|
-
*/
|
|
1496
|
-
private activeTriggerChannels: Map<string, string> = new Map();
|
|
1497
1638
|
private running = false;
|
|
1498
1639
|
private loopPromise: Promise<void> | null = null;
|
|
1499
1640
|
/** Quiesce/maintenance mode (issue #122). DELIBERATELY separate from
|
|
@@ -1516,6 +1657,14 @@ export class AgentFramework {
|
|
|
1516
1657
|
private maintenanceRunId = 0;
|
|
1517
1658
|
private currentMaintenanceRun: ContextMaintenanceRun | null = null;
|
|
1518
1659
|
private maintenanceHistory: ContextMaintenanceRun[] = [];
|
|
1660
|
+
/** Each agent's last reported maintenance failure, its whole text and when
|
|
1661
|
+
* it was alerted, until its maintenance goes without one
|
|
1662
|
+
* (reportMaintenanceFailure). */
|
|
1663
|
+
private maintenanceFailureReported = new Map<string, { text: string; at: number }>();
|
|
1664
|
+
/** Run records whose failure repeated one already reported, and whose report was suppressed. */
|
|
1665
|
+
private maintenanceRepeats = new WeakSet<ContextMaintenanceAgentRun>();
|
|
1666
|
+
/** An operator's maintain (maintenanceTick) asked for the pass running or about to run: the run it records is kept in the history whatever it met. A pass with nothing to run records none. */
|
|
1667
|
+
private maintenancePassRequested = false;
|
|
1519
1668
|
private providerGates: Map<string, LocalProviderGate> = new Map();
|
|
1520
1669
|
private providerAccelerationCooldowns: Map<string, ProviderAccelerationCooldown> = new Map();
|
|
1521
1670
|
private providerAccelerationRecoveries: Map<string, ProviderAccelerationRecovery> = new Map();
|
|
@@ -1538,32 +1687,6 @@ export class AgentFramework {
|
|
|
1538
1687
|
private processLoggingBroadcast: boolean;
|
|
1539
1688
|
private activeStreams: Map<string, Promise<void>> = new Map();
|
|
1540
1689
|
|
|
1541
|
-
/** Per-agent output locus FROZEN for the CURRENT logical turn. Resolved
|
|
1542
|
-
* eagerly in startAgentStream (home → addressed trigger → global default)
|
|
1543
|
-
* and never moved until the next turn: mid-turn injected messages — ambient
|
|
1544
|
-
* chatter, reactions, system markers — must not hijack where the agent's
|
|
1545
|
-
* plain prose lands (2026-07-21 Cairn lounge misroute). Lives here — not in
|
|
1546
|
-
* driveStream locals — so the pin survives a context-budget stream restart,
|
|
1547
|
-
* which continues the same logical turn in a fresh driveStream. Cleared or
|
|
1548
|
-
* re-set at the start of every non-restart turn. Since 2026-07-31 the pin
|
|
1549
|
-
* is no longer fully frozen: an ADDRESSED conversational injection — or a
|
|
1550
|
-
* conversational follow-up in a channel the agent itself explicitly sent
|
|
1551
|
-
* into this turn (turnEngagedChannels) — re-pins it at the boundary (see
|
|
1552
|
-
* the addressed re-pin block in the tool-result handler). Ambient chatter
|
|
1553
|
-
* elsewhere, reactions, and system markers still cannot move it. */
|
|
1554
|
-
private turnLocusPins: Map<string, string> = new Map();
|
|
1555
|
-
/** Channels the agent EXPLICITLY sent into during the CURRENT turn
|
|
1556
|
-
* (SEND_ENGAGEMENT_TOOLS, successful calls), per agent. Second re-pin
|
|
1557
|
-
* signal: a conversational reply in a channel the agent just engaged is
|
|
1558
|
-
* addressed by context even without a mention (2026-07-31 n=7: q's
|
|
1559
|
-
* #portables follow-up — no @mention — was answered in trailing prose
|
|
1560
|
-
* that followed the stale pin into repligate's DM). No author-kind
|
|
1561
|
-
* filter: agent-residents are full participants, and the bot flag
|
|
1562
|
-
* tracks nothing that matters (antra). Reactions/system markers are
|
|
1563
|
-
* excluded by isConversationalInjection; the engaged-this-turn scope
|
|
1564
|
-
* keeps unrelated channels from moving the pin. Cleared at every fresh
|
|
1565
|
-
* turn's start; budget restarts keep it. */
|
|
1566
|
-
private turnEngagedChannels: Map<string, Set<string>> = new Map();
|
|
1567
1690
|
/** A tool boundary injected fresh CONVERSATIONAL input (a real message —
|
|
1568
1691
|
* not a reaction or a system marker) into the live stream. Tells
|
|
1569
1692
|
* driveStream to clear sticky explicit-send suppression before handling
|
|
@@ -1579,12 +1702,13 @@ export class AgentFramework {
|
|
|
1579
1702
|
/** Latest bounced (undelivered) prose per agent — the `{{unsent}}` source. */
|
|
1580
1703
|
private proseClipboards: Map<string, string> = new Map();
|
|
1581
1704
|
/**
|
|
1582
|
-
* Each agent's routing state for its current turn (TurnRouting):
|
|
1583
|
-
*
|
|
1584
|
-
* hybrid's hold, a
|
|
1585
|
-
*
|
|
1586
|
-
*
|
|
1587
|
-
* turn
|
|
1705
|
+
* Each agent's routing state for its current logical turn (TurnRouting):
|
|
1706
|
+
* its trigger channel, locus and engaged channels, the sticky destination,
|
|
1707
|
+
* the turn's destination, hybrid's hold, a ` !` continuation and receipt
|
|
1708
|
+
* tallies. The start that opens a turn makes a
|
|
1709
|
+
* new object instead of clearing the old one, and every later stream of
|
|
1710
|
+
* the turn shares it (openLogical). A queued delivery holds the object of
|
|
1711
|
+
* the turn that queued it, so prose a turn leaves queued when it ends (a
|
|
1588
1712
|
* skip_reply ends it before its posts have run) still follows that turn's
|
|
1589
1713
|
* destination, and its late writes can't reach the next turn.
|
|
1590
1714
|
*/
|
|
@@ -1596,25 +1720,27 @@ export class AgentFramework {
|
|
|
1596
1720
|
*/
|
|
1597
1721
|
private proseSpeechChains: Map<string, Promise<void>> = new Map();
|
|
1598
1722
|
/**
|
|
1599
|
-
* Each agent's open logical turn
|
|
1600
|
-
*
|
|
1601
|
-
* completes, a tool ends it, or it fails or
|
|
1602
|
-
* start that routes prose while it is open
|
|
1603
|
-
*
|
|
1604
|
-
*
|
|
1605
|
-
*
|
|
1606
|
-
*
|
|
1607
|
-
*
|
|
1608
|
-
*
|
|
1609
|
-
*
|
|
1610
|
-
*
|
|
1611
|
-
*
|
|
1612
|
-
* a
|
|
1613
|
-
*
|
|
1614
|
-
* start
|
|
1615
|
-
*
|
|
1723
|
+
* Each agent's open logical turn: its routing state and receipt tallies
|
|
1724
|
+
* (TurnRouting). It stays open from the start that opens it until the
|
|
1725
|
+
* turn settles (settleAgent: it completes, a tool ends it, or it fails or
|
|
1726
|
+
* is abandoned), and every start that routes prose while it is open
|
|
1727
|
+
* continues it with that object: a framework retry, the start that
|
|
1728
|
+
* consumes tool results the non-streaming fallback left (whichever wake
|
|
1729
|
+
* the scheduler starts it with), the later compile a provider-capacity
|
|
1730
|
+
* hold defers a turn to. A silent start neither continues nor opens one,
|
|
1731
|
+
* and leaves the open turn's object untouched (its own is the live routing
|
|
1732
|
+
* while it runs: turnRouting). A stream that ends without
|
|
1733
|
+
* settling needs to do nothing to carry its turn. Settling only detaches
|
|
1734
|
+
* the turn from future starts: the settling stream keeps its own object,
|
|
1735
|
+
* which its remaining deliveries, its receipt and its ` !` check go on
|
|
1736
|
+
* using (a completion settles before it awaits its chain and writes its
|
|
1737
|
+
* receipt). So every logical end must close inheritance once, before the
|
|
1738
|
+
* next fresh start: a turn left open would hand its object, late writes
|
|
1739
|
+
* and all, to an unrelated turn. A tool-ended turn settles before the
|
|
1740
|
+
* next turn can start. Only the stream that holds the open turn closes
|
|
1741
|
+
* it; disposal drops it, and stop() clears every agent's.
|
|
1616
1742
|
*/
|
|
1617
|
-
private openLogical: Map<string,
|
|
1743
|
+
private openLogical: Map<string, TurnRouting> = new Map();
|
|
1618
1744
|
/** Consecutive bounce-wakes per agent; capped so an agent that keeps
|
|
1619
1745
|
* emitting unprefixed prose can't wake-loop (notices still append). */
|
|
1620
1746
|
private proseBounceStreaks: Map<string, number> = new Map();
|
|
@@ -1675,6 +1801,8 @@ export class AgentFramework {
|
|
|
1675
1801
|
* tick counts in the success log stop meaning anything. One kick per agent
|
|
1676
1802
|
* at a time; cleared when the kick settles (success or failure). */
|
|
1677
1803
|
private overBudgetDrainInFlight: Set<string> = new Set();
|
|
1804
|
+
/** The over-budget drain kicks running, which a branch move waits for as it waits for a maintenance pass. */
|
|
1805
|
+
private overBudgetDrains = new Set<Promise<void>>();
|
|
1678
1806
|
/** Per-agent current rewind episode: the single consolidated marker's id and
|
|
1679
1807
|
* how many turns have been shed so far. One marker per episode, updated in
|
|
1680
1808
|
* place (see updateRewindMarker); cleared when the episode ends. */
|
|
@@ -2490,17 +2618,27 @@ export class AgentFramework {
|
|
|
2490
2618
|
}
|
|
2491
2619
|
|
|
2492
2620
|
/**
|
|
2493
|
-
* Stop the event loop.
|
|
2494
|
-
*
|
|
2495
|
-
*
|
|
2496
|
-
*
|
|
2497
|
-
*
|
|
2621
|
+
* Stop the event loop. After a failed or timed-out wait on operator, module
|
|
2622
|
+
* or MCPL cleanup, storage stays open and stop() can be called again. A
|
|
2623
|
+
* timed-out wait lets teardown go on while that work may still act on what
|
|
2624
|
+
* is being torn down.
|
|
2625
|
+
* @param teardownTimeoutMs Optional budget for each wait on cleanup: an
|
|
2626
|
+
* operator's operations already running, then modules and MCPL together.
|
|
2627
|
+
* Omit to wait until cleanup settles, as required by existing module/child
|
|
2628
|
+
* contracts. This does not bound earlier stream, event-loop or maintenance
|
|
2629
|
+
* quiescence (a maintenance pass or over-budget drain kick in flight), or
|
|
2630
|
+
* cancel cleanup.
|
|
2498
2631
|
*/
|
|
2499
2632
|
stop(teardownTimeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS): Promise<void> {
|
|
2500
2633
|
try { validateShutdownTimeout(teardownTimeoutMs); }
|
|
2501
2634
|
catch (error) { return Promise.reject(error); }
|
|
2502
2635
|
if (this.stopPromise) return this.stopPromise;
|
|
2503
|
-
|
|
2636
|
+
// Claim the attempt before any of its body runs: stopOwned's synchronous
|
|
2637
|
+
// start can emit traces (the push coalescer's suspend() does), and a
|
|
2638
|
+
// listener that calls stop() there must get this attempt, not start a
|
|
2639
|
+
// second teardown beside it.
|
|
2640
|
+
let settle!: { resolve: () => void; reject: (error: unknown) => void };
|
|
2641
|
+
const attempt = new Promise<void>((resolve, reject) => { settle = { resolve, reject }; });
|
|
2504
2642
|
this.stopPromise = attempt;
|
|
2505
2643
|
void attempt.then(() => { this.stopCompleted = true; }, () => {});
|
|
2506
2644
|
// Failed cleanup can be retried while its original store is open. A
|
|
@@ -2508,6 +2646,9 @@ export class AgentFramework {
|
|
|
2508
2646
|
void attempt.catch(() => {
|
|
2509
2647
|
if (!this.ownedStoreClosed && this.stopPromise === attempt) this.stopPromise = null;
|
|
2510
2648
|
});
|
|
2649
|
+
// The body still starts in this call, so its synchronous steps (closing
|
|
2650
|
+
// the queue among them) happen before stop() returns.
|
|
2651
|
+
this.stopOwned(teardownTimeoutMs).then(settle.resolve, settle.reject);
|
|
2511
2652
|
return attempt;
|
|
2512
2653
|
}
|
|
2513
2654
|
|
|
@@ -2519,6 +2660,56 @@ export class AgentFramework {
|
|
|
2519
2660
|
}
|
|
2520
2661
|
}
|
|
2521
2662
|
|
|
2663
|
+
/** Register before invoking the body, so the set holds the operation for
|
|
2664
|
+
* its whole life: its synchronous trace callbacks can reenter stop(), and
|
|
2665
|
+
* that stop() must find it. Invocation stays in this stack, preserving
|
|
2666
|
+
* immediate mode changes; the returned promise carries its result to the
|
|
2667
|
+
* caller. */
|
|
2668
|
+
private trackOperatorOperation<T>(label: string, run: () => Promise<T>): Promise<T> {
|
|
2669
|
+
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
2670
|
+
let reject!: (error: unknown) => void;
|
|
2671
|
+
const operation = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
|
|
2672
|
+
const entry = { label, promise: operation.then(() => {}, () => {}) };
|
|
2673
|
+
this.operatorOperations.add(entry);
|
|
2674
|
+
void entry.promise.then(() => { this.operatorOperations.delete(entry); });
|
|
2675
|
+
try { resolve(run()); } catch (error) { reject(error); }
|
|
2676
|
+
return operation;
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
/** Whether stop() has begun: an attempt in flight (stopPromise is set before
|
|
2680
|
+
* its body runs, which emits traces before the queue closes) or a closed
|
|
2681
|
+
* queue (after a failed or completed stop()). The admission rule for every
|
|
2682
|
+
* operator entry point, the host/command listener included. */
|
|
2683
|
+
private stopHasBegun(): boolean {
|
|
2684
|
+
return !!this.stopPromise || !!this.queue?.isClosed;
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
private refuseOperatorAfterStop(verb: string): void {
|
|
2688
|
+
if (this.stopHasBegun()) throw new Error(verb + ' refused: the framework is stopping');
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
/** Wait in rounds until the set is empty: admitted work can still start work
|
|
2692
|
+
* of its own. All rounds share the caller's deadline rather than renewing
|
|
2693
|
+
* its budget. */
|
|
2694
|
+
private async waitForOperatorOperations(timeoutMs: number | undefined): Promise<void> {
|
|
2695
|
+
const deadline = timeoutMs === undefined ? undefined : performance.now() + timeoutMs;
|
|
2696
|
+
while (this.operatorOperations.size > 0) {
|
|
2697
|
+
const remaining = deadline === undefined ? undefined : Math.ceil(deadline - performance.now());
|
|
2698
|
+
const timedOut = () => new ShutdownTimeoutError(
|
|
2699
|
+
[...this.operatorOperations].map(({ label }) => label), timeoutMs!, [],
|
|
2700
|
+
);
|
|
2701
|
+
if (remaining !== undefined && remaining < 1) throw timedOut();
|
|
2702
|
+
try {
|
|
2703
|
+
await waitForShutdown([...this.operatorOperations], remaining);
|
|
2704
|
+
} catch (error) {
|
|
2705
|
+
// Report the original budget and everything still pending, including
|
|
2706
|
+
// child work added after this round's snapshot.
|
|
2707
|
+
if (error instanceof ShutdownTimeoutError) throw timedOut();
|
|
2708
|
+
throw error;
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2522
2713
|
private async stopOwned(teardownTimeoutMs: number | undefined): Promise<void> {
|
|
2523
2714
|
this.pushCoalescer?.suspend();
|
|
2524
2715
|
this.flushCoalescingSnapshot();
|
|
@@ -2560,8 +2751,10 @@ export class AgentFramework {
|
|
|
2560
2751
|
// A stopped host must never hang behind its own cooldown.
|
|
2561
2752
|
this.cancelProviderAdmission();
|
|
2562
2753
|
// An open logical turn doesn't survive a stop: a start after a restart
|
|
2563
|
-
// on this instance opens a new one rather than
|
|
2564
|
-
//
|
|
2754
|
+
// on this instance opens a new one rather than continuing the old turn's
|
|
2755
|
+
// routing (openLogical). A resume still continues its turn, with its
|
|
2756
|
+
// routing state (turnRouting), since it carries that turn's own request
|
|
2757
|
+
// on: beginAgentTurn opens the turn again for it.
|
|
2565
2758
|
this.openLogical.clear();
|
|
2566
2759
|
|
|
2567
2760
|
// Cancel all active streams
|
|
@@ -2587,14 +2780,27 @@ export class AgentFramework {
|
|
|
2587
2780
|
clearInterval(this.maintenanceTimer);
|
|
2588
2781
|
this.maintenanceTimer = null;
|
|
2589
2782
|
}
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2783
|
+
// The pass and any over-budget drain kick: a compression in flight lands
|
|
2784
|
+
// before the store closes.
|
|
2785
|
+
await this.settleMaintenancePass();
|
|
2593
2786
|
|
|
2594
2787
|
if (this.loopPromise) {
|
|
2595
2788
|
await this.loopPromise;
|
|
2596
2789
|
}
|
|
2597
2790
|
|
|
2791
|
+
// Admission closed when stop() began. What admitted operations start now,
|
|
2792
|
+
// such as a surgery's config materialization, joins the set, so wait until
|
|
2793
|
+
// the set is empty. This comes after the streams and the event loop, which
|
|
2794
|
+
// a quiesce or a surgery may be waiting on. If the budget runs out,
|
|
2795
|
+
// teardown goes on while the operation may still act on what it tears
|
|
2796
|
+
// down, and the store stays open for another stop().
|
|
2797
|
+
let operatorOperationsTimedOut: unknown;
|
|
2798
|
+
try {
|
|
2799
|
+
await this.waitForOperatorOperations(teardownTimeoutMs);
|
|
2800
|
+
} catch (error) {
|
|
2801
|
+
operatorOperationsTimedOut = error;
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2598
2804
|
// Stop typing indicators
|
|
2599
2805
|
this.channelRegistry?.stopAll();
|
|
2600
2806
|
|
|
@@ -2621,7 +2827,7 @@ export class AgentFramework {
|
|
|
2621
2827
|
}
|
|
2622
2828
|
// Timed-out or failed teardown retains its context and may still write.
|
|
2623
2829
|
// Sync available state best-effort, but leave its store open for a retry.
|
|
2624
|
-
const shutdownErrors: unknown[] = [];
|
|
2830
|
+
const shutdownErrors: unknown[] = operatorOperationsTimedOut === undefined ? [] : [operatorOperationsTimedOut];
|
|
2625
2831
|
for (const result of await Promise.allSettled(shutdownPromises)) {
|
|
2626
2832
|
if (result.status === 'rejected') shutdownErrors.push(result.reason);
|
|
2627
2833
|
}
|
|
@@ -2716,18 +2922,26 @@ export class AgentFramework {
|
|
|
2716
2922
|
}
|
|
2717
2923
|
private async withAuxiliaryAdmission<T>(agentName: string, run: () => Promise<T>, model?: string): Promise<T> {
|
|
2718
2924
|
const gate = this.providerGate(agentName);
|
|
2925
|
+
// A surgery's hold or a stop withdraws the call before a provider wait
|
|
2926
|
+
// can refuse it: either is the framework's own doing, and no failure.
|
|
2927
|
+
this.withdrawMaintenanceUnderSurgery();
|
|
2928
|
+
this.withdrawMaintenanceWhenStopping(agentName);
|
|
2719
2929
|
this.refuseHeldProviderModel(agentName, model);
|
|
2720
|
-
|
|
2930
|
+
// A parked call is woken when the gate opens, when stop() closes
|
|
2931
|
+
// admission, or when a surgery takes the store; one woken while the gate
|
|
2932
|
+
// is still closed parks again.
|
|
2933
|
+
while (this.providerGateBlocked(agentName)) {
|
|
2721
2934
|
gate.deferredAuxiliary++;
|
|
2722
2935
|
try {
|
|
2723
2936
|
await new Promise<void>((resolve) => gate.auxiliaryWaiters.push(resolve));
|
|
2724
2937
|
} finally {
|
|
2725
2938
|
gate.deferredAuxiliary = Math.max(0, gate.deferredAuxiliary - 1);
|
|
2726
2939
|
}
|
|
2940
|
+
if (this.providerAdmissionClosed) break;
|
|
2941
|
+
// A surgery that took the store while this call was parked withdraws it.
|
|
2942
|
+
this.withdrawMaintenanceUnderSurgery();
|
|
2727
2943
|
}
|
|
2728
|
-
|
|
2729
|
-
throw new Error(`Provider admission closed while stopping (${agentName})`);
|
|
2730
|
-
}
|
|
2944
|
+
this.withdrawMaintenanceWhenStopping(agentName);
|
|
2731
2945
|
// A wait recorded while this call was parked binds it too.
|
|
2732
2946
|
this.refuseHeldProviderModel(agentName, model);
|
|
2733
2947
|
gate.auxiliaryInFlight++;
|
|
@@ -2749,6 +2963,28 @@ export class AgentFramework {
|
|
|
2749
2963
|
if (gate.auxiliaryInFlight === 0) for (const resolve of gate.idleWaiters.splice(0)) resolve();
|
|
2750
2964
|
}
|
|
2751
2965
|
}
|
|
2966
|
+
/**
|
|
2967
|
+
* Maintenance makes no provider call while a surgery holds the store. The
|
|
2968
|
+
* surgery waits for the maintenance work that is running before it moves
|
|
2969
|
+
* the branch, so a call that parked at the gate would make it wait on
|
|
2970
|
+
* whatever reopens the gate: the end of a cooldown, or the retry turn a
|
|
2971
|
+
* cooldown queues, which the hold itself keeps from starting.
|
|
2972
|
+
*/
|
|
2973
|
+
private withdrawMaintenanceUnderSurgery(): void {
|
|
2974
|
+
if (this.surgeryHold) {
|
|
2975
|
+
throw new MaintenanceWithdrawnError(`a live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName} holds the store`);
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
/**
|
|
2979
|
+
* Nor once stop() has closed provider admission: a stop that catches a
|
|
2980
|
+
* tick is the framework stopping on purpose, so the call is withdrawn, as
|
|
2981
|
+
* under a surgery's hold, and no failure is reported for it.
|
|
2982
|
+
*/
|
|
2983
|
+
private withdrawMaintenanceWhenStopping(agentName: string): void {
|
|
2984
|
+
if (this.providerAdmissionClosed) {
|
|
2985
|
+
throw new MaintenanceWithdrawnError(`provider admission closed while stopping (${agentName})`);
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2752
2988
|
private auxiliaryMembraneFor(agentName: string): Membrane {
|
|
2753
2989
|
return this.membraneAdmitting((run, model) => this.withAuxiliaryAdmission(agentName, run, model));
|
|
2754
2990
|
}
|
|
@@ -2760,10 +2996,16 @@ export class AgentFramework {
|
|
|
2760
2996
|
* as a resident's are, so they bind its auxiliary calls too: a held model
|
|
2761
2997
|
* is refused without a call, and an auxiliary call's own stated wait is
|
|
2762
2998
|
* recorded for it. (An ephemeral run records nothing, its name being
|
|
2763
|
-
* single-use, so its context manager keeps the plain membrane.)
|
|
2999
|
+
* single-use, so its context manager keeps the plain membrane.) Its
|
|
3000
|
+
* compression is the background work a resident's is, whether a
|
|
3001
|
+
* maintenance tick or a drain after a message starts it, so it is withdrawn
|
|
3002
|
+
* as a resident's is: a call made while a surgery holds the store, or once
|
|
3003
|
+
* stop() has closed provider admission, is withdrawn without being sent.
|
|
2764
3004
|
*/
|
|
2765
3005
|
private conversationAuxiliaryMembraneFor(agentName: string): Membrane {
|
|
2766
3006
|
return this.membraneAdmitting(async (run, model) => {
|
|
3007
|
+
this.withdrawMaintenanceUnderSurgery();
|
|
3008
|
+
this.withdrawMaintenanceWhenStopping(agentName);
|
|
2767
3009
|
this.refuseHeldProviderModel(agentName, model);
|
|
2768
3010
|
try { return await run(); }
|
|
2769
3011
|
catch (error) {
|
|
@@ -2924,17 +3166,26 @@ export class AgentFramework {
|
|
|
2924
3166
|
return undefined;
|
|
2925
3167
|
}
|
|
2926
3168
|
/** Why an ephemeral run ended instead of sleeping a provider's stated wait,
|
|
2927
|
-
* for its caller
|
|
2928
|
-
|
|
2929
|
-
|
|
3169
|
+
* for its caller: the text, and the wait itself, which its rejection
|
|
3170
|
+
* carries (ProviderWaitNotSleptError). */
|
|
3171
|
+
private statedWaitNotSlept(error: Error): { error: string; statedWaitMs: number } {
|
|
3172
|
+
// Called only after nonOwningProviderWait answered 'fail', which takes a
|
|
3173
|
+
// numeric stated wait.
|
|
3174
|
+
const stated = isMembraneError(error) && typeof error.retryAfterMs === 'number' ? error.retryAfterMs : Number.NaN;
|
|
2930
3175
|
const cap = `${PROVIDER_ACCELERATION_MAX_COOLDOWN_MS / 1000} s`;
|
|
2931
|
-
if (
|
|
2932
|
-
return
|
|
2933
|
-
|
|
3176
|
+
if (Number.isNaN(stated)) {
|
|
3177
|
+
return {
|
|
3178
|
+
error: `${error.message} (the provider's stated wait is not a number, and an ephemeral run sleeps only a ` +
|
|
3179
|
+
`stated wait of ${cap} or less, so it ends now)`,
|
|
3180
|
+
statedWaitMs: Number.NaN,
|
|
3181
|
+
};
|
|
2934
3182
|
}
|
|
2935
3183
|
const asked = Number.isFinite(stated) ? `${Math.ceil(stated / 1000)} s` : 'longer than can be represented';
|
|
2936
|
-
return
|
|
2937
|
-
|
|
3184
|
+
return {
|
|
3185
|
+
error: `${error.message} (the provider asked to wait ${asked}; an ephemeral run doesn't sleep a stated wait ` +
|
|
3186
|
+
`longer than ${cap}, so it ends now)`,
|
|
3187
|
+
statedWaitMs: stated,
|
|
3188
|
+
};
|
|
2938
3189
|
}
|
|
2939
3190
|
/** When a hold's timer next fires: at its deadline, stepped past what one
|
|
2940
3191
|
* timer can hold. A hold with no deadline (a wait held until released, or
|
|
@@ -3236,7 +3487,32 @@ export class AgentFramework {
|
|
|
3236
3487
|
});
|
|
3237
3488
|
}
|
|
3238
3489
|
|
|
3490
|
+
/** resume()'s refusal while a surgery holds the store. */
|
|
3491
|
+
private refuseResumeUnderSurgery(): void {
|
|
3492
|
+
if (!this.surgeryHold) return;
|
|
3493
|
+
throw new OperatorActionError(
|
|
3494
|
+
'agent-busy',
|
|
3495
|
+
`Cannot resume: a live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName} holds the store — retry when it completes`,
|
|
3496
|
+
);
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3499
|
+
/** Whether maintenance work is running: a pass, or an over-budget drain kick. */
|
|
3500
|
+
private maintenanceRunning(): boolean {
|
|
3501
|
+
return this.maintenancePass !== null || this.overBudgetDrains.size > 0;
|
|
3502
|
+
}
|
|
3503
|
+
|
|
3504
|
+
/** Wait for the maintenance work that is running, whatever its outcome. */
|
|
3505
|
+
private async settleMaintenancePass(): Promise<void> {
|
|
3506
|
+
await Promise.allSettled([...(this.maintenancePass ? [this.maintenancePass] : []), ...this.overBudgetDrains]);
|
|
3507
|
+
}
|
|
3508
|
+
|
|
3239
3509
|
private async runQueuedMaintenance(): Promise<void> {
|
|
3510
|
+
// A surgery holds the store while it switches or forks a branch. No pass
|
|
3511
|
+
// starts while it's held (the timer's next pass after it runs as usual),
|
|
3512
|
+
// and a running pass starts no new tick. The surgery waits for that pass
|
|
3513
|
+
// to finish before it moves the branch, so no maintenance work overlaps
|
|
3514
|
+
// the move, and a failure a pass meets is never the surgery's doing.
|
|
3515
|
+
if (this.surgeryHold) return;
|
|
3240
3516
|
// A guard whose agent has left the map gets no activation to retry its
|
|
3241
3517
|
// queue; retry it here. It leaves busyGuards once nothing of it is left.
|
|
3242
3518
|
const live = new Set([...this.agents.values()].map((agent) => agent.toolResultGuard));
|
|
@@ -3248,32 +3524,66 @@ export class AgentFramework {
|
|
|
3248
3524
|
console.error(`[tool-result-guard] agent=${name} storage retry failed during maintenance:`, error);
|
|
3249
3525
|
}
|
|
3250
3526
|
}
|
|
3251
|
-
const
|
|
3527
|
+
for (const name of this.maintenanceFailureReported.keys()) {
|
|
3528
|
+
if (!this.agents.has(name)) this.maintenanceFailureReported.delete(name);
|
|
3529
|
+
}
|
|
3530
|
+
type Queued = { agent: Agent; record: ContextMaintenanceAgentRun }
|
|
3531
|
+
& ({ failed: true } | { failed: false; cm: ContextManager });
|
|
3532
|
+
const queued = [...this.agents.values()].flatMap((agent): Queued[] => {
|
|
3252
3533
|
try {
|
|
3253
3534
|
agent.toolResultGuard.flushUnrecorded();
|
|
3254
3535
|
} catch (error) {
|
|
3255
3536
|
console.error(`[tool-result-guard] agent=${agent.name} storage retry failed during maintenance:`, error);
|
|
3256
3537
|
}
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3538
|
+
// Whatever throws while one agent's maintenance is prepared is that
|
|
3539
|
+
// agent's failure, reported for it alone, while the others run.
|
|
3540
|
+
const failedAgent = (error: unknown): Queued[] => {
|
|
3541
|
+
const record: ContextMaintenanceAgentRun = {
|
|
3542
|
+
agentName: agent.name,
|
|
3543
|
+
startedAt: Date.now(),
|
|
3544
|
+
ticks: 0,
|
|
3545
|
+
readyBefore: false,
|
|
3546
|
+
};
|
|
3547
|
+
record.finishedAt = record.startedAt;
|
|
3548
|
+
this.reportMaintenanceFailure(
|
|
3549
|
+
agent.name,
|
|
3550
|
+
record,
|
|
3551
|
+
error instanceof Error ? String(error.message) : failureDescription(error),
|
|
3552
|
+
);
|
|
3553
|
+
return [{ agent, record, failed: true }];
|
|
3554
|
+
};
|
|
3555
|
+
let cm: ContextManager;
|
|
3556
|
+
let state: ReturnType<AgentFramework['contextReadiness']>;
|
|
3557
|
+
try {
|
|
3558
|
+
cm = agent.getContextManager();
|
|
3559
|
+
const tools = this.compressionToolsForAgent(agent.name);
|
|
3560
|
+
cm.setToolDefinitions(tools);
|
|
3561
|
+
if (this.providerGateBlocked(agent.name)) return [];
|
|
3562
|
+
state = this.contextReadiness(cm);
|
|
3563
|
+
} catch (error) {
|
|
3564
|
+
return failedAgent(error);
|
|
3565
|
+
}
|
|
3566
|
+
if ('ready' in state && state.ready) {
|
|
3567
|
+
this.maintenanceFailureReported.delete(agent.name);
|
|
3568
|
+
return [];
|
|
3569
|
+
}
|
|
3263
3570
|
const progress = this.contextProgress(cm);
|
|
3264
3571
|
const record: ContextMaintenanceAgentRun = {
|
|
3265
3572
|
agentName: agent.name,
|
|
3266
3573
|
startedAt: Date.now(),
|
|
3267
3574
|
ticks: 0,
|
|
3268
3575
|
readyBefore: false,
|
|
3269
|
-
...(pending ? { pendingBefore: pending } : {}),
|
|
3576
|
+
...('pending' in state && state.pending ? { pendingBefore: state.pending } : {}),
|
|
3270
3577
|
...(progress ? { progressBefore: progress } : {}),
|
|
3271
3578
|
};
|
|
3272
|
-
|
|
3273
|
-
agent,
|
|
3274
|
-
|
|
3275
|
-
record
|
|
3276
|
-
|
|
3579
|
+
if ('error' in state) {
|
|
3580
|
+
// Its readiness can't be read: that agent's failure, reported for it
|
|
3581
|
+
// alone, while the others run.
|
|
3582
|
+
record.finishedAt = Date.now();
|
|
3583
|
+
this.reportMaintenanceFailure(agent.name, record, state.error);
|
|
3584
|
+
return [{ agent, record, failed: true }];
|
|
3585
|
+
}
|
|
3586
|
+
return [{ agent, cm, record, failed: false }];
|
|
3277
3587
|
});
|
|
3278
3588
|
if (queued.length === 0) return;
|
|
3279
3589
|
|
|
@@ -3285,59 +3595,147 @@ export class AgentFramework {
|
|
|
3285
3595
|
this.currentMaintenanceRun = run;
|
|
3286
3596
|
|
|
3287
3597
|
try {
|
|
3288
|
-
|
|
3598
|
+
const running = queued.filter((item): item is Extract<Queued, { failed: false }> => !item.failed);
|
|
3599
|
+
// Settled, not all: the pass ends only when every agent's work has.
|
|
3600
|
+
const outcomes = await Promise.allSettled(running.map(async ({ agent, cm, record }) => {
|
|
3601
|
+
// A tick whose provider call a surgery or stop() withdrew ends this
|
|
3602
|
+
// agent's work for the pass, as the hold or the stop would have before
|
|
3603
|
+
// its next tick: it is no failure, and what the failure reports
|
|
3604
|
+
// remember stays as it is.
|
|
3605
|
+
let withdrawn = false;
|
|
3289
3606
|
try {
|
|
3290
3607
|
for (
|
|
3291
3608
|
let i = 0;
|
|
3292
|
-
i < MAINTENANCE_TICKS_PER_PASS && this.running && !cm.isReady();
|
|
3609
|
+
i < MAINTENANCE_TICKS_PER_PASS && this.running && !this.surgeryHold && !cm.isReady();
|
|
3293
3610
|
i++
|
|
3294
3611
|
) {
|
|
3295
3612
|
await cm.tick();
|
|
3296
3613
|
record.ticks++;
|
|
3297
3614
|
}
|
|
3298
3615
|
} catch (error) {
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
...(record.pendingBefore ? { pending: record.pendingBefore } : {}),
|
|
3310
|
-
...(record.progressBefore ? { progress: record.progressBefore } : {}),
|
|
3311
|
-
},
|
|
3312
|
-
},
|
|
3313
|
-
);
|
|
3616
|
+
if (error instanceof MaintenanceWithdrawnError) {
|
|
3617
|
+
withdrawn = true;
|
|
3618
|
+
console.error(`[context-maintenance] agent=${agent.name} stopped: ${error.message}`);
|
|
3619
|
+
} else {
|
|
3620
|
+
this.reportMaintenanceFailure(
|
|
3621
|
+
agent.name,
|
|
3622
|
+
record,
|
|
3623
|
+
error instanceof Error ? String(error.message) : failureDescription(error),
|
|
3624
|
+
);
|
|
3625
|
+
}
|
|
3314
3626
|
} finally {
|
|
3315
|
-
const pending = cm.getPendingWork()?.description;
|
|
3316
3627
|
record.finishedAt = Date.now();
|
|
3317
|
-
|
|
3318
|
-
if (
|
|
3628
|
+
const state = this.contextReadiness(cm);
|
|
3629
|
+
if ('error' in state) {
|
|
3630
|
+
// A run records an agent's first failure: a readiness refusal
|
|
3631
|
+
// after a tick that failed is read again by the next pass.
|
|
3632
|
+
if (record.error === undefined && !withdrawn) this.reportMaintenanceFailure(agent.name, record, state.error);
|
|
3633
|
+
} else {
|
|
3634
|
+
record.readyAfter = state.ready;
|
|
3635
|
+
if (state.pending) record.pendingAfter = state.pending;
|
|
3636
|
+
if (record.error === undefined && !withdrawn) this.maintenanceFailureReported.delete(agent.name);
|
|
3637
|
+
}
|
|
3319
3638
|
const progress = this.contextProgress(cm);
|
|
3320
3639
|
if (progress) record.progressAfter = progress;
|
|
3321
3640
|
}
|
|
3322
3641
|
}));
|
|
3642
|
+
for (const outcome of outcomes) {
|
|
3643
|
+
if (outcome.status === 'rejected') {
|
|
3644
|
+
console.error('[context-maintenance] reporting an agent\'s failure failed:', describeFailureWithFrames(outcome.reason));
|
|
3645
|
+
}
|
|
3646
|
+
}
|
|
3323
3647
|
} finally {
|
|
3324
3648
|
run.finishedAt = Date.now();
|
|
3325
3649
|
run.agents.sort((a, b) => a.agentName.localeCompare(b.agentName));
|
|
3326
3650
|
this.currentMaintenanceRun = null;
|
|
3327
|
-
|
|
3651
|
+
// A pass that only met failures already reported, and took no tick,
|
|
3652
|
+
// adds nothing the history doesn't hold, and a lasting failure would
|
|
3653
|
+
// otherwise fill it in minutes, pushing out the runs that did work. One
|
|
3654
|
+
// an operator asked for is kept: it's the pass their maintain reports.
|
|
3655
|
+
const requested = this.maintenancePassRequested;
|
|
3656
|
+
this.maintenancePassRequested = false;
|
|
3657
|
+
if (requested || !run.agents.every((record) => record.ticks === 0 && this.maintenanceRepeats.has(record))) this.maintenanceHistory.unshift(run);
|
|
3328
3658
|
if (this.maintenanceHistory.length > 50) this.maintenanceHistory.length = 50;
|
|
3329
3659
|
}
|
|
3330
3660
|
}
|
|
3331
3661
|
|
|
3662
|
+
/**
|
|
3663
|
+
* One agent's maintenance failure: recorded on its run, bounded as every
|
|
3664
|
+
* failure sink is (`record.error`), and logged and alerted for that agent
|
|
3665
|
+
* with the whole text. A failure that lasts, such as a manager whose
|
|
3666
|
+
* strategy failed to initialize, meets every pass, a few seconds apart. It
|
|
3667
|
+
* is logged and alerted again only when its whole text changes, once
|
|
3668
|
+
* opsAlert's cooldown for it has passed since the webhook last posted it
|
|
3669
|
+
* (so the webhook, which posts a kind at most once per cooldown, hears a
|
|
3670
|
+
* failure that lasts), or after a pass in which that agent's maintenance
|
|
3671
|
+
* went without a failure (maintenanceFailureReported). A record whose
|
|
3672
|
+
* failure isn't reported again is marked as a repeat, and a pass whose
|
|
3673
|
+
* every record is a repeat that took no tick stays out of the history.
|
|
3674
|
+
*/
|
|
3675
|
+
private reportMaintenanceFailure(agentName: string, record: ContextMaintenanceAgentRun, wholeError: string): void {
|
|
3676
|
+
record.error = boundFailureText(wholeError);
|
|
3677
|
+
const reported = this.maintenanceFailureReported.get(agentName);
|
|
3678
|
+
const now = Date.now();
|
|
3679
|
+
if (reported && reported.text === wholeError) {
|
|
3680
|
+
// The same failure again is alerted once the webhook's cooldown for it
|
|
3681
|
+
// has passed, counted from the webhook's last post of it: a report of
|
|
3682
|
+
// changed text that the webhook didn't post doesn't push the reminder
|
|
3683
|
+
// back. With no webhook, it's counted from this failure's last report.
|
|
3684
|
+
const since = this.opsAlertLastSent.get(`${agentName}:context-maintenance-failed`) ?? reported.at;
|
|
3685
|
+
if (now - since < this.opsAlertCooldownMs) {
|
|
3686
|
+
this.maintenanceRepeats.add(record);
|
|
3687
|
+
return;
|
|
3688
|
+
}
|
|
3689
|
+
}
|
|
3690
|
+
this.maintenanceFailureReported.set(agentName, { text: wholeError, at: now });
|
|
3691
|
+
console.error(`[context-maintenance] agent=${agentName} failed:`, record.error);
|
|
3692
|
+
this.opsAlert(
|
|
3693
|
+
'context-maintenance-failed',
|
|
3694
|
+
agentName,
|
|
3695
|
+
wholeError,
|
|
3696
|
+
{
|
|
3697
|
+
data: {
|
|
3698
|
+
scope: 'agent',
|
|
3699
|
+
...(record.pendingBefore ? { pending: record.pendingBefore } : {}),
|
|
3700
|
+
...(record.progressBefore ? { progress: record.progressBefore } : {}),
|
|
3701
|
+
},
|
|
3702
|
+
},
|
|
3703
|
+
);
|
|
3704
|
+
}
|
|
3705
|
+
|
|
3706
|
+
/**
|
|
3707
|
+
* An agent's context readiness, or the error that reading it threw. The
|
|
3708
|
+
* context manager refuses while its strategy's initialization is pending
|
|
3709
|
+
* or has failed (context-manager 0.14), and a strategy can refuse on its
|
|
3710
|
+
* own, as the autobiographical one does after a store-level branch switch
|
|
3711
|
+
* it hasn't reloaded for. Every such throw is that agent's failure.
|
|
3712
|
+
*/
|
|
3713
|
+
private contextReadiness(
|
|
3714
|
+
cm: ContextManager,
|
|
3715
|
+
): { ready: boolean; pending?: string } | { error: string } {
|
|
3716
|
+
try {
|
|
3717
|
+
const ready = cm.isReady();
|
|
3718
|
+
const pending = ready ? undefined : cm.getPendingWork()?.description;
|
|
3719
|
+
return pending ? { ready, pending } : { ready };
|
|
3720
|
+
} catch (error) {
|
|
3721
|
+
return { error: error instanceof Error ? String(error.message) : failureDescription(error) };
|
|
3722
|
+
}
|
|
3723
|
+
}
|
|
3724
|
+
|
|
3725
|
+
/** The strategy's progress snapshot, for diagnostics: one it can't give reads as none. */
|
|
3332
3726
|
private contextProgress(cm: ContextManager): Record<string, unknown> | undefined {
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3727
|
+
try {
|
|
3728
|
+
const strategy = cm.getStrategy() as {
|
|
3729
|
+
getProgressSnapshot?: () => unknown;
|
|
3730
|
+
};
|
|
3731
|
+
if (typeof strategy.getProgressSnapshot !== 'function') return undefined;
|
|
3732
|
+
const progress = strategy.getProgressSnapshot();
|
|
3733
|
+
return progress && typeof progress === 'object'
|
|
3734
|
+
? progress as Record<string, unknown>
|
|
3735
|
+
: undefined;
|
|
3736
|
+
} catch {
|
|
3737
|
+
return undefined;
|
|
3738
|
+
}
|
|
3341
3739
|
}
|
|
3342
3740
|
|
|
3343
3741
|
/**
|
|
@@ -4084,7 +4482,8 @@ export class AgentFramework {
|
|
|
4084
4482
|
`advance merges to go lower). Applying; the live compile budget is unchanged.`,
|
|
4085
4483
|
);
|
|
4086
4484
|
}
|
|
4087
|
-
} else if (preview.reason) {
|
|
4485
|
+
} else if (preview.reason && agent.contextManagerAnswers()) {
|
|
4486
|
+
// A manager that refuses its queries refuses the update below too.
|
|
4088
4487
|
console.warn(
|
|
4089
4488
|
`[budget-preflight] preview unavailable for ${agentName} (${preview.reason}); ` +
|
|
4090
4489
|
`applying without preflight`,
|
|
@@ -4093,7 +4492,7 @@ export class AgentFramework {
|
|
|
4093
4492
|
}
|
|
4094
4493
|
const result = agent.updateRuntimeSettings(patch);
|
|
4095
4494
|
if (opts?.persist !== false) {
|
|
4096
|
-
this.persistAgentRuntimeSettings(agentName,
|
|
4495
|
+
this.persistAgentRuntimeSettings(agentName, this.persistedRuntimeSettingsOf(agent));
|
|
4097
4496
|
}
|
|
4098
4497
|
this.recordOperatorAction({
|
|
4099
4498
|
kind: 'settings-update',
|
|
@@ -4114,7 +4513,7 @@ export class AgentFramework {
|
|
|
4114
4513
|
if (!agent) throw new Error(`Unknown agent: ${agentName}`);
|
|
4115
4514
|
const result = agent.resetRuntimeSettings(keys);
|
|
4116
4515
|
if (opts?.persist !== false) {
|
|
4117
|
-
this.persistAgentRuntimeSettings(agentName,
|
|
4516
|
+
this.persistAgentRuntimeSettings(agentName, this.persistedRuntimeSettingsOf(agent));
|
|
4118
4517
|
}
|
|
4119
4518
|
this.recordOperatorAction({
|
|
4120
4519
|
kind: 'settings-reset',
|
|
@@ -4133,7 +4532,7 @@ export class AgentFramework {
|
|
|
4133
4532
|
const agent = this.agents.get(agentName);
|
|
4134
4533
|
if (!agent) throw new Error(`Unknown agent: ${agentName}`);
|
|
4135
4534
|
const result = agent.cancelRuntimeSettingsTransition();
|
|
4136
|
-
this.persistAgentRuntimeSettings(agentName,
|
|
4535
|
+
this.persistAgentRuntimeSettings(agentName, this.persistedRuntimeSettingsOf(agent));
|
|
4137
4536
|
this.recordOperatorAction({
|
|
4138
4537
|
kind: 'settings-cancel-transition',
|
|
4139
4538
|
agent: agentName,
|
|
@@ -4216,6 +4615,15 @@ export class AgentFramework {
|
|
|
4216
4615
|
reason?: string;
|
|
4217
4616
|
timeoutMs?: number;
|
|
4218
4617
|
abandon?: boolean;
|
|
4618
|
+
}): Promise<HostModeStatus> {
|
|
4619
|
+
this.refuseOperatorAfterStop('quiesce');
|
|
4620
|
+
return this.trackOperatorOperation('host:quiesce', () => this.runQuiesce(opts));
|
|
4621
|
+
}
|
|
4622
|
+
|
|
4623
|
+
private async runQuiesce(opts?: {
|
|
4624
|
+
reason?: string;
|
|
4625
|
+
timeoutMs?: number;
|
|
4626
|
+
abandon?: boolean;
|
|
4219
4627
|
}): Promise<HostModeStatus> {
|
|
4220
4628
|
if (this.quiesced) {
|
|
4221
4629
|
// Idempotent — EXCEPT abandon escalation: a first quiesce that timed
|
|
@@ -4271,6 +4679,7 @@ export class AgentFramework {
|
|
|
4271
4679
|
// continuation's quiesce recheck requeues it — wait for that to happen).
|
|
4272
4680
|
while (
|
|
4273
4681
|
this.quiesced &&
|
|
4682
|
+
!this.queue?.isClosed &&
|
|
4274
4683
|
(this.activeTurnTokens.size > 0 || this.parkedAdmissionCount() > 0) &&
|
|
4275
4684
|
Date.now() < deadline
|
|
4276
4685
|
) {
|
|
@@ -4285,6 +4694,13 @@ export class AgentFramework {
|
|
|
4285
4694
|
console.error('[host-mode] quiesce superseded by a concurrent resume during drain');
|
|
4286
4695
|
return this.getHostModeStatus();
|
|
4287
4696
|
}
|
|
4697
|
+
// Stop cuts this drain short, including any requested abandon. It cancels
|
|
4698
|
+
// streams and waits for the event loop; a token with no stream settles
|
|
4699
|
+
// on its own. Return the status as it stands and keep the durable quiesce.
|
|
4700
|
+
if (this.queue?.isClosed) {
|
|
4701
|
+
console.error('[host-mode] quiesce stopped waiting for its drain: the framework is stopping');
|
|
4702
|
+
return this.getHostModeStatus();
|
|
4703
|
+
}
|
|
4288
4704
|
|
|
4289
4705
|
let abandoned = false;
|
|
4290
4706
|
let unabandonable: string[] = [];
|
|
@@ -4344,9 +4760,11 @@ export class AgentFramework {
|
|
|
4344
4760
|
);
|
|
4345
4761
|
}
|
|
4346
4762
|
}
|
|
4347
|
-
// Bounded grace for
|
|
4763
|
+
// Bounded grace for cancelled streams. Stop cancels streams too; a turn
|
|
4764
|
+
// with no stream settles on its own. Report the abandon that already ran,
|
|
4765
|
+
// even if stop cuts its grace short.
|
|
4348
4766
|
const grace = Date.now() + 10_000;
|
|
4349
|
-
while (this.quiesced && this.activeTurnTokens.size > 0 && Date.now() < grace) {
|
|
4767
|
+
while (this.quiesced && !this.queue?.isClosed && this.activeTurnTokens.size > 0 && Date.now() < grace) {
|
|
4350
4768
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
4351
4769
|
}
|
|
4352
4770
|
// Only turns still alive after the grace are worth reporting.
|
|
@@ -4363,18 +4781,32 @@ export class AgentFramework {
|
|
|
4363
4781
|
* strategy cannot preview would hold hosts hostage to a diagnostic.
|
|
4364
4782
|
*/
|
|
4365
4783
|
async resume(opts?: { force?: boolean }): Promise<HostModeStatus> {
|
|
4784
|
+
this.refuseOperatorAfterStop('resume');
|
|
4785
|
+
return this.trackOperatorOperation('host:resume', () => this.runResume(opts));
|
|
4786
|
+
}
|
|
4787
|
+
|
|
4788
|
+
private async runResume(opts?: { force?: boolean }): Promise<HostModeStatus> {
|
|
4366
4789
|
if (!this.quiesced) return this.getHostModeStatus();
|
|
4790
|
+
// A surgery switches or forks the branch while it holds the store, and a
|
|
4791
|
+
// manager whose initialization that left pending refuses its settings
|
|
4792
|
+
// read: resume once it's done, as the other operator calls do. Checked
|
|
4793
|
+
// again after the waits below, since a surgery can take the store while
|
|
4794
|
+
// resume waits for a pass or a preview.
|
|
4795
|
+
this.refuseResumeUnderSurgery();
|
|
4367
4796
|
|
|
4368
4797
|
// The canonical operator flow is `maintain; resume`, and a timer-driven
|
|
4369
4798
|
// pass may still be mid-flight: previewContext refuses to overlap one
|
|
4370
4799
|
// ("already running"), which used to degrade the gate to "no opinion".
|
|
4371
4800
|
// Wait for the pass first, then retry a few times for the case where a
|
|
4372
4801
|
// fresh pass started in between.
|
|
4373
|
-
if (this.
|
|
4374
|
-
await this.maintenancePass.catch(() => {});
|
|
4375
|
-
}
|
|
4802
|
+
if (this.maintenanceRunning()) await this.settleMaintenancePass();
|
|
4376
4803
|
const failing: Array<{ agentName: string; preview: RuntimeSettingsPreview }> = [];
|
|
4377
4804
|
for (const agentName of this.agents.keys()) {
|
|
4805
|
+
// A preview that isn't available gives no verdict, and resume goes on
|
|
4806
|
+
// for the others. That includes an agent whose context manager refuses
|
|
4807
|
+
// its queries, as when its strategy failed to initialize: that manager
|
|
4808
|
+
// refuses its compiles and writes until a switchBranch or setStrategy
|
|
4809
|
+
// succeeds, so the agent can't run over budget, and maintenance reports it.
|
|
4378
4810
|
let preview = this.previewAgentRuntimeSettings(agentName);
|
|
4379
4811
|
for (let attempt = 0; attempt < 8 && !preview.available && preview.reason === 'preview_in_flight'; attempt++) {
|
|
4380
4812
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
@@ -4390,6 +4822,13 @@ export class AgentFramework {
|
|
|
4390
4822
|
);
|
|
4391
4823
|
}
|
|
4392
4824
|
}
|
|
4825
|
+
this.refuseResumeUnderSurgery();
|
|
4826
|
+
// stop() closes the queue first. If it has begun, before this call or
|
|
4827
|
+
// during the waits above, leave the host quiesced, as it will boot,
|
|
4828
|
+
// rather than reopen what stop() is closing.
|
|
4829
|
+
if (this.queue?.isClosed) {
|
|
4830
|
+
throw new Error('resume refused: the framework is stopping, so the host stays quiesced');
|
|
4831
|
+
}
|
|
4393
4832
|
if (failing.length > 0 && !opts?.force) {
|
|
4394
4833
|
const detail = failing.map(({ agentName, preview }) =>
|
|
4395
4834
|
`${agentName}: folded floor ${preview.effective!.finalTokens} > hard budget ` +
|
|
@@ -4444,9 +4883,10 @@ export class AgentFramework {
|
|
|
4444
4883
|
await barrier.promise;
|
|
4445
4884
|
this.completeMcplDataPlaneGate(barrier);
|
|
4446
4885
|
} catch (error) {
|
|
4447
|
-
// The host
|
|
4448
|
-
//
|
|
4449
|
-
//
|
|
4886
|
+
// The host has resumed, so report the failed barrier without undoing
|
|
4887
|
+
// that. While serving, failMcplDataPlaneGate recycles the
|
|
4888
|
+
// connections and their reconnect flows reopen the data planes;
|
|
4889
|
+
// while the host stops, it releases the barrier instead.
|
|
4450
4890
|
await this.failMcplDataPlaneGate(barrier, 'quiesce resume', error);
|
|
4451
4891
|
}
|
|
4452
4892
|
}
|
|
@@ -4460,7 +4900,9 @@ export class AgentFramework {
|
|
|
4460
4900
|
// mid-quiesce boots quiesced but with these queues empty (sources like
|
|
4461
4901
|
// heartbeats re-deliver on their own cadence; one-shot wakes are lost).
|
|
4462
4902
|
// Deferred context WRITES are persisted while quiesced and survive.
|
|
4463
|
-
console.error(
|
|
4903
|
+
console.error(this.queue?.isClosed
|
|
4904
|
+
? `[host-mode] resumed — ${releasedRequests} parked wake(s) won't run because the framework is stopping`
|
|
4905
|
+
: `[host-mode] resumed — ${releasedRequests} parked wake(s) released`);
|
|
4464
4906
|
}
|
|
4465
4907
|
return this.getHostModeStatus();
|
|
4466
4908
|
}
|
|
@@ -4757,14 +5199,27 @@ export class AgentFramework {
|
|
|
4757
5199
|
* true in quiesce mode precisely so this machinery stays hot.
|
|
4758
5200
|
*/
|
|
4759
5201
|
async maintenanceTick(): Promise<ContextMaintenanceSnapshot & { ran: boolean }> {
|
|
5202
|
+
// A pass already running is stop()'s to wait for; a new one isn't started.
|
|
5203
|
+
this.refuseOperatorAfterStop('maintain');
|
|
4760
5204
|
if (!this.running) {
|
|
4761
|
-
// startQueuedMaintenance no-ops on a
|
|
4762
|
-
// a
|
|
5205
|
+
// startQueuedMaintenance no-ops on a framework that was never started
|
|
5206
|
+
// (a stopping one is refused above): a bare snapshot would masquerade
|
|
5207
|
+
// as a completed pass, so say so.
|
|
4763
5208
|
console.warn('[host-mode] maintenanceTick: framework is not running — no pass executed');
|
|
4764
5209
|
return { ...this.getContextMaintenanceSnapshot(), ran: false };
|
|
4765
5210
|
}
|
|
5211
|
+
if (this.surgeryHold) {
|
|
5212
|
+
// No pass starts while a surgery holds the store (runQueuedMaintenance).
|
|
5213
|
+
console.warn(
|
|
5214
|
+
`[host-mode] maintenanceTick: a ${this.surgeryHold.verb} for ${this.surgeryHold.agentName} holds the store — no pass executed`,
|
|
5215
|
+
);
|
|
5216
|
+
return { ...this.getContextMaintenanceSnapshot(), ran: false };
|
|
5217
|
+
}
|
|
5218
|
+
this.maintenancePassRequested = true;
|
|
4766
5219
|
this.startQueuedMaintenance();
|
|
4767
5220
|
await this.maintenancePass;
|
|
5221
|
+
// A pass with nothing queued records no run, and the next pass wasn't asked for.
|
|
5222
|
+
this.maintenancePassRequested = false;
|
|
4768
5223
|
return { ...this.getContextMaintenanceSnapshot(), ran: true };
|
|
4769
5224
|
}
|
|
4770
5225
|
|
|
@@ -4772,12 +5227,13 @@ export class AgentFramework {
|
|
|
4772
5227
|
getContextMaintenanceSnapshot(): ContextMaintenanceSnapshot {
|
|
4773
5228
|
const agents = [...this.agents.values()].map((agent) => {
|
|
4774
5229
|
const cm = agent.getContextManager();
|
|
4775
|
-
const
|
|
5230
|
+
const state = this.contextReadiness(cm);
|
|
4776
5231
|
const progress = this.contextProgress(cm);
|
|
4777
5232
|
return {
|
|
4778
5233
|
agentName: agent.name,
|
|
4779
|
-
|
|
4780
|
-
|
|
5234
|
+
...('error' in state
|
|
5235
|
+
? { ready: false, error: boundFailureText(state.error) }
|
|
5236
|
+
: { ready: state.ready, ...(state.pending ? { pending: state.pending } : {}) }),
|
|
4781
5237
|
...(progress ? { progress } : {}),
|
|
4782
5238
|
};
|
|
4783
5239
|
});
|
|
@@ -4795,13 +5251,28 @@ export class AgentFramework {
|
|
|
4795
5251
|
* were activated right now — WITHOUT running inference, opening a stream, or
|
|
4796
5252
|
* mutating agent state. Intended for debug/preview tooling.
|
|
4797
5253
|
*
|
|
4798
|
-
*
|
|
4799
|
-
*
|
|
4800
|
-
* `
|
|
4801
|
-
*
|
|
4802
|
-
*
|
|
4803
|
-
*
|
|
4804
|
-
*
|
|
5254
|
+
* What a preview commits (default): nothing of its own. It compiles as a
|
|
5255
|
+
* dry run (`dryRun`), filters tools and assembles the system prompt, then
|
|
5256
|
+
* delegates to `Agent.buildActivationRequest`. No tokens are spent, no
|
|
5257
|
+
* external MCPL server is contacted, no fold resolution is persisted and
|
|
5258
|
+
* none of the compression its fold plan calls for is queued; the consumed
|
|
5259
|
+
* watermark and any prepared budget change are left alone; and once
|
|
5260
|
+
* context-manager's thinking binding (context-manager#160, not yet
|
|
5261
|
+
* released) ships, no stamps are committed and no compile awaits
|
|
5262
|
+
* acceptance. In context-manager 0.14 its dry run leaves nothing else
|
|
5263
|
+
* behind but the release of an expired compression hold, as any compile
|
|
5264
|
+
* does (see `Agent.compileWithInjections`). The preview keeps in-memory
|
|
5265
|
+
* bookkeeping of its own, such as handing the context manager the
|
|
5266
|
+
* compression tool definitions, as every activation request does.
|
|
5267
|
+
* Under the autobiographical strategy, a budget the context can't fit
|
|
5268
|
+
* still throws, as for a committing compile: a dry run skips only
|
|
5269
|
+
* context-manager's post-pick check, and an adaptive fold plan that
|
|
5270
|
+
* doesn't fit then overruns at the middle's emission, with a refusal that
|
|
5271
|
+
* names the tokens and the limit. Windowed passthrough's newest message
|
|
5272
|
+
* alone is the one window that comes back larger than the budget. For a
|
|
5273
|
+
* verdict instead of the refusal, `previewContextSettings` returns
|
|
5274
|
+
* context-manager's own at a budget, with the agent's `maxTokens` reserved
|
|
5275
|
+
* for the response.
|
|
4805
5276
|
*
|
|
4806
5277
|
* The trade-off is fidelity: the dynamically-gathered ContextInjection[]
|
|
4807
5278
|
* (module `gatherContext` + MCPL `beforeInference` hooks) are NOT included
|
|
@@ -4832,13 +5303,15 @@ export class AgentFramework {
|
|
|
4832
5303
|
};
|
|
4833
5304
|
|
|
4834
5305
|
// An explicit budget compiles against a HYPOTHETICAL window instead of the
|
|
4835
|
-
// agent's live one.
|
|
4836
|
-
//
|
|
4837
|
-
//
|
|
4838
|
-
// Default: no dynamic injection gathering →
|
|
4839
|
-
//
|
|
5306
|
+
// agent's live one. Every preview is a dry run, so none of them settles
|
|
5307
|
+
// a converging descent or commits what a sent compile commits (see the
|
|
5308
|
+
// doc comment above).
|
|
5309
|
+
// Default: no dynamic injection gathering → no inference, no Chronicle
|
|
5310
|
+
// writes of its own, no external RPC. Opt in explicitly.
|
|
4840
5311
|
if (!opts?.injections) {
|
|
4841
|
-
return capture(await agent.buildActivationRequest(
|
|
5312
|
+
return capture(await agent.buildActivationRequest(
|
|
5313
|
+
tools, undefined, opts?.budget, presentation?.available ?? tools, { dryRun: true },
|
|
5314
|
+
));
|
|
4842
5315
|
}
|
|
4843
5316
|
|
|
4844
5317
|
// Full-fidelity path: mirrors startAgentStream's injection gathering.
|
|
@@ -4873,7 +5346,9 @@ export class AgentFramework {
|
|
|
4873
5346
|
}
|
|
4874
5347
|
}
|
|
4875
5348
|
|
|
4876
|
-
return capture(await agent.buildActivationRequest(
|
|
5349
|
+
return capture(await agent.buildActivationRequest(
|
|
5350
|
+
tools, injections, opts?.budget, presentation?.available ?? tools, { dryRun: true },
|
|
5351
|
+
));
|
|
4877
5352
|
}
|
|
4878
5353
|
|
|
4879
5354
|
/**
|
|
@@ -4883,9 +5358,11 @@ export class AgentFramework {
|
|
|
4883
5358
|
*
|
|
4884
5359
|
* Unlike `previewActivation` (which renders a request), this returns the
|
|
4885
5360
|
* picker's plan: resulting tokens, whether it fits, how many summaries would
|
|
4886
|
-
* have to be produced first, and the per-chunk fold levels.
|
|
4887
|
-
*
|
|
4888
|
-
*
|
|
5361
|
+
* have to be produced first, and the per-chunk fold levels. It commits
|
|
5362
|
+
* nothing: its select is a dry run, which in context-manager 0.14 leaves
|
|
5363
|
+
* what a select leaves behind as the last committing compile left it —
|
|
5364
|
+
* see `AutobiographicalStrategy.previewContext` and
|
|
5365
|
+
* `Agent.compileWithInjections`.
|
|
4889
5366
|
*
|
|
4890
5367
|
* Returns null when the agent's strategy has no fold plan (non-adaptive).
|
|
4891
5368
|
*/
|
|
@@ -4897,6 +5374,15 @@ export class AgentFramework {
|
|
|
4897
5374
|
): unknown {
|
|
4898
5375
|
const agent = this.agents.get(agentName);
|
|
4899
5376
|
if (!agent) throw new Error(`Unknown agent: ${agentName}`);
|
|
5377
|
+
return this.previewContextFor(agent, budgetTokens, overrides, opts);
|
|
5378
|
+
}
|
|
5379
|
+
|
|
5380
|
+
private previewContextFor(
|
|
5381
|
+
agent: Agent,
|
|
5382
|
+
budgetTokens: number,
|
|
5383
|
+
overrides?: Record<string, unknown>,
|
|
5384
|
+
opts?: { render?: boolean },
|
|
5385
|
+
): unknown {
|
|
4900
5386
|
const cm = agent.getContextManager() as unknown as {
|
|
4901
5387
|
previewContext?: (
|
|
4902
5388
|
budget: TokenBudget,
|
|
@@ -4930,9 +5416,14 @@ export class AgentFramework {
|
|
|
4930
5416
|
* the compile budget and rejected safe-by-construction paced descents.
|
|
4931
5417
|
*
|
|
4932
5418
|
* Never throws for preview-layer reasons: strategy without previewContext,
|
|
4933
|
-
* preview overlap, stale branch generation,
|
|
4934
|
-
* adaptive) path
|
|
4935
|
-
*
|
|
5419
|
+
* preview overlap, stale branch generation, the hierarchical (non-
|
|
5420
|
+
* adaptive) path, and a context manager that refuses its queries (its
|
|
5421
|
+
* strategy's initialization pending or failed) all come back as
|
|
5422
|
+
* `{available: false, reason}`. Unknown agent still throws, matching the
|
|
5423
|
+
* sibling accessors, and so does a patch whose values the agent refuses,
|
|
5424
|
+
* whatever its context manager's state. Whether the strategy takes live
|
|
5425
|
+
* tail and pace settings is the manager's to say, so while it refuses, a
|
|
5426
|
+
* patch that sets them gets no verdict either.
|
|
4936
5427
|
*/
|
|
4937
5428
|
previewAgentRuntimeSettings(
|
|
4938
5429
|
agentName: string,
|
|
@@ -4940,8 +5431,30 @@ export class AgentFramework {
|
|
|
4940
5431
|
): RuntimeSettingsPreview {
|
|
4941
5432
|
const agent = this.agents.get(agentName);
|
|
4942
5433
|
if (!agent) throw new Error(`Unknown agent: ${agentName}`);
|
|
4943
|
-
|
|
4944
|
-
|
|
5434
|
+
return this.previewRuntimeSettingsFor(agent, patch);
|
|
5435
|
+
}
|
|
5436
|
+
|
|
5437
|
+
/** previewAgentRuntimeSettings for an agent in hand: boot restore previews one not yet registered. */
|
|
5438
|
+
private previewRuntimeSettingsFor(
|
|
5439
|
+
agent: Agent,
|
|
5440
|
+
patch?: AgentRuntimeSettingsPatch,
|
|
5441
|
+
): RuntimeSettingsPreview {
|
|
5442
|
+
// A patch whose values the agent refuses throws, whatever its context
|
|
5443
|
+
// manager's state.
|
|
5444
|
+
if (patch && Object.keys(patch).length > 0) agent.checkRuntimeSettingsPatch(patch);
|
|
5445
|
+
let plan: ReturnType<Agent['planRuntimeSettings']>;
|
|
5446
|
+
let settings: ReturnType<Agent['getRuntimeSettings']>;
|
|
5447
|
+
try {
|
|
5448
|
+
plan = agent.planRuntimeSettings(patch);
|
|
5449
|
+
settings = agent.getRuntimeSettings();
|
|
5450
|
+
} catch (error) {
|
|
5451
|
+
// A manager that refuses its queries gives no verdict, as a preview
|
|
5452
|
+
// that can't be computed gives none: the agent's own state still says
|
|
5453
|
+
// its budget and the patch's path, and whatever stopped the preview is
|
|
5454
|
+
// its reason. When the manager answers, anything else throws as before.
|
|
5455
|
+
if (agent.contextManagerAnswers()) throw error;
|
|
5456
|
+
return { available: false, reason: previewStoppedReason(error), ...agent.runtimeBudgetPath(patch) };
|
|
5457
|
+
}
|
|
4945
5458
|
const base: RuntimeSettingsPreview = {
|
|
4946
5459
|
available: false,
|
|
4947
5460
|
path: plan.path,
|
|
@@ -4955,8 +5468,8 @@ export class AgentFramework {
|
|
|
4955
5468
|
reason?: string;
|
|
4956
5469
|
} => {
|
|
4957
5470
|
try {
|
|
4958
|
-
const result = this.
|
|
4959
|
-
|
|
5471
|
+
const result = this.previewContextFor(
|
|
5472
|
+
agent,
|
|
4960
5473
|
budgetTokens,
|
|
4961
5474
|
Object.keys(plan.overrides).length > 0 ? plan.overrides : undefined,
|
|
4962
5475
|
);
|
|
@@ -4965,20 +5478,7 @@ export class AgentFramework {
|
|
|
4965
5478
|
}
|
|
4966
5479
|
return { result: result as Record<string, unknown> };
|
|
4967
5480
|
} catch (error) {
|
|
4968
|
-
|
|
4969
|
-
// Structured mapping of the strategy's known throw paths. Message
|
|
4970
|
-
// matching is fragile by nature, so unknown messages surface verbatim
|
|
4971
|
-
// as the reason rather than being swallowed or rethrown.
|
|
4972
|
-
if (message.includes('requires reinitialization for the current branch generation')) {
|
|
4973
|
-
return { reason: 'branch_generation_changed' };
|
|
4974
|
-
}
|
|
4975
|
-
if (message.includes('already running; previews must not overlap')) {
|
|
4976
|
-
return { reason: 'preview_in_flight' };
|
|
4977
|
-
}
|
|
4978
|
-
if (message.includes('requires adaptiveResolution')) {
|
|
4979
|
-
return { reason: 'no_adaptive_resolution' };
|
|
4980
|
-
}
|
|
4981
|
-
return { reason: message };
|
|
5481
|
+
return { reason: previewStoppedReason(error) };
|
|
4982
5482
|
}
|
|
4983
5483
|
};
|
|
4984
5484
|
|
|
@@ -5113,22 +5613,41 @@ export class AgentFramework {
|
|
|
5113
5613
|
}
|
|
5114
5614
|
}
|
|
5115
5615
|
|
|
5616
|
+
/** An agent's persisted record, or null when it has none; throws when the store can't be read. */
|
|
5116
5617
|
private readAgentRuntimeSettings(agentName: string): AgentRuntimeSettingsOverrides | null {
|
|
5117
|
-
|
|
5118
|
-
|
|
5119
|
-
|
|
5120
|
-
|
|
5121
|
-
|
|
5122
|
-
return stored ? { ...stored } : null;
|
|
5123
|
-
} catch {
|
|
5124
|
-
return null;
|
|
5125
|
-
}
|
|
5618
|
+
const data = this.store.getStateJson(FRAMEWORK_STATE_ID) as {
|
|
5619
|
+
agentRuntimeSettings?: Record<string, AgentRuntimeSettingsOverrides>;
|
|
5620
|
+
} | null;
|
|
5621
|
+
const stored = data?.agentRuntimeSettings?.[agentName];
|
|
5622
|
+
return stored ? { ...stored } : null;
|
|
5126
5623
|
}
|
|
5127
5624
|
|
|
5128
5625
|
private validatePersistedAgentRuntimeSettings(
|
|
5129
5626
|
agentName: string,
|
|
5130
5627
|
overrides: AgentRuntimeSettingsOverrides,
|
|
5131
5628
|
): AgentRuntimeSettingsOverrides {
|
|
5629
|
+
// A malformed number is dropped, with a line that names the value's kind,
|
|
5630
|
+
// never its content. It could never apply: a budget, tail or pace kept
|
|
5631
|
+
// unapplied would be reported and written back as it is, and a marker
|
|
5632
|
+
// that can't be read isn't trusted, so the budget restores paced.
|
|
5633
|
+
const numbers = [
|
|
5634
|
+
['contextBudgetReachedTokens', 1],
|
|
5635
|
+
['contextBudgetTokens', 1],
|
|
5636
|
+
['tailTokens', 0],
|
|
5637
|
+
['transitionPaceTokens', 1],
|
|
5638
|
+
] as const;
|
|
5639
|
+
for (const [key, least] of numbers) {
|
|
5640
|
+
const value = overrides[key] as unknown;
|
|
5641
|
+
if (value === undefined || (Number.isSafeInteger(value) && (value as number) >= least)) continue;
|
|
5642
|
+
const kind = typeof value === 'number' ? `a number that isn't a ${least > 0 ? 'positive' : 'non-negative'} safe integer`
|
|
5643
|
+
: value === null ? 'null'
|
|
5644
|
+
: Array.isArray(value) ? 'an array'
|
|
5645
|
+
: typeof value === 'object' ? 'an object'
|
|
5646
|
+
: `a ${typeof value}`;
|
|
5647
|
+
console.error(`[agent-settings] ignoring an invalid persisted ${key} for agent "${agentName}": ${kind}`);
|
|
5648
|
+
const { [key]: _ignored, ...rest } = overrides;
|
|
5649
|
+
overrides = rest;
|
|
5650
|
+
}
|
|
5132
5651
|
if (
|
|
5133
5652
|
overrides.sameRoundThinkTextPolicy !== undefined &&
|
|
5134
5653
|
overrides.sameRoundThinkTextPolicy !== 'public' &&
|
|
@@ -5179,6 +5698,138 @@ export class AgentFramework {
|
|
|
5179
5698
|
this.store.setStateJson(FRAMEWORK_STATE_ID, state);
|
|
5180
5699
|
}
|
|
5181
5700
|
|
|
5701
|
+
/**
|
|
5702
|
+
* Apply an agent's persisted settings at boot, each on its own, the budget
|
|
5703
|
+
* last. A persisted setting never stops the agent booting, except a
|
|
5704
|
+
* think-text policy, which fails closed: an invalid one is refused before
|
|
5705
|
+
* this (validatePersistedAgentRuntimeSettings), and a valid one always
|
|
5706
|
+
* applies. A tail, pace or budget that can't be applied now (a tail or pace
|
|
5707
|
+
* on a strategy without live settings, a budget no longer above maxTokens,
|
|
5708
|
+
* a lower one restored paced on a strategy that can't prepare a smaller
|
|
5709
|
+
* window) is kept set but unapplied (Agent.keepRuntimeSettings), so a later
|
|
5710
|
+
* persisting write keeps it in the record.
|
|
5711
|
+
*
|
|
5712
|
+
* A budget below the recipe's restores as a paced descent from it, unless
|
|
5713
|
+
* the record carries its marker (`contextBudgetReachedTokens`, written when
|
|
5714
|
+
* a compile fit at it) and the preflight says it fits now: then it is
|
|
5715
|
+
* applied at once. The preflight plans the stored context as the context
|
|
5716
|
+
* manager will, with the restored tail and pace, so it sees what a restart
|
|
5717
|
+
* can change there: a new maxTokens (the response reserve) or strategy
|
|
5718
|
+
* configuration. It also sees a context that grew while the agent ran at
|
|
5719
|
+
* another budget after the record was written, through a persist: false
|
|
5720
|
+
* change. The system prompt, tools and injections are outside the context
|
|
5721
|
+
* manager's budget, so they don't enter it (context-manager #162). If a
|
|
5722
|
+
* compile at a budget applied at once this way is refused, the agent
|
|
5723
|
+
* restores it paced then (armed by Agent.restoreRuntimeSettings, run in
|
|
5724
|
+
* Agent.compileAtLiveBudgetNoting). A budget at or above the recipe's is
|
|
5725
|
+
* applied at once with or without the marker, as an increase always is.
|
|
5726
|
+
*
|
|
5727
|
+
* When a marked budget isn't applied at once or a setting can't be
|
|
5728
|
+
* applied, one line says why, what couldn't be applied, and the budget set
|
|
5729
|
+
* beside the one compiles plan at.
|
|
5730
|
+
*/
|
|
5731
|
+
private restorePersistedRuntimeSettings(
|
|
5732
|
+
agentName: string,
|
|
5733
|
+
agent: Agent,
|
|
5734
|
+
overrides: AgentRuntimeSettingsOverrides,
|
|
5735
|
+
): void {
|
|
5736
|
+
const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error));
|
|
5737
|
+
const { contextBudgetReachedTokens: reached, sameRoundThinkTextPolicy, ...settings } = overrides;
|
|
5738
|
+
if (sameRoundThinkTextPolicy !== undefined) agent.restoreRuntimeSettings({ sameRoundThinkTextPolicy });
|
|
5739
|
+
const unapplied: string[] = [];
|
|
5740
|
+
/** Restore one setting, or keep it set but unapplied; true if it applied. */
|
|
5741
|
+
const restore = (part: Pick<AgentRuntimeSettingsPatch, 'contextBudgetTokens' | 'tailTokens' | 'transitionPaceTokens'>) => {
|
|
5742
|
+
try {
|
|
5743
|
+
agent.restoreRuntimeSettings(part);
|
|
5744
|
+
return true;
|
|
5745
|
+
} catch (error) {
|
|
5746
|
+
agent.keepRuntimeSettings(part);
|
|
5747
|
+
unapplied.push(Object.entries(part).map(([key, value]) => `${key}=${value}`).join(', ') + ` (${messageOf(error)})`);
|
|
5748
|
+
return false;
|
|
5749
|
+
}
|
|
5750
|
+
};
|
|
5751
|
+
if (settings.tailTokens !== undefined) restore({ tailTokens: settings.tailTokens });
|
|
5752
|
+
if (settings.transitionPaceTokens !== undefined) restore({ transitionPaceTokens: settings.transitionPaceTokens });
|
|
5753
|
+
const budget = settings.contextBudgetTokens;
|
|
5754
|
+
let atOnce: string | undefined;
|
|
5755
|
+
let appliedAtOnce = false;
|
|
5756
|
+
if (budget !== undefined && reached === budget) {
|
|
5757
|
+
const patch: AgentRuntimeSettingsPatch = { contextBudgetTokens: budget, immediate: true };
|
|
5758
|
+
let why: string | undefined;
|
|
5759
|
+
try {
|
|
5760
|
+
const preview = this.previewRuntimeSettingsFor(agent, patch);
|
|
5761
|
+
if (!preview.available) why = `whether it fits now can't be checked (${preview.reason ?? 'no preview'})`;
|
|
5762
|
+
else if (preview.effective?.fits !== true) why = `the folded floor renders ${preview.effective?.finalTokens} tokens against it now`;
|
|
5763
|
+
} catch (error) {
|
|
5764
|
+
why = `whether it fits now can't be checked (${messageOf(error)})`;
|
|
5765
|
+
}
|
|
5766
|
+
if (why === undefined) {
|
|
5767
|
+
try {
|
|
5768
|
+
agent.restoreRuntimeSettings(patch, { provenBudget: budget });
|
|
5769
|
+
appliedAtOnce = true;
|
|
5770
|
+
} catch (error) {
|
|
5771
|
+
why = `applying it at once failed (${messageOf(error)}), though the preflight says it fits now`;
|
|
5772
|
+
}
|
|
5773
|
+
}
|
|
5774
|
+
if (why !== undefined) atOnce = `its record says a compile fit at contextBudgetTokens=${budget}, but ${why}`;
|
|
5775
|
+
}
|
|
5776
|
+
const budgetKept = budget !== undefined && !appliedAtOnce && !restore({ contextBudgetTokens: budget });
|
|
5777
|
+
if (atOnce === undefined && unapplied.length === 0) return;
|
|
5778
|
+
const now = agent.getRuntimeSettings();
|
|
5779
|
+
const said: string[] = [];
|
|
5780
|
+
if (atOnce !== undefined) {
|
|
5781
|
+
said.push(budgetKept ? atOnce : `${atOnce}; restored it ${now.transition === 'stable' ? 'without that proof' : 'paced instead'}`);
|
|
5782
|
+
}
|
|
5783
|
+
if (unapplied.length > 0) {
|
|
5784
|
+
said.push(`${unapplied.join(', ')} can't be applied now, so ${unapplied.length === 1 ? 'it stays' : 'they stay'} set but unapplied`);
|
|
5785
|
+
}
|
|
5786
|
+
console.error(
|
|
5787
|
+
`[agent-settings] ${agentName}: ${said.join('; ')}: contextBudgetTokens is ${now.contextBudgetTokens}, ` +
|
|
5788
|
+
`and compiles plan at ${now.effectiveContextBudgetTokens}${now.transition === 'stable' ? '' : ` (${now.transition})`}`,
|
|
5789
|
+
);
|
|
5790
|
+
}
|
|
5791
|
+
|
|
5792
|
+
/**
|
|
5793
|
+
* What persists for an agent: its overrides, and `contextBudgetReachedTokens`
|
|
5794
|
+
* when the agent's proof stands for the overridden budget: a compile fit at
|
|
5795
|
+
* it, or the boot applied it at once on the record's proof, and the live
|
|
5796
|
+
* budget hasn't changed since (context-manager #162). A
|
|
5797
|
+
* change of budget, immediate or not, carries none until a compile at it
|
|
5798
|
+
* fits; arming or cancelling a descent leaves the proof as it was.
|
|
5799
|
+
*/
|
|
5800
|
+
private persistedRuntimeSettingsOf(agent: Agent): AgentRuntimeSettingsOverrides {
|
|
5801
|
+
const overrides = agent.getRuntimeSettingsOverrides();
|
|
5802
|
+
const proven = agent.getProvenContextBudgetTokens();
|
|
5803
|
+
return overrides.contextBudgetTokens !== undefined && proven === overrides.contextBudgetTokens
|
|
5804
|
+
? { ...overrides, contextBudgetReachedTokens: proven }
|
|
5805
|
+
: overrides;
|
|
5806
|
+
}
|
|
5807
|
+
|
|
5808
|
+
/**
|
|
5809
|
+
* A compile at the agent's live budget came out differently from the last
|
|
5810
|
+
* (Agent.onContextBudgetCompiled). Keep the persisted record's
|
|
5811
|
+
* `contextBudgetReachedTokens` true to it: written when a compile fits at
|
|
5812
|
+
* the record's own budget, withdrawn when the context manager refuses that
|
|
5813
|
+
* budget. Nothing else in the record changes, and a budget other than the
|
|
5814
|
+
* record's (one applied with `persist: false`, or a descent's starting
|
|
5815
|
+
* budget) writes nothing. A record that can't be read, or written, is
|
|
5816
|
+
* logged, and the next compile at that budget tells it again.
|
|
5817
|
+
*/
|
|
5818
|
+
private recordCompiledContextBudget(agentName: string, budget: number, fit: boolean): boolean {
|
|
5819
|
+
try {
|
|
5820
|
+
const persisted = this.readAgentRuntimeSettings(agentName);
|
|
5821
|
+
if (!persisted || persisted.contextBudgetTokens !== budget) return true;
|
|
5822
|
+
if ((persisted.contextBudgetReachedTokens === budget) === fit) return true;
|
|
5823
|
+
const { contextBudgetReachedTokens: _previous, ...settings } = persisted;
|
|
5824
|
+
this.persistAgentRuntimeSettings(agentName, fit ? { ...settings, contextBudgetReachedTokens: budget } : settings);
|
|
5825
|
+
return true;
|
|
5826
|
+
} catch (error) {
|
|
5827
|
+
// The next compile at this budget tells it again.
|
|
5828
|
+
console.error(`[agent-settings] couldn't record ${agentName}'s compile at budget ${budget}:`, error);
|
|
5829
|
+
return false;
|
|
5830
|
+
}
|
|
5831
|
+
}
|
|
5832
|
+
|
|
5182
5833
|
private persistAgentRuntimeSettings(
|
|
5183
5834
|
agentName: string,
|
|
5184
5835
|
overrides: AgentRuntimeSettingsOverrides,
|
|
@@ -5354,6 +6005,20 @@ export class AgentFramework {
|
|
|
5354
6005
|
run.lastActivity = Date.now();
|
|
5355
6006
|
}
|
|
5356
6007
|
|
|
6008
|
+
/**
|
|
6009
|
+
* A start dropped before its stream sends anything (its agent was replaced,
|
|
6010
|
+
* or stop() closed admission or ended the lifecycle the start began in)
|
|
6011
|
+
* ends the logical turn it was running. stop() ends every open turn
|
|
6012
|
+
* (openLogical), but a fresh start that awaited its batches' assembly
|
|
6013
|
+
* across a stop() reaches its routing after it and opens its turn. That
|
|
6014
|
+
* turn would then stay open, and the agent's first start after a restart
|
|
6015
|
+
* would continue the dropped turn's routing. The identity check leaves any
|
|
6016
|
+
* other turn open.
|
|
6017
|
+
*/
|
|
6018
|
+
private endDroppedTurn(agentName: string, routing: TurnRouting): void {
|
|
6019
|
+
if (this.openLogical.get(agentName) === routing) this.openLogical.delete(agentName);
|
|
6020
|
+
}
|
|
6021
|
+
|
|
5357
6022
|
/** A logical turn's end, for every agent: it closes the agent's open
|
|
5358
6023
|
* logical turn when it is the settling stream's own (openLogical), and
|
|
5359
6024
|
* settles an active ephemeral run. The run's tool-call count comes from
|
|
@@ -5361,21 +6026,31 @@ export class AgentFramework {
|
|
|
5361
6026
|
* per-stream local survives budget restarts. */
|
|
5362
6027
|
private settleAgent(
|
|
5363
6028
|
agentName: string,
|
|
5364
|
-
outcome: {
|
|
5365
|
-
|
|
5366
|
-
|
|
5367
|
-
|
|
5368
|
-
|
|
6029
|
+
outcome: {
|
|
6030
|
+
stopReason: AgentSettleResult['stopReason'];
|
|
6031
|
+
speech: string;
|
|
6032
|
+
error?: string;
|
|
6033
|
+
/** An ephemeral run that ended rather than sleep the provider's stated
|
|
6034
|
+
* wait: that wait (statedWaitNotSlept). */
|
|
6035
|
+
statedWaitMs?: number;
|
|
6036
|
+
},
|
|
6037
|
+
/** The settling stream's logical turn (TurnRouting), when that stream
|
|
6038
|
+
* still owns the agent: it ends here, so the agent's next fresh start
|
|
6039
|
+
* opens a new one. A stream that no longer owns the agent passes none. */
|
|
6040
|
+
closes: TurnRouting | undefined,
|
|
5369
6041
|
): void {
|
|
5370
|
-
//
|
|
5371
|
-
// a newer turn
|
|
6042
|
+
// The identity check keeps a settle that carries an earlier turn's
|
|
6043
|
+
// object from closing a newer turn; keep it when editing call sites.
|
|
5372
6044
|
if (closes && this.openLogical.get(agentName) === closes) this.openLogical.delete(agentName);
|
|
5373
6045
|
const run = this.ephemeralRuns.get(agentName);
|
|
5374
6046
|
if (!run) return;
|
|
5375
6047
|
run.lastActivity = Date.now();
|
|
5376
6048
|
if (outcome.stopReason === 'exhausted') {
|
|
5377
6049
|
// The caller may log or relay the message, which can echo the whole request.
|
|
5378
|
-
|
|
6050
|
+
const message = boundFailureText(outcome.error ?? 'Unknown error');
|
|
6051
|
+
run.settle.reject(outcome.statedWaitMs === undefined
|
|
6052
|
+
? new Error(message)
|
|
6053
|
+
: new ProviderWaitNotSleptError(message, outcome.statedWaitMs));
|
|
5379
6054
|
return;
|
|
5380
6055
|
}
|
|
5381
6056
|
run.settle.resolve({
|
|
@@ -5391,6 +6066,9 @@ export class AgentFramework {
|
|
|
5391
6066
|
* The agent is temporarily registered, inference is triggered, and the
|
|
5392
6067
|
* framework drives the stream (emitting traces, logging, dispatching tools).
|
|
5393
6068
|
* Returns the agent's speech output when it finishes (no more tool calls).
|
|
6069
|
+
* Rejects with ProviderWaitNotSleptError when the run ends rather than sleep
|
|
6070
|
+
* a retryable failure's stated wait, and with a plain Error for any other
|
|
6071
|
+
* ending.
|
|
5394
6072
|
*
|
|
5395
6073
|
* The caller provides a pre-created agent + contextManager (from createEphemeralAgent).
|
|
5396
6074
|
* The task message should already be in the context manager.
|
|
@@ -5956,7 +6634,7 @@ export class AgentFramework {
|
|
|
5956
6634
|
const server = serverId ? this.mcplServerRegistry?.getServer(serverId) : null;
|
|
5957
6635
|
if (!server) {
|
|
5958
6636
|
console.error(
|
|
5959
|
-
`[inference-refusal] reaction skipped: no MCPL server for locus
|
|
6637
|
+
`[inference-refusal] reaction skipped: no MCPL server for locus ${logValue(incoming.channelId)} (agent=${agentName})`,
|
|
5960
6638
|
);
|
|
5961
6639
|
return;
|
|
5962
6640
|
}
|
|
@@ -6009,27 +6687,41 @@ export class AgentFramework {
|
|
|
6009
6687
|
idx = i; break;
|
|
6010
6688
|
}
|
|
6011
6689
|
if (idx < 0) return null;
|
|
6690
|
+
// The newest message can be the last shard of a body (a document stored
|
|
6691
|
+
// in shards). context-manager removes a shard only with the rest of its
|
|
6692
|
+
// body, so the rewind takes the whole body.
|
|
6693
|
+
const first = bodyRangeIn(all, idx).from;
|
|
6694
|
+
const members = all.slice(first, idx + 1);
|
|
6012
6695
|
const msg = all[idx];
|
|
6013
6696
|
const md = (msg.metadata ?? {}) as {
|
|
6014
6697
|
messageId?: unknown; channelId?: unknown; system?: unknown;
|
|
6015
6698
|
};
|
|
6016
6699
|
|
|
6017
|
-
const content = Array.isArray(
|
|
6700
|
+
const content = members.flatMap((m) => (Array.isArray(m.content) ? m.content : []));
|
|
6018
6701
|
const toolBlocks = content.filter((b) => typeOf(b) === 'tool_result');
|
|
6019
6702
|
const images = content.filter((b) => typeOf(b) === 'image').length;
|
|
6020
6703
|
const textLen = content
|
|
6021
6704
|
.filter((b) => typeOf(b) === 'text')
|
|
6022
6705
|
.reduce((n, b) => n + String((b as { text?: string }).text ?? '').length, 0);
|
|
6023
6706
|
|
|
6024
|
-
// Determine the complete exchange to remove.
|
|
6025
|
-
const removedIds: MessageId[] =
|
|
6707
|
+
// Determine the complete exchange to remove, newest first.
|
|
6708
|
+
const removedIds: MessageId[] = members.map((m) => m.id).reverse();
|
|
6709
|
+
// Where the exchange starts: this body, or the tool_use turn paired with it.
|
|
6710
|
+
let from = first;
|
|
6026
6711
|
let kind: RewindRecord['kind'];
|
|
6027
6712
|
let descriptor: string;
|
|
6028
6713
|
if (toolBlocks.length > 0) {
|
|
6029
6714
|
kind = 'tool';
|
|
6030
|
-
// Pair the tool_result with the tool_use assistant turn right before
|
|
6031
|
-
|
|
6032
|
-
|
|
6715
|
+
// Pair the tool_result with the tool_use assistant turn right before
|
|
6716
|
+
// it, the whole of it: a turn stored as a body carries its tool_use on
|
|
6717
|
+
// its first shard, not its last.
|
|
6718
|
+
if (first - 1 >= 0) {
|
|
6719
|
+
const turn = bodyRangeIn(all, first - 1);
|
|
6720
|
+
const turnMembers = all.slice(turn.from, turn.to + 1);
|
|
6721
|
+
if (turnMembers.some((m) => hasBlock(m, 'tool_use'))) {
|
|
6722
|
+
from = turn.from;
|
|
6723
|
+
removedIds.push(...turnMembers.map((m) => m.id).reverse());
|
|
6724
|
+
}
|
|
6033
6725
|
}
|
|
6034
6726
|
const sz = toolBlocks.reduce(
|
|
6035
6727
|
(n, b) => n + String((b as { content?: unknown }).content ?? '').length, 0);
|
|
@@ -6048,7 +6740,8 @@ export class AgentFramework {
|
|
|
6048
6740
|
? { channelId: String(md.channelId), messageId: String(md.messageId) }
|
|
6049
6741
|
: undefined;
|
|
6050
6742
|
|
|
6051
|
-
for
|
|
6743
|
+
// One removal for the whole exchange, so it is never half applied.
|
|
6744
|
+
removeStoredMessages(cm, all[from], msg);
|
|
6052
6745
|
return { kind, descriptor, removedIds, discordRef };
|
|
6053
6746
|
}
|
|
6054
6747
|
|
|
@@ -6243,7 +6936,15 @@ export class AgentFramework {
|
|
|
6243
6936
|
* stated retry-after, kept across restarts) for one `model` or all, as an
|
|
6244
6937
|
* explicit, recorded operator override.
|
|
6245
6938
|
*/
|
|
6246
|
-
private
|
|
6939
|
+
private handleHostCommand(
|
|
6940
|
+
serverId: string,
|
|
6941
|
+
params: HostCommandParams,
|
|
6942
|
+
): ReturnType<AgentFramework['runHostCommand']> {
|
|
6943
|
+
return this.trackOperatorOperation(`host/command:${String(params.command)}`, () => this.runHostCommand(serverId, params));
|
|
6944
|
+
}
|
|
6945
|
+
|
|
6946
|
+
/** handleHostCommand's work, run where stop() can wait for it. */
|
|
6947
|
+
private async runHostCommand(
|
|
6247
6948
|
serverId: string,
|
|
6248
6949
|
params: HostCommandParams,
|
|
6249
6950
|
): Promise<{
|
|
@@ -6415,28 +7116,75 @@ export class AgentFramework {
|
|
|
6415
7116
|
if (agent.state.status !== 'idle') {
|
|
6416
7117
|
return { ok: false, error: `Cannot hide while agent is ${agent.state.status}` };
|
|
6417
7118
|
}
|
|
7119
|
+
// A surgery switches or forks the branch while it holds the store, so
|
|
7120
|
+
// a removal now would land on the branch it's leaving, or meet a
|
|
7121
|
+
// manager mid-switch: refused as busy, as the other operator calls are.
|
|
7122
|
+
if (this.surgeryHold) {
|
|
7123
|
+
return {
|
|
7124
|
+
ok: false,
|
|
7125
|
+
error: `Cannot hide: a live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName} holds the store — retry when it completes`,
|
|
7126
|
+
};
|
|
7127
|
+
}
|
|
6418
7128
|
if (!params.fromMessageId) {
|
|
6419
7129
|
return { ok: false, error: 'hide: fromMessageId is required' };
|
|
6420
7130
|
}
|
|
6421
7131
|
const cm = agent.getContextManager();
|
|
6422
7132
|
const all = cm.getAllMessages();
|
|
6423
|
-
|
|
6424
|
-
|
|
7133
|
+
// Only a message that carries a Discord id matches: a missing one
|
|
7134
|
+
// would otherwise read as the text "undefined".
|
|
7135
|
+
const discordIdOf = (m: StoredMessage) => {
|
|
7136
|
+
const id = (m.metadata as { messageId?: unknown } | undefined)?.messageId;
|
|
7137
|
+
return id === undefined || id === null || id === '' ? undefined : String(id);
|
|
7138
|
+
};
|
|
7139
|
+
const byDiscordId = (did: string) => all.findIndex((m) => discordIdOf(m) === String(did));
|
|
7140
|
+
/** Runs in stored order, those that overlap or touch made one. */
|
|
7141
|
+
const merged = (runs: Array<{ from: number; to: number }>) => {
|
|
7142
|
+
const out: Array<{ from: number; to: number }> = [];
|
|
7143
|
+
for (const { from, to } of [...runs].sort((a, b) => a.from - b.from)) {
|
|
7144
|
+
const last = out[out.length - 1];
|
|
7145
|
+
if (last && from <= last.to + 1) last.to = Math.max(last.to, to);
|
|
7146
|
+
else out.push({ from, to });
|
|
7147
|
+
}
|
|
7148
|
+
return out;
|
|
7149
|
+
};
|
|
7150
|
+
// A message stored as a body (a long one, in shards) goes whole, and so
|
|
7151
|
+
// does every copy of it: a write cut short leaves a partial body before
|
|
7152
|
+
// its retry, both carrying the Discord id.
|
|
7153
|
+
const copiesOf = (did: string) => {
|
|
7154
|
+
const runs: Array<{ from: number; to: number }> = [];
|
|
7155
|
+
all.forEach((m, i) => { if (discordIdOf(m) === String(did)) runs.push(bodyRangeIn(all, i)); });
|
|
7156
|
+
return runs;
|
|
7157
|
+
};
|
|
6425
7158
|
|
|
6426
|
-
// Collect Discord refs of every message in
|
|
7159
|
+
// Collect Discord refs of every message in the runs that carries one,
|
|
6427
7160
|
// so the surface can mark them. channelId here is whatever the ingest
|
|
6428
7161
|
// path stored (raw id or "discord:guild:channel"); the surface
|
|
6429
7162
|
// normalizes it.
|
|
6430
|
-
|
|
7163
|
+
// Each Discord message once, though every shard of a body carries it.
|
|
7164
|
+
const refsIn = (runs: Array<{ from: number; to: number }>) => {
|
|
6431
7165
|
const refs: Array<{ channelId: string; messageId: string }> = [];
|
|
6432
|
-
|
|
6433
|
-
|
|
6434
|
-
|
|
7166
|
+
const seen = new Set<string>();
|
|
7167
|
+
for (const { from, to } of runs) {
|
|
7168
|
+
for (let i = from; i <= to; i++) {
|
|
7169
|
+
const md = all[i].metadata as { messageId?: unknown; channelId?: unknown } | undefined;
|
|
7170
|
+
if (!md?.messageId || !md?.channelId) continue;
|
|
7171
|
+
const key = `${String(md.channelId)}\u0000${String(md.messageId)}`;
|
|
7172
|
+
if (seen.has(key)) continue;
|
|
7173
|
+
seen.add(key);
|
|
6435
7174
|
refs.push({ channelId: String(md.channelId), messageId: String(md.messageId) });
|
|
6436
7175
|
}
|
|
6437
7176
|
}
|
|
6438
7177
|
return refs;
|
|
6439
7178
|
};
|
|
7179
|
+
// How many stored messages the runs hold, a body (a long message, in
|
|
7180
|
+
// shards) counting once.
|
|
7181
|
+
const messagesIn = (runs: Array<{ from: number; to: number }>) => {
|
|
7182
|
+
let count = 0;
|
|
7183
|
+
for (const { from, to } of runs) {
|
|
7184
|
+
for (let i = from; i <= to; i = bodyRangeIn(all, i).to + 1) count++;
|
|
7185
|
+
}
|
|
7186
|
+
return count;
|
|
7187
|
+
};
|
|
6440
7188
|
|
|
6441
7189
|
const fromIdx = byDiscordId(params.fromMessageId);
|
|
6442
7190
|
if (fromIdx < 0) {
|
|
@@ -6452,12 +7200,24 @@ export class AgentFramework {
|
|
|
6452
7200
|
if (toIdx < 0) {
|
|
6453
7201
|
return { ok: false, error: `Message ${params.toMessageId} is not an addressable message in context.` };
|
|
6454
7202
|
}
|
|
6455
|
-
|
|
6456
|
-
|
|
6457
|
-
|
|
6458
|
-
|
|
7203
|
+
// What's between the two messages, after the earlier one began and
|
|
7204
|
+
// before the later one began: from the earlier one's first stored
|
|
7205
|
+
// copy through the later one's first, each whole. Each end is every
|
|
7206
|
+
// copy of it besides, as the single form takes, so a copy stored
|
|
7207
|
+
// outside that span (a write cut short and retried later) is taken
|
|
7208
|
+
// on its own, and nothing else stored after the later one began is
|
|
7209
|
+
// taken. Offline recovery's range is the same.
|
|
7210
|
+
const span = {
|
|
7211
|
+
from: bodyRangeIn(all, Math.min(fromIdx, toIdx)).from,
|
|
7212
|
+
to: bodyRangeIn(all, Math.max(fromIdx, toIdx)).to,
|
|
7213
|
+
};
|
|
7214
|
+
const runs = merged([span, ...copiesOf(params.fromMessageId), ...copiesOf(params.toMessageId)]);
|
|
7215
|
+
const refs = refsIn(runs);
|
|
7216
|
+
const hidden = messagesIn(runs);
|
|
7217
|
+
const rangeIds = runs.flatMap(({ from, to }) => all.slice(from, to + 1).map((m) => String(m.id)));
|
|
7218
|
+
for (const { from, to } of runs) removeStoredMessages(cm, all[from], all[to]);
|
|
6459
7219
|
console.error(
|
|
6460
|
-
`[host-command] hide agent=${agentName} range removed=${
|
|
7220
|
+
`[host-command] hide agent=${agentName} range removed=${hidden} ` +
|
|
6461
7221
|
`(${params.fromMessageId}..${params.toMessageId}) by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`,
|
|
6462
7222
|
);
|
|
6463
7223
|
const loggedRange = capIds(rangeIds);
|
|
@@ -6468,35 +7228,45 @@ export class AgentFramework {
|
|
|
6468
7228
|
params: { fromMessageId: params.fromMessageId, toMessageId: params.toMessageId },
|
|
6469
7229
|
result: {
|
|
6470
7230
|
branch: this.store.currentBranch().name,
|
|
6471
|
-
hidden
|
|
7231
|
+
hidden,
|
|
6472
7232
|
removedIds: loggedRange.ids,
|
|
6473
7233
|
...(loggedRange.truncated ? { removedIdsTruncated: true } : {}),
|
|
6474
7234
|
},
|
|
6475
7235
|
});
|
|
6476
7236
|
return {
|
|
6477
7237
|
ok: true,
|
|
6478
|
-
hidden
|
|
7238
|
+
hidden,
|
|
6479
7239
|
hiddenRefs: refs,
|
|
6480
7240
|
lastVisible: await this.lastVisiblePreview(agentName),
|
|
6481
7241
|
};
|
|
6482
7242
|
}
|
|
6483
|
-
const
|
|
6484
|
-
const
|
|
6485
|
-
|
|
7243
|
+
const runs = merged(copiesOf(params.fromMessageId));
|
|
7244
|
+
const refs = refsIn(runs);
|
|
7245
|
+
const hidden = messagesIn(runs);
|
|
7246
|
+
const hiddenIds = runs.flatMap(({ from, to }) => all.slice(from, to + 1).map((m) => String(m.id)));
|
|
7247
|
+
for (const { from, to } of runs) removeStoredMessages(cm, all[from], all[to]);
|
|
6486
7248
|
console.error(
|
|
6487
|
-
`[host-command] hide agent=${agentName} removed
|
|
7249
|
+
`[host-command] hide agent=${agentName} removed=${hidden} (${params.fromMessageId}) ` +
|
|
6488
7250
|
`by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`,
|
|
6489
7251
|
);
|
|
7252
|
+
// Every shard of every copy goes, so the ids are bounded as the
|
|
7253
|
+
// range form's are.
|
|
7254
|
+
const loggedIds = capIds(hiddenIds);
|
|
6490
7255
|
this.recordOperatorAction({
|
|
6491
7256
|
kind: 'hide',
|
|
6492
7257
|
agent: agentName,
|
|
6493
7258
|
requester: hostCommandRequester(serverId, params),
|
|
6494
7259
|
params: { fromMessageId: params.fromMessageId },
|
|
6495
|
-
result: {
|
|
7260
|
+
result: {
|
|
7261
|
+
branch: this.store.currentBranch().name,
|
|
7262
|
+
hidden,
|
|
7263
|
+
removedIds: loggedIds.ids,
|
|
7264
|
+
...(loggedIds.truncated ? { removedIdsTruncated: true } : {}),
|
|
7265
|
+
},
|
|
6496
7266
|
});
|
|
6497
7267
|
return {
|
|
6498
7268
|
ok: true,
|
|
6499
|
-
hidden
|
|
7269
|
+
hidden,
|
|
6500
7270
|
hiddenRefs: refs,
|
|
6501
7271
|
lastVisible: await this.lastVisiblePreview(agentName),
|
|
6502
7272
|
};
|
|
@@ -6550,7 +7320,7 @@ export class AgentFramework {
|
|
|
6550
7320
|
let undone = 0;
|
|
6551
7321
|
try {
|
|
6552
7322
|
for (let i = 0; i < requested; i++) {
|
|
6553
|
-
const r = this.
|
|
7323
|
+
const r = this.runUndoLastTurn(agentName, hostCommandRequester(serverId, params));
|
|
6554
7324
|
if (!r.undone) break;
|
|
6555
7325
|
undone++;
|
|
6556
7326
|
}
|
|
@@ -6558,7 +7328,7 @@ export class AgentFramework {
|
|
|
6558
7328
|
// e.g. "Cannot undo while agent is streaming" — report what happened,
|
|
6559
7329
|
// including any turns already undone before the failure.
|
|
6560
7330
|
const msg = error instanceof Error ? error.message : String(error);
|
|
6561
|
-
if (undone === 0) return { ok: false, error: msg };
|
|
7331
|
+
if (undone === 0) return { ok: false, error: msg, ...(error instanceof OperatorActionError ? { code: error.code } : {}) };
|
|
6562
7332
|
console.error(`[host-command] undo partially failed after ${undone}/${requested}: ${msg}`);
|
|
6563
7333
|
}
|
|
6564
7334
|
|
|
@@ -6877,9 +7647,45 @@ export class AgentFramework {
|
|
|
6877
7647
|
}
|
|
6878
7648
|
|
|
6879
7649
|
// ==========================================================================
|
|
6880
|
-
// Undo / Redo
|
|
7650
|
+
// Branch switch, Undo / Redo
|
|
6881
7651
|
// ==========================================================================
|
|
6882
7652
|
|
|
7653
|
+
/**
|
|
7654
|
+
* Switch the store to an existing branch, start the delivery of the
|
|
7655
|
+
* Discord awareness marks the move changes (startDiscordAwarenessDelivery),
|
|
7656
|
+
* and re-materialize the config mount from it. Resolves once the mount has
|
|
7657
|
+
* run; if it fails, it rejects with the store already moved. The marks'
|
|
7658
|
+
* delivery isn't awaited, and its failure doesn't fail the switch. The
|
|
7659
|
+
* store has moved by the time the call returns, before its promise
|
|
7660
|
+
* settles, and a refusal moves nothing. An operator's operation: refused
|
|
7661
|
+
* once stop() has begun, and waited for by stop() while it runs.
|
|
7662
|
+
*
|
|
7663
|
+
* The branch moves under every agent sharing the store, so the switch
|
|
7664
|
+
* reserves the store as a surgery does: every agent must be idle, or it is
|
|
7665
|
+
* refused (OperatorActionError 'agent-busy'), and no turn starts until the
|
|
7666
|
+
* switch is done.
|
|
7667
|
+
*/
|
|
7668
|
+
async switchBranch(name: string): Promise<void> {
|
|
7669
|
+
this.refuseOperatorAfterStop('switch branch');
|
|
7670
|
+
const release = this.reserveStoreForSurgery('switch branch', 'every agent');
|
|
7671
|
+
try {
|
|
7672
|
+
// A maintenance tick already running finishes before the switch.
|
|
7673
|
+
if (this.maintenanceRunning()) await this.settleMaintenancePass();
|
|
7674
|
+
this.store.switchBranch(name);
|
|
7675
|
+
// Reversible Discord awareness markers are part of the branch
|
|
7676
|
+
// projection. Their delivery starts behind the data-plane gate, which
|
|
7677
|
+
// goes up before the store is released, and the switch doesn't wait
|
|
7678
|
+
// for Discord.
|
|
7679
|
+
this.startDiscordAwarenessDelivery('branch switch');
|
|
7680
|
+
const ws = this.moduleRegistry.getModule('workspace');
|
|
7681
|
+
if (ws && 'materializeMount' in ws) {
|
|
7682
|
+
await (ws as { materializeMount: (m: string) => Promise<void> }).materializeMount('_config');
|
|
7683
|
+
}
|
|
7684
|
+
} finally {
|
|
7685
|
+
release();
|
|
7686
|
+
}
|
|
7687
|
+
}
|
|
7688
|
+
|
|
6883
7689
|
/**
|
|
6884
7690
|
* Undo the last inference turn for an agent.
|
|
6885
7691
|
*
|
|
@@ -6895,6 +7701,18 @@ export class AgentFramework {
|
|
|
6895
7701
|
turnIndex?: number;
|
|
6896
7702
|
fromBranch?: string;
|
|
6897
7703
|
toBranch?: string;
|
|
7704
|
+
} {
|
|
7705
|
+
this.refuseOperatorAfterStop('undo');
|
|
7706
|
+
return this.runUndoLastTurn(agentName, requester);
|
|
7707
|
+
}
|
|
7708
|
+
|
|
7709
|
+
/** undoLastTurn's work, for a host/command undo already admitted: a stop()
|
|
7710
|
+
* begun by a trace of one of its turns doesn't refuse the next. */
|
|
7711
|
+
private runUndoLastTurn(agentName: string, requester?: OperatorRequester): {
|
|
7712
|
+
undone: boolean;
|
|
7713
|
+
turnIndex?: number;
|
|
7714
|
+
fromBranch?: string;
|
|
7715
|
+
toBranch?: string;
|
|
6898
7716
|
} {
|
|
6899
7717
|
const agent = this.agents.get(agentName);
|
|
6900
7718
|
if (!agent) {
|
|
@@ -6903,6 +7721,10 @@ export class AgentFramework {
|
|
|
6903
7721
|
if (agent.state.status !== 'idle') {
|
|
6904
7722
|
throw new Error(`Cannot undo while agent is ${agent.state.status}`);
|
|
6905
7723
|
}
|
|
7724
|
+
// A surgery or a branch switch holds the store while it awaits its steps.
|
|
7725
|
+
if (this.surgeryHold) {
|
|
7726
|
+
throw new OperatorActionError('agent-busy', `Cannot undo: the store is under live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName}`);
|
|
7727
|
+
}
|
|
6906
7728
|
|
|
6907
7729
|
const checkpoints = this.getTurnCheckpoints(agentName);
|
|
6908
7730
|
if (checkpoints.length === 0) {
|
|
@@ -6918,11 +7740,7 @@ export class AgentFramework {
|
|
|
6918
7740
|
this.store.createBranchAt(undoBranchName, currentBranch.name, checkpoint.sequenceBefore);
|
|
6919
7741
|
this.store.switchBranch(undoBranchName);
|
|
6920
7742
|
|
|
6921
|
-
|
|
6922
|
-
const wsUndo = this.moduleRegistry.getModule('workspace');
|
|
6923
|
-
if (wsUndo && 'materializeMount' in wsUndo) {
|
|
6924
|
-
(wsUndo as any).materializeMount('_config').catch(() => {});
|
|
6925
|
-
}
|
|
7743
|
+
this.materializeConfigMountAfterBranchSwitch();
|
|
6926
7744
|
|
|
6927
7745
|
// Push onto redo stack
|
|
6928
7746
|
let redoStack = this.redoStacks.get(agentName);
|
|
@@ -6966,6 +7784,7 @@ export class AgentFramework {
|
|
|
6966
7784
|
fromBranch?: string;
|
|
6967
7785
|
toBranch?: string;
|
|
6968
7786
|
} {
|
|
7787
|
+
this.refuseOperatorAfterStop('redo');
|
|
6969
7788
|
const agent = this.agents.get(agentName);
|
|
6970
7789
|
if (!agent) {
|
|
6971
7790
|
throw new Error(`Unknown agent: ${agentName}`);
|
|
@@ -6973,6 +7792,10 @@ export class AgentFramework {
|
|
|
6973
7792
|
if (agent.state.status !== 'idle') {
|
|
6974
7793
|
throw new Error(`Cannot redo while agent is ${agent.state.status}`);
|
|
6975
7794
|
}
|
|
7795
|
+
// A surgery or a branch switch holds the store while it awaits its steps.
|
|
7796
|
+
if (this.surgeryHold) {
|
|
7797
|
+
throw new OperatorActionError('agent-busy', `Cannot redo: the store is under live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName}`);
|
|
7798
|
+
}
|
|
6976
7799
|
|
|
6977
7800
|
const redoStack = this.redoStacks.get(agentName);
|
|
6978
7801
|
if (!redoStack || redoStack.length === 0) {
|
|
@@ -6984,11 +7807,7 @@ export class AgentFramework {
|
|
|
6984
7807
|
|
|
6985
7808
|
this.store.switchBranch(branchName);
|
|
6986
7809
|
|
|
6987
|
-
|
|
6988
|
-
const wsRedo = this.moduleRegistry.getModule('workspace');
|
|
6989
|
-
if (wsRedo && 'materializeMount' in wsRedo) {
|
|
6990
|
-
(wsRedo as any).materializeMount('_config').catch(() => {});
|
|
6991
|
-
}
|
|
7810
|
+
this.materializeConfigMountAfterBranchSwitch();
|
|
6992
7811
|
|
|
6993
7812
|
// Restore the checkpoint
|
|
6994
7813
|
const checkpoints = this.getTurnCheckpoints(agentName);
|
|
@@ -7110,10 +7929,19 @@ export class AgentFramework {
|
|
|
7110
7929
|
// arrivals during the awaited switch (see surgeryHold).
|
|
7111
7930
|
const hold = { verb, agentName, since: Date.now() };
|
|
7112
7931
|
this.surgeryHold = hold;
|
|
7932
|
+
// Maintenance calls parked at a provider gate wake, find the hold and are
|
|
7933
|
+
// withdrawn, so the wait for running maintenance can't wait on them.
|
|
7934
|
+
for (const gate of this.providerGates.values()) for (const resolve of gate.auxiliaryWaiters.splice(0)) resolve();
|
|
7935
|
+
// stop() waits for the release (see stopOwned): a surgery admitted before
|
|
7936
|
+
// stop() began finishes, flush included, before the store closes.
|
|
7937
|
+
let settle!: () => void;
|
|
7938
|
+
void this.trackOperatorOperation(`surgery:${verb}`, () => new Promise<void>((resolve) => { settle = resolve; }));
|
|
7113
7939
|
let released = false;
|
|
7114
7940
|
return () => {
|
|
7115
7941
|
if (released) return;
|
|
7116
7942
|
released = true;
|
|
7943
|
+
// The release runs to its end before stop() can go on.
|
|
7944
|
+
settle();
|
|
7117
7945
|
if (this.surgeryHold === hold) this.surgeryHold = null;
|
|
7118
7946
|
for (const [name, token] of reserved) {
|
|
7119
7947
|
if (this.activeTurnTokens.get(name) === token) this.activeTurnTokens.delete(name);
|
|
@@ -7200,6 +8028,7 @@ export class AgentFramework {
|
|
|
7200
8028
|
markers: SurgeryMarkerReceipt;
|
|
7201
8029
|
lastVisible: { participant?: string; role?: string; preview?: string } | null;
|
|
7202
8030
|
}> {
|
|
8031
|
+
this.refuseOperatorAfterStop('roll back');
|
|
7203
8032
|
const logBase: OperatorLogInput = {
|
|
7204
8033
|
kind: 'rollback',
|
|
7205
8034
|
agent: agentName,
|
|
@@ -7242,6 +8071,8 @@ export class AgentFramework {
|
|
|
7242
8071
|
let branchName: string;
|
|
7243
8072
|
let markers: SurgeryMarkerReceipt;
|
|
7244
8073
|
try {
|
|
8074
|
+
// A maintenance tick already running finishes before the switch.
|
|
8075
|
+
if (this.maintenanceRunning()) await this.settleMaintenancePass();
|
|
7245
8076
|
// Prepare the external side effect before switching Chronicle. If the
|
|
7246
8077
|
// process dies after the switch but before activate(), startup
|
|
7247
8078
|
// promotes this batch by matching targetBranch to the active branch.
|
|
@@ -7352,6 +8183,7 @@ export class AgentFramework {
|
|
|
7352
8183
|
markers: SurgeryMarkerReceipt;
|
|
7353
8184
|
lastVisible: { participant?: string; role?: string; preview?: string } | null;
|
|
7354
8185
|
}> {
|
|
8186
|
+
this.refuseOperatorAfterStop('suppress');
|
|
7355
8187
|
const requestedIds = [...new Set(opts.messageIds.map(String))];
|
|
7356
8188
|
const logBase: OperatorLogInput = {
|
|
7357
8189
|
kind: 'suppress',
|
|
@@ -7409,6 +8241,8 @@ export class AgentFramework {
|
|
|
7409
8241
|
let createdBranch: string;
|
|
7410
8242
|
let markers: SurgeryMarkerReceipt;
|
|
7411
8243
|
try {
|
|
8244
|
+
// A maintenance tick already running finishes before the fork.
|
|
8245
|
+
if (this.maintenanceRunning()) await this.settleMaintenancePass();
|
|
7412
8246
|
const markerBatch = this.discordAwarenessOutbox?.prepare({
|
|
7413
8247
|
agentName,
|
|
7414
8248
|
sourceBranch,
|
|
@@ -7577,9 +8411,9 @@ export class AgentFramework {
|
|
|
7577
8411
|
* undo or redo) without the caller awaiting it. No operator response waits
|
|
7578
8412
|
* for Discord to accept marks: the MCPL data-plane gate alone holds channel
|
|
7579
8413
|
* traffic that arrives after it until they are attempted.
|
|
7580
|
-
*
|
|
7581
|
-
*
|
|
7582
|
-
*
|
|
8414
|
+
* The sync's body installs that gate synchronously, before its first
|
|
8415
|
+
* await, so calling this while a surgery still holds the store puts the
|
|
8416
|
+
* gate up before the store is released. With no drain running, the
|
|
7583
8417
|
* drain starts in the same synchronous run (the first reaction request can
|
|
7584
8418
|
* be written before this returns); with one running, this generation's pass
|
|
7585
8419
|
* follows it (drainForGeneration). Either way delivery continues
|
|
@@ -7590,10 +8424,20 @@ export class AgentFramework {
|
|
|
7590
8424
|
* (queued when a surgery began, or arriving during its switch or fork): the
|
|
7591
8425
|
* surgery's store reservation, which used to hold such a turn through the
|
|
7592
8426
|
* drain, now ends once the gate is up. A failure is accounted by the gate
|
|
7593
|
-
* itself: the current generation's failure recycles the connections,
|
|
7594
|
-
*
|
|
7595
|
-
*
|
|
7596
|
-
*
|
|
8427
|
+
* itself: the current generation's failure recycles the connections (or,
|
|
8428
|
+
* once the host is stopping, releases the gate instead), and a superseded
|
|
8429
|
+
* one leaves that to the newer generation, whose own pass meets the same
|
|
8430
|
+
* ledger. Here it is logged and raised as an ops alert, and the next sync,
|
|
8431
|
+
* reconnect or start delivers what is still pending.
|
|
8432
|
+
*
|
|
8433
|
+
* Like a server's drain, the delivery runs apart from the operation that
|
|
8434
|
+
* started it: it isn't an operator's operation, so stop() doesn't wait for
|
|
8435
|
+
* it. A drain already running when stop() begins runs on while the
|
|
8436
|
+
* connections close, and what it can't deliver stays pending in the
|
|
8437
|
+
* durable outbox. Once stop() has begun, none is started, since there's
|
|
8438
|
+
* no point opening a drain that the teardown is about to cut off: the
|
|
8439
|
+
* marks stay pending, and the next start's barrier reconciles the outbox
|
|
8440
|
+
* with the branch and delivers them.
|
|
7597
8441
|
*
|
|
7598
8442
|
* @param after What moved the branch, for the log and the alert
|
|
7599
8443
|
* ("rollback", "branch switch", "undo" …).
|
|
@@ -7602,7 +8446,14 @@ export class AgentFramework {
|
|
|
7602
8446
|
*/
|
|
7603
8447
|
startDiscordAwarenessDelivery(after: string, agentName = 'framework'): void {
|
|
7604
8448
|
if (!this.discordAwarenessOutbox) return;
|
|
7605
|
-
this.
|
|
8449
|
+
if (this.stopHasBegun()) {
|
|
8450
|
+
console.error(
|
|
8451
|
+
`[discord-awareness] delivery after ${after} agent=${agentName} not started: ` +
|
|
8452
|
+
'the framework is stopping; the next start delivers what is pending',
|
|
8453
|
+
);
|
|
8454
|
+
return;
|
|
8455
|
+
}
|
|
8456
|
+
this.runDiscordAwarenessSync().catch((error) => {
|
|
7606
8457
|
const detail = error instanceof Error ? error.message : String(error);
|
|
7607
8458
|
console.error(`[discord-awareness] delivery after ${after} agent=${agentName} failed: ${detail}`);
|
|
7608
8459
|
this.opsAlert(
|
|
@@ -7613,14 +8464,19 @@ export class AgentFramework {
|
|
|
7613
8464
|
});
|
|
7614
8465
|
}
|
|
7615
8466
|
|
|
7616
|
-
/**
|
|
7617
|
-
*
|
|
8467
|
+
/** Start config file IO without holding the caller or a surgery's store
|
|
8468
|
+
* reservation. The gate picks it up by mtime; stop() still waits for it. */
|
|
7618
8469
|
private materializeConfigMountAfterBranchSwitch(): void {
|
|
7619
8470
|
const ws = this.moduleRegistry.getModule('workspace');
|
|
7620
8471
|
if (ws && 'materializeMount' in ws) {
|
|
7621
|
-
|
|
7622
|
-
.materializeMount('_config')
|
|
7623
|
-
|
|
8472
|
+
void this.trackOperatorOperation('config:materialize',
|
|
8473
|
+
() => (ws as { materializeMount: (m: string) => Promise<void> }).materializeMount('_config'),
|
|
8474
|
+
).catch((error) => {
|
|
8475
|
+
console.error(
|
|
8476
|
+
'[workspace] re-materializing the config mount after a branch switch failed:',
|
|
8477
|
+
error instanceof Error ? error.message : error,
|
|
8478
|
+
);
|
|
8479
|
+
});
|
|
7624
8480
|
}
|
|
7625
8481
|
}
|
|
7626
8482
|
|
|
@@ -7804,12 +8660,23 @@ export class AgentFramework {
|
|
|
7804
8660
|
this.adoptToolResultGuard(agent);
|
|
7805
8661
|
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
7806
8662
|
this.sharedSlotAgents.add(agent);
|
|
7807
|
-
|
|
8663
|
+
let restoredSettings: AgentRuntimeSettingsOverrides | null = null;
|
|
8664
|
+
try {
|
|
8665
|
+
restoredSettings = this.readAgentRuntimeSettings(config.name);
|
|
8666
|
+
} catch (error) {
|
|
8667
|
+
console.error(
|
|
8668
|
+
`[agent-settings] ${config.name}: its persisted settings can't be read ` +
|
|
8669
|
+
`(${error instanceof Error ? error.message : String(error)}); it boots at the recipe's`,
|
|
8670
|
+
);
|
|
8671
|
+
}
|
|
7808
8672
|
if (restoredSettings) {
|
|
7809
|
-
|
|
8673
|
+
this.restorePersistedRuntimeSettings(
|
|
8674
|
+
config.name,
|
|
8675
|
+
agent,
|
|
7810
8676
|
this.validatePersistedAgentRuntimeSettings(config.name, restoredSettings),
|
|
7811
8677
|
);
|
|
7812
8678
|
}
|
|
8679
|
+
agent.onContextBudgetCompiled((budget, fit) => this.recordCompiledContextBudget(config.name, budget, fit));
|
|
7813
8680
|
this.agents.set(config.name, agent);
|
|
7814
8681
|
this.agentConfigs.set(config.name, config);
|
|
7815
8682
|
|
|
@@ -8077,11 +8944,17 @@ export class AgentFramework {
|
|
|
8077
8944
|
// queued, they flush at the target's own next boundary — with
|
|
8078
8945
|
// injection — or in driveStream's finally when its turn ends.
|
|
8079
8946
|
const midTurnInjections: Array<{ participant: string; content: ContentBlock[]; metadata?: MessageMetadata }> = [];
|
|
8947
|
+
// The injected messages' ids, for a boundary that leaves them to the
|
|
8948
|
+
// turn's next start as arrivals.
|
|
8949
|
+
const injectedIds = new Map<object, MessageId>();
|
|
8080
8950
|
{
|
|
8081
8951
|
const deferred = this.drainDeferredFor(agent.name);
|
|
8082
8952
|
if (deferred.length > 0) {
|
|
8083
8953
|
for (const msg of deferred) {
|
|
8084
|
-
agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id));
|
|
8954
|
+
const id = agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id));
|
|
8955
|
+
// Every other resident reads what this boundary stores; for its
|
|
8956
|
+
// own agent, the boundary weighs what it injects below.
|
|
8957
|
+
this.noteForOtherResidents(agent, msg.metadata, id);
|
|
8085
8958
|
// Injection guards: tool blocks would corrupt the tool-cycle
|
|
8086
8959
|
// structure the membrane enforces, and a message named as the
|
|
8087
8960
|
// agent itself would render as an ASSISTANT turn on the wire
|
|
@@ -8091,11 +8964,13 @@ export class AgentFramework {
|
|
|
8091
8964
|
(b) => b.type === 'tool_use' || b.type === 'tool_result'
|
|
8092
8965
|
);
|
|
8093
8966
|
if (!hasToolBlocks && msg.participant !== agent.name) {
|
|
8094
|
-
|
|
8967
|
+
const injection = {
|
|
8095
8968
|
participant: msg.participant,
|
|
8096
8969
|
content: msg.content,
|
|
8097
8970
|
...(msg.metadata ? { metadata: msg.metadata } : {}),
|
|
8098
|
-
}
|
|
8971
|
+
};
|
|
8972
|
+
midTurnInjections.push(injection);
|
|
8973
|
+
injectedIds.set(injection, id);
|
|
8099
8974
|
}
|
|
8100
8975
|
}
|
|
8101
8976
|
}
|
|
@@ -8138,13 +9013,18 @@ export class AgentFramework {
|
|
|
8138
9013
|
// under budget instead of dispatching a doomed request.
|
|
8139
9014
|
// Projection terms: prior round's real input + prior round's
|
|
8140
9015
|
// OUTPUT (the just-generated thinking/text/tool_use joins the next
|
|
8141
|
-
// round's input — omitting it is a systematic underestimate) +
|
|
8142
|
-
//
|
|
9016
|
+
// round's input — omitting it is a systematic underestimate) + the
|
|
9017
|
+
// growth since that round, when calls after it reported no prompt
|
|
9018
|
+
// (lastStreamUnreportedTokens) + blocks about to be appended +
|
|
9019
|
+
// reserve for the next response. It runs once a call on this
|
|
9020
|
+
// stream has reported a prompt: before then there is no figure to
|
|
9021
|
+
// project from.
|
|
8143
9022
|
const projectedRealTokens = currentState.stream
|
|
8144
9023
|
&& agent.physicalWindowTokens !== undefined
|
|
8145
9024
|
&& agent.lastStreamRealInputTokens > 0
|
|
8146
9025
|
? agent.lastStreamRealInputTokens
|
|
8147
9026
|
+ agent.lastStreamOutputTokens
|
|
9027
|
+
+ agent.lastStreamUnreportedTokens
|
|
8148
9028
|
+ estimateAppendedRoundTokens(currentState.toolResults, spilled, midTurnInjections)
|
|
8149
9029
|
+ agent.maxTokens
|
|
8150
9030
|
: 0;
|
|
@@ -8174,15 +9054,18 @@ export class AgentFramework {
|
|
|
8174
9054
|
// classifier-safe D-class from the 2026-07-24 ablation). It also
|
|
8175
9055
|
// updates lastAnnouncedLocus so the next turn's announce-on-change
|
|
8176
9056
|
// diffs against what the agent was actually last told.
|
|
8177
|
-
|
|
8178
|
-
|
|
8179
|
-
|
|
8180
|
-
|
|
9057
|
+
// The turn's own mode: a silent turn routes no prose, whatever the
|
|
9058
|
+
// agent's mode, so its boundary neither re-pins nor notes. The
|
|
9059
|
+
// trigger is this stream's: its start holds the agent's turn token
|
|
9060
|
+
// while the agent waits on this round's tools.
|
|
9061
|
+
const routesLocus = (agent.proseRouting === 'locus' || agent.proseRouting === 'hybrid')
|
|
9062
|
+
&& !this.activeTurnTriggers.get(agent.name)?.suppressProse;
|
|
9063
|
+
if (routesLocus && !shouldEndTurn && !overBudget && currentState.stream) {
|
|
8181
9064
|
// Two signals qualify an injection to move the pin (n=6 + n=7):
|
|
8182
9065
|
// - chat:addressed — someone explicitly spoke TO the agent
|
|
8183
9066
|
// (mention / reply / DM);
|
|
8184
9067
|
// - a conversational follow-up in a channel the agent itself
|
|
8185
|
-
// explicitly sent into THIS turn (
|
|
9068
|
+
// explicitly sent into THIS turn (TurnRouting.engaged) —
|
|
8186
9069
|
// conversation the agent just engaged continues without
|
|
8187
9070
|
// re-mentioning it (2026-07-31: q's #portables reply, no @,
|
|
8188
9071
|
// answered in prose that followed the stale pin into
|
|
@@ -8193,44 +9076,23 @@ export class AgentFramework {
|
|
|
8193
9076
|
// Reactions and system markers are already excluded by
|
|
8194
9077
|
// isConversationalInjection, and the engaged-this-turn scope
|
|
8195
9078
|
// keeps unrelated channels from moving the pin.
|
|
8196
|
-
|
|
8197
|
-
const
|
|
8198
|
-
|
|
8199
|
-
|
|
8200
|
-
|
|
8201
|
-
|
|
8202
|
-
|
|
8203
|
-
|
|
8204
|
-
|
|
8205
|
-
|
|
8206
|
-
?.channelId as string | undefined;
|
|
8207
|
-
if (newLocus && this.turnLocusPins.get(agent.name) !== newLocus) {
|
|
8208
|
-
const prevPin = this.turnLocusPins.get(agent.name) ?? null;
|
|
8209
|
-
this.turnLocusPins.set(agent.name, newLocus);
|
|
8210
|
-
this.lastAnnouncedLocus.set(agent.name, newLocus);
|
|
8211
|
-
const label = this.channelRegistry?.getDescriptor(newLocus)?.label;
|
|
8212
|
-
const shown = label && label !== newLocus
|
|
8213
|
-
? `${label.startsWith('#') ? label : `#${label}`} (${newLocus})`
|
|
8214
|
-
: newLocus;
|
|
8215
|
-
const noticeContent: ContentBlock[] = [{
|
|
8216
|
-
type: 'text',
|
|
8217
|
-
text:
|
|
8218
|
-
`[routing] The conversation moved to ${shown} — your plain ` +
|
|
8219
|
-
'speech now lands there for the rest of this turn. Other ' +
|
|
8220
|
-
'channels need an explicit send tool.',
|
|
8221
|
-
}];
|
|
8222
|
-
const noticeMeta = { system: true, kind: 'routing-notice' } as MessageMetadata;
|
|
8223
|
-
try {
|
|
8224
|
-
const id = agent.getContextManager().addMessage('user', noticeContent, noticeMeta);
|
|
8225
|
-
this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
|
|
8226
|
-
midTurnInjections.push({ participant: 'user', content: noticeContent, metadata: noticeMeta });
|
|
8227
|
-
console.error(
|
|
8228
|
-
`[routing] ${agent.name}: mid-turn addressed re-pin ${prevPin ?? '(none)'} -> ${newLocus}`,
|
|
8229
|
-
);
|
|
8230
|
-
} catch (err) {
|
|
8231
|
-
console.error('mid-turn locus re-pin: failed to record routing notice:', err);
|
|
8232
|
-
}
|
|
9079
|
+
// The last injection that qualifies wins (repinChannel).
|
|
9080
|
+
const routing = this.routingOf(agent.name);
|
|
9081
|
+
let newLocus: string | undefined;
|
|
9082
|
+
for (const inj of midTurnInjections) newLocus = this.repinChannel(inj.metadata, routing) ?? newLocus;
|
|
9083
|
+
if (newLocus) {
|
|
9084
|
+
// What this boundary just stored is newer than any arrival the
|
|
9085
|
+
// turn holds, so the arrivals can't move the locus back.
|
|
9086
|
+
routing.arrivals = [];
|
|
9087
|
+
const notice = this.repinTurnLocus(agent, routing, newLocus, 'mid-turn addressed');
|
|
9088
|
+
if (notice) midTurnInjections.push(notice);
|
|
8233
9089
|
}
|
|
9090
|
+
} else if (routesLocus && !shouldEndTurn) {
|
|
9091
|
+
// A boundary that can't re-pin now (over budget, or no live
|
|
9092
|
+
// stream to tell) leaves its injections to the turn's next start,
|
|
9093
|
+
// as arrivals: a budget restart, or a re-inference on these tool
|
|
9094
|
+
// results.
|
|
9095
|
+
for (const inj of midTurnInjections) this.noteArrival(agent.name, inj.metadata, injectedIds.get(inj)!);
|
|
8234
9096
|
}
|
|
8235
9097
|
|
|
8236
9098
|
if (shouldEndTurn) {
|
|
@@ -8248,7 +9110,7 @@ export class AgentFramework {
|
|
|
8248
9110
|
// agent must be wakeable the moment it goes idle. (Idempotent;
|
|
8249
9111
|
// driveStream's finally is the backstop.)
|
|
8250
9112
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
8251
|
-
this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' }, this.turnRouting.get(agent.name)
|
|
9113
|
+
this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' }, this.turnRouting.get(agent.name));
|
|
8252
9114
|
this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name });
|
|
8253
9115
|
} else if (overBudget || overPhysical) {
|
|
8254
9116
|
// Context budget exceeded: break the stream, let compile() compress.
|
|
@@ -8292,6 +9154,12 @@ export class AgentFramework {
|
|
|
8292
9154
|
silentHeartbeat: this.activeTurnTriggers.get(agent.name)?.silentHeartbeat,
|
|
8293
9155
|
});
|
|
8294
9156
|
} else if (currentState.stream) {
|
|
9157
|
+
// What this round appends is in the next call's prompt. If that
|
|
9158
|
+
// call reports no prompt, the next boundary projects it from the
|
|
9159
|
+
// growth since the last call that did.
|
|
9160
|
+
if (agent.physicalWindowTokens !== undefined) {
|
|
9161
|
+
agent.lastStreamUnreportedTokens += estimateAppendedRoundTokens(currentState.toolResults, spilled, midTurnInjections);
|
|
9162
|
+
}
|
|
8295
9163
|
// Streaming path: convert results and resume the stream.
|
|
8296
9164
|
// Mid-turn messages collected above ride along as injected user
|
|
8297
9165
|
// messages (membrane ≥0.5.72) — appended after the tool_result
|
|
@@ -8483,7 +9351,7 @@ export class AgentFramework {
|
|
|
8483
9351
|
// Same backstop as the push lane: no row, no wake, no fork spawned for a
|
|
8484
9352
|
// message with nothing visible in it.
|
|
8485
9353
|
if (isVisiblyEmptyContent(event.content)) {
|
|
8486
|
-
console.error(`[channel-incoming-dropped] server=${event.serverId} channel=${event.channelId} messageId=${event.messageId} reason=empty-content`);
|
|
9354
|
+
console.error(`[channel-incoming-dropped] server=${event.serverId} channel=${logValue(event.channelId)} messageId=${logValue(event.messageId)} reason=empty-content`);
|
|
8487
9355
|
this.emitTrace({
|
|
8488
9356
|
type: 'mcpl:empty-content-dropped', lane: 'channel', serverId: event.serverId,
|
|
8489
9357
|
channelId: event.channelId, messageId: event.messageId, ...(event.eventId ? { eventId: event.eventId } : {}),
|
|
@@ -8669,7 +9537,7 @@ export class AgentFramework {
|
|
|
8669
9537
|
});
|
|
8670
9538
|
} catch (error) {
|
|
8671
9539
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
8672
|
-
console.error(`Failed to spawn conversation agent for ${event.channelId}:`, err);
|
|
9540
|
+
console.error(`Failed to spawn conversation agent for ${logValue(event.channelId)}:`, err);
|
|
8673
9541
|
this.emitTrace({
|
|
8674
9542
|
type: 'mcpl:conversation-spawn-failed',
|
|
8675
9543
|
channelId: event.channelId,
|
|
@@ -8722,9 +9590,10 @@ export class AgentFramework {
|
|
|
8722
9590
|
/**
|
|
8723
9591
|
* Spawn a persistent per-channel conversation agent ("fork") from the
|
|
8724
9592
|
* template agent: own ContextManager under `conversations/{name}`, seeded
|
|
8725
|
-
* with
|
|
8726
|
-
* wholesale-copy pattern
|
|
8727
|
-
* but never
|
|
9593
|
+
* with the template's compiled context without its thinking or its unpaired
|
|
9594
|
+
* tool blocks (the SubagentModule wholesale-copy pattern; see the seeding
|
|
9595
|
+
* comment below), registered in the event loop like any agent — but never
|
|
9596
|
+
* primary.
|
|
8728
9597
|
*/
|
|
8729
9598
|
private async createConversationAgent(name: string, channelId: string): Promise<Agent> {
|
|
8730
9599
|
const router = this.conversationRouter!;
|
|
@@ -8758,14 +9627,54 @@ export class AgentFramework {
|
|
|
8758
9627
|
// persisted precisely so names aren't reused, but if this namespace has
|
|
8759
9628
|
// history anyway (counter state lost, crash between spawn and persist),
|
|
8760
9629
|
// seeding again would stack another template copy on top of it.
|
|
8761
|
-
|
|
9630
|
+
//
|
|
9631
|
+
// Both compiles are dry runs: neither is sent, and the template's runs
|
|
9632
|
+
// while the template may be mid-stream, where a committing compile
|
|
9633
|
+
// would commit fold resolutions and queue the compression its fold plan
|
|
9634
|
+
// calls for (and, once context-manager's thinking binding ships, fence
|
|
9635
|
+
// its stream's replies). In context-manager 0.14 a dry run leaves
|
|
9636
|
+
// nothing else behind but the release of an expired compression hold
|
|
9637
|
+
// (see Agent.compileWithInjections), so the template's request in
|
|
9638
|
+
// flight keeps its calibration estimate.
|
|
9639
|
+
// The template's compile runs at context-manager's default budget (none
|
|
9640
|
+
// is passed), and a dry run still refuses a budget it can't fit: it
|
|
9641
|
+
// skips only the post-pick check, and an adaptive fold plan that
|
|
9642
|
+
// doesn't fit then overruns at the middle's emission. So a template
|
|
9643
|
+
// whose fold plan doesn't fit that budget fails the spawn with
|
|
9644
|
+
// context-manager's refusal, which names the tokens and the limit, as
|
|
9645
|
+
// a committing compile failed it. Only a windowed-passthrough template
|
|
9646
|
+
// whose newest message alone exceeds it seeds that message, larger than
|
|
9647
|
+
// the budget. The fork's own compiles meet its window by its strategy's
|
|
9648
|
+
// rule: passthrough, the fork's default, keeps the newest messages that
|
|
9649
|
+
// fit; an adaptive strategy folds to fit or throws over_budget for the
|
|
9650
|
+
// drain breaker.
|
|
9651
|
+
// The seed leaves out the template's thinking (withoutThinking): a
|
|
9652
|
+
// signed block verifies only while everything before it is what it was
|
|
9653
|
+
// minted under, and a fork's system, tools and scoped injections don't
|
|
9654
|
+
// promise that, while its own namespace holds no stamps that could
|
|
9655
|
+
// vouch for the copies. On models that don't bind thinking to its
|
|
9656
|
+
// prefix a copy would have been kept, so there the fork loses the
|
|
9657
|
+
// template's reasoning.
|
|
9658
|
+
// Then context-manager's stripUnpairedToolBlocks drops each call whose
|
|
9659
|
+
// result isn't in the seed and each result whose call isn't, keeping
|
|
9660
|
+
// the rest of their messages (a message left with nothing becomes its
|
|
9661
|
+
// "[tool call omitted]" placeholder). A fork spawned while the template
|
|
9662
|
+
// is between a round's call and its results (the coalescer can spawn
|
|
9663
|
+
// one outside the event loop) would otherwise give the fork's first
|
|
9664
|
+
// request an unanswered call: the passthrough strategies have no
|
|
9665
|
+
// pairing pass, and an adaptive template's compile has been through its
|
|
9666
|
+
// own.
|
|
9667
|
+
const { messages: existing } = await contextManager.compile(undefined, undefined, { dryRun: true });
|
|
8762
9668
|
if (existing.length === 0) {
|
|
8763
9669
|
// The copy is the fork's own, and the template's annotation edits only
|
|
8764
9670
|
// the template's message: seed only once each of the template's
|
|
8765
9671
|
// annotations has finished.
|
|
8766
9672
|
const { messages: compiled } = await template.toolResultGuard.afterAnnotations(
|
|
8767
|
-
() => template.getContextManager().compile());
|
|
8768
|
-
|
|
9673
|
+
() => template.getContextManager().compile(undefined, undefined, { dryRun: true }));
|
|
9674
|
+
const withoutReasoning = withoutThinking(compiled);
|
|
9675
|
+
const seed = stripUnpairedToolBlocks(withoutReasoning);
|
|
9676
|
+
logSeedOmissions(name, template.name, compiled, withoutReasoning, seed);
|
|
9677
|
+
for (const msg of seed) {
|
|
8769
9678
|
const participant = msg.participant === template.name ? name : msg.participant;
|
|
8770
9679
|
contextManager.addMessage(participant, msg.content);
|
|
8771
9680
|
}
|
|
@@ -9044,7 +9953,21 @@ export class AgentFramework {
|
|
|
9044
9953
|
if (!server?.isConnected) throw new Error('server disconnected');
|
|
9045
9954
|
return server.sendPushRender(params);
|
|
9046
9955
|
},
|
|
9047
|
-
audit: (record) =>
|
|
9956
|
+
audit: (record) => {
|
|
9957
|
+
// A render result the host refused falls back to the occurrence's own
|
|
9958
|
+
// content; say so on the host, with the server, the subject and the
|
|
9959
|
+
// failing field, not only in the trace. The error is a field like the
|
|
9960
|
+
// rest (logValue, spaces escaped), not a host-message tail: a server
|
|
9961
|
+
// that answers push/render with an error chooses its message.
|
|
9962
|
+
if (record.kind === 'render-failed') {
|
|
9963
|
+
console.error(
|
|
9964
|
+
`[push-render-failed] server=${logValue(record.serverId ?? '')} subject=${logValue(record.subject ?? '')} ` +
|
|
9965
|
+
`eventId=${logValue(record.eventId ?? '')}${record.field !== undefined ? ` field=${logValue(record.field)}` : ''}: ` +
|
|
9966
|
+
`${logValue(record.error ?? '')} (the occurrence's own content is delivered instead)`,
|
|
9967
|
+
);
|
|
9968
|
+
}
|
|
9969
|
+
this.emitTrace({ type: 'mcpl:coalescing', ...record });
|
|
9970
|
+
},
|
|
9048
9971
|
save: (snapshot) => this.scheduleCoalescingSnapshot(snapshot),
|
|
9049
9972
|
recordReceipt: (record) => {
|
|
9050
9973
|
// Written now, not at the throttled flush: an acknowledged acceptance
|
|
@@ -9151,7 +10074,7 @@ export class AgentFramework {
|
|
|
9151
10074
|
throw new CoalesceError('eventId', 'eventId is required with coalesce');
|
|
9152
10075
|
}
|
|
9153
10076
|
if (typeof message.timestamp !== 'string') throw new CoalesceError('timestamp', 'timestamp is required');
|
|
9154
|
-
validateCoalescedContent(message.content,
|
|
10077
|
+
validateCoalescedContent(message.content, { allowEmpty: message.coalesce.retract === true });
|
|
9155
10078
|
const c = message.coalesce;
|
|
9156
10079
|
const result = await this.pushCoalescer.accept({
|
|
9157
10080
|
serverId,
|
|
@@ -9178,7 +10101,7 @@ export class AgentFramework {
|
|
|
9178
10101
|
validateCoalesceMember(params.coalesce, 'push');
|
|
9179
10102
|
if (typeof params.eventId !== 'string' || !params.eventId) throw new CoalesceError('eventId', 'eventId is required');
|
|
9180
10103
|
if (typeof params.timestamp !== 'string') throw new CoalesceError('timestamp', 'timestamp is required');
|
|
9181
|
-
validateCoalescedContent(params.payload?.content,
|
|
10104
|
+
validateCoalescedContent(params.payload?.content, {
|
|
9182
10105
|
allowEmpty: params.coalesce.retract === true || isSilentHeartbeatMarker({
|
|
9183
10106
|
serverId, featureSet: params.featureSet, origin: params.origin, content: params.payload?.content,
|
|
9184
10107
|
}),
|
|
@@ -9267,7 +10190,7 @@ export class AgentFramework {
|
|
|
9267
10190
|
this.persistConversationRouterState();
|
|
9268
10191
|
return [decision.agentName];
|
|
9269
10192
|
} catch (err) {
|
|
9270
|
-
console.error(`[coalescing] could not spawn a conversation agent for ${occ.scope.id}:`, err);
|
|
10193
|
+
console.error(`[coalescing] could not spawn a conversation agent for ${logValue(occ.scope.id)}:`, err);
|
|
9271
10194
|
return [];
|
|
9272
10195
|
}
|
|
9273
10196
|
}
|
|
@@ -9456,7 +10379,7 @@ export class AgentFramework {
|
|
|
9456
10379
|
const agent = this.agents.get(p.agent);
|
|
9457
10380
|
if (!agent) return false;
|
|
9458
10381
|
try {
|
|
9459
|
-
agent.getContextManager()
|
|
10382
|
+
removeStoredMessage(agent.getContextManager(), p.messageId as MessageId);
|
|
9460
10383
|
this.emitTrace({ type: 'message:removed', messageId: p.messageId, source: 'mcpl:coalescing' });
|
|
9461
10384
|
return true;
|
|
9462
10385
|
} catch (err) {
|
|
@@ -9475,7 +10398,7 @@ export class AgentFramework {
|
|
|
9475
10398
|
// model would wake to `[Continue]` or an older message re-presented as
|
|
9476
10399
|
// the newest, a wake with no visible cause.
|
|
9477
10400
|
if (isVisiblyEmptyContent(event.content) && !isSilentHeartbeatMarker(event)) {
|
|
9478
|
-
console.error(`[push-event-dropped] server=${event.serverId} eventId=${event.eventId} reason=empty-content`);
|
|
10401
|
+
console.error(`[push-event-dropped] server=${event.serverId} eventId=${logValue(event.eventId ?? '')} reason=empty-content`);
|
|
9479
10402
|
this.emitTrace({ type: 'mcpl:empty-content-dropped', lane: 'push', serverId: event.serverId, eventId: event.eventId });
|
|
9480
10403
|
return undefined;
|
|
9481
10404
|
}
|
|
@@ -9540,7 +10463,7 @@ export class AgentFramework {
|
|
|
9540
10463
|
const id = this.addMessage('user', content, metadata, { placement, bypassDeferralFor: event.assemblingFor });
|
|
9541
10464
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
|
|
9542
10465
|
} else {
|
|
9543
|
-
console.error(`[heartbeat] ${event.serverId}: accepted silent scheduled wake ${event.eventId}`);
|
|
10466
|
+
console.error(`[heartbeat] ${event.serverId}: accepted silent scheduled wake ${logValue(event.eventId ?? '')}`);
|
|
9544
10467
|
}
|
|
9545
10468
|
|
|
9546
10469
|
if (event.triggerInference) {
|
|
@@ -9944,7 +10867,7 @@ export class AgentFramework {
|
|
|
9944
10867
|
/** Record plain-prose segments this turn suppressed. */
|
|
9945
10868
|
private recordProseSuppression(routing: TurnRouting, count: number): void {
|
|
9946
10869
|
if (count <= 0) return;
|
|
9947
|
-
routing.
|
|
10870
|
+
routing.suppressed += count;
|
|
9948
10871
|
}
|
|
9949
10872
|
|
|
9950
10873
|
/** Record a successful plain-prose delivery for this turn's receipt. */
|
|
@@ -9953,7 +10876,7 @@ export class AgentFramework {
|
|
|
9953
10876
|
outcome: { delivered: boolean; channelId: string } | null | undefined,
|
|
9954
10877
|
): void {
|
|
9955
10878
|
if (!outcome?.delivered) return;
|
|
9956
|
-
routing.
|
|
10879
|
+
routing.deliveries.push(outcome.channelId);
|
|
9957
10880
|
}
|
|
9958
10881
|
|
|
9959
10882
|
/**
|
|
@@ -9970,11 +10893,11 @@ export class AgentFramework {
|
|
|
9970
10893
|
* ([discord-send-failed]); this is the success half.
|
|
9971
10894
|
*/
|
|
9972
10895
|
private appendProseDeliveryReceipt(agent: Agent, routing: TurnRouting): void {
|
|
9973
|
-
const list = routing.
|
|
9974
|
-
const suppressed = routing.
|
|
10896
|
+
const list = routing.deliveries;
|
|
10897
|
+
const suppressed = routing.suppressed;
|
|
9975
10898
|
if (list.length === 0 && suppressed === 0) return;
|
|
9976
|
-
routing.
|
|
9977
|
-
routing.
|
|
10899
|
+
routing.deliveries = [];
|
|
10900
|
+
routing.suppressed = 0;
|
|
9978
10901
|
const seen = new Set<string>();
|
|
9979
10902
|
const shown: string[] = [];
|
|
9980
10903
|
for (const id of list ?? []) {
|
|
@@ -10026,7 +10949,6 @@ export class AgentFramework {
|
|
|
10026
10949
|
if (!hasBaseline) this.lastAnnouncedLocus.set(agentName, locus);
|
|
10027
10950
|
return;
|
|
10028
10951
|
}
|
|
10029
|
-
this.lastAnnouncedLocus.set(agentName, locus);
|
|
10030
10952
|
|
|
10031
10953
|
const agent = this.agents.get(agentName);
|
|
10032
10954
|
if (!agent) return;
|
|
@@ -10053,6 +10975,9 @@ export class AgentFramework {
|
|
|
10053
10975
|
[{ type: 'text', text }],
|
|
10054
10976
|
{ system: true, kind: 'routing-notice' },
|
|
10055
10977
|
);
|
|
10978
|
+
// The baseline is what the agent was told: it moves once the notice is
|
|
10979
|
+
// stored, so a notice that fails is announced again at the next turn.
|
|
10980
|
+
this.lastAnnouncedLocus.set(agentName, locus);
|
|
10056
10981
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
|
|
10057
10982
|
console.error(
|
|
10058
10983
|
`[routing] ${agentName}: locus ${prev ?? '(none)'} -> ${locus ?? '(none)'} (announced in window)`,
|
|
@@ -10062,6 +10987,127 @@ export class AgentFramework {
|
|
|
10062
10987
|
}
|
|
10063
10988
|
}
|
|
10064
10989
|
|
|
10990
|
+
/**
|
|
10991
|
+
* The channel a message moves its turn's locus to, by the addressed
|
|
10992
|
+
* re-pin rule (2026-07-31, n=6 and n=7): a conversational message (not a
|
|
10993
|
+
* reaction or a system marker) that addressed the agent, or that follows
|
|
10994
|
+
* up in a channel the turn sent into (TurnRouting.engaged). Undefined for
|
|
10995
|
+
* any other message. A tool boundary weighs the messages it injects by it,
|
|
10996
|
+
* and noteArrival the ones no boundary of the turn weighs.
|
|
10997
|
+
*/
|
|
10998
|
+
private repinChannel(metadata: MessageMetadata | undefined, routing: TurnRouting): string | undefined {
|
|
10999
|
+
if (!isConversationalInjection(metadata)) return undefined;
|
|
11000
|
+
const m = metadata as Record<string, unknown> | undefined;
|
|
11001
|
+
if (typeof m?.channelId !== 'string') return undefined;
|
|
11002
|
+
if (isAddressedMessage(m.tags as string[] | undefined, m)) return m.channelId;
|
|
11003
|
+
return routing.engaged.has(m.channelId) ? m.channelId : undefined;
|
|
11004
|
+
}
|
|
11005
|
+
|
|
11006
|
+
/**
|
|
11007
|
+
* Weigh a message that no tool boundary of the agent's open logical turn
|
|
11008
|
+
* will weigh: one that would re-pin the locus becomes one of the turn's
|
|
11009
|
+
* arrivals (TurnRouting.arrivals). A message tune-out diverted
|
|
11010
|
+
* (metadata.tuneOut) never enters a resident's compiled view, so the agent
|
|
11011
|
+
* won't see it, and it moves nothing.
|
|
11012
|
+
*/
|
|
11013
|
+
private noteArrival(agentName: string, metadata: MessageMetadata | undefined, messageId: MessageId): void {
|
|
11014
|
+
const open = this.openLogical.get(agentName);
|
|
11015
|
+
if (!open) return;
|
|
11016
|
+
if ((metadata as { tuneOut?: unknown } | undefined)?.tuneOut) return;
|
|
11017
|
+
const channelId = this.repinChannel(metadata, open);
|
|
11018
|
+
if (channelId) open.arrivals.push({ messageId, channelId });
|
|
11019
|
+
}
|
|
11020
|
+
|
|
11021
|
+
/**
|
|
11022
|
+
* Whether `agent`'s store is the residents' one message slot, which every
|
|
11023
|
+
* ordinary resident reads (sharedSlotAgents). The subconscious reads that
|
|
11024
|
+
* slot through a merged view, but its own store is isolated, as a fork's
|
|
11025
|
+
* or an ephemeral agent's is.
|
|
11026
|
+
*/
|
|
11027
|
+
private isOrdinaryResident(agent: Agent): boolean {
|
|
11028
|
+
return this.sharedSlotAgents.has(agent) && agent.name !== this.subconsciousAgentName;
|
|
11029
|
+
}
|
|
11030
|
+
|
|
11031
|
+
/**
|
|
11032
|
+
* Weigh a message just stored in an ordinary resident's store for every
|
|
11033
|
+
* other ordinary resident's open turn, running or not: residents read one
|
|
11034
|
+
* message slot, and a tool boundary weighs only the messages drained for
|
|
11035
|
+
* its own agent, so no boundary of another resident's turn weighs it.
|
|
11036
|
+
*/
|
|
11037
|
+
private noteForOtherResidents(agent: Agent, metadata: MessageMetadata | undefined, messageId: MessageId): void {
|
|
11038
|
+
if (!this.isOrdinaryResident(agent)) return;
|
|
11039
|
+
for (const reader of this.sharedSlotAgents) {
|
|
11040
|
+
if (reader !== agent && this.agents.get(reader.name) === reader && this.isOrdinaryResident(reader)) {
|
|
11041
|
+
this.noteArrival(reader.name, metadata, messageId);
|
|
11042
|
+
}
|
|
11043
|
+
}
|
|
11044
|
+
}
|
|
11045
|
+
|
|
11046
|
+
/**
|
|
11047
|
+
* The channel of the turn's newest arrival whose message is still in the
|
|
11048
|
+
* agent's store (TurnRouting.arrivals). A message removed since it was
|
|
11049
|
+
* stored, such as an unread coalesced message its server retracted, moves
|
|
11050
|
+
* nothing, and an earlier one that remains still does. Compression keeps a
|
|
11051
|
+
* message in the store; the last compile can't be the test, since an
|
|
11052
|
+
* arrival between the turn's streams isn't in it yet.
|
|
11053
|
+
*/
|
|
11054
|
+
private remainingArrival(agent: Agent, routing: TurnRouting): string | undefined {
|
|
11055
|
+
const cm = agent.getContextManager();
|
|
11056
|
+
for (let i = routing.arrivals.length - 1; i >= 0; i--) {
|
|
11057
|
+
const { messageId, channelId } = routing.arrivals[i]!;
|
|
11058
|
+
// The store answers null for a message that isn't there. A store that
|
|
11059
|
+
// can't be read throws here, as anywhere else in the start, rather
|
|
11060
|
+
// than have its candidates read as withdrawn.
|
|
11061
|
+
if (cm.getMessage(messageId)) return channelId;
|
|
11062
|
+
}
|
|
11063
|
+
return undefined;
|
|
11064
|
+
}
|
|
11065
|
+
|
|
11066
|
+
/**
|
|
11067
|
+
* Move a turn's locus to the channel its conversation moved to: an
|
|
11068
|
+
* addressed message, or a follow-up in a channel the turn engaged (the
|
|
11069
|
+
* addressed re-pin, repinChannel). Ambient chatter never moves it. Leaves
|
|
11070
|
+
* a durable `[routing]` notice in the window and updates
|
|
11071
|
+
* lastAnnouncedLocus once it's stored, so the next turn's announce-on-change
|
|
11072
|
+
* diffs against what the agent was last told. Returns the notice as stored, for a live
|
|
11073
|
+
* stream to inject with its boundary's other messages. Returns undefined
|
|
11074
|
+
* when the locus is already there, and when the notice couldn't be
|
|
11075
|
+
* stored. The locus has moved then too, but the announce baseline hasn't,
|
|
11076
|
+
* so the agent's next turn announces where its speech lands.
|
|
11077
|
+
*/
|
|
11078
|
+
private repinTurnLocus(
|
|
11079
|
+
agent: Agent,
|
|
11080
|
+
routing: TurnRouting,
|
|
11081
|
+
channelId: string,
|
|
11082
|
+
cause: string,
|
|
11083
|
+
): { participant: string; content: ContentBlock[]; metadata: MessageMetadata } | undefined {
|
|
11084
|
+
if (routing.locus === channelId) return undefined;
|
|
11085
|
+
const prevPin = routing.locus ?? null;
|
|
11086
|
+
routing.locus = channelId;
|
|
11087
|
+
const label = this.channelRegistry?.getDescriptor(channelId)?.label;
|
|
11088
|
+
const shown = label && label !== channelId
|
|
11089
|
+
? `${label.startsWith('#') ? label : `#${label}`} (${channelId})`
|
|
11090
|
+
: channelId;
|
|
11091
|
+
const content: ContentBlock[] = [{
|
|
11092
|
+
type: 'text',
|
|
11093
|
+
text:
|
|
11094
|
+
`[routing] The conversation moved to ${shown} — your plain ` +
|
|
11095
|
+
'speech now lands there for the rest of this turn. Other ' +
|
|
11096
|
+
'channels need an explicit send tool.',
|
|
11097
|
+
}];
|
|
11098
|
+
const metadata = { system: true, kind: 'routing-notice' } as MessageMetadata;
|
|
11099
|
+
try {
|
|
11100
|
+
const id = agent.getContextManager().addMessage('user', content, metadata);
|
|
11101
|
+
this.lastAnnouncedLocus.set(agent.name, channelId);
|
|
11102
|
+
this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
|
|
11103
|
+
console.error(`[routing] ${agent.name}: ${cause} re-pin ${prevPin ?? '(none)'} -> ${channelId}`);
|
|
11104
|
+
return { participant: 'user', content, metadata };
|
|
11105
|
+
} catch (err) {
|
|
11106
|
+
console.error('turn locus re-pin: failed to record routing notice:', err);
|
|
11107
|
+
return undefined;
|
|
11108
|
+
}
|
|
11109
|
+
}
|
|
11110
|
+
|
|
10065
11111
|
/**
|
|
10066
11112
|
* Durable window notice for a channel the agent did not ask to open —
|
|
10067
11113
|
* subscription-policy admission, or an open forced by the agent's own
|
|
@@ -10113,10 +11159,10 @@ export class AgentFramework {
|
|
|
10113
11159
|
*/
|
|
10114
11160
|
private requestProseContinuation(agent: Agent, routing: TurnRouting): void {
|
|
10115
11161
|
// The flag may be shared with an earlier stream of the same logical
|
|
10116
|
-
// turn (
|
|
11162
|
+
// turn (TurnRouting); its deliveries are on the agent's chain ahead of
|
|
10117
11163
|
// this stream's, so by the time this check runs, they have run.
|
|
10118
|
-
if (!routing.
|
|
10119
|
-
routing.
|
|
11164
|
+
if (!routing.continuation) return;
|
|
11165
|
+
routing.continuation = false;
|
|
10120
11166
|
// An explicit turn's check can run after its stream has returned. A
|
|
10121
11167
|
// stopped framework, or an agent no longer registered under its name (a
|
|
10122
11168
|
// disposed fork or ephemeral run), takes no new turn from it.
|
|
@@ -10231,7 +11277,7 @@ export class AgentFramework {
|
|
|
10231
11277
|
const normalized = envelope.replace(/^\s+(?=>>>)/, '');
|
|
10232
11278
|
const attempted = normalized.startsWith('>>>');
|
|
10233
11279
|
const parsed = parseHybridProsePrefix(normalized);
|
|
10234
|
-
if (parsed.continueTurn) routing.
|
|
11280
|
+
if (parsed.continueTurn) routing.continuation = true;
|
|
10235
11281
|
if (parsed.kind === 'private') {
|
|
10236
11282
|
delete routing.pin;
|
|
10237
11283
|
routing.hybridHold = true;
|
|
@@ -10309,7 +11355,7 @@ export class AgentFramework {
|
|
|
10309
11355
|
const parsed = parseProsePrefix(rawText);
|
|
10310
11356
|
// A private round (one that called skip_reply) asks for no publication
|
|
10311
11357
|
// or continuation, even when delivery runs after that turn has ended.
|
|
10312
|
-
if (parsed.continueTurn && !round.keepPrivate) routing.
|
|
11358
|
+
if (parsed.continueTurn && !round.keepPrivate) routing.continuation = true;
|
|
10313
11359
|
|
|
10314
11360
|
if (parsed.kind === 'private') {
|
|
10315
11361
|
// The text already lives in the assistant message (window/chronicle);
|
|
@@ -10593,15 +11639,75 @@ export class AgentFramework {
|
|
|
10593
11639
|
let tokenHandedOff = false;
|
|
10594
11640
|
try {
|
|
10595
11641
|
tokenHandedOff = await this.beginAgentTurn(agent, trigger, attempt, turnToken, ownsProviderGate);
|
|
11642
|
+
} catch (error) {
|
|
11643
|
+
// A throw outside the stream's own failure handling (the checkpoint,
|
|
11644
|
+
// locus resolution, the typing indicator) drops this start's trigger,
|
|
11645
|
+
// and nothing retries or holds it. The agent's open logical turn is the
|
|
11646
|
+
// one this start was opening, continuing or resuming, so it ends here,
|
|
11647
|
+
// as a terminal failure in beginAgentTurn's own catch ends its turn:
|
|
11648
|
+
// the next start opens a turn of its own rather than continuing this
|
|
11649
|
+
// one's routing, and an ephemeral run fails with the error rather than
|
|
11650
|
+
// waiting out its watchdog. A silent start has no part in an open turn.
|
|
11651
|
+
if (!trigger?.suppressProse) {
|
|
11652
|
+
this.settleAgent(agent.name, {
|
|
11653
|
+
stopReason: 'exhausted', speech: '', error: error instanceof Error ? error.message : String(error),
|
|
11654
|
+
}, this.openLogical.get(agent.name));
|
|
11655
|
+
}
|
|
11656
|
+
throw error;
|
|
10596
11657
|
} finally {
|
|
10597
11658
|
if (!tokenHandedOff && this.activeTurnTokens.get(agent.name) === turnToken) {
|
|
10598
11659
|
this.activeTurnTokens.delete(agent.name);
|
|
10599
11660
|
this.activeTurnTriggers.delete(agent.name);
|
|
10600
11661
|
}
|
|
10601
11662
|
if (!tokenHandedOff && ownsProviderGate) this.releasePrimaryProviderGate(agent.name);
|
|
11663
|
+
// A start that ends before its stream exists lands what was deferred
|
|
11664
|
+
// during it, as a stream's teardown does. Left queued, those messages
|
|
11665
|
+
// would land only at the agent's next start, after any stored meanwhile.
|
|
11666
|
+
// Per message, as the turn-start flush does: one store failure must
|
|
11667
|
+
// neither drop the messages behind it nor replace the start's own error.
|
|
11668
|
+
if (!tokenHandedOff && !this.activeTurnTokens.has(agent.name)
|
|
11669
|
+
&& this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
|
|
11670
|
+
for (const msg of this.drainDeferredFor(agent.name)) {
|
|
11671
|
+
try {
|
|
11672
|
+
this.addMessage(msg.participant, msg.content, msg.metadata, {
|
|
11673
|
+
deferredWriteId: msg.id,
|
|
11674
|
+
...(msg.forAgent ? { forAgent: msg.forAgent } : {}),
|
|
11675
|
+
});
|
|
11676
|
+
} catch (err) {
|
|
11677
|
+
console.error(
|
|
11678
|
+
`[deferred-flush] failed-start flush failed to store a message for ${agent.name} ` +
|
|
11679
|
+
`(participant=${msg.participant}, id=${msg.id}):`,
|
|
11680
|
+
err,
|
|
11681
|
+
);
|
|
11682
|
+
}
|
|
11683
|
+
}
|
|
11684
|
+
this.ackDeferredWrites();
|
|
11685
|
+
}
|
|
10602
11686
|
}
|
|
10603
11687
|
}
|
|
10604
11688
|
|
|
11689
|
+
/**
|
|
11690
|
+
* End a turn that beginAgentTurn drops before it sends anything. The turn
|
|
11691
|
+
* emitted inference:started, and listeners that bracket a turn, such as a
|
|
11692
|
+
* typing loop or an activity indicator, end it on a terminal event, so it
|
|
11693
|
+
* gets inference:aborted with `reason`, and lastInference records its end
|
|
11694
|
+
* as driveStream's would, when the record is still this turn's. Nothing of
|
|
11695
|
+
* the kind when another turn of this agent's name holds the turn token (one
|
|
11696
|
+
* begun after a stop and start, or by a replacement agent object): the
|
|
11697
|
+
* event and the record carry only the name, so this frame's would end that
|
|
11698
|
+
* newer turn instead.
|
|
11699
|
+
*/
|
|
11700
|
+
private abortDroppedTurn(agentName: string, turnToken: number, reason: string, startedAt: number): void {
|
|
11701
|
+
const owner = this.activeTurnTokens.get(agentName);
|
|
11702
|
+
if (owner !== undefined && owner !== turnToken) return;
|
|
11703
|
+
const now = Date.now();
|
|
11704
|
+
// Only this turn's own record ends: a name whose run was disposed meanwhile
|
|
11705
|
+
// (runEphemeralToCompletion deletes its record) gets none back.
|
|
11706
|
+
const last = this.lastInferenceAt.get(agentName);
|
|
11707
|
+
if (last?.startedAt === startedAt) this.lastInferenceAt.set(agentName, { ...last, endedAt: now });
|
|
11708
|
+
this.emitTrace({ type: 'inference:aborted', agentName, reason, durationMs: now - startedAt });
|
|
11709
|
+
}
|
|
11710
|
+
|
|
10605
11711
|
/**
|
|
10606
11712
|
* Body of startAgentStream, split out so the caller's try/finally owns the
|
|
10607
11713
|
* turn token unconditionally. Returns true iff the token was handed off to
|
|
@@ -10615,7 +11721,7 @@ export class AgentFramework {
|
|
|
10615
11721
|
ownsProviderGate: boolean,
|
|
10616
11722
|
): Promise<boolean> {
|
|
10617
11723
|
const lifecycle = this.lifecycleGeneration;
|
|
10618
|
-
const
|
|
11724
|
+
const resumesTurn = resumesLogicalTurn(trigger?.reason);
|
|
10619
11725
|
const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting;
|
|
10620
11726
|
// Flush messages deferred during the PREVIOUS turn — before the
|
|
10621
11727
|
// checkpoint, the locus announcement, and the compile — so a turn started
|
|
@@ -10633,7 +11739,7 @@ export class AgentFramework {
|
|
|
10633
11739
|
// products — undoing the turn must not destroy them.
|
|
10634
11740
|
if (
|
|
10635
11741
|
attempt === 0 &&
|
|
10636
|
-
!
|
|
11742
|
+
!resumesTurn &&
|
|
10637
11743
|
this.deferredMessages.length > 0
|
|
10638
11744
|
) {
|
|
10639
11745
|
{
|
|
@@ -10646,6 +11752,8 @@ export class AgentFramework {
|
|
|
10646
11752
|
const id = agent.getContextManager().addMessage(
|
|
10647
11753
|
msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id),
|
|
10648
11754
|
);
|
|
11755
|
+
this.noteArrival(agent.name, msg.metadata, id);
|
|
11756
|
+
this.noteForOtherResidents(agent, msg.metadata, id);
|
|
10649
11757
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'deferred-flush:turn-start' });
|
|
10650
11758
|
} catch (err) {
|
|
10651
11759
|
console.error(
|
|
@@ -10664,7 +11772,7 @@ export class AgentFramework {
|
|
|
10664
11772
|
// not yet run) and before the checkpoint (they are the turn's inputs).
|
|
10665
11773
|
// Not on a context-budget restart: that continues the same logical turn.
|
|
10666
11774
|
let assembly: AssemblyResult | undefined;
|
|
10667
|
-
if (attempt === 0 && !
|
|
11775
|
+
if (attempt === 0 && !resumesTurn && this.pushCoalescer?.pendingBatches()) {
|
|
10668
11776
|
try {
|
|
10669
11777
|
assembly = await this.pushCoalescer.assemble(agent.name);
|
|
10670
11778
|
} catch (err) {
|
|
@@ -10711,45 +11819,57 @@ export class AgentFramework {
|
|
|
10711
11819
|
this.redoStacks.delete(agent.name); // new work invalidates redo
|
|
10712
11820
|
}
|
|
10713
11821
|
|
|
10714
|
-
//
|
|
10715
|
-
//
|
|
10716
|
-
//
|
|
10717
|
-
//
|
|
10718
|
-
//
|
|
10719
|
-
//
|
|
10720
|
-
//
|
|
10721
|
-
//
|
|
10722
|
-
//
|
|
10723
|
-
//
|
|
10724
|
-
//
|
|
10725
|
-
//
|
|
10726
|
-
//
|
|
10727
|
-
|
|
10728
|
-
|
|
10729
|
-
|
|
11822
|
+
// This start's routing: its logical turn's (TurnRouting), which holds
|
|
11823
|
+
// everything the turn has decided about where its speech goes.
|
|
11824
|
+
// - A resume (a budget restart, a tool-result-guard retry) carries its
|
|
11825
|
+
// turn's request on and keeps the turn's object as it is, and the
|
|
11826
|
+
// turn stays, or after a stop() becomes again, the agent's open turn.
|
|
11827
|
+
// - A silent start routes no prose. It gets an object of its own, with
|
|
11828
|
+
// no locus, so it neither takes nor disturbs an open turn's. Its
|
|
11829
|
+
// object is the agent's live routing while it runs, and the open
|
|
11830
|
+
// turn's next start makes the turn's object live again. (No silent
|
|
11831
|
+
// start runs while a prose turn is open in production: a capacity
|
|
11832
|
+
// hold holds silent wakes with the turn's, and a re-inference's wake
|
|
11833
|
+
// is queued before its stream ends, so it batches with them.)
|
|
11834
|
+
// - Any other start continues the agent's open logical turn, if one is
|
|
11835
|
+
// open (openLogical): a framework retry, the start that consumes tool
|
|
11836
|
+
// results the non-streaming fallback left (whichever wake the
|
|
11837
|
+
// scheduler starts it with), the later compile a provider-capacity
|
|
11838
|
+
// hold defers a turn to. It takes the turn's object: the sticky
|
|
11839
|
+
// destination, the destination and the hold, the ` !` and receipt
|
|
11840
|
+
// tallies, the trigger channel, the locus with any re-pin, and the
|
|
11841
|
+
// engaged channels. A delivery still queued from an earlier stream
|
|
11842
|
+
// runs ahead of this one's on the agent's chain, so what it sets
|
|
11843
|
+
// applies to this stream's prose, in the order the turn wrote them.
|
|
11844
|
+
// With no turn open, the start opens one.
|
|
11845
|
+
// A new object doesn't clear the previous turn's: prose that turn left
|
|
11846
|
+
// queued still reads and writes its own.
|
|
11847
|
+
let routing: TurnRouting;
|
|
11848
|
+
let opensTurn = false;
|
|
11849
|
+
if (resumesTurn) {
|
|
11850
|
+
routing = this.routingOf(agent.name);
|
|
11851
|
+
// The turn a resume continues is open, unless a stop() ended every
|
|
11852
|
+
// open turn since: it opens again, so its later starts continue it.
|
|
11853
|
+
if (!trigger?.suppressProse && !this.openLogical.has(agent.name)) this.openLogical.set(agent.name, routing);
|
|
11854
|
+
} else if (trigger?.suppressProse) {
|
|
11855
|
+
routing = freshTurnRouting();
|
|
11856
|
+
opensTurn = true;
|
|
11857
|
+
} else {
|
|
11858
|
+
const open = this.openLogical.get(agent.name);
|
|
11859
|
+
if (open) {
|
|
11860
|
+
routing = open;
|
|
10730
11861
|
} else {
|
|
10731
|
-
|
|
10732
|
-
|
|
10733
|
-
|
|
10734
|
-
|
|
10735
|
-
// FREEZE this turn's outbound locus now (turn-frozen routing). Resolved
|
|
10736
|
-
// once — home channel for forks, else the triggering channel, else the
|
|
10737
|
-
// global default — and never moved for the rest of the turn: mid-turn
|
|
10738
|
-
// injections must not redirect the agent's plain prose (ambient chatter /
|
|
10739
|
-
// reactions hijacked it before, 2026-07-21). A context-budget restart
|
|
10740
|
-
// continues the same logical turn, so it keeps the existing pin. The
|
|
10741
|
-
// eager snapshot also protects heartbeat turns from a moving
|
|
10742
|
-
// defaultPublishChannel mid-turn. If the effective locus differs from the
|
|
10743
|
-
// last one announced, drop a durable `[routing]` notice into the window
|
|
10744
|
-
// BEFORE this turn compiles, so the agent always knows where its voice
|
|
10745
|
-
// goes (announce-on-change only — no per-turn chatter, append-only for
|
|
10746
|
-
// KV stability).
|
|
10747
|
-
if (continuingTurn) {
|
|
10748
|
-
const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
|
|
10749
|
-
this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
|
|
11862
|
+
routing = freshTurnRouting();
|
|
11863
|
+
this.openLogical.set(agent.name, routing);
|
|
11864
|
+
opensTurn = true;
|
|
11865
|
+
}
|
|
10750
11866
|
}
|
|
11867
|
+
this.turnRouting.set(agent.name, routing);
|
|
10751
11868
|
|
|
10752
|
-
if (
|
|
11869
|
+
if (resumesTurn) {
|
|
11870
|
+
const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
|
|
11871
|
+
this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
|
|
11872
|
+
} else {
|
|
10753
11873
|
if (attempt === 0) this.maybePrimeProseMode(agent);
|
|
10754
11874
|
const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
|
|
10755
11875
|
if (attempt === 0) {
|
|
@@ -10759,72 +11879,63 @@ export class AgentFramework {
|
|
|
10759
11879
|
// A framework retry is a new physical stream in the same logical turn.
|
|
10760
11880
|
this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
|
|
10761
11881
|
}
|
|
10762
|
-
|
|
10763
|
-
|
|
10764
|
-
|
|
10765
|
-
|
|
10766
|
-
//
|
|
10767
|
-
//
|
|
10768
|
-
//
|
|
10769
|
-
|
|
10770
|
-
|
|
10771
|
-
//
|
|
10772
|
-
//
|
|
10773
|
-
//
|
|
10774
|
-
//
|
|
10775
|
-
//
|
|
10776
|
-
|
|
10777
|
-
|
|
10778
|
-
|
|
10779
|
-
|
|
10780
|
-
|
|
10781
|
-
|
|
10782
|
-
|
|
10783
|
-
|
|
10784
|
-
// Explicit mode uses a turn-scoped `>>` target; disabled mode has no
|
|
10785
|
-
// prose target at all. Neither mode freezes or announces a locus.
|
|
10786
|
-
this.midTurnInputSignals.delete(agent.name);
|
|
10787
|
-
this.turnLocusPins.delete(agent.name);
|
|
10788
|
-
} else {
|
|
11882
|
+
this.midTurnInputSignals.delete(agent.name);
|
|
11883
|
+
}
|
|
11884
|
+
|
|
11885
|
+
if (opensTurn) {
|
|
11886
|
+
// The turn's trigger channel (item-3 redux): the channel of a
|
|
11887
|
+
// channel/DM-triggered turn, and none for a heartbeat / no-trigger
|
|
11888
|
+
// turn, which falls back to the global default rather than inheriting
|
|
11889
|
+
// a previous turn's channel.
|
|
11890
|
+
routing.triggerChannel = trigger?.channelId;
|
|
11891
|
+
// FREEZE the turn's outbound locus now (turn-frozen routing): the home
|
|
11892
|
+
// channel for forks, else the trigger channel, else the global default.
|
|
11893
|
+
// Mid-turn injections must not redirect the agent's plain prose
|
|
11894
|
+
// (ambient chatter and reactions hijacked it before, 2026-07-21), and
|
|
11895
|
+
// the eager snapshot protects heartbeat turns from a moving
|
|
11896
|
+
// defaultPublishChannel. Explicit mode uses a turn-scoped `>>` target
|
|
11897
|
+
// and disabled mode has no prose target, so neither freezes or
|
|
11898
|
+
// announces a locus. If the effective locus differs from the last one
|
|
11899
|
+
// announced, a durable `[routing]` notice goes into the window BEFORE
|
|
11900
|
+
// this turn compiles, so the agent always knows where its voice goes
|
|
11901
|
+
// (announce-on-change only — no per-turn chatter, append-only for KV
|
|
11902
|
+
// stability).
|
|
11903
|
+
if (turnProseRouting !== 'explicit' && turnProseRouting !== 'disabled') {
|
|
10789
11904
|
const locus = this.channelRegistry?.resolveLocus(agent.name) ?? null;
|
|
10790
|
-
if (locus !== null)
|
|
10791
|
-
else this.turnLocusPins.delete(agent.name);
|
|
10792
|
-
this.midTurnInputSignals.delete(agent.name);
|
|
11905
|
+
if (locus !== null) routing.locus = locus;
|
|
10793
11906
|
if (attempt === 0) this.announceLocusIfChanged(agent.name, locus);
|
|
10794
11907
|
}
|
|
11908
|
+
} else {
|
|
11909
|
+
// Every other start of the turn keeps its locus: a resume, or a start
|
|
11910
|
+
// that continues the open turn. A message no tool boundary of the turn
|
|
11911
|
+
// weighed can still move it, as it would have at a boundary
|
|
11912
|
+
// (TurnRouting.arrivals).
|
|
11913
|
+
// Only a turn that has a locus reads its arrivals' messages.
|
|
11914
|
+
const arrival = turnProseRouting === 'locus' || turnProseRouting === 'hybrid'
|
|
11915
|
+
? this.remainingArrival(agent, routing)
|
|
11916
|
+
: undefined;
|
|
11917
|
+
routing.arrivals = [];
|
|
11918
|
+
if (arrival) this.repinTurnLocus(agent, routing, arrival, 'arrival');
|
|
10795
11919
|
}
|
|
10796
|
-
|
|
10797
|
-
//
|
|
10798
|
-
|
|
10799
|
-
// opened/pinned a channel mid-turn.
|
|
10800
|
-
if (trigger?.suppressProse) {
|
|
10801
|
-
this.turnLocusPins.delete(agent.name);
|
|
10802
|
-
// Silent: no routing state. A continuing silent start keeps its turn's
|
|
10803
|
-
// logical turn; a fresh one keeps the one of its own it was given.
|
|
10804
|
-
const silent = freshTurnRouting();
|
|
10805
|
-
const logical = this.turnRouting.get(agent.name)?.logical;
|
|
10806
|
-
if (logical) silent.logical = logical;
|
|
10807
|
-
this.turnRouting.set(agent.name, silent);
|
|
10808
|
-
}
|
|
10809
|
-
// This start's own logical turn: a failure before its stream exists
|
|
10810
|
-
// settles it, and no other start's.
|
|
10811
|
-
const startLogical = this.turnRouting.get(agent.name)?.logical;
|
|
11920
|
+
// A silent start, or a disabled agent's, has no locus to show, whatever a
|
|
11921
|
+
// channel_open in a silent turn left on its object.
|
|
11922
|
+
const turnLocus = turnProseRouting === 'disabled' ? undefined : routing.locus;
|
|
10812
11923
|
|
|
10813
11924
|
this.touchEphemeralRun(agent.name, true);
|
|
11925
|
+
const turnStartedAt = Date.now();
|
|
10814
11926
|
this.emitTrace({
|
|
10815
11927
|
type: 'inference:started',
|
|
10816
11928
|
agentName: agent.name,
|
|
10817
|
-
// The turn
|
|
10818
|
-
|
|
10819
|
-
channelId: this.turnLocusPins.get(agent.name),
|
|
11929
|
+
// The turn's locus, kept across a start that continues the turn.
|
|
11930
|
+
channelId: turnLocus,
|
|
10820
11931
|
...(trigger?.suppressProse ? { silent: true } : {}),
|
|
10821
11932
|
});
|
|
10822
11933
|
// A budget restart continues the same logical inference window. The
|
|
10823
11934
|
// predecessor keeps EventGate liveness until its successor terminates.
|
|
10824
|
-
if (!
|
|
11935
|
+
if (!resumesTurn) {
|
|
10825
11936
|
this.eventGate?.onInferenceStarted(agent.name);
|
|
10826
11937
|
}
|
|
10827
|
-
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt:
|
|
11938
|
+
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: turnStartedAt });
|
|
10828
11939
|
|
|
10829
11940
|
// Typing indicator, started at TURN START. It says "attending", which is
|
|
10830
11941
|
// true from the moment the turn is accepted — but it used to start inside
|
|
@@ -10838,7 +11949,7 @@ export class AgentFramework {
|
|
|
10838
11949
|
const earlyTypingChannel =
|
|
10839
11950
|
turnProseRouting === 'explicit'
|
|
10840
11951
|
? trigger?.channelId ?? null
|
|
10841
|
-
:
|
|
11952
|
+
: turnLocus ?? null;
|
|
10842
11953
|
if (earlyTypingChannel) this.channelRegistry?.startTyping(earlyTypingChannel);
|
|
10843
11954
|
|
|
10844
11955
|
try {
|
|
@@ -10899,8 +12010,13 @@ export class AgentFramework {
|
|
|
10899
12010
|
// and stop() may close provider admission. Do not create a provider
|
|
10900
12011
|
// stream after its generation lost ownership, or once admission closed.
|
|
10901
12012
|
if (this.agents.get(agent.name) !== agent || this.providerAdmissionClosed) {
|
|
12013
|
+
const why = this.agents.get(agent.name) !== agent ? 'the agent was replaced or removed' : 'the framework was stopping';
|
|
12014
|
+
const reason = `a turn assembled while ${why} is dropped before it sends anything`;
|
|
12015
|
+
console.error(`[turn] ${agent.name}: ${reason}`);
|
|
12016
|
+
this.endDroppedTurn(agent.name, routing);
|
|
10902
12017
|
this.channelRegistry?.stopTyping();
|
|
10903
12018
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
12019
|
+
this.abortDroppedTurn(agent.name, turnToken, reason, turnStartedAt);
|
|
10904
12020
|
return false;
|
|
10905
12021
|
}
|
|
10906
12022
|
|
|
@@ -10929,10 +12045,11 @@ export class AgentFramework {
|
|
|
10929
12045
|
// Membrane's yielding stream starts its request when it is first
|
|
10930
12046
|
// iterated, so cancelling it here, before driveStream, sends nothing.
|
|
10931
12047
|
if (this.agents.get(agent.name) !== agent || this.providerAdmissionClosed || lifecycle !== this.lifecycleGeneration) {
|
|
10932
|
-
|
|
10933
|
-
this.
|
|
10934
|
-
:
|
|
10935
|
-
|
|
12048
|
+
const why = this.agents.get(agent.name) !== agent ? 'the agent was replaced or removed'
|
|
12049
|
+
: this.providerAdmissionClosed ? 'the framework was stopping'
|
|
12050
|
+
: 'the framework was stopped and started again';
|
|
12051
|
+
const reason = `a turn compiled while ${why} is dropped before it sends anything`;
|
|
12052
|
+
console.error(`[turn] ${agent.name}: ${reason}`);
|
|
10936
12053
|
stream.cancel();
|
|
10937
12054
|
agent.cancelStream();
|
|
10938
12055
|
if (this.agents.get(agent.name) === agent) {
|
|
@@ -10942,8 +12059,10 @@ export class AgentFramework {
|
|
|
10942
12059
|
this.activeStreams.delete(agent.name);
|
|
10943
12060
|
this.pendingAssistantBlocks.delete(agent.name);
|
|
10944
12061
|
}
|
|
12062
|
+
this.endDroppedTurn(agent.name, routing);
|
|
10945
12063
|
this.channelRegistry?.stopTyping();
|
|
10946
12064
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
12065
|
+
this.abortDroppedTurn(agent.name, turnToken, reason, turnStartedAt);
|
|
10947
12066
|
return false;
|
|
10948
12067
|
}
|
|
10949
12068
|
// A conversation agent takes no provider gate, so its own compression
|
|
@@ -11040,11 +12159,13 @@ export class AgentFramework {
|
|
|
11040
12159
|
this.activeTurnTokens.delete(agent.name);
|
|
11041
12160
|
this.activeTurnTriggers.delete(agent.name);
|
|
11042
12161
|
}
|
|
12162
|
+
// This start's own logical turn: a failure before its stream exists
|
|
12163
|
+
// settles it, and no other start's.
|
|
11043
12164
|
this.settleAgent(agent.name, {
|
|
11044
12165
|
stopReason: 'exhausted',
|
|
11045
12166
|
speech: '',
|
|
11046
|
-
|
|
11047
|
-
},
|
|
12167
|
+
...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
|
|
12168
|
+
}, routing);
|
|
11048
12169
|
this.emitTrace({
|
|
11049
12170
|
type: 'inference:exhausted',
|
|
11050
12171
|
agentName: agent.name,
|
|
@@ -11092,8 +12213,9 @@ export class AgentFramework {
|
|
|
11092
12213
|
const myStreamId = agent.streamId;
|
|
11093
12214
|
// Membrane usage events are cumulative across the native/XML tool loop.
|
|
11094
12215
|
// Keep the previous cumulative sample so consumers that operate at the
|
|
11095
|
-
// physical provider-call boundary (estimator calibration and
|
|
11096
|
-
//
|
|
12216
|
+
// physical provider-call boundary (estimator calibration and the
|
|
12217
|
+
// physical-window projection) see this call only, not an ever-growing
|
|
12218
|
+
// turn total.
|
|
11097
12219
|
let previousUsage = {
|
|
11098
12220
|
inputTokens: 0,
|
|
11099
12221
|
outputTokens: 0,
|
|
@@ -11117,15 +12239,14 @@ export class AgentFramework {
|
|
|
11117
12239
|
let generationLost = false;
|
|
11118
12240
|
|
|
11119
12241
|
// ---- Present-while-acting turn state ---------------------------------
|
|
11120
|
-
// Output locus for the WHOLE logical turn: frozen
|
|
11121
|
-
// (home → addressed trigger → global default)
|
|
11122
|
-
//
|
|
11123
|
-
//
|
|
11124
|
-
// not redirect the agent's
|
|
11125
|
-
//
|
|
11126
|
-
//
|
|
11127
|
-
const resolveTurnLocus = (): string | null =>
|
|
11128
|
-
this.turnLocusPins.get(agent.name) ?? null;
|
|
12242
|
+
// Output locus for the WHOLE logical turn: frozen when the turn opened
|
|
12243
|
+
// (home → addressed trigger → global default), and moved only by a
|
|
12244
|
+
// re-pin (an addressed message, or a follow-up in a channel the turn
|
|
12245
|
+
// engaged) or the agent's own channel_open. Ambient messages and
|
|
12246
|
+
// reactions arriving at a tool boundary must not redirect the agent's
|
|
12247
|
+
// task narration (2026-07-21 Cairn misroute). The locus lives in the
|
|
12248
|
+
// turn's routing state, so every stream of the turn shares it.
|
|
12249
|
+
const resolveTurnLocus = (): string | null => streamRouting.locus ?? null;
|
|
11129
12250
|
|
|
11130
12251
|
// Ordered delivery chain for live-routed prose. Links are enqueued
|
|
11131
12252
|
// WITHOUT awaiting in the stream-event loop — an awaited network post
|
|
@@ -11137,11 +12258,13 @@ export class AgentFramework {
|
|
|
11137
12258
|
//
|
|
11138
12259
|
// Every stream queues its deliveries behind the agent's latest link, so
|
|
11139
12260
|
// the agent's posts go out in the order they were written, across a
|
|
11140
|
-
// turn's streams
|
|
11141
|
-
//
|
|
11142
|
-
//
|
|
11143
|
-
//
|
|
11144
|
-
|
|
12261
|
+
// turn's streams and across turns. So an earlier stream's deliveries,
|
|
12262
|
+
// and the sticky destination or hold they set, come before this stream's
|
|
12263
|
+
// own. A stream queues only while it is the agent's current stream: the
|
|
12264
|
+
// event loop stops at the first event after a replacement. A turn that a
|
|
12265
|
+
// tool ends (skip_reply) settles before its queued prose has posted; that
|
|
12266
|
+
// prose keeps its own turn's routing state (turnRouting), and the next
|
|
12267
|
+
// turn's posts wait behind it.
|
|
11145
12268
|
let turnSpeechChain: Promise<void> = this.proseSpeechChains.get(agent.name) ?? Promise.resolve();
|
|
11146
12269
|
this.proseSpeechChains.set(agent.name, turnSpeechChain);
|
|
11147
12270
|
const chainSpeech = (link: () => Promise<void>, failure: string): void => {
|
|
@@ -11233,14 +12356,14 @@ export class AgentFramework {
|
|
|
11233
12356
|
? new ProseStreamRouter({
|
|
11234
12357
|
mode: turnProseRouting === 'explicit' ? 'explicit' : turnProseRouting === 'hybrid' ? 'hybrid' : 'locus',
|
|
11235
12358
|
// Explicit text has no destination until a `>>` prefix names one,
|
|
11236
|
-
// so a fresh turn's stream starts suppressed.
|
|
11237
|
-
// turn (a
|
|
11238
|
-
// destination the turn's delivered rounds
|
|
11239
|
-
// round's prefixes don't count, and the
|
|
11240
|
-
// deliveries needn't have run. The trigger channel
|
|
11241
|
-
// explicit mode's typing indicator shows.
|
|
12359
|
+
// so a fresh turn's stream starts suppressed. A later stream of the
|
|
12360
|
+
// turn (a resume, a retry, a re-inference, a capacity-deferred
|
|
12361
|
+
// compile) starts at the destination the turn's delivered rounds
|
|
12362
|
+
// have set: a private round's prefixes don't count, and the
|
|
12363
|
+
// gateway's queued deliveries needn't have run. The trigger channel
|
|
12364
|
+
// is only where explicit mode's typing indicator shows.
|
|
11242
12365
|
initialTarget: turnProseRouting === 'explicit'
|
|
11243
|
-
?
|
|
12366
|
+
? streamRouting.destination
|
|
11244
12367
|
: typingChannel,
|
|
11245
12368
|
resolve: (spec) => {
|
|
11246
12369
|
const r = this.channelRegistry!.resolveProseTarget(spec);
|
|
@@ -11820,7 +12943,7 @@ export class AgentFramework {
|
|
|
11820
12943
|
.filter((block: ContentBlock): block is ContentBlock & { type: 'text' } => block.type === 'text')
|
|
11821
12944
|
.map((block) => block.text)
|
|
11822
12945
|
.join('\n'),
|
|
11823
|
-
}, streamRouting
|
|
12946
|
+
}, streamRouting);
|
|
11824
12947
|
|
|
11825
12948
|
this.emitTrace({
|
|
11826
12949
|
type: 'inference:completed',
|
|
@@ -12121,13 +13244,11 @@ export class AgentFramework {
|
|
|
12121
13244
|
this.recordProseSuppression(streamRouting, segments.length);
|
|
12122
13245
|
}
|
|
12123
13246
|
} else {
|
|
12124
|
-
//
|
|
12125
|
-
//
|
|
12126
|
-
//
|
|
12127
|
-
//
|
|
12128
|
-
//
|
|
12129
|
-
// between segments — and a turn that narrated live into one
|
|
12130
|
-
// channel must not land its postscript in another.
|
|
13247
|
+
// The turn's own locus, read from this stream's routing
|
|
13248
|
+
// state. This dispatch runs AFTER the agent is idle (see PR
|
|
13249
|
+
// #32 note below), so a next turn may already have started
|
|
13250
|
+
// with routing of its own, and a turn that narrated live into
|
|
13251
|
+
// one channel must not land its postscript in another.
|
|
12131
13252
|
const locus = resolveTurnLocus();
|
|
12132
13253
|
console.error(
|
|
12133
13254
|
`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} ${liveProseRouting ? 'trailing ' : ''}prose segment(s) -> ${locus ?? '(default)'}`,
|
|
@@ -12214,7 +13335,7 @@ export class AgentFramework {
|
|
|
12214
13335
|
stopReason: 'exhausted',
|
|
12215
13336
|
speech: '',
|
|
12216
13337
|
error: 'Turn abandoned by operator quiesce',
|
|
12217
|
-
}, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting
|
|
13338
|
+
}, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting : undefined);
|
|
12218
13339
|
this.emitTrace({
|
|
12219
13340
|
type: 'inference:aborted',
|
|
12220
13341
|
agentName: agent.name,
|
|
@@ -12251,8 +13372,8 @@ export class AgentFramework {
|
|
|
12251
13372
|
this.settleAgent(agent.name, {
|
|
12252
13373
|
stopReason: 'exhausted',
|
|
12253
13374
|
speech: '',
|
|
12254
|
-
|
|
12255
|
-
}, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting
|
|
13375
|
+
...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
|
|
13376
|
+
}, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting : undefined);
|
|
12256
13377
|
this.emitTrace({
|
|
12257
13378
|
type: 'inference:exhausted',
|
|
12258
13379
|
agentName: agent.name,
|
|
@@ -12309,7 +13430,7 @@ export class AgentFramework {
|
|
|
12309
13430
|
stopReason: 'exhausted',
|
|
12310
13431
|
speech: '',
|
|
12311
13432
|
error: 'Turn abandoned by operator quiesce',
|
|
12312
|
-
}, streamRouting
|
|
13433
|
+
}, streamRouting);
|
|
12313
13434
|
}
|
|
12314
13435
|
this.emitTrace({
|
|
12315
13436
|
type: 'inference:aborted',
|
|
@@ -12353,7 +13474,7 @@ export class AgentFramework {
|
|
|
12353
13474
|
stopReason: 'exhausted',
|
|
12354
13475
|
speech: '',
|
|
12355
13476
|
error: `Stream aborted: ${reason}`,
|
|
12356
|
-
}, streamRouting
|
|
13477
|
+
}, streamRouting);
|
|
12357
13478
|
this.emitTrace({
|
|
12358
13479
|
type: 'inference:exhausted',
|
|
12359
13480
|
agentName: agent.name,
|
|
@@ -12380,12 +13501,9 @@ export class AgentFramework {
|
|
|
12380
13501
|
}
|
|
12381
13502
|
|
|
12382
13503
|
case 'usage': {
|
|
13504
|
+
// The stream's accumulated fresh input, which is what the
|
|
13505
|
+
// maxStreamTokens budget checks.
|
|
12383
13506
|
agent.lastStreamInputTokens = event.usage.inputTokens;
|
|
12384
|
-
agent.lastStreamRealInputTokens =
|
|
12385
|
-
(event.usage.inputTokens ?? 0) +
|
|
12386
|
-
(event.usage.cacheCreationTokens ?? 0) +
|
|
12387
|
-
(event.usage.cacheReadTokens ?? 0);
|
|
12388
|
-
agent.lastStreamOutputTokens = event.usage.outputTokens ?? 0;
|
|
12389
13507
|
|
|
12390
13508
|
// Closed-loop estimator calibration (2026-07-12). Sample the REAL
|
|
12391
13509
|
// prefix size of THIS API call (fresh + cache write + cache read)
|
|
@@ -12416,6 +13534,34 @@ export class AgentFramework {
|
|
|
12416
13534
|
};
|
|
12417
13535
|
previousUsage = cumulativeUsage;
|
|
12418
13536
|
outputTokensSinceStamp += perCallUsage.outputTokens;
|
|
13537
|
+
const realTotal =
|
|
13538
|
+
perCallUsage.inputTokens +
|
|
13539
|
+
perCallUsage.cacheCreationTokens +
|
|
13540
|
+
perCallUsage.cacheReadTokens;
|
|
13541
|
+
// The physical-window projection adds the next two fields as the
|
|
13542
|
+
// prior round's real input and output, so they take this call's
|
|
13543
|
+
// share, never the event's own figures: the event is the stream's
|
|
13544
|
+
// running total, which after k calls holds k prompts and would
|
|
13545
|
+
// restart a stream whose next request still fits. Every real call
|
|
13546
|
+
// has a prompt, and a call that ends at a tool boundary wrote
|
|
13547
|
+
// output (its tool_use), so a 0 in either is a count the adapter
|
|
13548
|
+
// left at 0, not an empty call. A call that reported no prompt is
|
|
13549
|
+
// unreported: the fields stay the last call that reported one,
|
|
13550
|
+
// which is what the provider billed, and its own output joins the
|
|
13551
|
+
// growth since that call (lastStreamUnreportedTokens). An output
|
|
13552
|
+
// of 0 counts as maxTokens, the bound on it, in either case, so
|
|
13553
|
+
// the projection errs toward a restart. That holds once a call on
|
|
13554
|
+
// the stream has reported a prompt: the projection runs only then.
|
|
13555
|
+
const callOutputTokens = perCallUsage.outputTokens > 0
|
|
13556
|
+
? perCallUsage.outputTokens
|
|
13557
|
+
: agent.maxTokens;
|
|
13558
|
+
if (realTotal > 0) {
|
|
13559
|
+
agent.lastStreamRealInputTokens = realTotal;
|
|
13560
|
+
agent.lastStreamOutputTokens = callOutputTokens;
|
|
13561
|
+
agent.lastStreamUnreportedTokens = 0;
|
|
13562
|
+
} else {
|
|
13563
|
+
agent.lastStreamUnreportedTokens += callOutputTokens;
|
|
13564
|
+
}
|
|
12419
13565
|
const strat = (agent as unknown as {
|
|
12420
13566
|
getContextManager?: () => { getStrategy?: () => unknown };
|
|
12421
13567
|
}).getContextManager?.()?.getStrategy?.() as
|
|
@@ -12430,10 +13576,6 @@ export class AgentFramework {
|
|
|
12430
13576
|
}
|
|
12431
13577
|
| undefined;
|
|
12432
13578
|
try {
|
|
12433
|
-
const realTotal =
|
|
12434
|
-
perCallUsage.inputTokens +
|
|
12435
|
-
perCallUsage.cacheCreationTokens +
|
|
12436
|
-
perCallUsage.cacheReadTokens;
|
|
12437
13579
|
strat?.reportRealInputTokens?.(realTotal);
|
|
12438
13580
|
} catch { /* calibration is best-effort */ }
|
|
12439
13581
|
|
|
@@ -12512,8 +13654,8 @@ export class AgentFramework {
|
|
|
12512
13654
|
this.settleAgent(agent.name, {
|
|
12513
13655
|
stopReason: 'exhausted',
|
|
12514
13656
|
speech: '',
|
|
12515
|
-
|
|
12516
|
-
}, streamRouting
|
|
13657
|
+
...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
|
|
13658
|
+
}, streamRouting);
|
|
12517
13659
|
this.emitTrace({
|
|
12518
13660
|
type: 'inference:exhausted',
|
|
12519
13661
|
agentName: agent.name,
|
|
@@ -12727,6 +13869,16 @@ export class AgentFramework {
|
|
|
12727
13869
|
agentName: string,
|
|
12728
13870
|
toolName: string,
|
|
12729
13871
|
input: Record<string, unknown>,
|
|
13872
|
+
): Promise<{ toolUseId: string; result: ToolResult }> {
|
|
13873
|
+
this.refuseOperatorAfterStop('puppet');
|
|
13874
|
+
return this.trackOperatorOperation('puppet:tool-call', () => this.runPuppetToolCall(agentName, toolName, input));
|
|
13875
|
+
}
|
|
13876
|
+
|
|
13877
|
+
/** puppetToolCall's work, run where stop() can wait for it. */
|
|
13878
|
+
private async runPuppetToolCall(
|
|
13879
|
+
agentName: string,
|
|
13880
|
+
toolName: string,
|
|
13881
|
+
input: Record<string, unknown>,
|
|
12730
13882
|
): Promise<{ toolUseId: string; result: ToolResult }> {
|
|
12731
13883
|
const agent = this.agents.get(agentName);
|
|
12732
13884
|
if (!agent) throw new Error(`Unknown agent: ${agentName}`);
|
|
@@ -14427,6 +15579,12 @@ export class AgentFramework {
|
|
|
14427
15579
|
opts?.deferredWriteId ? withDeferredWriteId(metadata, opts.deferredWriteId) : metadata,
|
|
14428
15580
|
);
|
|
14429
15581
|
if (opts?.placement) { opts.placement.agent = agent.name; opts.placement.messageId = stored; }
|
|
15582
|
+
// Stored while none of the agent's turns is alive, or assembled for the
|
|
15583
|
+
// start now beginning: no tool boundary will weigh it.
|
|
15584
|
+
if (!this.activeTurnTokens.has(agent.name) || opts?.bypassDeferralFor === agent.name) {
|
|
15585
|
+
this.noteArrival(agent.name, metadata, stored);
|
|
15586
|
+
}
|
|
15587
|
+
this.noteForOtherResidents(agent, metadata, stored);
|
|
14430
15588
|
return stored;
|
|
14431
15589
|
}
|
|
14432
15590
|
|
|
@@ -14478,7 +15636,7 @@ export class AgentFramework {
|
|
|
14478
15636
|
if (!agent) {
|
|
14479
15637
|
throw new Error('No agents configured');
|
|
14480
15638
|
}
|
|
14481
|
-
agent.getContextManager()
|
|
15639
|
+
removeStoredMessage(agent.getContextManager(), id);
|
|
14482
15640
|
}
|
|
14483
15641
|
|
|
14484
15642
|
private getMessage(id: MessageId): StoredMessage | null {
|
|
@@ -14952,34 +16110,58 @@ export class AgentFramework {
|
|
|
14952
16110
|
// this turn — surface it on the ops-alert pipeline (ops:alert trace +
|
|
14953
16111
|
// webhook → fleet-watch) immediately, not only at the hard-down streak.
|
|
14954
16112
|
// The per-(agent,kind) cooldown throttles repeats; failures.log already
|
|
14955
|
-
// has the per-exhaustion record from logFailure above.
|
|
14956
|
-
|
|
14957
|
-
|
|
14958
|
-
|
|
14959
|
-
|
|
14960
|
-
|
|
14961
|
-
|
|
14962
|
-
|
|
14963
|
-
|
|
14964
|
-
|
|
14965
|
-
|
|
14966
|
-
if (agent && overBudget && !this.overBudgetDrainInFlight.has(agentName)) {
|
|
16113
|
+
// has the per-exhaustion record from logFailure above. It says whether
|
|
16114
|
+
// the drain breaker kicks, decided where the kick is. What ends drains is
|
|
16115
|
+
// stop(), not a framework that was never started: one run with
|
|
16116
|
+
// runUntilIdle() has no maintenance timer, so a drain is its recovery.
|
|
16117
|
+
const drainState = !overBudget ? undefined
|
|
16118
|
+
: !agent ? 'unavailable (no agent handle)'
|
|
16119
|
+
: this.providerAdmissionClosed ? 'not kicked: the framework is stopping'
|
|
16120
|
+
: this.surgeryHold ? 'not kicked: a surgery holds the store'
|
|
16121
|
+
: this.overBudgetDrainInFlight.has(agentName) ? 'already draining'
|
|
16122
|
+
: 'kicking';
|
|
16123
|
+
if (agent && drainState === 'kicking') {
|
|
14967
16124
|
this.overBudgetDrainInFlight.add(agentName);
|
|
14968
|
-
|
|
16125
|
+
// Like a maintenance pass: none starts once stop() has begun, it starts
|
|
16126
|
+
// no tick while a surgery holds the store, and a surgery, a
|
|
16127
|
+
// switchBranch or stop() waits for the tick in flight. It joins the
|
|
16128
|
+
// drains they wait for before the alert is raised and before its body
|
|
16129
|
+
// runs, so the alert's listeners find it: one that begins stop() or a
|
|
16130
|
+
// surgery waits for it, and its body then ticks none and says why. So
|
|
16131
|
+
// does a stop() made from inside its first tick, before that tick's
|
|
16132
|
+
// first await.
|
|
16133
|
+
const drain = Promise.resolve().then(async () => {
|
|
14969
16134
|
let ticks = 0;
|
|
14970
16135
|
try {
|
|
14971
16136
|
const cm = agent.getContextManager();
|
|
14972
|
-
while (ticks < 8) {
|
|
16137
|
+
while (ticks < 8 && !this.providerAdmissionClosed && !this.surgeryHold) {
|
|
14973
16138
|
await cm.tick();
|
|
14974
16139
|
ticks++;
|
|
14975
16140
|
}
|
|
14976
|
-
|
|
16141
|
+
const stopped = ticks >= 8 ? '' : this.providerAdmissionClosed ? ', stopped as the framework stops' : ', stopped while a surgery holds the store';
|
|
16142
|
+
console.error(`[inference-failed] drain kicked for ${agentName} (OverBudget breaker, ${ticks} ticks${stopped})`);
|
|
14977
16143
|
} catch (err) {
|
|
14978
|
-
|
|
16144
|
+
if (err instanceof MaintenanceWithdrawnError) {
|
|
16145
|
+
console.error(`[inference-failed] drain kicked for ${agentName} (OverBudget breaker, ${ticks} ticks, stopped: ${err.message})`);
|
|
16146
|
+
} else {
|
|
16147
|
+
console.error(`[inference-failed] drain kick failed for ${agentName} after ${ticks} ticks: ${describeFailureWithFrames(err)}`);
|
|
16148
|
+
}
|
|
14979
16149
|
} finally {
|
|
14980
16150
|
this.overBudgetDrainInFlight.delete(agentName);
|
|
14981
16151
|
}
|
|
14982
|
-
})
|
|
16152
|
+
});
|
|
16153
|
+
this.overBudgetDrains.add(drain);
|
|
16154
|
+
void drain.finally(() => { this.overBudgetDrains.delete(drain); });
|
|
16155
|
+
}
|
|
16156
|
+
if (overBudget) {
|
|
16157
|
+
this.opsAlert(
|
|
16158
|
+
'context-refusal',
|
|
16159
|
+
agentName,
|
|
16160
|
+
`compile refused (${errorType ?? 'unclassified'}): ${boundFailureText(wholeReason, FAILURE_SUMMARY_MAX_CHARS)} — ` +
|
|
16161
|
+
`drain breaker ${drainState}; ` +
|
|
16162
|
+
`if this persists, raise contextBudgetTokens or overBudgetGraceRatio.`,
|
|
16163
|
+
{ skipLog: true },
|
|
16164
|
+
);
|
|
14983
16165
|
}
|
|
14984
16166
|
|
|
14985
16167
|
// (3) Hard-down escalation on repeated identical failure.
|
|
@@ -15163,13 +16345,22 @@ export class AgentFramework {
|
|
|
15163
16345
|
homeChannelResolver: (agentName) =>
|
|
15164
16346
|
this.conversationAgentHomes.get(agentName)
|
|
15165
16347
|
?? this.conversationRouter?.channelForAgent(agentName),
|
|
15166
|
-
//
|
|
15167
|
-
//
|
|
15168
|
-
//
|
|
15169
|
-
//
|
|
15170
|
-
//
|
|
15171
|
-
//
|
|
15172
|
-
|
|
16348
|
+
// A single TRUNK agent's channel for its CURRENT turn (item-3 redux):
|
|
16349
|
+
// its locus once the turn has one, a re-pin's or a channel_open's
|
|
16350
|
+
// included, else the channel that triggered the turn. connectome-host
|
|
16351
|
+
// runs every agent as a trunk (it never exposes conversation forks), so
|
|
16352
|
+
// without this a reply falls back to the process-global
|
|
16353
|
+
// most-recent-inbound locus and a concurrent message in another
|
|
16354
|
+
// channel hijacks it. An opening start resolves the turn's locus from
|
|
16355
|
+
// it (the trigger channel then), and the channel context names it, so
|
|
16356
|
+
// a later start of the turn is told where its unprefixed speech lands
|
|
16357
|
+
// unless a hybrid `>>>` destination takes it. Empty for a heartbeat /
|
|
16358
|
+
// no-trigger turn until its locus is resolved, so that resolution
|
|
16359
|
+
// takes the global fallback; the turn then keeps that channel.
|
|
16360
|
+
activeChannelResolver: (agentName) => {
|
|
16361
|
+
const routing = this.turnRouting.get(agentName);
|
|
16362
|
+
return routing?.locus ?? routing?.triggerChannel;
|
|
16363
|
+
},
|
|
15173
16364
|
// A text-only turn whose speech couldn't be delivered must not vanish
|
|
15174
16365
|
// silently: record a `[discord-send-failed]` marker in chronicle so the
|
|
15175
16366
|
// agent sees, on her next turn, that her reply never reached the human.
|
|
@@ -15265,15 +16456,17 @@ export class AgentFramework {
|
|
|
15265
16456
|
}
|
|
15266
16457
|
// Fail-open: log and continue with remaining servers
|
|
15267
16458
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
15268
|
-
console.error(`Failed to connect MCPL server "${config.id}"
|
|
16459
|
+
console.error(`Failed to connect MCPL server "${config.id}": ${err.message}${withStderr(err)}`);
|
|
15269
16460
|
// Reaching this catch means no stub was created, so nothing will
|
|
15270
16461
|
// retry — except in the edge case where addServer succeeded (with a
|
|
15271
16462
|
// reconnecting stub) and a later setup step threw; config.reconnect
|
|
15272
16463
|
// is the best cheap approximation of that.
|
|
16464
|
+
const stderrTail = stderrTailOf(err);
|
|
15273
16465
|
this.emitTrace({
|
|
15274
16466
|
type: 'mcpl:server-connect-failed',
|
|
15275
16467
|
serverId: config.id,
|
|
15276
16468
|
error: err.message,
|
|
16469
|
+
...(stderrTail === undefined ? {} : { stderrTail }),
|
|
15277
16470
|
attempt: 0,
|
|
15278
16471
|
willRetry: config.reconnect === true,
|
|
15279
16472
|
});
|
|
@@ -15300,9 +16493,20 @@ export class AgentFramework {
|
|
|
15300
16493
|
/**
|
|
15301
16494
|
* Reconcile the durable ledger with Chronicle, then deliver every server's
|
|
15302
16495
|
* work. A drain still running is waited for and then followed by a fresh
|
|
15303
|
-
* pass (drainForGeneration).
|
|
16496
|
+
* pass (drainForGeneration). Like the other operator entry points, it's
|
|
16497
|
+
* refused once stop() has begun, and stop() waits for one already running.
|
|
16498
|
+
* The framework's own branch moves start their delivery without awaiting
|
|
16499
|
+
* it (startDiscordAwarenessDelivery).
|
|
15304
16500
|
*/
|
|
15305
|
-
async syncDiscordAwarenessMarkers(
|
|
16501
|
+
async syncDiscordAwarenessMarkers(onlyServerId?: string): Promise<void> {
|
|
16502
|
+
this.refuseOperatorAfterStop('awareness sync');
|
|
16503
|
+
return this.trackOperatorOperation('awareness:sync', () => this.runDiscordAwarenessSync(onlyServerId));
|
|
16504
|
+
}
|
|
16505
|
+
|
|
16506
|
+
/** The sync's work. It installs the MCPL data-plane gate before its first
|
|
16507
|
+
* await, so a caller that starts it while holding the store puts the gate
|
|
16508
|
+
* up before releasing the store. */
|
|
16509
|
+
private async runDiscordAwarenessSync(_onlyServerId?: string): Promise<void> {
|
|
15306
16510
|
if (!this.discordAwarenessOutbox) return;
|
|
15307
16511
|
// A targeted retry may discover pending work for other Discord servers
|
|
15308
16512
|
// during reconciliation. Safety is global, so one generation accounts for
|
|
@@ -15342,8 +16546,7 @@ export class AgentFramework {
|
|
|
15342
16546
|
`${interval.fromId}..${interval.toId} is only partially present`,
|
|
15343
16547
|
);
|
|
15344
16548
|
}
|
|
15345
|
-
|
|
15346
|
-
else cm.removeMessages(from.id, to.id);
|
|
16549
|
+
removeStoredMessages(cm, from, to);
|
|
15347
16550
|
}
|
|
15348
16551
|
this.discordAwarenessOutbox.activate(batch.id);
|
|
15349
16552
|
console.error(`[discord-awareness] resumed suppression batch ${batch.id}`);
|
|
@@ -15658,6 +16861,15 @@ export class AgentFramework {
|
|
|
15658
16861
|
error: `Discord awareness accounting unhealthy: ${err.message}`,
|
|
15659
16862
|
});
|
|
15660
16863
|
}
|
|
16864
|
+
// A stopping host closes these connections itself, so recycling them now
|
|
16865
|
+
// would start reconnects only for stop() to close them again. Release the
|
|
16866
|
+
// barrier instead, as a completed one is released: nothing waits on a
|
|
16867
|
+
// rejected barrier, and unless the host is quiesced, what the pause held
|
|
16868
|
+
// back reaches the listeners' stopping refusal.
|
|
16869
|
+
if (this.queue?.isClosed) {
|
|
16870
|
+
this.completeMcplDataPlaneGate(barrier);
|
|
16871
|
+
return;
|
|
16872
|
+
}
|
|
15661
16873
|
await Promise.all(connections.map(async (connection) => {
|
|
15662
16874
|
await connection.reconnectAfterFailure().catch((closeError) => {
|
|
15663
16875
|
console.error(
|
|
@@ -16191,6 +17403,13 @@ export class AgentFramework {
|
|
|
16191
17403
|
*/
|
|
16192
17404
|
async connectMcplServer(
|
|
16193
17405
|
config: import('./mcpl/types.js').McplServerConfig,
|
|
17406
|
+
): Promise<void> {
|
|
17407
|
+
this.refuseOperatorAfterStop('connect MCPL server');
|
|
17408
|
+
return this.trackOperatorOperation('mcpl:connect', () => this.runConnectMcplServer(config));
|
|
17409
|
+
}
|
|
17410
|
+
|
|
17411
|
+
private async runConnectMcplServer(
|
|
17412
|
+
config: import('./mcpl/types.js').McplServerConfig,
|
|
16194
17413
|
): Promise<void> {
|
|
16195
17414
|
// Lazily bring up the MCPL subsystem — a framework that started with zero
|
|
16196
17415
|
// configured servers can still deploy its first one at runtime.
|
|
@@ -16227,6 +17446,11 @@ export class AgentFramework {
|
|
|
16227
17446
|
* No-op-ish if the server is not connected (still clears routing state).
|
|
16228
17447
|
*/
|
|
16229
17448
|
async disconnectMcplServer(id: string): Promise<void> {
|
|
17449
|
+
this.refuseOperatorAfterStop('disconnect MCPL server');
|
|
17450
|
+
return this.trackOperatorOperation('mcpl:disconnect', () => this.runDisconnectMcplServer(id));
|
|
17451
|
+
}
|
|
17452
|
+
|
|
17453
|
+
private async runDisconnectMcplServer(id: string): Promise<void> {
|
|
16230
17454
|
if (!this.mcplServerRegistry) {
|
|
16231
17455
|
throw new Error('MCPL subsystem is not initialized');
|
|
16232
17456
|
}
|
|
@@ -16260,6 +17484,14 @@ export class AgentFramework {
|
|
|
16260
17484
|
async restartMcplServer(
|
|
16261
17485
|
id: string,
|
|
16262
17486
|
newConfig?: import('./mcpl/types.js').McplServerConfig,
|
|
17487
|
+
): Promise<void> {
|
|
17488
|
+
this.refuseOperatorAfterStop('restart MCPL server');
|
|
17489
|
+
return this.trackOperatorOperation('mcpl:restart', () => this.runRestartMcplServer(id, newConfig));
|
|
17490
|
+
}
|
|
17491
|
+
|
|
17492
|
+
private async runRestartMcplServer(
|
|
17493
|
+
id: string,
|
|
17494
|
+
newConfig?: import('./mcpl/types.js').McplServerConfig,
|
|
16263
17495
|
): Promise<void> {
|
|
16264
17496
|
const config = newConfig ?? this.mcplServerConfigs.get(id);
|
|
16265
17497
|
if (!config) {
|
|
@@ -16271,8 +17503,8 @@ export class AgentFramework {
|
|
|
16271
17503
|
// refuses the restart and leaves the running child up, whatever the
|
|
16272
17504
|
// server's baseline or transport. (No outbox configured: nothing to read.)
|
|
16273
17505
|
this.getPlacedReactionBaseline();
|
|
16274
|
-
await this.
|
|
16275
|
-
await this.
|
|
17506
|
+
await this.runDisconnectMcplServer(id);
|
|
17507
|
+
await this.runConnectMcplServer(config);
|
|
16276
17508
|
}
|
|
16277
17509
|
|
|
16278
17510
|
/**
|
|
@@ -16569,8 +17801,8 @@ export class AgentFramework {
|
|
|
16569
17801
|
try {
|
|
16570
17802
|
if (this.discordAwarenessBarrier) await this.awaitDiscordAwarenessBarriers();
|
|
16571
17803
|
// A stopping host runs no host command: refused like the requests
|
|
16572
|
-
// above, before its handler runs.
|
|
16573
|
-
if (this.
|
|
17804
|
+
// above, before its handler runs, from the moment stop() has begun.
|
|
17805
|
+
if (this.stopHasBegun()) {
|
|
16574
17806
|
this.refuseServerItem('host/command', connection.id, answer, { kind: 'refused' });
|
|
16575
17807
|
return;
|
|
16576
17808
|
}
|
|
@@ -16656,20 +17888,22 @@ export class AgentFramework {
|
|
|
16656
17888
|
// Surface connect/reconnect failures. Before these traces existed the
|
|
16657
17889
|
// only receipt was a console.error on the host's own stderr — invisible
|
|
16658
17890
|
// unless someone ssh'd in and read the process log.
|
|
16659
|
-
connection.on('connect-failed', (params: { error: string; attempt: number }) => {
|
|
17891
|
+
connection.on('connect-failed', (params: { error: string; stderrTail?: string; attempt: number }) => {
|
|
16660
17892
|
this.emitTrace({
|
|
16661
17893
|
type: 'mcpl:server-connect-failed',
|
|
16662
17894
|
serverId: connection.id,
|
|
16663
17895
|
error: params.error,
|
|
17896
|
+
...(params.stderrTail === undefined ? {} : { stderrTail: params.stderrTail }),
|
|
16664
17897
|
attempt: params.attempt,
|
|
16665
17898
|
willRetry: connection.willReconnect,
|
|
16666
17899
|
});
|
|
16667
17900
|
});
|
|
16668
|
-
connection.on('reconnect-failed', (params: { error: string; attempt: number }) => {
|
|
17901
|
+
connection.on('reconnect-failed', (params: { error: string; stderrTail?: string; attempt: number }) => {
|
|
16669
17902
|
this.emitTrace({
|
|
16670
17903
|
type: 'mcpl:server-connect-failed',
|
|
16671
17904
|
serverId: connection.id,
|
|
16672
17905
|
error: params.error,
|
|
17906
|
+
...(params.stderrTail === undefined ? {} : { stderrTail: params.stderrTail }),
|
|
16673
17907
|
attempt: params.attempt,
|
|
16674
17908
|
willRetry: connection.willReconnect,
|
|
16675
17909
|
});
|
|
@@ -16681,6 +17915,10 @@ export class AgentFramework {
|
|
|
16681
17915
|
this.opsAlert(
|
|
16682
17916
|
'mcpl-down',
|
|
16683
17917
|
connection.id,
|
|
17918
|
+
// Not the server's stderr (params.stderrTail): the webhook posts
|
|
17919
|
+
// outside the process, and a server's stderr can hold a credential
|
|
17920
|
+
// its configuration gives it, such as a token, which nothing here
|
|
17921
|
+
// redacts.
|
|
16684
17922
|
`MCPL server unreachable (attempt ${params.attempt}): ${params.error}`,
|
|
16685
17923
|
);
|
|
16686
17924
|
}
|
|
@@ -16726,7 +17964,7 @@ export class AgentFramework {
|
|
|
16726
17964
|
// on it would erase every checkpoint tree on an ordinary host restart while
|
|
16727
17965
|
// a SIGKILL preserves them. Permanent removal is owned solely by
|
|
16728
17966
|
// disconnectMcplServer, which deletes the trees explicitly.
|
|
16729
|
-
connection.on('close', (code?: number | null, signal?: string | null) => {
|
|
17967
|
+
connection.on('close', (code?: number | null, signal?: string | null, info?: McplConnectionCloseInfo) => {
|
|
16730
17968
|
this.featureSetManager?.removeServer(connection.id);
|
|
16731
17969
|
// RFC-008: a closed provider's class hints are stale — on reconnect it
|
|
16732
17970
|
// may class its tools differently. Until the next tools/list its tools
|
|
@@ -16737,6 +17975,9 @@ export class AgentFramework {
|
|
|
16737
17975
|
serverId: connection.id,
|
|
16738
17976
|
code: code ?? null,
|
|
16739
17977
|
signal: signal ?? null,
|
|
17978
|
+
// The transport's own words only: a WebSocket server's close text
|
|
17979
|
+
// stays off the trace.
|
|
17980
|
+
reason: info?.reason !== undefined && !info.reasonFromPeer ? boundFailureText(info.reason) : null,
|
|
16740
17981
|
willReconnect: connection.willReconnect,
|
|
16741
17982
|
});
|
|
16742
17983
|
this.emitTrace({ type: 'module:removed', moduleName: `mcpl:${connection.id}` });
|
|
@@ -16989,6 +18230,9 @@ export class AgentFramework {
|
|
|
16989
18230
|
this.emitTrace({ type: 'tool:started', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, input: call.input });
|
|
16990
18231
|
const startTime = Date.now();
|
|
16991
18232
|
const args = (call.input && typeof call.input === 'object') ? call.input as Record<string, unknown> : {};
|
|
18233
|
+
// The turn that sends: a send's engaged channel is recorded on it, even
|
|
18234
|
+
// when the send's result, or the open that follows, comes after the turn.
|
|
18235
|
+
const sendingTurn = SEND_ENGAGEMENT_TOOLS.has(toolName) ? this.turnRouting.get(agentName) : undefined;
|
|
16992
18236
|
|
|
16993
18237
|
// Build state params for stateful tools (Step 8).
|
|
16994
18238
|
// A tools/list attribution selects that exact stateful set. Untagged tools
|
|
@@ -17088,12 +18332,7 @@ export class AgentFramework {
|
|
|
17088
18332
|
// the microtask queue, i.e. before the tool-result event
|
|
17089
18333
|
// that evaluates injections.
|
|
17090
18334
|
if (status !== 'unknown-channel' && status !== 'ambiguous') {
|
|
17091
|
-
|
|
17092
|
-
if (!engaged) {
|
|
17093
|
-
engaged = new Set();
|
|
17094
|
-
this.turnEngagedChannels.set(agentName, engaged);
|
|
17095
|
-
}
|
|
17096
|
-
engaged.add(channelId ?? target);
|
|
18335
|
+
sendingTurn?.engaged.add(channelId ?? target);
|
|
17097
18336
|
}
|
|
17098
18337
|
if (status === 'opened') {
|
|
17099
18338
|
console.error(
|
|
@@ -17211,6 +18450,15 @@ export class AgentFramework {
|
|
|
17211
18450
|
|
|
17212
18451
|
this.emitTrace({ type: 'tool:started', module: 'channels', tool: call.name, callId: call.id, input: call.input });
|
|
17213
18452
|
const startTime = Date.now();
|
|
18453
|
+
// The turn that called: a channel_open's result records its trigger
|
|
18454
|
+
// channel there, however late it arrives, and moves its locus while the
|
|
18455
|
+
// turn is still open, even once another start's routing is the agent's
|
|
18456
|
+
// live one. A silent turn's open moves no locus: no silent start or
|
|
18457
|
+
// resume registers its routing state as the open turn (openLogical), and
|
|
18458
|
+
// a non-silent resume that does, a budget restart batched with an
|
|
18459
|
+
// ordinary wake, starts only once its over-budget boundary has every
|
|
18460
|
+
// result of the round.
|
|
18461
|
+
const callingTurn = this.routingOf(agentName);
|
|
17214
18462
|
|
|
17215
18463
|
this.channelRegistry!.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName })
|
|
17216
18464
|
.then((result) => {
|
|
@@ -17219,10 +18467,10 @@ export class AgentFramework {
|
|
|
17219
18467
|
// A successful channel_open plants the agent's feet in the opened
|
|
17220
18468
|
// channel. The agent's own deliberate open is the strongest "my next
|
|
17221
18469
|
// words go here" signal in the system — stronger than any injection —
|
|
17222
|
-
// so it MOVES the current turn's
|
|
17223
|
-
//
|
|
17224
|
-
//
|
|
17225
|
-
//
|
|
18470
|
+
// so it MOVES the current turn's locus (TurnRouting.locus), not just
|
|
18471
|
+
// its trigger channel. (The original 2026-07-21 Aria fix set only
|
|
18472
|
+
// the trigger channel, which resolveLocus reads at the NEXT turn's
|
|
18473
|
+
// freeze — the turn-frozen refactor had silently
|
|
17226
18474
|
// regressed the "reply right after opening" case; found while
|
|
17227
18475
|
// mapping the routing logic 2026-07-31, antra-ratified fix.) The
|
|
17228
18476
|
// announcement rides THIS TOOL RESULT — model-requested content,
|
|
@@ -17234,10 +18482,19 @@ export class AgentFramework {
|
|
|
17234
18482
|
(result.data as { channelId?: string } | undefined)?.channelId
|
|
17235
18483
|
?? (call.input as { channelId?: string } | undefined)?.channelId;
|
|
17236
18484
|
if (opened) {
|
|
17237
|
-
|
|
18485
|
+
callingTurn.triggerChannel = opened;
|
|
17238
18486
|
const openerAgent = this.agents.get(agentName);
|
|
17239
|
-
|
|
17240
|
-
|
|
18487
|
+
// The move is one this result tells the agent, so it needs the
|
|
18488
|
+
// turn that called still open. A result that lands after the turn
|
|
18489
|
+
// settled is dropped, so the agent never reads it, whether or not
|
|
18490
|
+
// a later start has replaced the turn's routing state yet: the
|
|
18491
|
+
// turn's locus stays where the agent was last told, for a resume
|
|
18492
|
+
// that opens the turn again, and so does the announce baseline,
|
|
18493
|
+
// which is the agent's across turns.
|
|
18494
|
+
if (openerAgent
|
|
18495
|
+
&& (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')
|
|
18496
|
+
&& this.openLogical.get(agentName) === callingTurn) {
|
|
18497
|
+
callingTurn.locus = opened;
|
|
17241
18498
|
this.lastAnnouncedLocus.set(agentName, opened);
|
|
17242
18499
|
result = {
|
|
17243
18500
|
...result,
|
|
@@ -17520,7 +18777,7 @@ export class AgentFramework {
|
|
|
17520
18777
|
console.error(`[sleep] ${agentName}: no prose target this turn — sleep announcement not posted (explicit mode never guesses)`);
|
|
17521
18778
|
}
|
|
17522
18779
|
} else {
|
|
17523
|
-
const locus = this.
|
|
18780
|
+
const locus = this.turnRouting.get(agentName)?.locus ?? null;
|
|
17524
18781
|
this.channelRegistry.routeSpeech(agentName, text, locus).catch((err) => {
|
|
17525
18782
|
console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
|
|
17526
18783
|
});
|
|
@@ -18429,9 +19686,10 @@ export class AgentFramework {
|
|
|
18429
19686
|
// Conversation identity = the agent (a trunk agent IS its own conversation;
|
|
18430
19687
|
// forks get their own agent). The turn's channel LOCUS — the "proper
|
|
18431
19688
|
// conversation tracking" this once flagged as a TODO — is now tracked
|
|
18432
|
-
//
|
|
18433
|
-
//
|
|
18434
|
-
//
|
|
19689
|
+
// on the turn's routing state (TurnRouting.locus, from its trigger
|
|
19690
|
+
// channel) and surfaced to the agent via buildChannelContext
|
|
19691
|
+
// (channels.defaultOutgoing) below, so the agent is told the same
|
|
19692
|
+
// channel its speech will route to (item-3 redux).
|
|
18435
19693
|
conversationId: agent.name,
|
|
18436
19694
|
turnIndex: 0, // Simplified; needs per-conversation counter TODO
|
|
18437
19695
|
userMessage: null, // Could extract from trigger context
|